chore: cleanup dead code + format with Pint

- Remove unused FeaturesController (empty stubs, no routes)
- Remove ConvertSpatialFile CLI command (unused; service used in LayerManager)
- Remove MigrateGeojsonToFeatures CLI command (one-shot migration, not referenced)
- Remove .claude/worktrees/ (11 old agent worktrees from June)
- Apply Laravel Pint formatting across 219 files (style only, no functional changes)

Tests: 101 passing (319 assertions)
API routes: unchanged (8 routes intact)
This commit is contained in:
Javier Braña
2026-08-28 13:04:28 +02:00
parent 2dccd59385
commit 90630379fb
139 changed files with 2888 additions and 2510 deletions
@@ -1,44 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Services\SpatialFileConverter;
use App\Models\Phase;
class ConvertSpatialFile extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'convert:spatial {file} {phase_id}';
protected $description = 'Convert a spatial file (DWG, SHP, KML, GeoJSON) to GeoJSON and attach to phase';
/**
* Execute the console command.
*/
public function handle()
{
$filePath = $this->argument('file');
$phaseId = $this->argument('phase_id');
$phase = Phase::findOrFail($phaseId);
$file = new \Illuminate\Http\UploadedFile($filePath, basename($filePath));
$geojson = SpatialFileConverter::convertToGeoJson($file, $file->getClientOriginalName());
if ($geojson) {
$layer = $phase->layers()->create([
'project_id' => $phase->project_id,
'name' => 'Converted: ' . basename($filePath),
'geojson_data' => $geojson,
'uploaded_by' => 1, // admin
'original_file' => $filePath
]);
$this->info("GeoJSON saved to layer ID {$layer->id}");
} else {
$this->error("Conversion failed for file type.");
}
}
}
@@ -1,45 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Layer;
use App\Models\Feature;
use Illuminate\Console\Command;
class MigrateGeojsonToFeatures extends Command
{
protected $signature = 'migrate:geojson-to-features';
protected $description = 'Migrate features from layer.geojson_data to features table';
public function handle()
{
$layers = Layer::whereNotNull('geojson_data')->get();
$totalFeatures = 0;
foreach ($layers as $layer) {
$geojson = $layer->geojson_data;
if (!isset($geojson['features'])) continue;
foreach ($geojson['features'] as $featureData) {
$geometry = $featureData['geometry'];
$props = $featureData['properties'] ?? [];
Feature::create([
'layer_id' => $layer->id,
'name' => $props['name'] ?? null,
'geometry' => $geometry,
'properties' => $props,
'template_id' => $props['template_id'] ?? null,
'progress' => $props['progress'] ?? 0,
'responsible' => $props['responsible'] ?? null,
]);
$totalFeatures++;
}
// Opcional: Marcar la capa como migrada (podrías agregar columna 'migrated_at')
$this->info("Layer {$layer->id} ({$layer->name}) migrated.");
}
$this->info("Total features migrated: {$totalFeatures}");
}
}
+8 -8
View File
@@ -3,7 +3,6 @@
namespace App\DTO;
use Carbon\Carbon;
use Illuminate\Support\Collection;
class ReportFilters
{
@@ -21,10 +20,10 @@ class ReportFilters
return new self(
dateFrom: isset($data['date_from']) ? Carbon::parse($data['date_from']) : null,
dateTo: isset($data['date_to']) ? Carbon::parse($data['date_to']) : null,
entityTypes: $data['entity_types'] ?? ['phases','features','inspections','issues','tasks'],
includePhotos: (bool)($data['include_photos'] ?? false),
entityTypes: $data['entity_types'] ?? ['phases', 'features', 'inspections', 'issues', 'tasks'],
includePhotos: (bool) ($data['include_photos'] ?? false),
format: $data['format'] ?? 'html',
includeCharts: (bool)($data['include_charts'] ?? false),
includeCharts: (bool) ($data['include_charts'] ?? false),
);
}
@@ -55,14 +54,15 @@ class ReportFilters
public function getDateRangeLabel(): string
{
if ($this->dateFrom && $this->dateTo) {
return $this->dateFrom->format('d/m/Y') . ' - ' . $this->dateTo->format('d/m/Y');
return $this->dateFrom->format('d/m/Y').' - '.$this->dateTo->format('d/m/Y');
}
if ($this->dateFrom) {
return 'Desde ' . $this->dateFrom->format('d/m/Y');
return 'Desde '.$this->dateFrom->format('d/m/Y');
}
if ($this->dateTo) {
return 'Hasta ' . $this->dateTo->format('d/m/Y');
return 'Hasta '.$this->dateTo->format('d/m/Y');
}
return 'Todo el período';
}
}
}
+2 -2
View File
@@ -18,7 +18,7 @@ class InspectionsExport implements FromCollection, WithHeadings
'status',
'notes',
'created_at',
'updated_at'
'updated_at',
])->get();
}
@@ -32,7 +32,7 @@ class InspectionsExport implements FromCollection, WithHeadings
'Estado',
'Notas',
'Creado el',
'Actualizado el'
'Actualizado el',
];
}
}
+2 -2
View File
@@ -18,7 +18,7 @@ class PhasesExport implements FromCollection, WithHeadings
'start_date',
'end_date',
'created_at',
'updated_at'
'updated_at',
])->get();
}
@@ -32,7 +32,7 @@ class PhasesExport implements FromCollection, WithHeadings
'Fecha de inicio',
'Fecha de fin',
'Creado el',
'Actualizado el'
'Actualizado el',
];
}
}
+73 -68
View File
@@ -5,20 +5,22 @@ namespace App\Exports;
use App\DTO\ReportFilters;
use App\Models\Project;
use Maatwebsite\Excel\Concerns\FromArray;
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
use Maatwebsite\Excel\Concerns\WithTitle;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithStyles;
use Maatwebsite\Excel\Concerns\WithColumnWidths;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
use Maatwebsite\Excel\Concerns\WithStyles;
use Maatwebsite\Excel\Concerns\WithTitle;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
class ProjectReportExport implements WithMultipleSheets
{
protected Project $project;
protected ReportFilters $filters;
protected array $data;
public function __construct(Project $project, ReportFilters $filters, array $data)
@@ -34,35 +36,35 @@ class ProjectReportExport implements WithMultipleSheets
new SummarySheet($this->data['summary'] ?? [], $this->project, $this->filters),
];
if (!empty($this->data['phases'])) {
if (! empty($this->data['phases'])) {
$sheets[] = new PhasesSheet($this->data['phases']);
}
if (!empty($this->data['features'])) {
if (! empty($this->data['features'])) {
$sheets[] = new FeaturesSheet($this->data['features']);
}
if (!empty($this->data['inspections'])) {
if (! empty($this->data['inspections'])) {
$sheets[] = new InspectionsSheet($this->data['inspections']);
}
if (!empty($this->data['issues'])) {
if (! empty($this->data['issues'])) {
$sheets[] = new IssuesSheet($this->data['issues']);
}
if (!empty($this->data['tasks'])) {
if (! empty($this->data['tasks'])) {
$sheets[] = new TasksSheet($this->data['tasks']);
}
if (!empty($this->data['deviations'])) {
if (! empty($this->data['deviations'])) {
$sheets[] = new DeviationsSheet($this->data['deviations']);
}
if (!empty($this->data['media'])) {
if (! empty($this->data['media'])) {
$sheets[] = new MediaSheet($this->data['media']);
}
if (!empty($this->data['progress_curve'])) {
if (! empty($this->data['progress_curve'])) {
$sheets[] = new ProgressCurveSheet($this->data['progress_curve']);
}
@@ -77,7 +79,7 @@ class ProjectReportExport implements WithMultipleSheets
// Base Sheet with common styling
// ============================================================
abstract class BaseSheet implements FromArray, WithHeadings, WithStyles, WithColumnWidths, WithTitle
abstract class BaseSheet implements FromArray, WithColumnWidths, WithHeadings, WithStyles, WithTitle
{
protected array $rows = [];
@@ -129,7 +131,7 @@ abstract class BaseSheet implements FromArray, WithHeadings, WithStyles, WithCol
// Auto-filter
$sheet->setAutoFilter($sheet->calculateWorksheetDimension());
// Freeze header row
$sheet->freezePane('A2');
}
@@ -147,13 +149,14 @@ abstract class BaseSheet implements FromArray, WithHeadings, WithStyles, WithCol
class SummarySheet extends BaseSheet
{
protected Project $project;
protected ReportFilters $filters;
public function __construct(array $summary, Project $project, ReportFilters $filters)
{
$this->project = $project;
$this->filters = $filters;
$rows = [
['Informe de Proyecto', $project->name],
['Referencia', $project->reference ?? '—'],
@@ -194,7 +197,7 @@ class SummarySheet extends BaseSheet
['Retrasadas', $summary['phases_delayed'] ?? 0],
['Sin datos', $summary['phases_no_data'] ?? 0],
];
parent::__construct($rows);
}
@@ -218,10 +221,10 @@ class PhasesSheet extends BaseSheet
public function headings(): array
{
return [
'ID', 'Fase', 'Orden', 'Color', 'Inicio Plan', 'Fin Plan',
'Inicio Real', 'Fin Real', 'Progreso (%)', 'Progreso Plan (%)',
'ID', 'Fase', 'Orden', 'Color', 'Inicio Plan', 'Fin Plan',
'Inicio Real', 'Fin Real', 'Progreso (%)', 'Progreso Plan (%)',
'Desvío Fin (días)', 'Desvío Inicio (días)', 'SPI', 'En Plazo',
'Elementos', 'Completados', 'Capas'
'Elementos', 'Completados', 'Capas',
];
}
@@ -252,9 +255,9 @@ class PhasesSheet extends BaseSheet
public function columnWidths(): array
{
return ['A' => 8, 'B' => 25, 'C' => 8, 'D' => 10, 'E' => 12, 'F' => 12,
'G' => 12, 'H' => 12, 'I' => 12, 'J' => 14, 'K' => 14, 'L' => 14,
'M' => 8, 'N' => 10, 'O' => 10, 'P' => 12, 'Q' => 10];
return ['A' => 8, 'B' => 25, 'C' => 8, 'D' => 10, 'E' => 12, 'F' => 12,
'G' => 12, 'H' => 12, 'I' => 12, 'J' => 14, 'K' => 14, 'L' => 14,
'M' => 8, 'N' => 10, 'O' => 10, 'P' => 12, 'Q' => 10];
}
}
@@ -270,7 +273,7 @@ class FeaturesSheet extends BaseSheet
'ID', 'Elemento', 'Fase', 'Capa', 'Estado', 'Progreso (%)', 'Progreso Plan (%)',
'Inicio Plan', 'Fin Plan', 'Inicio Real', 'Fin Real', 'Desvío Fin (días)',
'Desvío Inicio (días)', 'SPI', 'En Plazo', 'Responsable', 'Template',
'Última Inspección', 'Resultado', 'Inspecciones', 'Issues Abiertos'
'Última Inspección', 'Resultado', 'Inspecciones', 'Issues Abiertos',
];
}
@@ -305,10 +308,10 @@ class FeaturesSheet extends BaseSheet
public function columnWidths(): array
{
return ['A' => 8, 'B' => 25, 'C' => 20, 'D' => 20, 'E' => 15, 'F' => 12,
'G' => 14, 'H' => 12, 'I' => 12, 'J' => 12, 'K' => 12, 'L' => 14,
'M' => 14, 'N' => 8, 'O' => 10, 'P' => 20, 'Q' => 20, 'R' => 14,
'S' => 12, 'T' => 12, 'U' => 12];
return ['A' => 8, 'B' => 25, 'C' => 20, 'D' => 20, 'E' => 15, 'F' => 12,
'G' => 14, 'H' => 12, 'I' => 12, 'J' => 12, 'K' => 12, 'L' => 14,
'M' => 14, 'N' => 8, 'O' => 10, 'P' => 20, 'Q' => 20, 'R' => 14,
'S' => 12, 'T' => 12, 'U' => 12];
}
}
@@ -321,8 +324,8 @@ class InspectionsSheet extends BaseSheet
public function headings(): array
{
return [
'ID', 'Elemento', 'Fase', 'Template', 'Inspector', 'Fecha',
'Estado', 'Resultado', 'Notas', 'Fotos'
'ID', 'Elemento', 'Fase', 'Template', 'Inspector', 'Fecha',
'Estado', 'Resultado', 'Notas', 'Fotos',
];
}
@@ -346,8 +349,8 @@ class InspectionsSheet extends BaseSheet
public function columnWidths(): array
{
return ['A' => 8, 'B' => 25, 'C' => 20, 'D' => 25, 'E' => 20, 'F' => 18,
'G' => 12, 'H' => 12, 'I' => 40, 'J' => 8];
return ['A' => 8, 'B' => 25, 'C' => 20, 'D' => 25, 'E' => 20, 'F' => 18,
'G' => 12, 'H' => 12, 'I' => 40, 'J' => 8];
}
}
@@ -360,9 +363,9 @@ class IssuesSheet extends BaseSheet
public function headings(): array
{
return [
'ID', 'Título', 'Elemento', 'Fase', 'Prioridad', 'Estado',
'Reportado por', 'Asignado a', 'Creado', 'Cerrado', 'Días abierto',
'Tareas total', 'Tareas completadas'
'ID', 'Título', 'Elemento', 'Fase', 'Prioridad', 'Estado',
'Reportado por', 'Asignado a', 'Creado', 'Cerrado', 'Días abierto',
'Tareas total', 'Tareas completadas',
];
}
@@ -389,8 +392,8 @@ class IssuesSheet extends BaseSheet
public function columnWidths(): array
{
return ['A' => 8, 'B' => 35, 'C' => 20, 'D' => 20, 'E' => 12, 'F' => 15,
'G' => 20, 'H' => 20, 'I' => 12, 'J' => 12, 'K' => 12, 'L' => 12, 'M' => 14];
return ['A' => 8, 'B' => 35, 'C' => 20, 'D' => 20, 'E' => 12, 'F' => 15,
'G' => 20, 'H' => 20, 'I' => 12, 'J' => 12, 'K' => 12, 'L' => 12, 'M' => 14];
}
}
@@ -404,15 +407,16 @@ class TasksSheet extends BaseSheet
{
return [
'ID', 'Tarea', 'Fase', 'Estado', 'Prioridad', 'Asignado', 'Creador',
'Fecha inicio', 'Fecha fin', 'Completada', 'Horas est.', 'Horas real',
'Progreso (%)', 'Vencida', 'Subtareas'
'Fecha inicio', 'Fecha fin', 'Completada', 'Horas est.', 'Horas real',
'Progreso (%)', 'Vencida', 'Subtareas',
];
}
public function array(): array
{
return array_map(function ($task) {
$subtasksStr = implode('; ', array_map(fn($st) => "{$st['title']} ({$st['status']})", $task['subtasks']));
$subtasksStr = implode('; ', array_map(fn ($st) => "{$st['title']} ({$st['status']})", $task['subtasks']));
return [
$task['id'],
$task['title'],
@@ -435,9 +439,9 @@ class TasksSheet extends BaseSheet
public function columnWidths(): array
{
return ['A' => 8, 'B' => 30, 'C' => 20, 'D' => 15, 'E' => 12, 'F' => 20,
'G' => 20, 'H' => 12, 'I' => 12, 'J' => 12, 'K' => 10, 'L' => 10,
'M' => 12, 'N' => 10, 'O' => 40];
return ['A' => 8, 'B' => 30, 'C' => 20, 'D' => 15, 'E' => 12, 'F' => 20,
'G' => 20, 'H' => 12, 'I' => 12, 'J' => 12, 'K' => 10, 'L' => 10,
'M' => 12, 'N' => 10, 'O' => 40];
}
}
@@ -453,14 +457,14 @@ class DeviationsSheet extends BaseSheet
{
$this->deviations = $deviations;
$rows = [];
// Phase deviations
if (!empty($deviations['phases'])) {
if (! empty($deviations['phases'])) {
$rows[] = ['=== DESVÍOS POR FASE ===', '', '', '', '', '', '', '', '', '', ''];
$rows[] = ['ID', 'Fase', 'Inicio Plan', 'Fin Plan', 'Inicio Real', 'Fin Real',
'Desvío Inicio (d)', 'Desvío Fin (d)', 'Prog. Plan (%)', 'Prog. Real (%)',
'Desvío Prog.', 'SPI', 'En Plazo'];
$rows[] = ['ID', 'Fase', 'Inicio Plan', 'Fin Plan', 'Inicio Real', 'Fin Real',
'Desvío Inicio (d)', 'Desvío Fin (d)', 'Prog. Plan (%)', 'Prog. Real (%)',
'Desvío Prog.', 'SPI', 'En Plazo'];
foreach ($deviations['phases'] as $phase) {
$rows[] = [
$phase['id'], $phase['name'], $phase['planned_start'], $phase['planned_end'],
@@ -475,12 +479,12 @@ class DeviationsSheet extends BaseSheet
}
// Feature deviations
if (!empty($deviations['features'])) {
if (! empty($deviations['features'])) {
$rows[] = ['=== DESVÍOS POR ELEMENTO ===', '', '', '', '', '', '', '', '', '', '', '', ''];
$rows[] = ['ID', 'Elemento', 'Fase', 'Inicio Plan', 'Fin Plan', 'Inicio Real', 'Fin Real',
'Desvío Inicio (d)', 'Desvío Fin (d)', 'Prog. Plan (%)', 'Prog. Real (%)',
'Desvío Prog.', 'SPI', 'En Plazo', 'Responsable'];
$rows[] = ['ID', 'Elemento', 'Fase', 'Inicio Plan', 'Fin Plan', 'Inicio Real', 'Fin Real',
'Desvío Inicio (d)', 'Desvío Fin (d)', 'Prog. Plan (%)', 'Prog. Real (%)',
'Desvío Prog.', 'SPI', 'En Plazo', 'Responsable'];
foreach ($deviations['features'] as $feature) {
$rows[] = [
$feature['id'], $feature['name'], $feature['phase'],
@@ -497,7 +501,7 @@ class DeviationsSheet extends BaseSheet
}
// Summary
if (!empty($deviations['summary'])) {
if (! empty($deviations['summary'])) {
$rows[] = ['=== RESUMEN DESVÍOS ===', ''];
$rows[] = ['Fases retrasadas', $deviations['summary']['phases_delayed'] ?? 0];
$rows[] = ['Fases adelantadas', $deviations['summary']['phases_early'] ?? 0];
@@ -522,9 +526,9 @@ class DeviationsSheet extends BaseSheet
public function columnWidths(): array
{
return ['A' => 25, 'B' => 25, 'C' => 20, 'D' => 12, 'E' => 12, 'F' => 12,
'G' => 12, 'H' => 14, 'I' => 14, 'J' => 14, 'K' => 14, 'L' => 10,
'M' => 10, 'N' => 20];
return ['A' => 25, 'B' => 25, 'C' => 20, 'D' => 12, 'E' => 12, 'F' => 12,
'G' => 12, 'H' => 14, 'I' => 14, 'J' => 14, 'K' => 14, 'L' => 10,
'M' => 10, 'N' => 20];
}
}
@@ -558,8 +562,8 @@ class MediaSheet extends BaseSheet
public function columnWidths(): array
{
return ['A' => 8, 'B' => 30, 'C' => 12, 'D' => 15, 'E' => 30, 'F' => 12,
'G' => 20, 'H' => 18, 'I' => 50];
return ['A' => 8, 'B' => 30, 'C' => 12, 'D' => 15, 'E' => 30, 'F' => 12,
'G' => 20, 'H' => 18, 'I' => 50];
}
}
@@ -574,17 +578,17 @@ class ProgressCurveSheet extends BaseSheet
public function __construct(array $curveData)
{
$this->curveData = $curveData;
$rows = [['Fecha', 'Progreso Planificado (%)', 'Progreso Real (%)']];
$labels = $curveData['labels'] ?? [];
$planned = $curveData['planned'] ?? [];
$actual = $curveData['actual'] ?? [];
for ($i = 0; $i < count($labels); $i++) {
$rows[] = [$labels[$i], $planned[$i] ?? 0, $actual[$i] ?? 0];
}
$this->rows = $rows;
}
@@ -611,13 +615,14 @@ class ProgressCurveSheet extends BaseSheet
class ParametersSheet extends BaseSheet
{
protected ReportFilters $filters;
protected Project $project;
public function __construct(ReportFilters $filters, Project $project)
{
$this->filters = $filters;
$this->project = $project;
$rows = [
['Parámetro', 'Valor'],
['Proyecto', $project->name],
@@ -625,7 +630,7 @@ class ParametersSheet extends BaseSheet
['Fecha desde', $filters->dateFrom?->format('d/m/Y') ?? '—'],
['Fecha hasta', $filters->dateTo?->format('d/m/Y') ?? '—'],
['Entidades incluidas', implode(', ', array_map(
fn($e) => $filters->getAvailableEntities()[$e] ?? $e,
fn ($e) => $filters->getAvailableEntities()[$e] ?? $e,
$filters->entityTypes
))],
['Incluir fotos', $filters->includePhotos ? 'Sí' : 'No'],
@@ -634,7 +639,7 @@ class ParametersSheet extends BaseSheet
['Generado', now()->format('d/m/Y H:i')],
['Generado por', auth()->guard()->user()?->name ?? 'Sistema'],
];
parent::__construct($rows);
}
@@ -652,4 +657,4 @@ class ParametersSheet extends BaseSheet
{
return ['A' => 30, 'B' => 50];
}
}
}
+2 -2
View File
@@ -18,7 +18,7 @@ class ProjectsExport implements FromCollection, WithHeadings
'end_date',
'status',
'created_at',
'updated_at'
'updated_at',
])->get();
}
@@ -32,7 +32,7 @@ class ProjectsExport implements FromCollection, WithHeadings
'Fecha de fin',
'Estado',
'Creado el',
'Actualizado el'
'Actualizado el',
];
}
}
@@ -17,8 +17,8 @@ class AuthController extends Controller
public function login(Request $request)
{
$data = $request->validate([
'email' => ['required', 'email'],
'password' => ['required', 'string'],
'email' => ['required', 'email'],
'password' => ['required', 'string'],
'device_name' => ['required', 'string', 'max:255'],
'app_version' => ['nullable', 'string', 'max:50'],
]);
@@ -39,15 +39,15 @@ class AuthController extends Controller
Device::updateOrCreate(
['user_id' => $user->id, 'name' => $data['device_name']],
[
'token_id' => $token->accessToken->id,
'app_version' => $data['app_version'] ?? null,
'token_id' => $token->accessToken->id,
'app_version' => $data['app_version'] ?? null,
'last_seen_at' => now(),
]
);
return response()->json([
'token' => $token->plainTextToken,
'user' => $this->userPayload($user),
'user' => $this->userPayload($user),
]);
}
@@ -70,10 +70,10 @@ class AuthController extends Controller
private function userPayload(User $user): array
{
return [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'roles' => $user->getRoleNames(),
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'roles' => $user->getRoleNames(),
'permissions' => $user->getAllPermissions()->pluck('name')->values(),
];
}
+35 -34
View File
@@ -13,31 +13,31 @@ use App\Models\Phase;
use App\Models\Project;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class MediaController extends Controller
{
private array $map = [
'feature' => Feature::class,
'issue' => Issue::class,
'issue_task' => IssueTask::class,
'feature' => Feature::class,
'issue' => Issue::class,
'issue_task' => IssueTask::class,
'issue_comment' => IssueComment::class,
'project' => Project::class,
'phase' => Phase::class,
'layer' => Layer::class,
'project' => Project::class,
'phase' => Phase::class,
'layer' => Layer::class,
];
/** Upload a file (multipart) and attach it to a parent record. Idempotent by uuid. */
public function upload(Request $request)
{
$data = $request->validate([
'uuid' => ['required', 'uuid'],
'uuid' => ['required', 'uuid'],
'parent_entity' => ['required', Rule::in(array_keys($this->map))],
'parent_id' => ['required', 'integer'],
'file' => ['required', 'file', 'max:20480'], // 20 MB
'category' => ['nullable', 'in:image,document,other'],
'description' => ['nullable', 'string'],
'parent_id' => ['required', 'integer'],
'file' => ['required', 'file', 'max:20480'], // 20 MB
'category' => ['nullable', 'in:image,document,other'],
'description' => ['nullable', 'string'],
]);
// Idempotency: same uuid already uploaded → return it.
@@ -59,15 +59,15 @@ class MediaController extends Controller
$mime = $file->getClientMimeType();
$media = $parent->media()->create([
'uuid' => $data['uuid'],
'name' => $file->getClientOriginalName(),
'file_path' => $path,
'file_type' => $mime,
'file_extension' => $file->getClientOriginalExtension(),
'file_size' => $file->getSize(),
'category' => $data['category'] ?? (Str::startsWith($mime, 'image/') ? 'image' : 'document'),
'description' => $data['description'] ?? null,
'uploaded_by' => $user->id,
'uuid' => $data['uuid'],
'name' => $file->getClientOriginalName(),
'file_path' => $path,
'file_type' => $mime,
'file_extension' => $file->getClientOriginalExtension(),
'file_size' => $file->getSize(),
'category' => $data['category'] ?? (Str::startsWith($mime, 'image/') ? 'image' : 'document'),
'description' => $data['description'] ?? null,
'uploaded_by' => $user->id,
'client_updated_at' => $request->input('client_updated_at'),
]);
@@ -77,14 +77,14 @@ class MediaController extends Controller
private function projectOf(string $entity, $parent): ?Project
{
return match ($entity) {
'project' => $parent,
'phase' => $parent->project,
'layer' => $parent->phase?->project,
'feature' => $parent->layer?->phase?->project,
'issue' => $parent->project,
'issue_task' => $parent->issue?->project,
'project' => $parent,
'phase' => $parent->project,
'layer' => $parent->phase?->project,
'feature' => $parent->layer?->phase?->project,
'issue' => $parent->project,
'issue_task' => $parent->issue?->project,
'issue_comment' => $parent->issue?->project,
default => null,
default => null,
};
}
@@ -93,6 +93,7 @@ class MediaController extends Controller
if (! $project) {
return false;
}
return $user->can('manage all')
|| $project->users()->where('user_id', $user->id)->exists();
}
@@ -100,12 +101,12 @@ class MediaController extends Controller
private function payload(Media $m): array
{
return [
'id' => $m->id,
'uuid' => $m->uuid,
'url' => $m->url,
'name' => $m->name,
'file_type' => $m->file_type,
'category' => $m->category,
'id' => $m->id,
'uuid' => $m->uuid,
'url' => $m->url,
'name' => $m->name,
'file_type' => $m->file_type,
'category' => $m->category,
'updated_at' => $m->updated_at?->toIso8601String(),
];
}
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Feature;
use App\Models\FeatureType;
use App\Models\Inspection;
use App\Models\InspectionTemplate;
use App\Models\Issue;
@@ -53,35 +54,35 @@ class ProjectApiController extends Controller
$templates = $changed(InspectionTemplate::whereHas('projects', fn ($q) => $q->where('projects.id', $project->id)))->get();
$allIssueIds = Issue::withTrashed()->where('project_id', $project->id)->pluck('id');
$issueTasks = $changed(IssueTask::whereIn('issue_id', $allIssueIds))->get();
$issueTasks = $changed(IssueTask::whereIn('issue_id', $allIssueIds))->get();
$issueComments = $changed(IssueComment::whereIn('issue_id', $allIssueIds))->get();
$featureIds = Feature::whereIn('layer_id', $allLayerIds)->pluck('id');
$issueIds = Issue::where('project_id', $project->id)->pluck('id');
$taskIds = IssueTask::whereIn('issue_id', $allIssueIds)->pluck('id');
$issueIds = Issue::where('project_id', $project->id)->pluck('id');
$taskIds = IssueTask::whereIn('issue_id', $allIssueIds)->pluck('id');
$commentIds = IssueComment::whereIn('issue_id', $allIssueIds)->pluck('id');
$media = $changed(Media::where(function ($q) use ($project, $featureIds, $issueIds, $taskIds, $commentIds) {
$q->where(fn ($w) => $w->where('mediable_type', Project::class)->where('mediable_id', $project->id))
->orWhere(fn ($w) => $w->where('mediable_type', Feature::class)->whereIn('mediable_id', $featureIds))
->orWhere(fn ($w) => $w->where('mediable_type', Issue::class)->whereIn('mediable_id', $issueIds))
->orWhere(fn ($w) => $w->where('mediable_type', IssueTask::class)->whereIn('mediable_id', $taskIds))
->orWhere(fn ($w) => $w->where('mediable_type', IssueComment::class)->whereIn('mediable_id', $commentIds));
->orWhere(fn ($w) => $w->where('mediable_type', Feature::class)->whereIn('mediable_id', $featureIds))
->orWhere(fn ($w) => $w->where('mediable_type', Issue::class)->whereIn('mediable_id', $issueIds))
->orWhere(fn ($w) => $w->where('mediable_type', IssueTask::class)->whereIn('mediable_id', $taskIds))
->orWhere(fn ($w) => $w->where('mediable_type', IssueComment::class)->whereIn('mediable_id', $commentIds));
}))->get();
return response()->json([
'server_time' => now()->toIso8601String(),
'project' => $this->mapProject($project),
'phases' => $phases->map(fn ($p) => $this->mapPhase($p))->values(),
'layers' => $layers->map(fn ($l) => $this->mapLayer($l))->values(),
'features' => $features->map(fn ($f) => $this->mapFeature($f))->values(),
'feature_types' => \App\Models\FeatureType::orderBy('name')->get(['id', 'name', 'color'])->values(),
'inspections' => $inspections->map(fn ($i) => $this->mapInspection($i))->values(),
'issues' => $issues->map(fn ($i) => $this->mapIssue($i))->values(),
'issue_tasks' => $issueTasks->map(fn ($t) => $this->mapIssueTask($t))->values(),
'server_time' => now()->toIso8601String(),
'project' => $this->mapProject($project),
'phases' => $phases->map(fn ($p) => $this->mapPhase($p))->values(),
'layers' => $layers->map(fn ($l) => $this->mapLayer($l))->values(),
'features' => $features->map(fn ($f) => $this->mapFeature($f))->values(),
'feature_types' => FeatureType::orderBy('name')->get(['id', 'name', 'color'])->values(),
'inspections' => $inspections->map(fn ($i) => $this->mapInspection($i))->values(),
'issues' => $issues->map(fn ($i) => $this->mapIssue($i))->values(),
'issue_tasks' => $issueTasks->map(fn ($t) => $this->mapIssueTask($t))->values(),
'issue_comments' => $issueComments->map(fn ($c) => $this->mapIssueComment($c))->values(),
'templates' => $templates->map(fn ($t) => $this->mapTemplate($t))->values(),
'media' => $media->map(fn ($m) => $this->mapMedia($m))->values(),
'deleted' => $since ? $this->tombstones($since, $project, $allPhaseIds, $allLayerIds, $allIssueIds) : (object) [],
'templates' => $templates->map(fn ($t) => $this->mapTemplate($t))->values(),
'media' => $media->map(fn ($m) => $this->mapMedia($m))->values(),
'deleted' => $since ? $this->tombstones($since, $project, $allPhaseIds, $allLayerIds, $allIssueIds) : (object) [],
]);
}
@@ -118,12 +119,12 @@ class ProjectApiController extends Controller
private function tombstones(Carbon $since, Project $project, $allPhaseIds, $allLayerIds, $allIssueIds): array
{
return [
'phases' => Phase::onlyTrashed()->where('project_id', $project->id)->where('deleted_at', '>', $since)->pluck('id')->values(),
'layers' => Layer::onlyTrashed()->whereIn('phase_id', $allPhaseIds)->where('deleted_at', '>', $since)->pluck('id')->values(),
'features' => Feature::onlyTrashed()->whereIn('layer_id', $allLayerIds)->where('deleted_at', '>', $since)->pluck('id')->values(),
'inspections' => Inspection::onlyTrashed()->where('project_id', $project->id)->where('deleted_at', '>', $since)->pluck('id')->values(),
'issues' => Issue::onlyTrashed()->where('project_id', $project->id)->where('deleted_at', '>', $since)->pluck('id')->values(),
'issue_tasks' => IssueTask::onlyTrashed()->whereIn('issue_id', $allIssueIds)->where('deleted_at', '>', $since)->pluck('id')->values(),
'phases' => Phase::onlyTrashed()->where('project_id', $project->id)->where('deleted_at', '>', $since)->pluck('id')->values(),
'layers' => Layer::onlyTrashed()->whereIn('phase_id', $allPhaseIds)->where('deleted_at', '>', $since)->pluck('id')->values(),
'features' => Feature::onlyTrashed()->whereIn('layer_id', $allLayerIds)->where('deleted_at', '>', $since)->pluck('id')->values(),
'inspections' => Inspection::onlyTrashed()->where('project_id', $project->id)->where('deleted_at', '>', $since)->pluck('id')->values(),
'issues' => Issue::onlyTrashed()->where('project_id', $project->id)->where('deleted_at', '>', $since)->pluck('id')->values(),
'issue_tasks' => IssueTask::onlyTrashed()->whereIn('issue_id', $allIssueIds)->where('deleted_at', '>', $since)->pluck('id')->values(),
'issue_comments' => IssueComment::onlyTrashed()->whereIn('issue_id', $allIssueIds)->where('deleted_at', '>', $since)->pluck('id')->values(),
];
}
@@ -209,7 +210,7 @@ class ProjectApiController extends Controller
'id' => $t->id, 'project_id' => $t->project_id, 'phase_id' => $t->phase_id,
'name' => $t->name, 'description' => $t->description, 'fields' => $t->fields,
'version' => $t->updated_at?->timestamp,
'hash' => md5(json_encode($t->fields) . $t->name),
'hash' => md5(json_encode($t->fields).$t->name),
'updated_at' => $t->updated_at?->toIso8601String(),
];
}
@@ -217,10 +218,10 @@ class ProjectApiController extends Controller
private function mapMedia(Media $m): array
{
$entity = [
Project::class => 'project',
Feature::class => 'feature',
Issue::class => 'issue',
IssueTask::class => 'issue_task',
Project::class => 'project',
Feature::class => 'feature',
Issue::class => 'issue',
IssueTask::class => 'issue_task',
IssueComment::class => 'issue_comment',
][$m->mediable_type] ?? class_basename($m->mediable_type);
@@ -232,4 +233,3 @@ class ProjectApiController extends Controller
];
}
}
+102 -100
View File
@@ -30,12 +30,12 @@ class SyncController extends Controller
public function sync(Request $request)
{
$data = $request->validate([
'operations' => ['required', 'array'],
'operations.*.entity' => ['required', 'string'],
'operations.*.op' => ['required', 'string'],
'operations.*.uuid' => ['required', 'uuid'],
'operations.*.data' => ['required', 'array'],
'operations.*.client_updated_at' => ['nullable', 'date'],
'operations' => ['required', 'array'],
'operations.*.entity' => ['required', 'string'],
'operations.*.op' => ['required', 'string'],
'operations.*.uuid' => ['required', 'uuid'],
'operations.*.data' => ['required', 'array'],
'operations.*.client_updated_at' => ['nullable', 'date'],
]);
$user = $request->user();
@@ -74,16 +74,16 @@ class SyncController extends Controller
}
try {
$result = match ($op['entity'] . '.' . $op['op']) {
$result = match ($op['entity'].'.'.$op['op']) {
'progress_update.create' => $this->progressUpdateCreate($user, $uuid, $op),
'inspection.create' => $this->inspectionCreate($user, $uuid, $op),
'issue.create' => $this->issueCreate($user, $uuid, $op),
'issue.update' => $this->issueUpdate($user, $uuid, $op),
'issue_task.create' => $this->issueTaskCreate($user, $uuid, $op),
'issue_task.update' => $this->issueTaskUpdate($user, $uuid, $op),
'issue_comment.create' => $this->issueCommentCreate($user, $uuid, $op),
'feature.update' => $this->featureUpdate($user, $uuid, $op),
default => $this->error($uuid, 'unsupported entity/op: ' . $op['entity'] . '.' . $op['op']),
'inspection.create' => $this->inspectionCreate($user, $uuid, $op),
'issue.create' => $this->issueCreate($user, $uuid, $op),
'issue.update' => $this->issueUpdate($user, $uuid, $op),
'issue_task.create' => $this->issueTaskCreate($user, $uuid, $op),
'issue_task.update' => $this->issueTaskUpdate($user, $uuid, $op),
'issue_comment.create' => $this->issueCommentCreate($user, $uuid, $op),
'feature.update' => $this->featureUpdate($user, $uuid, $op),
default => $this->error($uuid, 'unsupported entity/op: '.$op['entity'].'.'.$op['op']),
};
} catch (\Throwable $e) {
$result = $this->error($uuid, $e->getMessage());
@@ -92,11 +92,11 @@ class SyncController extends Controller
// Record only terminal successes so conflicts/errors can be safely retried.
if ($result['status'] === 'applied') {
SyncLog::create([
'user_id' => $user->id,
'op_uuid' => $uuid,
'entity' => $op['entity'],
'op' => $op['op'],
'status' => 'applied',
'user_id' => $user->id,
'op_uuid' => $uuid,
'entity' => $op['entity'],
'op' => $op['op'],
'status' => 'applied',
'server_id' => $result['server_id'] ?? null,
]);
}
@@ -115,11 +115,11 @@ class SyncController extends Controller
$v = Validator::make($op['data'], [
'phase_id' => ['required', 'integer', 'exists:phases,id'],
'progress' => ['required', 'integer', 'min:0', 'max:100'],
'comment' => ['nullable', 'string'],
'comment' => ['nullable', 'string'],
'location' => ['nullable', 'array'],
]);
if ($v->fails()) {
return $this->error($uuid, 'validation: ' . $v->errors()->first());
return $this->error($uuid, 'validation: '.$v->errors()->first());
}
$d = $v->validated();
@@ -129,12 +129,12 @@ class SyncController extends Controller
}
$pu = ProgressUpdate::create([
'uuid' => $uuid,
'phase_id' => $phase->id,
'user_id' => $user->id,
'progress_percent' => $d['progress'],
'comment' => $d['comment'] ?? null,
'location' => $d['location'] ?? null,
'uuid' => $uuid,
'phase_id' => $phase->id,
'user_id' => $user->id,
'progress_percent' => $d['progress'],
'comment' => $d['comment'] ?? null,
'location' => $d['location'] ?? null,
'client_updated_at' => $op['client_updated_at'] ?? null,
]);
@@ -153,15 +153,15 @@ class SyncController extends Controller
}
$v = Validator::make($op['data'], [
'feature_id' => ['required', 'integer', 'exists:features,id'],
'feature_id' => ['required', 'integer', 'exists:features,id'],
'template_id' => ['nullable', 'integer', 'exists:inspection_templates,id'],
'data' => ['nullable', 'array'],
'status' => ['nullable', 'string'],
'result' => ['nullable', 'string'],
'notes' => ['nullable', 'string'],
'data' => ['nullable', 'array'],
'status' => ['nullable', 'string'],
'result' => ['nullable', 'string'],
'notes' => ['nullable', 'string'],
]);
if ($v->fails()) {
return $this->error($uuid, 'validation: ' . $v->errors()->first());
return $this->error($uuid, 'validation: '.$v->errors()->first());
}
$d = $v->validated();
@@ -172,16 +172,16 @@ class SyncController extends Controller
}
$inspection = Inspection::create([
'uuid' => $uuid,
'project_id' => $project->id,
'layer_id' => $feature->layer_id,
'feature_id' => $feature->id,
'template_id' => $d['template_id'] ?? null,
'user_id' => $user->id,
'data' => $d['data'] ?? [],
'status' => $d['status'] ?? 'completed',
'result' => $d['result'] ?? null,
'notes' => $d['notes'] ?? null,
'uuid' => $uuid,
'project_id' => $project->id,
'layer_id' => $feature->layer_id,
'feature_id' => $feature->id,
'template_id' => $d['template_id'] ?? null,
'user_id' => $user->id,
'data' => $d['data'] ?? [],
'status' => $d['status'] ?? 'completed',
'result' => $d['result'] ?? null,
'notes' => $d['notes'] ?? null,
'client_updated_at' => $op['client_updated_at'] ?? null,
]);
@@ -197,16 +197,16 @@ class SyncController extends Controller
}
$v = Validator::make($op['data'], [
'project_id' => ['required', 'integer', 'exists:projects,id'],
'feature_id' => ['nullable', 'integer', 'exists:features,id'],
'title' => ['required', 'string', 'max:255'],
'project_id' => ['required', 'integer', 'exists:projects,id'],
'feature_id' => ['nullable', 'integer', 'exists:features,id'],
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'priority' => ['nullable', 'in:' . implode(',', Issue::PRIORITIES)],
'status' => ['nullable', 'in:' . implode(',', Issue::STATUSES)],
'type' => ['nullable', 'in:' . implode(',', Issue::TYPES)],
'priority' => ['nullable', 'in:'.implode(',', Issue::PRIORITIES)],
'status' => ['nullable', 'in:'.implode(',', Issue::STATUSES)],
'type' => ['nullable', 'in:'.implode(',', Issue::TYPES)],
]);
if ($v->fails()) {
return $this->error($uuid, 'validation: ' . $v->errors()->first());
return $this->error($uuid, 'validation: '.$v->errors()->first());
}
$d = $v->validated();
@@ -216,15 +216,15 @@ class SyncController extends Controller
}
$issue = Issue::create([
'uuid' => $uuid,
'project_id' => $project->id,
'feature_id' => $d['feature_id'] ?? null,
'title' => $d['title'],
'description' => $d['description'] ?? null,
'priority' => $d['priority'] ?? 'medium',
'status' => $d['status'] ?? 'open',
'type' => $d['type'] ?? 'other',
'reported_by' => $user->id,
'uuid' => $uuid,
'project_id' => $project->id,
'feature_id' => $d['feature_id'] ?? null,
'title' => $d['title'],
'description' => $d['description'] ?? null,
'priority' => $d['priority'] ?? 'medium',
'status' => $d['status'] ?? 'open',
'type' => $d['type'] ?? 'other',
'reported_by' => $user->id,
'client_updated_at' => $op['client_updated_at'] ?? null,
]);
@@ -234,17 +234,17 @@ class SyncController extends Controller
private function issueUpdate(User $user, string $uuid, array $op): array
{
$v = Validator::make($op['data'], [
'id' => ['required', 'integer', 'exists:issues,id'],
'title' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'priority' => ['nullable', 'in:' . implode(',', Issue::PRIORITIES)],
'status' => ['nullable', 'in:' . implode(',', Issue::STATUSES)],
'type' => ['nullable', 'in:' . implode(',', Issue::TYPES)],
'assigned_to' => ['nullable', 'integer', 'exists:users,id'],
'id' => ['required', 'integer', 'exists:issues,id'],
'title' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'priority' => ['nullable', 'in:'.implode(',', Issue::PRIORITIES)],
'status' => ['nullable', 'in:'.implode(',', Issue::STATUSES)],
'type' => ['nullable', 'in:'.implode(',', Issue::TYPES)],
'assigned_to' => ['nullable', 'integer', 'exists:users,id'],
'resolution_notes' => ['nullable', 'string'],
]);
if ($v->fails()) {
return $this->error($uuid, 'validation: ' . $v->errors()->first());
return $this->error($uuid, 'validation: '.$v->errors()->first());
}
$d = $v->validated();
@@ -276,14 +276,14 @@ class SyncController extends Controller
}
$v = Validator::make($op['data'], [
'issue_id' => ['required', 'integer', 'exists:issues,id'],
'title' => ['required', 'string', 'max:255'],
'issue_id' => ['required', 'integer', 'exists:issues,id'],
'title' => ['required', 'string', 'max:255'],
'assigned_to' => ['nullable', 'integer', 'exists:users,id'],
'due_date' => ['nullable', 'date'],
'is_done' => ['nullable', 'boolean'],
'due_date' => ['nullable', 'date'],
'is_done' => ['nullable', 'boolean'],
]);
if ($v->fails()) {
return $this->error($uuid, 'validation: ' . $v->errors()->first());
return $this->error($uuid, 'validation: '.$v->errors()->first());
}
$d = $v->validated();
@@ -294,15 +294,15 @@ class SyncController extends Controller
$done = $d['is_done'] ?? false;
$task = IssueTask::create([
'uuid' => $uuid,
'issue_id' => $issue->id,
'title' => $d['title'],
'assigned_to' => $d['assigned_to'] ?? null,
'due_date' => $d['due_date'] ?? null,
'is_done' => $done,
'done_at' => $done ? now() : null,
'done_by' => $done ? $user->id : null,
'order' => ((int) $issue->tasks()->max('order')) + 1,
'uuid' => $uuid,
'issue_id' => $issue->id,
'title' => $d['title'],
'assigned_to' => $d['assigned_to'] ?? null,
'due_date' => $d['due_date'] ?? null,
'is_done' => $done,
'done_at' => $done ? now() : null,
'done_by' => $done ? $user->id : null,
'order' => ((int) $issue->tasks()->max('order')) + 1,
'client_updated_at' => $op['client_updated_at'] ?? null,
]);
@@ -312,14 +312,14 @@ class SyncController extends Controller
private function issueTaskUpdate(User $user, string $uuid, array $op): array
{
$v = Validator::make($op['data'], [
'id' => ['required', 'integer', 'exists:issue_tasks,id'],
'title' => ['nullable', 'string', 'max:255'],
'id' => ['required', 'integer', 'exists:issue_tasks,id'],
'title' => ['nullable', 'string', 'max:255'],
'assigned_to' => ['nullable', 'integer', 'exists:users,id'],
'due_date' => ['nullable', 'date'],
'is_done' => ['nullable', 'boolean'],
'due_date' => ['nullable', 'date'],
'is_done' => ['nullable', 'boolean'],
]);
if ($v->fails()) {
return $this->error($uuid, 'validation: ' . $v->errors()->first());
return $this->error($uuid, 'validation: '.$v->errors()->first());
}
$d = $v->validated();
@@ -354,10 +354,10 @@ class SyncController extends Controller
$v = Validator::make($op['data'], [
'issue_id' => ['required', 'integer', 'exists:issues,id'],
'body' => ['required', 'string', 'max:5000'],
'body' => ['required', 'string', 'max:5000'],
]);
if ($v->fails()) {
return $this->error($uuid, 'validation: ' . $v->errors()->first());
return $this->error($uuid, 'validation: '.$v->errors()->first());
}
$d = $v->validated();
@@ -367,10 +367,10 @@ class SyncController extends Controller
}
$comment = IssueComment::create([
'uuid' => $uuid,
'issue_id' => $issue->id,
'user_id' => $user->id,
'body' => $d['body'],
'uuid' => $uuid,
'issue_id' => $issue->id,
'user_id' => $user->id,
'body' => $d['body'],
'client_updated_at' => $op['client_updated_at'] ?? null,
]);
@@ -382,15 +382,15 @@ class SyncController extends Controller
private function featureUpdate(User $user, string $uuid, array $op): array
{
$v = Validator::make($op['data'], [
'id' => ['required', 'integer', 'exists:features,id'],
'status' => ['nullable', 'string'],
'progress' => ['nullable', 'integer', 'min:0', 'max:100'],
'responsible' => ['nullable', 'string'],
'is_active' => ['nullable', 'boolean'],
'id' => ['required', 'integer', 'exists:features,id'],
'status' => ['nullable', 'string'],
'progress' => ['nullable', 'integer', 'min:0', 'max:100'],
'responsible' => ['nullable', 'string'],
'is_active' => ['nullable', 'boolean'],
'feature_type_id' => ['nullable', 'integer', 'exists:feature_types,id'],
]);
if ($v->fails()) {
return $this->error($uuid, 'validation: ' . $v->errors()->first());
return $this->error($uuid, 'validation: '.$v->errors()->first());
}
$d = $v->validated();
@@ -424,6 +424,7 @@ class SyncController extends Controller
if (! $project) {
return false;
}
return $user->can('manage all')
|| $project->users()->where('user_id', $user->id)->exists();
}
@@ -440,11 +441,12 @@ class SyncController extends Controller
$clientAt = Carbon::parse($op['client_updated_at']);
if ($model->updated_at && $model->updated_at->gt($clientAt)) {
return [
'uuid' => $uuid,
'uuid' => $uuid,
'status' => 'conflict',
'server' => $model->fresh()->toArray(),
];
}
return null;
}
@@ -1,65 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\features;
use Illuminate\Http\Request;
class FeaturesController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
//
}
/**
* Show the form for creating a new resource.
*/
public function create()
{
//
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
//
}
/**
* Display the specified resource.
*/
public function show(features $features)
{
//
}
/**
* Show the form for editing the specified resource.
*/
public function edit(features $features)
{
//
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, features $features)
{
//
}
/**
* Remove the specified resource from storage.
*/
public function destroy(features $features)
{
//
}
}
+2 -4
View File
@@ -4,8 +4,6 @@ namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
class ProfileController extends Controller
{
@@ -26,7 +24,7 @@ class ProfileController extends Controller
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|max:255|unique:users,email,' . $user->id,
'email' => 'required|email|max:255|unique:users,email,'.$user->id,
]);
$user->update($validated);
@@ -52,4 +50,4 @@ class ProfileController extends Controller
return redirect('/');
}
}
}
+3 -2
View File
@@ -3,7 +3,6 @@
namespace App\Http\Controllers;
use App\Models\Project;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
@@ -16,6 +15,7 @@ class ProjectController extends Controller
public function index()
{
Gate::authorize('view projects');
return view('projects.index');
}
@@ -37,6 +37,7 @@ class ProjectController extends Controller
// Assign creator as supervisor in project
$project->users()->attach(Auth::id(), ['role_in_project' => 'supervisor']);
return redirect()->route('projects.map', $project)->with('success', 'Proyecto creado');
}
@@ -94,4 +95,4 @@ class ProjectController extends Controller
// (lo validaremos dentro del componente Livewire)
return view('projects.map', compact('project'));
}
}
}
@@ -1,8 +1,10 @@
<?php
namespace App\Http\Controllers;
use App\Models\Inspection;
use App\Models\Issue;
use App\Models\Project;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class ProjectReportController extends Controller
@@ -10,7 +12,7 @@ class ProjectReportController extends Controller
public function show(Project $project)
{
$user = Auth::user();
if (!$user->can('manage all') && !$project->users()->where('user_id', $user->id)->exists()) {
if (! $user->can('manage all') && ! $project->users()->where('user_id', $user->id)->exists()) {
abort(403);
}
@@ -20,11 +22,11 @@ class ProjectReportController extends Controller
->get();
$stats = [
'total_features' => $phases->flatMap(fn($p) => $p->layers)->flatMap(fn($l) => $l->features)->count(),
'completed_features' => $phases->flatMap(fn($p) => $p->layers)->flatMap(fn($l) => $l->features)->where('status', 'completed')->count(),
'total_inspections' => \App\Models\Inspection::where('project_id', $project->id)->count(),
'open_issues' => \App\Models\Issue::where('project_id', $project->id)->where('status', 'open')->count(),
'avg_progress' => round($phases->avg('progress_percent') ?? 0),
'total_features' => $phases->flatMap(fn ($p) => $p->layers)->flatMap(fn ($l) => $l->features)->count(),
'completed_features' => $phases->flatMap(fn ($p) => $p->layers)->flatMap(fn ($l) => $l->features)->where('status', 'completed')->count(),
'total_inspections' => Inspection::where('project_id', $project->id)->count(),
'open_issues' => Issue::where('project_id', $project->id)->where('status', 'open')->count(),
'avg_progress' => round($phases->avg('progress_percent') ?? 0),
];
$pdf_data = compact('project', 'phases', 'stats');
+7 -7
View File
@@ -3,12 +3,12 @@
namespace App\Http\Controllers;
use App\DTO\ReportFilters;
use App\Exports\ProjectReportExport;
use App\Models\Project;
use App\Services\ReportGenerator;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Maatwebsite\Excel\Facades\Excel;
use App\Exports\ProjectReportExport;
class ReportController extends Controller
{
@@ -19,7 +19,7 @@ class ReportController extends Controller
{
$this->authorizeProjectAccess($project);
return \Livewire\Livewire::mount(\App\Livewire\Reports\ReportBuilder::class, [
return view('reports.builder', [
'project' => $project,
]);
}
@@ -65,8 +65,8 @@ class ReportController extends Controller
protected function downloadExcel(Project $project, ReportFilters $filters, array $data)
{
$export = new ProjectReportExport($project, $filters, $data);
$filename = 'informe_' . $project->name . '_' . now()->format('Ymd_His') . '.xlsx';
$filename = 'informe_'.$project->name.'_'.now()->format('Ymd_His').'.xlsx';
return Excel::download($export, $filename);
}
@@ -76,13 +76,13 @@ class ReportController extends Controller
protected function authorizeProjectAccess(Project $project): void
{
$user = Auth::user();
if ($user->can('manage all')) {
return;
}
if (!$project->users()->where('user_id', $user->id)->exists()) {
if (! $project->users()->where('user_id', $user->id)->exists()) {
abort(403, 'No tienes acceso a este proyecto.');
}
}
}
}
@@ -2,15 +2,12 @@
namespace App\Http\Controllers\Reports;
use App\Http\Controllers\Controller;
use App\Models\Project;
use App\Models\Phase;
use App\Models\Inspection;
use Maatwebsite\Excel\Facades\Excel;
use App\Exports\ProjectsExport;
use App\Exports\PhasesExport;
use App\Exports\InspectionsExport;
use App\Exports\PhasesExport;
use App\Exports\ProjectsExport;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Maatwebsite\Excel\Facades\Excel;
class ExportController extends Controller
{
+3 -2
View File
@@ -2,9 +2,9 @@
namespace App\Http\Middleware;
use App\Models\Project;
use Closure;
use Illuminate\Http\Request;
use App\Models\Project;
class BindProjectModel
{
@@ -18,6 +18,7 @@ class BindProjectModel
$route->setParameter('project', $project);
}
}
return $next($request);
}
}
}
+4 -4
View File
@@ -27,7 +27,7 @@ class SetLocale
}
// 2. From session
if (!$locale && Session::has('locale')) {
if (! $locale && Session::has('locale')) {
$sessionLocale = Session::get('locale');
if (in_array($sessionLocale, $allowedLocales)) {
$locale = $sessionLocale;
@@ -35,7 +35,7 @@ class SetLocale
}
// 3. From browser Accept-Language
if (!$locale) {
if (! $locale) {
$browserLang = substr($request->server('HTTP_ACCEPT_LANGUAGE', 'en'), 0, 2);
if (in_array($browserLang, $allowedLocales)) {
$locale = $browserLang;
@@ -43,7 +43,7 @@ class SetLocale
}
// 4. Default to app locale
if (!$locale) {
if (! $locale) {
$locale = config('app.locale', 'en');
}
@@ -52,4 +52,4 @@ class SetLocale
return $next($request);
}
}
}
+5 -5
View File
@@ -22,7 +22,7 @@ class CaptureDailyProgressSnapshot implements ShouldQueue
public function handle(): void
{
$date = $this->snapshotDate ? Carbon::parse($this->snapshotDate) : Carbon::today();
Log::info('Starting daily progress snapshot capture', ['date' => $date->toDateString()]);
$captured = 0;
@@ -45,7 +45,7 @@ class CaptureDailyProgressSnapshot implements ShouldQueue
private function capturePhaseSnapshots(Carbon $date): int
{
$count = 0;
Phase::with(['project'])->chunkById(100, function ($phases) use ($date, &$count) {
foreach ($phases as $phase) {
// Skip if snapshot already exists for this date
@@ -87,7 +87,7 @@ class CaptureDailyProgressSnapshot implements ShouldQueue
private function captureFeatureSnapshots(Carbon $date): int
{
$count = 0;
Feature::with(['layer.phase.project'])->chunkById(200, function ($features) use ($date, &$count) {
foreach ($features as $feature) {
if (ProgressSnapshot::where('trackable_type', Feature::class)
@@ -131,7 +131,7 @@ class CaptureDailyProgressSnapshot implements ShouldQueue
private function captureTaskSnapshots(Carbon $date): int
{
$count = 0;
Task::with(['project', 'phase'])->chunkById(200, function ($tasks) use ($date, &$count) {
foreach ($tasks as $task) {
if (ProgressSnapshot::where('trackable_type', Task::class)
@@ -171,4 +171,4 @@ class CaptureDailyProgressSnapshot implements ShouldQueue
return $count;
}
}
}
+8 -6
View File
@@ -2,9 +2,9 @@
namespace App\Livewire\Admin;
use Livewire\Component;
use Livewire\Attributes\Layout;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
@@ -14,9 +14,11 @@ class RoleForm extends Component
public ?Role $role = null;
public string $name = '';
public string $description = '';
private const PROTECTED_ROLES = ['Admin'];
private const CORE_PERMISSION = 'manage all';
public function mount(?Role $role = null): void
@@ -24,8 +26,8 @@ class RoleForm extends Component
abort_unless(Auth::user()?->can('manage roles'), 403);
if ($role && $role->exists) {
$this->role = $role;
$this->name = $role->name;
$this->role = $role;
$this->name = $role->name;
$this->description = $role->description ?? '';
}
}
@@ -33,7 +35,7 @@ class RoleForm extends Component
public function save()
{
$this->validate([
'name' => 'required|string|max:50|unique:roles,name' . ($this->role ? ',' . $this->role->id : ''),
'name' => 'required|string|max:50|unique:roles,name'.($this->role ? ','.$this->role->id : ''),
'description' => 'nullable|string|max:255',
], [], ['name' => 'nombre', 'description' => 'descripción']);
@@ -46,7 +48,7 @@ class RoleForm extends Component
$this->role->save();
} else {
Role::create([
'name' => $this->name,
'name' => $this->name,
'description' => $this->description ?: null,
]);
}
+11 -6
View File
@@ -2,21 +2,23 @@
namespace App\Livewire\Admin;
use Livewire\Component;
use Livewire\Attributes\Layout;
use Illuminate\Support\Facades\Auth;
use Spatie\Permission\Models\Role;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
#[Layout('layouts.app')]
class RolePermissionManager extends Component
{
public string $newRole = '';
public string $newPermission = '';
/** Roles that must not be deleted or stripped of core powers. */
private const PROTECTED_ROLES = ['Admin'];
private const CORE_PERMISSION = 'manage all';
public function mount(): void
@@ -36,7 +38,8 @@ class RolePermissionManager extends Component
if ($role->hasPermissionTo($permissionName)) {
// Admin must always keep the core permission
if ($role->name === 'Admin' && $permissionName === self::CORE_PERMISSION) {
$this->dispatch('notify', "El rol Admin no puede perder '" . self::CORE_PERMISSION . "'.");
$this->dispatch('notify', "El rol Admin no puede perder '".self::CORE_PERMISSION."'.");
return;
}
$role->revokePermissionTo($permissionName);
@@ -66,6 +69,7 @@ class RolePermissionManager extends Component
if (in_array($role->name, self::PROTECTED_ROLES, true)) {
$this->dispatch('notify', "El rol '{$role->name}' está protegido y no se puede borrar.");
return;
}
@@ -91,7 +95,8 @@ class RolePermissionManager extends Component
$permission = Permission::findOrFail($permissionId);
if ($permission->name === self::CORE_PERMISSION) {
$this->dispatch('notify', "El permiso '" . self::CORE_PERMISSION . "' está protegido y no se puede borrar.");
$this->dispatch('notify', "El permiso '".self::CORE_PERMISSION."' está protegido y no se puede borrar.");
return;
}
@@ -103,7 +108,7 @@ class RolePermissionManager extends Component
public function render()
{
return view('livewire.roles.role-permission-manager', [
'roles' => Role::with('permissions')->orderBy('name')->get(),
'roles' => Role::with('permissions')->orderBy('name')->get(),
'permissions' => Permission::orderBy('name')->get(),
]);
}
+39 -37
View File
@@ -2,9 +2,9 @@
namespace App\Livewire\Admin;
use Illuminate\Database\Eloquent\Builder;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
use Illuminate\Database\Eloquent\Builder;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
@@ -17,8 +17,8 @@ class RoleTable extends DataTableComponent
public function configure(): void
{
$this->setPrimaryKey('id')
->setDefaultSort('name', 'asc')
->setSortingPillsEnabled(false);
->setDefaultSort('name', 'asc')
->setSortingPillsEnabled(false);
}
public function builder(): Builder
@@ -30,48 +30,48 @@ class RoleTable extends DataTableComponent
{
return [
Column::make(__('Name'), 'name')
->sortable()
->searchable()
->format(fn ($value, $row) =>
'<a href="'.route('admin.roles.show', $row->id).'" class="font-semibold text-primary hover:underline" wire:navigate>'.e($value).'</a>'
. (in_array($row->name, self::PROTECTED_ROLES, true) ? ' <span class="badge badge-ghost badge-xs">protegido</span>' : '')
)
->html(),
->sortable()
->searchable()
->format(fn ($value, $row) => '<a href="'.route('admin.roles.show', $row->id).'" class="font-semibold text-primary hover:underline" wire:navigate>'.e($value).'</a>'
.(in_array($row->name, self::PROTECTED_ROLES, true) ? ' <span class="badge badge-ghost badge-xs">protegido</span>' : '')
)
->html(),
Column::make(__('Description'), 'description')
->sortable()
->searchable()
->format(fn ($value) => $value
? '<span class="text-sm text-gray-500">'.e($value).'</span>'
: '<span class="text-gray-300">—</span>')
->html(),
->sortable()
->searchable()
->format(fn ($value) => $value
? '<span class="text-sm text-gray-500">'.e($value).'</span>'
: '<span class="text-gray-300">—</span>')
->html(),
Column::make(__('Permissions'))
->label(fn ($row) => '<span class="badge badge-outline badge-sm">'.(int) $row->permissions_count.'</span>')
->html(),
->label(fn ($row) => '<span class="badge badge-outline badge-sm">'.(int) $row->permissions_count.'</span>')
->html(),
Column::make(__('Users'))
->label(fn ($row) => '<span class="badge badge-ghost badge-sm">'.(int) $row->users_count.'</span>')
->html(),
->label(fn ($row) => '<span class="badge badge-ghost badge-sm">'.(int) $row->users_count.'</span>')
->html(),
Column::make(__('Actions'))
->label(function ($row) {
$show = route('admin.roles.show', $row->id);
$edit = route('admin.roles.edit', $row->id);
$eye = '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>';
$pencil = '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>';
$trash = '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>';
->label(function ($row) {
$show = route('admin.roles.show', $row->id);
$edit = route('admin.roles.edit', $row->id);
$eye = '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>';
$pencil = '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>';
$trash = '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>';
$html = '<div class="flex items-center gap-1">';
$html .= '<a href="'.$show.'" class="btn btn-xs btn-ghost" title="Ver" wire:navigate>'.$eye.'</a>';
$html .= '<a href="'.$edit.'" class="btn btn-xs btn-ghost text-info" title="Editar" wire:navigate>'.$pencil.'</a>';
if (! in_array($row->name, self::PROTECTED_ROLES, true)) {
$html .= '<button wire:click="deleteRole('.$row->id.')" wire:confirm="¿Eliminar el rol \''.e($row->name).'\'?" class="btn btn-xs btn-ghost text-error" title="Eliminar">'.$trash.'</button>';
}
$html .= '</div>';
return $html;
})
->html(),
$html = '<div class="flex items-center gap-1">';
$html .= '<a href="'.$show.'" class="btn btn-xs btn-ghost" title="Ver" wire:navigate>'.$eye.'</a>';
$html .= '<a href="'.$edit.'" class="btn btn-xs btn-ghost text-info" title="Editar" wire:navigate>'.$pencil.'</a>';
if (! in_array($row->name, self::PROTECTED_ROLES, true)) {
$html .= '<button wire:click="deleteRole('.$row->id.')" wire:confirm="¿Eliminar el rol \''.e($row->name).'\'?" class="btn btn-xs btn-ghost text-error" title="Eliminar">'.$trash.'</button>';
}
$html .= '</div>';
return $html;
})
->html(),
];
}
@@ -84,7 +84,9 @@ class RoleTable extends DataTableComponent
{
$roles = Role::whereIn('id', $this->selected)->get();
foreach ($roles as $role) {
if (in_array($role->name, self::PROTECTED_ROLES, true)) continue;
if (in_array($role->name, self::PROTECTED_ROLES, true)) {
continue;
}
$role->delete();
}
$this->clearSelected();
+16 -9
View File
@@ -2,23 +2,26 @@
namespace App\Livewire\Admin;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use App\Models\User;
use Spatie\Permission\Models\Role;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
#[Layout('layouts.app')]
class RoleView extends Component
{
public Role $role;
public string $tab = 'ficha'; // ficha | permisos
public $newUserId = '';
private const PROTECTED_ROLES = ['Admin'];
private const CORE_PERMISSION = 'manage all';
public function mount(Role $role): void
@@ -38,7 +41,8 @@ class RoleView extends Component
if ($this->role->name === 'Admin'
&& $permissionName === self::CORE_PERMISSION
&& $this->role->hasPermissionTo($permissionName)) {
$this->dispatch('notify', "El rol Admin no puede perder '" . self::CORE_PERMISSION . "'.");
$this->dispatch('notify', "El rol Admin no puede perder '".self::CORE_PERMISSION."'.");
return;
}
@@ -91,6 +95,7 @@ class RoleView extends Component
{
if (in_array($this->role->name, self::PROTECTED_ROLES, true)) {
$this->dispatch('notify', "El rol '{$this->role->name}' está protegido y no se puede borrar.");
return;
}
$this->role->delete();
@@ -107,6 +112,7 @@ class RoleView extends Component
return 'General';
}
$resource = Str::afterLast($name, ' ');
return Str::headline($resource ?: 'General');
}
@@ -126,6 +132,7 @@ class RoleView extends Component
->groupBy(fn ($perm) => $perm->group ?: $this->sectionFor($perm->name))
->sortBy(function ($perms, $section) use ($order) {
$i = array_search($section, $order, true);
return $i === false ? 999 : $i;
});
@@ -133,11 +140,11 @@ class RoleView extends Component
->orderBy('first_name')->orderBy('name')->get();
return view('livewire.roles.role-view', [
'users' => $users,
'users' => $users,
'availableUsers' => $availableUsers,
'grouped' => $grouped,
'rolePerms' => $this->role->permissions->pluck('name')->toArray(),
'isProtected' => in_array($this->role->name, self::PROTECTED_ROLES, true),
'grouped' => $grouped,
'rolePerms' => $this->role->permissions->pluck('name')->toArray(),
'isProtected' => in_array($this->role->name, self::PROTECTED_ROLES, true),
]);
}
}
+18 -18
View File
@@ -2,20 +2,20 @@
namespace App\Livewire\Client;
use Livewire\Component;
use App\Models\Project;
use App\Models\Phase;
use App\Models\Inspection;
use App\Models\Feature;
use App\Models\ChangeOrder;
use Carbon\Carbon;
use App\Models\Project;
use Livewire\Component;
class ClientProjects extends Component
{
public $projects = [];
public $selectedProject = null;
public $projectDetails = [];
public $galleryImages = [];
public $changeOrders = [];
public function mount()
@@ -29,7 +29,7 @@ class ClientProjects extends Component
$user = auth()->user();
$this->projects = $user->projects()
->wherePivot('role_in_project', 'client')
->with(['phases' => function($query) {
->with(['phases' => function ($query) {
$query->select('id', 'project_id', 'name', 'progress_percent');
}])
->get()
@@ -44,17 +44,17 @@ class ClientProjects extends Component
public function loadProjectDetails()
{
if (!$this->selectedProject) {
if (! $this->selectedProject) {
return;
}
$project = Project::with([
'phases.features',
'inspections.template',
'changeOrders' // Load change orders for this project
'changeOrders', // Load change orders for this project
])->find($this->selectedProject);
if (!$project) {
if (! $project) {
return;
}
@@ -75,11 +75,11 @@ class ClientProjects extends Component
->latest()
->take(3)
->get()
->map(function($media) {
->map(function ($media) {
return [
'url' => $media->url,
'title' => $media->name,
'date' => $media->created_at->format('d/m/Y')
'date' => $media->created_at->format('d/m/Y'),
];
})
->toArray();
@@ -93,18 +93,18 @@ class ClientProjects extends Component
[
'url' => 'https://via.placeholder.com/400x300?text=Avance+1',
'title' => 'Avance inicial',
'date' => now()->subDays(30)->format('d/m/Y')
'date' => now()->subDays(30)->format('d/m/Y'),
],
[
'url' => 'https://via.placeholder.com/400x300?text=Avance+2',
'title' => 'Estructura levantada',
'date' => now()->subDays(15)->format('d/m/Y')
'date' => now()->subDays(15)->format('d/m/Y'),
],
[
'url' => 'https://via.placeholder.com/400x300?text=Avance+3',
'title' => 'Instalaciones',
'date' => now()->subDays(5)->format('d/m/Y')
]
'date' => now()->subDays(5)->format('d/m/Y'),
],
];
}
@@ -112,14 +112,14 @@ class ClientProjects extends Component
$this->changeOrders = $project->changeOrders
->orderBy('requested_at', 'desc')
->get()
->map(function($order) {
->map(function ($order) {
return [
'id' => $order->id,
'title' => $order->title,
'description' => $order->description,
'status' => $order->status,
'requested_at' => $order->requested_at->format('d/m/Y'),
'amount' => $order->amount
'amount' => $order->amount,
];
})
->toArray();
+3 -3
View File
@@ -2,10 +2,10 @@
namespace App\Livewire\Common;
use Livewire\Component;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
use Livewire\Component;
class LanguageSwitcher extends Component
{
@@ -26,7 +26,7 @@ class LanguageSwitcher extends Component
public function updatedCurrentLocale(string $locale): void
{
if (!in_array($locale, ['en', 'es', 'fr', 'ru'])) {
if (! in_array($locale, ['en', 'es', 'fr', 'ru'])) {
return;
}
@@ -51,4 +51,4 @@ class LanguageSwitcher extends Component
{
return view('livewire.common.language-switcher');
}
}
}
+3 -1
View File
@@ -2,13 +2,15 @@
namespace App\Livewire\Common;
use Livewire\Component;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class NotificationBell extends Component
{
public $notifications = [];
public $unreadCount = 0;
public $showDropdown = false;
public function mount()
+47 -36
View File
@@ -2,11 +2,11 @@
namespace App\Livewire\Companies;
use Livewire\Component;
use Livewire\WithFileUploads;
use Livewire\Attributes\Layout;
use App\Models\Company;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithFileUploads;
#[Layout('layouts.app')]
class CompanyForm extends Component
@@ -16,50 +16,61 @@ class CompanyForm extends Component
public ?Company $company = null;
// Form fields
public string $name = '';
public string $apodo = '';
public string $tax_id = '';
public string $estado = 'activo';
public string $type = 'other';
public string $name = '';
public string $apodo = '';
public string $tax_id = '';
public string $estado = 'activo';
public string $type = 'other';
public string $address = '';
public string $phone = '';
public string $email = '';
public string $phone = '';
public string $email = '';
public string $website = '';
public string $notes = '';
public string $notes = '';
public $logo = null;
public function mount(?Company $company = null): void
{
if ($company && $company->exists) {
$this->company = $company;
$this->name = $company->name;
$this->apodo = $company->apodo ?? '';
$this->tax_id = $company->tax_id ?? '';
$this->estado = $company->estado ?? 'activo';
$this->type = $company->type ?? 'other';
$this->name = $company->name;
$this->apodo = $company->apodo ?? '';
$this->tax_id = $company->tax_id ?? '';
$this->estado = $company->estado ?? 'activo';
$this->type = $company->type ?? 'other';
$this->address = $company->address ?? '';
$this->phone = $company->phone ?? '';
$this->email = $company->email ?? '';
$this->phone = $company->phone ?? '';
$this->email = $company->email ?? '';
$this->website = $company->website ?? '';
$this->notes = $company->notes ?? '';
$this->notes = $company->notes ?? '';
}
}
protected function rules(): array
{
$id = $this->company?->id ?? 'NULL';
return [
'name' => 'required|string|max:255',
'apodo' => 'nullable|string|max:100',
'tax_id' => "nullable|string|max:50|unique:companies,tax_id,{$id}",
'estado' => 'required|in:activo,inactivo,suspendido',
'type' => 'required|in:owner,constructor,subcontractor,consultant,supplier,other',
'name' => 'required|string|max:255',
'apodo' => 'nullable|string|max:100',
'tax_id' => "nullable|string|max:50|unique:companies,tax_id,{$id}",
'estado' => 'required|in:activo,inactivo,suspendido',
'type' => 'required|in:owner,constructor,subcontractor,consultant,supplier,other',
'address' => 'nullable|string',
'phone' => 'nullable|string|max:30',
'email' => 'nullable|email|max:255',
'phone' => 'nullable|string|max:30',
'email' => 'nullable|email|max:255',
'website' => 'nullable|url|max:255',
'notes' => 'nullable|string',
'logo' => 'nullable|image|max:2048',
'notes' => 'nullable|string',
'logo' => 'nullable|image|max:2048',
];
}
@@ -68,16 +79,16 @@ class CompanyForm extends Component
$this->validate();
$data = [
'name' => $this->name,
'apodo' => $this->apodo ?: null,
'tax_id' => $this->tax_id ?: null,
'estado' => $this->estado,
'type' => $this->type,
'name' => $this->name,
'apodo' => $this->apodo ?: null,
'tax_id' => $this->tax_id ?: null,
'estado' => $this->estado,
'type' => $this->type,
'address' => $this->address ?: null,
'phone' => $this->phone ?: null,
'email' => $this->email ?: null,
'phone' => $this->phone ?: null,
'email' => $this->email ?: null,
'website' => $this->website ?: null,
'notes' => $this->notes ?: null,
'notes' => $this->notes ?: null,
];
if ($this->logo) {
+15 -13
View File
@@ -2,29 +2,31 @@
namespace App\Livewire\Companies;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\Company;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class CompanyManagement extends Component
{
public string $search = '';
public string $filterType = '';
public string $search = '';
public string $filterType = '';
public string $filterEstado = '';
public function getCompaniesProperty()
{
return Company::when($this->search, function ($q) {
$s = '%' . $this->search . '%';
$q->where(fn($q2) => $q2
->where('name', 'like', $s)
->orWhere('apodo', 'like', $s)
->orWhere('tax_id', 'like', $s));
})
->when($this->filterType, fn($q) => $q->where('type', $this->filterType))
->when($this->filterEstado, fn($q) => $q->where('estado', $this->filterEstado))
$s = '%'.$this->search.'%';
$q->where(fn ($q2) => $q2
->where('name', 'like', $s)
->orWhere('apodo', 'like', $s)
->orWhere('tax_id', 'like', $s));
})
->when($this->filterType, fn ($q) => $q->where('type', $this->filterType))
->when($this->filterEstado, fn ($q) => $q->where('estado', $this->filterEstado))
->withCount('projects')
->orderBy('name')
->get();
@@ -45,7 +47,7 @@ class CompanyManagement extends Component
return response()->streamDownload(function () use ($companies) {
$handle = fopen('php://output', 'w');
fprintf($handle, chr(0xEF) . chr(0xBB) . chr(0xBF));
fprintf($handle, chr(0xEF).chr(0xBB).chr(0xBF));
fputcsv($handle, ['Nombre', 'Apodo', 'NIF/Tax ID', 'Tipo', 'Estado', 'Dirección', 'Teléfono', 'Email', 'Website', 'Proyectos', 'Creación']);
foreach ($companies as $c) {
fputcsv($handle, [
+100 -93
View File
@@ -2,13 +2,12 @@
namespace App\Livewire\Companies;
use App\Models\Company;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Storage;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
use Rappasoft\LaravelLivewireTables\Views\Filters\SelectFilter;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use App\Models\Company;
class CompanyTable extends DataTableComponent
{
@@ -17,17 +16,17 @@ class CompanyTable extends DataTableComponent
public function configure(): void
{
$this->setPrimaryKey('id')
->setDefaultSort('name', 'asc')
->setSortingPillsEnabled(false)
->setAdditionalSelects([
'companies.id as id',
'companies.apodo as apodo',
'companies.tax_id as tax_id',
'companies.phone as phone',
'companies.email as email',
'companies.logo_path as logo_path',
'companies.created_at as created_at',
]);
->setDefaultSort('name', 'asc')
->setSortingPillsEnabled(false)
->setAdditionalSelects([
'companies.id as id',
'companies.apodo as apodo',
'companies.tax_id as tax_id',
'companies.phone as phone',
'companies.email as email',
'companies.logo_path as logo_path',
'companies.created_at as created_at',
]);
}
public function builder(): Builder
@@ -39,100 +38,108 @@ class CompanyTable extends DataTableComponent
{
return [
Column::make('Empresa', 'name')
->sortable()
->searchable()
->format(function ($value, $row) {
$logoHtml = '';
if ($row->logo_path && Storage::disk('public')->exists($row->logo_path)) {
$url = Storage::disk('public')->url($row->logo_path);
$logoHtml = '<img src="'.e($url).'" class="w-9 h-9 rounded object-contain border border-base-300 shrink-0" />';
} else {
$logoHtml = '<div class="w-9 h-9 rounded bg-base-200 flex items-center justify-center shrink-0 text-gray-400">
->sortable()
->searchable()
->format(function ($value, $row) {
$logoHtml = '';
if ($row->logo_path && Storage::disk('public')->exists($row->logo_path)) {
$url = Storage::disk('public')->url($row->logo_path);
$logoHtml = '<img src="'.e($url).'" class="w-9 h-9 rounded object-contain border border-base-300 shrink-0" />';
} else {
$logoHtml = '<div class="w-9 h-9 rounded bg-base-200 flex items-center justify-center shrink-0 text-gray-400">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-2 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg>
</div>';
}
$html = '<div class="flex items-center gap-3">'.$logoHtml.'<div>';
$html .= '<p class="font-semibold text-sm leading-tight">'.e($value).'</p>';
if ($row->apodo) $html .= '<p class="text-xs text-gray-500">'.e($row->apodo).'</p>';
if ($row->tax_id) $html .= '<p class="text-xs text-gray-400">NIF: '.e($row->tax_id).'</p>';
$html .= '</div></div>';
return $html;
})
->html(),
}
$html = '<div class="flex items-center gap-3">'.$logoHtml.'<div>';
$html .= '<p class="font-semibold text-sm leading-tight">'.e($value).'</p>';
if ($row->apodo) {
$html .= '<p class="text-xs text-gray-500">'.e($row->apodo).'</p>';
}
if ($row->tax_id) {
$html .= '<p class="text-xs text-gray-400">NIF: '.e($row->tax_id).'</p>';
}
$html .= '</div></div>';
return $html;
})
->html(),
Column::make('Tipo', 'type')
->sortable()
->format(function ($value) {
$map = [
'owner' => ['badge-success', 'Promotor'],
'constructor' => ['badge-primary', 'Constructor'],
'subcontractor' => ['badge-secondary', 'Subcontratista'],
'consultant' => ['badge-info', 'Consultor'],
'supplier' => ['badge-warning', 'Proveedor'],
];
[$cls, $label] = $map[$value] ?? ['badge-ghost', 'Otro'];
return '<span class="badge badge-sm '.$cls.'">'.$label.'</span>';
})
->html(),
->sortable()
->format(function ($value) {
$map = [
'owner' => ['badge-success', 'Promotor'],
'constructor' => ['badge-primary', 'Constructor'],
'subcontractor' => ['badge-secondary', 'Subcontratista'],
'consultant' => ['badge-info', 'Consultor'],
'supplier' => ['badge-warning', 'Proveedor'],
];
[$cls, $label] = $map[$value] ?? ['badge-ghost', 'Otro'];
return '<span class="badge badge-sm '.$cls.'">'.$label.'</span>';
})
->html(),
Column::make('Contacto', 'phone')
->format(function ($value, $row) {
$html = '';
if ($row->phone) {
$html .= '<div class="flex items-center gap-1 text-sm">
->format(function ($value, $row) {
$html = '';
if ($row->phone) {
$html .= '<div class="flex items-center gap-1 text-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 opacity-50 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"/></svg>
'.e($row->phone).'</div>';
}
if ($row->email) {
$html .= '<div class="flex items-center gap-1 text-xs text-gray-500 max-w-[180px] truncate">
}
if ($row->email) {
$html .= '<div class="flex items-center gap-1 text-xs text-gray-500 max-w-[180px] truncate">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 opacity-50 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
'.e($row->email).'</div>';
}
return $html ?: '<span class="text-gray-300">—</span>';
})
->html(),
}
return $html ?: '<span class="text-gray-300">—</span>';
})
->html(),
Column::make('Estado', 'estado')
->sortable()
->format(function ($value) {
$map = [
'activo' => ['badge-success', 'Activo'],
'inactivo' => ['badge-ghost', 'Inactivo'],
'suspendido' => ['badge-error', 'Suspendido'],
];
[$cls, $label] = $map[$value ?? 'activo'] ?? ['badge-ghost', ucfirst($value ?? 'activo')];
return '<span class="badge badge-sm '.$cls.'">'.$label.'</span>';
})
->html(),
->sortable()
->format(function ($value) {
$map = [
'activo' => ['badge-success', 'Activo'],
'inactivo' => ['badge-ghost', 'Inactivo'],
'suspendido' => ['badge-error', 'Suspendido'],
];
[$cls, $label] = $map[$value ?? 'activo'] ?? ['badge-ghost', ucfirst($value ?? 'activo')];
return '<span class="badge badge-sm '.$cls.'">'.$label.'</span>';
})
->html(),
Column::make('Proyectos')
->label(fn ($row) =>
'<span class="badge badge-outline badge-sm">'.(int)($row->projects_count ?? 0).'</span>'
)
->html(),
->label(fn ($row) => '<span class="badge badge-outline badge-sm">'.(int) ($row->projects_count ?? 0).'</span>'
)
->html(),
Column::make('Acciones')
->label(function ($row) {
$ver = route('companies.show', $row->id);
$editar = route('companies.edit', $row->id);
$name = addslashes($row->name);
->label(function ($row) {
$ver = route('companies.show', $row->id);
$editar = route('companies.edit', $row->id);
$name = addslashes($row->name);
$html = '<div class="flex items-center justify-end gap-1">';
$html .= '<a href="'.$ver.'" class="btn btn-xs btn-outline" title="Ver" wire:navigate>
$html = '<div class="flex items-center justify-end gap-1">';
$html .= '<a href="'.$ver.'" class="btn btn-xs btn-outline" title="Ver" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</a>';
$html .= '<a href="'.$editar.'" class="btn btn-xs btn-outline btn-info" title="Editar" wire:navigate>
$html .= '<a href="'.$editar.'" class="btn btn-xs btn-outline btn-info" title="Editar" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</a>';
$html .= '<button wire:click="deleteCompany('.$row->id.')"
$html .= '<button wire:click="deleteCompany('.$row->id.')"
wire:confirm="¿Eliminar \''.$name.'\'? Esta acción no se puede deshacer."
class="btn btn-xs btn-outline btn-error" title="Eliminar">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>';
$html .= '</div>';
return $html;
})
->html(),
$html .= '</div>';
return $html;
})
->html(),
];
}
@@ -141,21 +148,21 @@ class CompanyTable extends DataTableComponent
return [
SelectFilter::make('Tipo', 'type')
->options([
'' => 'Tipo: todos',
'owner' => 'Promotor',
'constructor' => 'Constructor',
'' => 'Tipo: todos',
'owner' => 'Promotor',
'constructor' => 'Constructor',
'subcontractor' => 'Subcontratista',
'consultant' => 'Consultor',
'supplier' => 'Proveedor',
'other' => 'Otro',
'consultant' => 'Consultor',
'supplier' => 'Proveedor',
'other' => 'Otro',
])
->filter(fn (Builder $query, string $value) => $query->where('type', $value)),
SelectFilter::make('Estado', 'estado')
->options([
'' => 'Estado: todos',
'activo' => 'Activo',
'inactivo' => 'Inactivo',
'' => 'Estado: todos',
'activo' => 'Activo',
'inactivo' => 'Inactivo',
'suspendido' => 'Suspendido',
])
->filter(fn (Builder $query, string $value) => $query->where('estado', $value)),
+28 -20
View File
@@ -2,45 +2,53 @@
namespace App\Livewire\Companies;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\Company;
use App\Models\Issue;
use App\Models\Project;
use App\Models\User;
use App\Models\Issue;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class CompanyView extends Component
{
public Company $company;
public string $activeTab = 'summary';
public string $activeTab = 'summary';
// Projects tab
public ?int $addProjectId = null;
public string $addProjectRole = '';
public ?int $addProjectId = null;
public string $addProjectRole = '';
public $availableProjects;
// People tab
public ?int $assignUserId = null;
public ?int $assignUserId = null;
public $assignableUsers;
// Notes tab
public string $notes = '';
public bool $editingNotes = false;
public string $notes = '';
public bool $editingNotes = false;
// Stats (computed once in mount, refreshed on mutations)
public int $usersCount = 0;
public int $projectsCount = 0;
public float $avgProgress = 0.0;
public int $openIssues = 0;
public int $usersCount = 0;
public int $projectsCount = 0;
public float $avgProgress = 0.0;
public int $openIssues = 0;
public function mount(Company $company): void
{
abort_unless(Auth::user()->can('view companies'), 403);
$this->company = $company->load(['users.roles', 'projects.phases']);
$this->notes = $company->notes ?? '';
$this->notes = $company->notes ?? '';
$this->loadAvailableProjects();
$this->loadAssignableUsers();
@@ -60,16 +68,16 @@ class CompanyView extends Component
{
$this->assignableUsers = User::where(function ($q) {
$q->where('company_id', '!=', $this->company->id)
->orWhereNull('company_id');
->orWhereNull('company_id');
})->orderBy('name')->get();
}
private function computeStats(): void
{
$this->usersCount = $this->company->users->count();
$this->usersCount = $this->company->users->count();
$this->projectsCount = $this->company->projects->count();
$this->avgProgress = round(
$this->company->projects->flatMap(fn($p) => $p->phases)->avg('progress_percent') ?? 0
$this->avgProgress = round(
$this->company->projects->flatMap(fn ($p) => $p->phases)->avg('progress_percent') ?? 0
);
$userIds = $this->company->users->pluck('id');
$this->openIssues = $userIds->isNotEmpty()
@@ -89,7 +97,7 @@ class CompanyView extends Component
public function assignProject(): void
{
$this->validate([
'addProjectId' => 'required|exists:projects,id',
'addProjectId' => 'required|exists:projects,id',
'addProjectRole' => 'required|string|max:150',
], [], ['addProjectId' => 'proyecto', 'addProjectRole' => 'rol en proyecto']);
@@ -98,7 +106,7 @@ class CompanyView extends Component
]);
$this->company->load('projects.phases');
$this->addProjectId = null;
$this->addProjectId = null;
$this->addProjectRole = '';
$this->loadAvailableProjects();
$this->computeStats();
@@ -18,29 +18,35 @@ class GlobalTemplateManager extends Component
public $templates;
public $editingTemplate = null;
public $showForm = false;
public $form = [
'name' => '',
'name' => '',
'description' => '',
'fields' => [],
'fields' => [],
];
// ── Importar desde CSV/Excel ───────────────────────────────────────────
public $showImportFileModal = false;
public $importFile = null;
public $importPreviewFields = [];
public $importTemplateName = '';
public $importError = '';
public $showImportFileModal = false;
public $importFile = null;
public $importPreviewFields = [];
public $importTemplateName = '';
public $importError = '';
public $fieldTypes = [
'text' => 'Texto corto',
'textarea' => 'Texto largo',
'integer' => 'Número entero',
'decimal' => 'Número decimal',
'text' => 'Texto corto',
'textarea' => 'Texto largo',
'integer' => 'Número entero',
'decimal' => 'Número decimal',
'percentage' => 'Porcentaje (0-100)',
'boolean' => 'Sí/No (checkbox)',
'date' => 'Fecha',
'select' => 'Lista desplegable',
'boolean' => 'Sí/No (checkbox)',
'date' => 'Fecha',
'select' => 'Lista desplegable',
];
public function mount()
@@ -66,9 +72,9 @@ class GlobalTemplateManager extends Component
{
$template = InspectionTemplate::findOrFail($id);
$this->form = [
'name' => $template->name,
'name' => $template->name,
'description' => $template->description ?? '',
'fields' => $template->fields ?? [],
'fields' => $template->fields ?? [],
];
$this->editingTemplate = $id;
$this->showForm = true;
@@ -83,9 +89,9 @@ class GlobalTemplateManager extends Component
public function resetForm()
{
$this->form = [
'name' => '',
'name' => '',
'description' => '',
'fields' => [],
'fields' => [],
];
$this->editingTemplate = null;
}
@@ -93,17 +99,17 @@ class GlobalTemplateManager extends Component
public function addField()
{
$this->form['fields'][] = [
'group' => '',
'name' => '',
'label' => '',
'group' => '',
'name' => '',
'label' => '',
'question' => '',
'type' => 'text',
'options' => '',
'type' => 'text',
'options' => '',
'required' => false,
'min' => null,
'max' => null,
'step' => null,
'help' => '',
'min' => null,
'max' => null,
'step' => null,
'help' => '',
];
}
@@ -116,15 +122,15 @@ class GlobalTemplateManager extends Component
public function saveTemplate()
{
$this->validate([
'form.name' => 'required|string|max:255',
'form.name' => 'required|string|max:255',
'form.fields' => 'array',
]);
$data = [
'name' => $this->form['name'],
'name' => $this->form['name'],
'description' => $this->form['description'],
'project_id' => null,
'fields' => array_values($this->form['fields']),
'project_id' => null,
'fields' => array_values($this->form['fields']),
];
if ($this->editingTemplate) {
@@ -153,10 +159,10 @@ class GlobalTemplateManager extends Component
public function openImportFileModal()
{
$this->importFile = null;
$this->importFile = null;
$this->importPreviewFields = [];
$this->importTemplateName = '';
$this->importError = '';
$this->importTemplateName = '';
$this->importError = '';
$this->showImportFileModal = true;
}
@@ -164,31 +170,34 @@ class GlobalTemplateManager extends Component
{
$headers = ['Content-Type' => 'text/csv'];
$csv = "\xEF\xBB\xBF" // BOM UTF-8 (para que Excel respete los acentos)
. "group,name,label,question,type,required,options,min,max,step,help\n"
. "Dimensiones,altura,Altura (m),¿Cumple la altura de proyecto?,decimal,1,,0,100,0.1,Medir con flexómetro\n"
. "Dimensiones,material,Material,,select,1,Hormigón|Acero|Madera,,,,\n"
. "Acabados,ok,¿Acabado correcto?,,boolean,1,,,,,\n";
return response()->streamDownload(fn () => print($csv), 'plantilla_ejemplo.csv', $headers);
."group,name,label,question,type,required,options,min,max,step,help\n"
."Dimensiones,altura,Altura (m),¿Cumple la altura de proyecto?,decimal,1,,0,100,0.1,Medir con flexómetro\n"
."Dimensiones,material,Material,,select,1,Hormigón|Acero|Madera,,,,\n"
."Acabados,ok,¿Acabado correcto?,,boolean,1,,,,,\n";
return response()->streamDownload(fn () => print ($csv), 'plantilla_ejemplo.csv', $headers);
}
public function parseImportFile()
{
$this->importError = '';
$this->validate([
'importFile' => 'required|file|mimes:csv,txt,xlsx,xls|max:5120',
'importFile' => 'required|file|mimes:csv,txt,xlsx,xls|max:5120',
'importTemplateName' => 'required|string|max:255',
]);
try {
$rows = $this->readFileRows();
} catch (\Throwable $e) {
$this->importError = 'No se pudo leer el archivo: ' . $e->getMessage();
$this->importError = 'No se pudo leer el archivo: '.$e->getMessage();
return;
}
$fields = $this->parseRows($rows);
if (empty($fields)) {
$this->importError = 'No se encontraron filas válidas.';
return;
}
$this->importPreviewFields = $fields;
@@ -196,19 +205,21 @@ class GlobalTemplateManager extends Component
public function confirmImportFile()
{
if (empty($this->importPreviewFields) || empty($this->importTemplateName)) return;
if (empty($this->importPreviewFields) || empty($this->importTemplateName)) {
return;
}
InspectionTemplate::create([
'name' => $this->importTemplateName,
'name' => $this->importTemplateName,
'description' => 'Importado desde archivo',
'project_id' => null,
'fields' => array_values($this->importPreviewFields),
'project_id' => null,
'fields' => array_values($this->importPreviewFields),
]);
$this->showImportFileModal = false;
$this->importPreviewFields = [];
$this->importTemplateName = '';
$this->importFile = null;
$this->importTemplateName = '';
$this->importFile = null;
$this->loadTemplates();
$this->dispatch('templates-changed');
$this->dispatch('notify', 'Plantilla importada');
@@ -216,7 +227,7 @@ class GlobalTemplateManager extends Component
private function readFileRows(): array
{
$ext = strtolower($this->importFile->getClientOriginalExtension());
$ext = strtolower($this->importFile->getClientOriginalExtension());
$path = $this->importFile->getRealPath();
// Fila no vacía = tiene al menos una celda con contenido (no filtramos por
@@ -226,21 +237,27 @@ class GlobalTemplateManager extends Component
if ($ext === 'xlsx' || $ext === 'xls') {
$spreadsheet = IOFactory::load($path);
$sheet = $spreadsheet->getActiveSheet();
$rows = $sheet->toArray(null, true, true, false);
$sheet = $spreadsheet->getActiveSheet();
$rows = $sheet->toArray(null, true, true, false);
array_shift($rows);
return array_values(array_filter($rows, $notEmpty));
}
$rows = [];
$rows = [];
$handle = fopen($path, 'r');
$bom = fread($handle, 3);
if ($bom !== "\xEF\xBB\xBF") rewind($handle);
if ($bom !== "\xEF\xBB\xBF") {
rewind($handle);
}
fgetcsv($handle);
while (($row = fgetcsv($handle)) !== false) {
if ($notEmpty($row)) $rows[] = $row;
if ($notEmpty($row)) {
$rows[] = $row;
}
}
fclose($handle);
return $rows;
}
@@ -250,24 +267,27 @@ class GlobalTemplateManager extends Component
// group, name, label, question, type, required, options, min, max, step, help
$fields = [];
foreach ($rows as $row) {
$row = array_values((array) $row);
$row = array_values((array) $row);
$rawName = trim($row[1] ?? '');
if ($rawName === '') continue;
if ($rawName === '') {
continue;
}
$fields[] = [
'group' => trim($row[0] ?? ''),
'name' => $this->slugify($rawName),
'label' => trim($row[2] ?? '') ?: $rawName,
'group' => trim($row[0] ?? ''),
'name' => $this->slugify($rawName),
'label' => trim($row[2] ?? '') ?: $rawName,
'question' => trim($row[3] ?? ''),
'type' => $this->normalizeType($row[4] ?? 'text'),
'type' => $this->normalizeType($row[4] ?? 'text'),
'required' => in_array(strtolower(trim($row[5] ?? '0')), ['1', 'si', 'sí', 'yes', 'true']),
'options' => trim($row[6] ?? ''),
'min' => ($row[7] ?? '') !== '' ? $row[7] : null,
'max' => ($row[8] ?? '') !== '' ? $row[8] : null,
'step' => ($row[9] ?? '') !== '' ? $row[9] : null,
'help' => trim($row[10] ?? ''),
'options' => trim($row[6] ?? ''),
'min' => ($row[7] ?? '') !== '' ? $row[7] : null,
'max' => ($row[8] ?? '') !== '' ? $row[8] : null,
'step' => ($row[9] ?? '') !== '' ? $row[9] : null,
'help' => trim($row[10] ?? ''),
];
}
return $fields;
}
@@ -276,6 +296,7 @@ class GlobalTemplateManager extends Component
$str = mb_strtolower(trim($str));
$str = preg_replace('/\s+/', '_', $str);
$str = preg_replace('/[^a-z0-9_]/i', '', $str);
return trim($str, '_') ?: 'campo';
}
@@ -291,6 +312,7 @@ class GlobalTemplateManager extends Component
'date' => 'date', 'fecha' => 'date',
'select' => 'select', 'lista' => 'select', 'dropdown' => 'select', 'opciones' => 'select',
];
return $map[strtolower(trim($type))] ?? 'text';
}
@@ -18,13 +18,13 @@ class InspectionTemplatesTable extends DataTableComponent
public function configure(): void
{
$this->setPrimaryKey('id')
->setDefaultSort('inspection_templates.name', 'asc')
->setSortingPillsEnabled(false)
->setSecondaryHeaderEnabled()
->setAdditionalSelects([
'inspection_templates.id as id',
'inspection_templates.fields as fields',
]);
->setDefaultSort('inspection_templates.name', 'asc')
->setSortingPillsEnabled(false)
->setSecondaryHeaderEnabled()
->setAdditionalSelects([
'inspection_templates.id as id',
'inspection_templates.fields as fields',
]);
}
/** Refrescar cuando el manager crea/edita/borra. */
@@ -46,47 +46,46 @@ class InspectionTemplatesTable extends DataTableComponent
{
return [
Column::make('Plantilla', 'name')
->sortable()->searchable()
->secondaryHeaderFilter('name')
->format(fn ($value) => '<span class="font-medium">' . e($value) . '</span>')
->html(),
->sortable()->searchable()
->secondaryHeaderFilter('name')
->format(fn ($value) => '<span class="font-medium">'.e($value).'</span>')
->html(),
Column::make('Descripción', 'description')
->searchable()
->secondaryHeaderFilter('description')
->format(fn ($value) => $value
? '<span class="text-sm text-base-content/70">' . e($value) . '</span>'
: '<span class="text-base-content/30 text-xs">—</span>')
->html(),
->searchable()
->secondaryHeaderFilter('description')
->format(fn ($value) => $value
? '<span class="text-sm text-base-content/70">'.e($value).'</span>'
: '<span class="text-base-content/30 text-xs">—</span>')
->html(),
Column::make('Campos')
->label(fn ($row) =>
'<span class="badge badge-ghost badge-sm">' . count($row->fields ?? []) . '</span>')
->html(),
->label(fn ($row) => '<span class="badge badge-ghost badge-sm">'.count($row->fields ?? []).'</span>')
->html(),
Column::make('Proyectos')
->secondaryHeaderFilter('usage')
->label(function ($row) {
$n = (int) ($row->projects_count ?? 0);
$cls = $n > 0 ? 'badge-info' : 'badge-ghost';
return '<span class="badge ' . $cls . ' badge-sm">' . $n . '</span>';
})
->html(),
->secondaryHeaderFilter('usage')
->label(function ($row) {
$n = (int) ($row->projects_count ?? 0);
$cls = $n > 0 ? 'badge-info' : 'badge-ghost';
return '<span class="badge '.$cls.' badge-sm">'.$n.'</span>';
})
->html(),
Column::make('Acciones')
->label(fn ($row) =>
'<div class="flex justify-end gap-1">
<button wire:click="$dispatch(\'template-edit\', { id: ' . $row->id . ' })"
->label(fn ($row) => '<div class="flex justify-end gap-1">
<button wire:click="$dispatch(\'template-edit\', { id: '.$row->id.' })"
class="btn btn-xs btn-ghost" title="Editar">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</button>
<button wire:click="$dispatch(\'template-delete\', { id: ' . $row->id . ' })"
wire:confirm="¿Eliminar la plantilla \'' . e($row->name) . '\'? Esta acción no se puede deshacer."
<button wire:click="$dispatch(\'template-delete\', { id: '.$row->id.' })"
wire:confirm="¿Eliminar la plantilla \''.e($row->name).'\'? Esta acción no se puede deshacer."
class="btn btn-xs btn-error btn-outline" title="Eliminar">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>')
->html(),
->html(),
];
}
@@ -95,17 +94,21 @@ class InspectionTemplatesTable extends DataTableComponent
return [
TextFilter::make('Plantilla', 'name')
->config(['placeholder' => 'Buscar nombre…'])
->filter(fn (Builder $q, string $v) => $q->where('inspection_templates.name', 'like', '%' . $v . '%')),
->filter(fn (Builder $q, string $v) => $q->where('inspection_templates.name', 'like', '%'.$v.'%')),
TextFilter::make('Descripción', 'description')
->config(['placeholder' => 'Buscar descripción…'])
->filter(fn (Builder $q, string $v) => $q->where('inspection_templates.description', 'like', '%' . $v . '%')),
->filter(fn (Builder $q, string $v) => $q->where('inspection_templates.description', 'like', '%'.$v.'%')),
SelectFilter::make('Uso', 'usage')
->options(['' => 'Todos', 'used' => 'En uso (≥1 proyecto)', 'unused' => 'Sin uso'])
->filter(function (Builder $q, string $v) {
if ($v === 'used') $q->has('projects');
if ($v === 'unused') $q->doesntHave('projects');
if ($v === 'used') {
$q->has('projects');
}
if ($v === 'unused') {
$q->doesntHave('projects');
}
}),
];
}
@@ -12,11 +12,15 @@ use Livewire\Component;
class IssueChecklistManager extends Component
{
public Project $project;
public $templates = [];
public bool $showForm = false;
public $editingId = null;
public string $name = '';
public array $items = [''];
public function mount(Project $project)
@@ -29,6 +33,7 @@ class IssueChecklistManager extends Component
private function canAccessProject(): bool
{
$user = Auth::user();
return $user->can('manage all')
|| $this->project->users()->where('user_id', $user->id)->exists();
}
@@ -74,14 +79,15 @@ class IssueChecklistManager extends Component
public function save(): void
{
$this->validate([
'name' => 'required|string|max:255',
'items' => 'required|array',
'name' => 'required|string|max:255',
'items' => 'required|array',
'items.*' => 'nullable|string|max:255',
]);
$items = array_values(array_filter(array_map('trim', $this->items), fn ($v) => $v !== ''));
if (empty($items)) {
$this->addError('items', 'Añade al menos una tarea.');
return;
}
+29 -23
View File
@@ -4,13 +4,13 @@ namespace App\Livewire\Issues;
use App\Models\Issue;
use App\Models\IssueChecklistTemplate;
use App\Models\IssueComment;
use App\Models\IssueTask;
use App\Models\Media;
use App\Models\Project;
use App\Notifications\IssueCommentedNotification;
use App\Notifications\IssueStatusChangedNotification;
use App\Notifications\IssueTaskAssignedNotification;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Component;
@@ -22,19 +22,24 @@ class IssueDetail extends Component
use WithFileUploads;
public Project $project;
public Issue $issue;
// New task form
public string $newTaskTitle = '';
public $newTaskAssignee = '';
public $newTaskDue = '';
// Checklist templates
public $checklistTemplates = [];
public $applyTemplateId = '';
// New comment form
public string $newComment = '';
public $commentPhoto = null; // single optional photo on a comment
// Issue-level photos
@@ -61,6 +66,7 @@ class IssueDetail extends Component
private function canAccessProject(): bool
{
$user = Auth::user();
return $user->can('manage all')
|| $this->project->users()->where('user_id', $user->id)->exists();
}
@@ -97,17 +103,17 @@ class IssueDetail extends Component
{
abort_unless($this->canEdit(), 403);
$this->validate([
'newTaskTitle' => 'required|string|max:255',
'newTaskTitle' => 'required|string|max:255',
'newTaskAssignee' => 'nullable|exists:users,id',
'newTaskDue' => 'nullable|date',
'newTaskDue' => 'nullable|date',
]);
$task = $this->issue->tasks()->create([
'title' => $this->newTaskTitle,
'title' => $this->newTaskTitle,
'assigned_to' => $this->newTaskAssignee ?: null,
'due_date' => $this->newTaskDue ?: null,
'order' => ((int) $this->issue->tasks()->max('order')) + 1,
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'due_date' => $this->newTaskDue ?: null,
'order' => ((int) $this->issue->tasks()->max('order')) + 1,
'uuid' => (string) Str::uuid(),
]);
// Notify the assignee (unless they assigned it to themselves).
@@ -134,7 +140,7 @@ class IssueDetail extends Component
$this->issue->tasks()->create([
'title' => $title,
'order' => ++$order,
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'uuid' => (string) Str::uuid(),
]);
}
@@ -170,7 +176,7 @@ class IssueDetail extends Component
{
// Anyone who can view the issue (and is a member) may comment / report progress.
$this->validate([
'newComment' => 'nullable|string|max:5000',
'newComment' => 'nullable|string|max:5000',
'commentPhoto' => 'nullable|image|max:20480', // 20 MB
]);
@@ -182,8 +188,8 @@ class IssueDetail extends Component
$comment = $this->issue->comments()->create([
'user_id' => Auth::id(),
'body' => trim($this->newComment) ?: '(foto)',
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'body' => trim($this->newComment) ?: '(foto)',
'uuid' => (string) Str::uuid(),
]);
if ($this->commentPhoto) {
@@ -215,7 +221,7 @@ class IssueDetail extends Component
public function deleteMedia($mediaId): void
{
$media = \App\Models\Media::findOrFail($mediaId);
$media = Media::findOrFail($mediaId);
$user = Auth::user();
abort_unless($user->can('delete media') || $media->uploaded_by === $user->id, 403);
$media->delete();
@@ -230,14 +236,14 @@ class IssueDetail extends Component
$path = $file->store("uploads/issues/{$this->issue->id}/{$entity}", 'public');
$parent->media()->create([
'name' => $file->getClientOriginalName(),
'file_path' => $path,
'file_type' => $mime,
'name' => $file->getClientOriginalName(),
'file_path' => $path,
'file_type' => $mime,
'file_extension' => $file->getClientOriginalExtension(),
'file_size' => $file->getSize(),
'category' => str_starts_with($mime, 'image/') ? 'image' : 'document',
'uploaded_by' => Auth::id(),
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'file_size' => $file->getSize(),
'category' => str_starts_with($mime, 'image/') ? 'image' : 'document',
'uploaded_by' => Auth::id(),
'uuid' => (string) Str::uuid(),
]);
}
@@ -256,8 +262,8 @@ class IssueDetail extends Component
{
abort_unless($this->canEdit(), 403);
$this->issue->update([
'status' => 'resolved',
'resolved_at' => $this->issue->resolved_at ?? now(),
'status' => 'resolved',
'resolved_at' => $this->issue->resolved_at ?? now(),
'resolution_notes' => $this->resolutionNotes ?: null,
]);
$this->notifyStakeholders(new IssueStatusChangedNotification($this->issue, 'resolved'));
@@ -269,7 +275,7 @@ class IssueDetail extends Component
{
abort_unless($this->canEdit(), 403);
$this->issue->update([
'status' => 'closed',
'status' => 'closed',
'resolved_at' => $this->issue->resolved_at ?? now(),
]);
$this->notifyStakeholders(new IssueStatusChangedNotification($this->issue, 'closed'));
+38 -27
View File
@@ -2,6 +2,7 @@
namespace App\Livewire\Issues;
use App\Models\Feature;
use App\Models\Issue;
use App\Models\Project;
use App\Notifications\IssueAssignedNotification;
@@ -14,22 +15,31 @@ use Livewire\Component;
class IssueForm extends Component
{
public Project $project;
public ?Issue $issue = null; // null = create, set = edit
public $projectUsers = [];
// Form fields
public $title = '';
public $description = '';
public $status = 'open';
public $priority = 'medium';
public $type = 'defect';
public $assignedTo = '';
public $resolutionNotes = '';
// Optional context (e.g. when reporting from a map feature)
public $featureId = null;
public $inspectionId = null;
public $featureName = null; // shown when the issue is pre-linked to a map element
public function mount(Project $project, ?Issue $issue = null)
@@ -44,22 +54,22 @@ class IssueForm extends Component
$this->projectUsers = $project->users()->orderBy('name')->get();
if ($issue) {
$this->issue = $issue;
$this->title = $issue->title;
$this->description = $issue->description ?? '';
$this->status = $issue->status;
$this->priority = $issue->priority;
$this->type = $issue->type ?? 'defect';
$this->assignedTo = $issue->assigned_to ?? '';
$this->issue = $issue;
$this->title = $issue->title;
$this->description = $issue->description ?? '';
$this->status = $issue->status;
$this->priority = $issue->priority;
$this->type = $issue->type ?? 'defect';
$this->assignedTo = $issue->assigned_to ?? '';
$this->resolutionNotes = $issue->resolution_notes ?? '';
$this->featureId = $issue->feature_id;
$this->inspectionId = $issue->inspection_id;
$this->featureName = $issue->feature?->name;
$this->featureId = $issue->feature_id;
$this->inspectionId = $issue->inspection_id;
$this->featureName = $issue->feature?->name;
} elseif ($featureId = request()->integer('feature')) {
// Pre-link to a map element when reporting from the project map.
$feature = \App\Models\Feature::with('layer.phase')->find($featureId);
$feature = Feature::with('layer.phase')->find($featureId);
if ($feature && $feature->layer?->phase?->project_id === $project->id) {
$this->featureId = $feature->id;
$this->featureId = $feature->id;
$this->featureName = $feature->name;
}
}
@@ -68,6 +78,7 @@ class IssueForm extends Component
private function canAccessProject(): bool
{
$user = Auth::user();
return $user->can('manage all')
|| $this->project->users()->where('user_id', $user->id)->exists();
}
@@ -75,12 +86,12 @@ class IssueForm extends Component
protected function rules(): array
{
return [
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'status' => 'required|in:' . implode(',', Issue::STATUSES),
'priority' => 'required|in:' . implode(',', Issue::PRIORITIES),
'type' => 'required|in:' . implode(',', Issue::TYPES),
'assignedTo' => 'nullable|exists:users,id',
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'status' => 'required|in:'.implode(',', Issue::STATUSES),
'priority' => 'required|in:'.implode(',', Issue::PRIORITIES),
'type' => 'required|in:'.implode(',', Issue::TYPES),
'assignedTo' => 'nullable|exists:users,id',
'resolutionNotes' => 'nullable|string',
];
}
@@ -91,14 +102,14 @@ class IssueForm extends Component
$this->validate();
$payload = [
'title' => $this->title,
'description' => $this->description,
'status' => $this->status,
'priority' => $this->priority,
'type' => $this->type,
'feature_id' => $this->featureId,
'inspection_id' => $this->inspectionId,
'assigned_to' => $this->assignedTo ?: null,
'title' => $this->title,
'description' => $this->description,
'status' => $this->status,
'priority' => $this->priority,
'type' => $this->type,
'feature_id' => $this->featureId,
'inspection_id' => $this->inspectionId,
'assigned_to' => $this->assignedTo ?: null,
'resolution_notes' => $this->resolutionNotes ?: null,
];
@@ -120,7 +131,7 @@ class IssueForm extends Component
}
} else {
$issue = Issue::create(array_merge($payload, [
'project_id' => $this->project->id,
'project_id' => $this->project->id,
'reported_by' => Auth::id(),
]));
+8 -7
View File
@@ -2,12 +2,12 @@
namespace App\Livewire\Issues;
use Livewire\Component;
use App\Models\Issue;
use App\Models\Project;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Illuminate\Support\Facades\Auth;
use App\Models\Project;
use App\Models\Issue;
use Livewire\Component;
#[Layout('layouts.app')]
class IssueManager extends Component
@@ -24,6 +24,7 @@ class IssueManager extends Component
private function canAccessProject(): bool
{
$user = Auth::user();
return $user->can('manage all')
|| $this->project->users()->where('user_id', $user->id)->exists();
}
@@ -44,11 +45,11 @@ class IssueManager extends Component
->pluck('c', 'status');
return view('livewire.issues.issue-manager', [
'countOpen' => (int) ($counts['open'] ?? 0),
'countOpen' => (int) ($counts['open'] ?? 0),
'countInReview' => (int) ($counts['in_review'] ?? 0),
'countResolved' => (int) ($counts['resolved'] ?? 0),
'countClosed' => (int) ($counts['closed'] ?? 0),
'countTotal' => (int) $counts->sum(),
'countClosed' => (int) ($counts['closed'] ?? 0),
'countTotal' => (int) $counts->sum(),
]);
}
}
+105 -94
View File
@@ -3,6 +3,7 @@
namespace App\Livewire\Issues;
use App\Models\Issue;
use App\Models\Project;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
@@ -19,9 +20,9 @@ class IssueTable extends DataTableComponent
public function configure(): void
{
$this->setPrimaryKey('id')
->setDefaultSort('created_at', 'desc')
->setSortingPillsEnabled(false)
->setAdditionalSelects(['issues.id as id', 'issues.created_at as created_at']);
->setDefaultSort('created_at', 'desc')
->setSortingPillsEnabled(false)
->setAdditionalSelects(['issues.id as id', 'issues.created_at as created_at']);
}
public function builder(): Builder
@@ -31,7 +32,7 @@ class IssueTable extends DataTableComponent
abort_unless(
$user->can('view issues') && (
$user->can('manage all')
|| \App\Models\Project::whereKey($this->projectId)->whereHas('users', fn ($q) => $q->where('user_id', $user->id))->exists()
|| Project::whereKey($this->projectId)->whereHas('users', fn ($q) => $q->where('user_id', $user->id))->exists()
),
403
);
@@ -53,119 +54,129 @@ class IssueTable extends DataTableComponent
{
return [
Column::make('Prioridad', 'priority')
->sortable()
->format(function ($value, $row) {
$label = ['low' => 'Bajo', 'medium' => 'Medio', 'high' => 'Alto', 'critical' => 'Crítico'][$value] ?? ucfirst($value);
$textColor = in_array($value, ['critical', 'high']) ? '#fff' : '#1f2937';
return '<span class="badge badge-sm font-semibold" style="background-color:'.$row->priority_color.';color:'.$textColor.';border-color:transparent;">'.$label.'</span>';
})
->html(),
->sortable()
->format(function ($value, $row) {
$label = ['low' => 'Bajo', 'medium' => 'Medio', 'high' => 'Alto', 'critical' => 'Crítico'][$value] ?? ucfirst($value);
$textColor = in_array($value, ['critical', 'high']) ? '#fff' : '#1f2937';
return '<span class="badge badge-sm font-semibold" style="background-color:'.$row->priority_color.';color:'.$textColor.';border-color:transparent;">'.$label.'</span>';
})
->html(),
Column::make('Título', 'title')
->sortable()
->searchable()
->format(function ($value, $row) {
$url = route('projects.issues.show', [$this->projectId, $row->id]);
$html = '<a href="'.$url.'" wire:navigate class="font-medium text-sm link link-hover">'.e($value).'</a>';
if ($row->description) {
$html .= '<div class="text-xs text-base-content/50 truncate max-w-xs">'.e(Str::limit($row->description, 60)).'</div>';
}
$meta = [];
if ($row->reporter) $meta[] = 'Reportado por '.e($row->reporter->name);
if ($row->comments_count) $meta[] = '💬 '.$row->comments_count;
if ($row->media_count) $meta[] = '📷 '.$row->media_count;
if ($meta) {
$html .= '<div class="text-xs text-base-content/40 mt-0.5">'.implode(' · ', $meta).'</div>';
}
if ($row->tasks_count) {
$pct = (int) round($row->tasks_done_count / $row->tasks_count * 100);
$html .= '<div class="flex items-center gap-2 mt-1 max-w-xs">
->sortable()
->searchable()
->format(function ($value, $row) {
$url = route('projects.issues.show', [$this->projectId, $row->id]);
$html = '<a href="'.$url.'" wire:navigate class="font-medium text-sm link link-hover">'.e($value).'</a>';
if ($row->description) {
$html .= '<div class="text-xs text-base-content/50 truncate max-w-xs">'.e(Str::limit($row->description, 60)).'</div>';
}
$meta = [];
if ($row->reporter) {
$meta[] = 'Reportado por '.e($row->reporter->name);
}
if ($row->comments_count) {
$meta[] = '💬 '.$row->comments_count;
}
if ($row->media_count) {
$meta[] = '📷 '.$row->media_count;
}
if ($meta) {
$html .= '<div class="text-xs text-base-content/40 mt-0.5">'.implode(' · ', $meta).'</div>';
}
if ($row->tasks_count) {
$pct = (int) round($row->tasks_done_count / $row->tasks_count * 100);
$html .= '<div class="flex items-center gap-2 mt-1 max-w-xs">
<progress class="progress progress-success w-24 h-1.5" value="'.$pct.'" max="100"></progress>
<span class="text-xs text-base-content/50">'.$row->tasks_done_count.'/'.$row->tasks_count.' tareas</span>
</div>';
}
if ($row->overdue_tasks_count) {
$html .= '<div class="mt-1"><span class="badge badge-error badge-sm gap-1">⏰ '.$row->overdue_tasks_count.' vencida'.($row->overdue_tasks_count > 1 ? 's' : '').'</span></div>';
}
return $html;
})
->html(),
}
if ($row->overdue_tasks_count) {
$html .= '<div class="mt-1"><span class="badge badge-error badge-sm gap-1">⏰ '.$row->overdue_tasks_count.' vencida'.($row->overdue_tasks_count > 1 ? 's' : '').'</span></div>';
}
return $html;
})
->html(),
Column::make('Tipo', 'type')
->sortable()
->format(fn ($value, $row) =>
'<span class="badge badge-sm" style="background-color:'.$row->type_color.';color:#fff;border-color:transparent;">'.e($row->type_label).'</span>')
->html(),
->sortable()
->format(fn ($value, $row) => '<span class="badge badge-sm" style="background-color:'.$row->type_color.';color:#fff;border-color:transparent;">'.e($row->type_label).'</span>')
->html(),
Column::make('Feature')
->label(fn ($row) => $row->feature
? '<span class="badge badge-outline badge-sm">'.e($row->feature->name).'</span>'
: '<span class="text-base-content/30 text-xs">—</span>')
->html(),
->label(fn ($row) => $row->feature
? '<span class="badge badge-outline badge-sm">'.e($row->feature->name).'</span>'
: '<span class="text-base-content/30 text-xs">—</span>')
->html(),
Column::make('Estado', 'status')
->sortable()
->format(function ($value, $row) {
$label = ['open' => 'Abierto', 'in_review' => 'En revisión', 'resolved' => 'Resuelto', 'closed' => 'Cerrado'][$value] ?? ucfirst($value);
return '<span class="badge badge-sm" style="background-color:'.$row->status_color.';color:#fff;border-color:transparent;">'.$label.'</span>';
})
->html(),
->sortable()
->format(function ($value, $row) {
$label = ['open' => 'Abierto', 'in_review' => 'En revisión', 'resolved' => 'Resuelto', 'closed' => 'Cerrado'][$value] ?? ucfirst($value);
return '<span class="badge badge-sm" style="background-color:'.$row->status_color.';color:#fff;border-color:transparent;">'.$label.'</span>';
})
->html(),
Column::make('Asignado a')
->label(fn ($row) => $row->assignee
? '<span class="text-sm">'.e($row->assignee->name).'</span>'
: '<span class="text-base-content/30 text-xs">Sin asignar</span>')
->html(),
->label(fn ($row) => $row->assignee
? '<span class="text-sm">'.e($row->assignee->name).'</span>'
: '<span class="text-base-content/30 text-xs">Sin asignar</span>')
->html(),
Column::make('Fecha', 'created_at')
->sortable()
->format(function ($value, $row) {
$html = $row->created_at->format('d/m/Y');
if ($row->resolved_at) {
$html .= '<div class="text-success text-xs">Res. '.$row->resolved_at->format('d/m/Y').'</div>';
}
return $html;
})
->html(),
->sortable()
->format(function ($value, $row) {
$html = $row->created_at->format('d/m/Y');
if ($row->resolved_at) {
$html .= '<div class="text-success text-xs">Res. '.$row->resolved_at->format('d/m/Y').'</div>';
}
return $html;
})
->html(),
Column::make('Acciones')
->label(function ($row) {
$user = Auth::user();
$detail = route('projects.issues.show', [$this->projectId, $row->id]);
$edit = route('projects.issues.edit', [$this->projectId, $row->id]);
->label(function ($row) {
$user = Auth::user();
$detail = route('projects.issues.show', [$this->projectId, $row->id]);
$edit = route('projects.issues.edit', [$this->projectId, $row->id]);
$html = '<div class="flex items-center justify-end gap-1 flex-wrap">';
$html .= '<a href="'.$detail.'" wire:navigate class="btn btn-xs btn-ghost" title="Abrir detalle">
$html = '<div class="flex items-center justify-end gap-1 flex-wrap">';
$html .= '<a href="'.$detail.'" wire:navigate class="btn btn-xs btn-ghost" title="Abrir detalle">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
</a>';
if ($user->can('edit issues')) {
$html .= '<a href="'.$edit.'" wire:navigate class="btn btn-xs btn-ghost" title="Editar">
if ($user->can('edit issues')) {
$html .= '<a href="'.$edit.'" wire:navigate class="btn btn-xs btn-ghost" title="Editar">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</a>';
if (in_array($row->status, ['open', 'in_review'])) {
$html .= '<button wire:click="resolve('.$row->id.')" class="btn btn-xs btn-success" title="Marcar como resuelto">
if (in_array($row->status, ['open', 'in_review'])) {
$html .= '<button wire:click="resolve('.$row->id.')" class="btn btn-xs btn-success" title="Marcar como resuelto">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
</button>';
}
if ($row->status !== 'closed') {
$html .= '<button wire:click="close('.$row->id.')" class="btn btn-xs btn-neutral" title="Cerrar">
}
if ($row->status !== 'closed') {
$html .= '<button wire:click="close('.$row->id.')" class="btn btn-xs btn-neutral" title="Cerrar">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>';
}
}
}
}
if ($user->can('delete issues')) {
$html .= '<button wire:click="deleteIssue('.$row->id.')" wire:confirm="¿Eliminar esta incidencia? Esta acción no se puede deshacer."
if ($user->can('delete issues')) {
$html .= '<button wire:click="deleteIssue('.$row->id.')" wire:confirm="¿Eliminar esta incidencia? Esta acción no se puede deshacer."
class="btn btn-xs btn-error btn-outline" title="Eliminar">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>';
}
}
$html .= '</div>';
return $html;
})
->html(),
$html .= '</div>';
return $html;
})
->html(),
];
}
@@ -174,26 +185,26 @@ class IssueTable extends DataTableComponent
return [
SelectFilter::make('Estado', 'status')
->options([
'' => 'Estado: todos',
'open' => 'Abierto',
'' => 'Estado: todos',
'open' => 'Abierto',
'in_review' => 'En revisión',
'resolved' => 'Resuelto',
'closed' => 'Cerrado',
'resolved' => 'Resuelto',
'closed' => 'Cerrado',
])
->filter(fn (Builder $query, string $value) => $query->where('issues.status', $value)),
SelectFilter::make('Prioridad', 'priority')
->options([
'' => 'Prioridad: todas',
'' => 'Prioridad: todas',
'critical' => 'Crítica',
'high' => 'Alta',
'medium' => 'Media',
'low' => 'Baja',
'high' => 'Alta',
'medium' => 'Media',
'low' => 'Baja',
])
->filter(fn (Builder $query, string $value) => $query->where('issues.priority', $value)),
SelectFilter::make('Tipo', 'type')
->options(['' => 'Tipo: todos'] + \App\Models\Issue::typeLabels())
->options(['' => 'Tipo: todos'] + Issue::typeLabels())
->filter(fn (Builder $query, string $value) => $query->where('issues.type', $value)),
];
}
+122 -86
View File
@@ -2,19 +2,19 @@
namespace App\Livewire\Layers;
use Livewire\Component;
use Livewire\WithFileUploads;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use App\Models\Project;
use App\Models\Phase;
use App\Models\Layer;
use App\Models\Feature;
use App\Models\InspectionTemplate;
use App\Models\Layer;
use App\Models\Phase;
use App\Models\Project;
use App\Services\SpatialFileConverter;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
use Livewire\WithFileUploads;
#[Layout('layouts.app')]
class LayerManager extends Component
@@ -22,29 +22,39 @@ class LayerManager extends Component
use WithFileUploads;
public Project $project;
public Phase $phase;
public Phase $phase;
public $layers;
public $selectedLayer = null;
public $visibleLayers = [];
public $uploadFile = null;
public $layerName = '';
public $layerColor = '#3b82f6';
public $uploadFile = null;
public $layerName = '';
public $layerColor = '#3b82f6';
// Batch assign
public $templates = [];
public $templates = [];
public $batchTemplateId = null;
public $batchStatus = '';
public $batchStatus = '';
public function mount(Project $project, Phase $phase)
{
$this->project = $project;
$this->phase = $phase;
$this->phase = $phase;
if ($this->phase->project_id !== $this->project->id) abort(404);
if ($this->phase->project_id !== $this->project->id) {
abort(404);
}
$user = Auth::user();
if (!$user->can('manage all') && !$project->users()->where('user_id', $user->id)->exists()) {
if (! $user->can('manage all') && ! $project->users()->where('user_id', $user->id)->exists()) {
abort(403);
}
@@ -71,28 +81,28 @@ class LayerManager extends Component
private function buildLayerPayload(Layer $layer): array
{
$color = $layer->color ?: '#3b82f6';
$color = $layer->color ?: '#3b82f6';
$features = ($layer->relationLoaded('features') ? $layer->features : $layer->features()->get())
->map(fn($f) => [
'type' => 'Feature',
'id' => $f->id,
'geometry' => $f->geometry,
->map(fn ($f) => [
'type' => 'Feature',
'id' => $f->id,
'geometry' => $f->geometry,
'properties' => [
'name' => $f->name ?? 'Elemento',
'progress' => $f->progress,
'status' => $f->status ?? 'planned',
'name' => $f->name ?? 'Elemento',
'progress' => $f->progress,
'status' => $f->status ?? 'planned',
'responsible' => $f->responsible,
'template_id' => $f->template_id,
],
])->values()->toArray();
return [
'id' => $layer->id,
'color' => $color,
'id' => $layer->id,
'color' => $color,
'geojson' => [
'type' => 'FeatureCollection',
'type' => 'FeatureCollection',
'features' => $features,
'style' => ['color' => $color],
'style' => ['color' => $color],
],
];
}
@@ -101,8 +111,8 @@ class LayerManager extends Component
{
$this->layers->loadMissing('features');
$this->dispatch('initialLayersData', [
'layers' => $this->layers->map(fn($l) => $this->buildLayerPayload($l)),
'visibleLayers' => $this->visibleLayers,
'layers' => $this->layers->map(fn ($l) => $this->buildLayerPayload($l)),
'visibleLayers' => $this->visibleLayers,
'selectedLayerId' => $this->selectedLayer?->id,
]);
}
@@ -113,6 +123,7 @@ class LayerManager extends Component
{
if ($this->selectedLayer && $this->selectedLayer->id == $layerId) {
$this->dispatch('notify', 'No puedes ocultar la capa que estás editando');
return;
}
if (in_array($layerId, $this->visibleLayers)) {
@@ -128,9 +139,11 @@ class LayerManager extends Component
public function selectLayer($layerId)
{
$this->selectedLayer = Layer::with('features')->find($layerId);
if (!$this->selectedLayer) return;
if (! $this->selectedLayer) {
return;
}
if (!in_array($layerId, $this->visibleLayers)) {
if (! in_array($layerId, $this->visibleLayers)) {
$this->visibleLayers[] = $layerId;
$this->dispatch('visibilityChanged', $this->visibleLayers);
}
@@ -139,9 +152,9 @@ class LayerManager extends Component
$this->dispatch('layerSelectedForEdit', [
'layerId' => $layerId,
'geojson' => $payload['geojson'],
'color' => $payload['color'],
'color' => $payload['color'],
]);
$this->dispatch('notify', 'Editando: ' . $this->selectedLayer->name);
$this->dispatch('notify', 'Editando: '.$this->selectedLayer->name);
}
// ── Import file ───────────────────────────────────────────────────────────
@@ -149,32 +162,35 @@ class LayerManager extends Component
public function importFile()
{
$user = Auth::user();
if (!$user->can('upload layers')) {
if (! $user->can('upload layers')) {
$this->dispatch('notify', 'Sin permisos para subir capas');
return;
}
$this->validate([
'uploadFile' => 'required|file|max:51200',
'layerName' => 'required|string|max:255',
'layerName' => 'required|string|max:255',
'layerColor' => 'nullable|string|size:7',
]);
$ext = strtolower($this->uploadFile->getClientOriginalExtension());
$ext = strtolower($this->uploadFile->getClientOriginalExtension());
$allowed = ['geojson', 'json', 'kmz', 'kml', 'shp', 'dwg', 'zip'];
if (!in_array($ext, $allowed)) {
$this->dispatch('notify', 'Extensión no permitida. Válidas: ' . implode(', ', $allowed));
if (! in_array($ext, $allowed)) {
$this->dispatch('notify', 'Extensión no permitida. Válidas: '.implode(', ', $allowed));
return;
}
$geojson = SpatialFileConverter::convertToGeoJson($this->uploadFile);
if (!$geojson) {
if (! $geojson) {
$this->dispatch('notify', 'No se pudo convertir el archivo. Comprueba que sea GeoJSON, KML o Shapefile válido.');
return;
}
$layerColor = $this->layerColor ?: '#3b82f6';
$layerName = $this->layerName;
$layerName = $this->layerName;
try {
DB::transaction(function () use ($geojson, $layerColor, $layerName, $user) {
@@ -183,28 +199,30 @@ class LayerManager extends Component
);
$layer = Layer::create([
'project_id' => $this->project->id,
'phase_id' => $this->phase->id,
'name' => $layerName,
'color' => $layerColor,
'project_id' => $this->project->id,
'phase_id' => $this->phase->id,
'name' => $layerName,
'color' => $layerColor,
'original_file' => $path,
'uploaded_by' => $user->id,
'uploaded_by' => $user->id,
]);
$idx = 0;
foreach ($geojson['features'] ?? [] as $fd) {
$idx++;
$name = trim($fd['properties']['name'] ?? '');
if ($name === '') $name = $layerName . ' — Elemento ' . $idx;
if ($name === '') {
$name = $layerName.' — Elemento '.$idx;
}
Feature::create([
'layer_id' => $layer->id,
'name' => $name,
'geometry' => $fd['geometry'],
'properties' => $fd['properties'] ?? [],
'layer_id' => $layer->id,
'name' => $name,
'geometry' => $fd['geometry'],
'properties' => $fd['properties'] ?? [],
'template_id' => $fd['properties']['template_id'] ?? null,
'progress' => $fd['properties']['progress'] ?? 0,
'status' => in_array($fd['properties']['status'] ?? '', Feature::STATUSES)
'progress' => $fd['properties']['progress'] ?? 0,
'status' => in_array($fd['properties']['status'] ?? '', Feature::STATUSES)
? $fd['properties']['status']
: 'planned',
'responsible' => $fd['properties']['responsible'] ?? null,
@@ -214,7 +232,8 @@ class LayerManager extends Component
$this->visibleLayers[] = $layer->id;
});
} catch (\Throwable $e) {
$this->dispatch('notify', 'Error al importar: ' . $e->getMessage());
$this->dispatch('notify', 'Error al importar: '.$e->getMessage());
return;
}
@@ -229,18 +248,19 @@ class LayerManager extends Component
public function createEmptyLayer()
{
$user = Auth::user();
if (!$user->can('upload layers')) {
if (! $user->can('upload layers')) {
$this->dispatch('notify', 'Sin permisos para crear capas');
return;
}
$layer = Layer::create([
'project_id' => $this->project->id,
'phase_id' => $this->phase->id,
'name' => $this->layerName ?: 'Nueva capa',
'color' => $this->layerColor ?: '#3b82f6',
'project_id' => $this->project->id,
'phase_id' => $this->phase->id,
'name' => $this->layerName ?: 'Nueva capa',
'color' => $this->layerColor ?: '#3b82f6',
'original_file' => null,
'uploaded_by' => $user->id,
'uploaded_by' => $user->id,
]);
$this->loadLayers();
@@ -255,18 +275,20 @@ class LayerManager extends Component
#[On('save-manual-geojson')]
public function saveManualGeojson($geojsonString)
{
if (!$this->selectedLayer) {
if (! $this->selectedLayer) {
$this->dispatch('notify', 'No hay capa seleccionada');
return;
}
$geojson = json_decode($geojsonString, true);
if (json_last_error() !== JSON_ERROR_NONE || !isset($geojson['features'])) {
if (json_last_error() !== JSON_ERROR_NONE || ! isset($geojson['features'])) {
$this->dispatch('notify', 'GeoJSON inválido');
return;
}
$layerId = $this->selectedLayer->id;
$layerId = $this->selectedLayer->id;
$layerName = $this->selectedLayer->name;
try {
@@ -278,16 +300,18 @@ class LayerManager extends Component
foreach ($geojson['features'] as $fd) {
$idx++;
$name = trim($fd['properties']['name'] ?? '');
if ($name === '') $name = $layerName . ' — Elemento ' . $idx;
if ($name === '') {
$name = $layerName.' — Elemento '.$idx;
}
Feature::create([
'layer_id' => $layerId,
'name' => $name,
'geometry' => $fd['geometry'],
'properties' => $fd['properties'] ?? [],
'layer_id' => $layerId,
'name' => $name,
'geometry' => $fd['geometry'],
'properties' => $fd['properties'] ?? [],
'template_id' => $fd['properties']['template_id'] ?? null,
'progress' => $fd['properties']['progress'] ?? 0,
'status' => in_array($fd['properties']['status'] ?? '', Feature::STATUSES)
'progress' => $fd['properties']['progress'] ?? 0,
'status' => in_array($fd['properties']['status'] ?? '', Feature::STATUSES)
? $fd['properties']['status']
: 'planned',
'responsible' => $fd['properties']['responsible'] ?? null,
@@ -295,14 +319,15 @@ class LayerManager extends Component
}
});
} catch (\Throwable $e) {
$this->dispatch('notify', 'Error al guardar: ' . $e->getMessage());
$this->dispatch('notify', 'Error al guardar: '.$e->getMessage());
return;
}
$this->loadLayers();
$this->selectLayer($this->selectedLayer->id);
$this->emitInitialLayersData();
$this->dispatch('notify', count($geojson['features']) . ' elementos guardados');
$this->dispatch('notify', count($geojson['features']).' elementos guardados');
}
// ── Delete layer ──────────────────────────────────────────────────────────
@@ -310,13 +335,19 @@ class LayerManager extends Component
public function deleteLayer($layerId)
{
$user = Auth::user();
if (!$user->can('delete layers')) abort(403);
if (! $user->can('delete layers')) {
abort(403);
}
// Verify it belongs to this phase (prevents cross-project deletion)
$layer = Layer::where('id', $layerId)->where('phase_id', $this->phase->id)->first();
if (!$layer) return;
if (! $layer) {
return;
}
if ($layer->original_file) Storage::disk('public')->delete($layer->original_file);
if ($layer->original_file) {
Storage::disk('public')->delete($layer->original_file);
}
$layer->features()->delete();
$layer->delete();
@@ -337,25 +368,27 @@ class LayerManager extends Component
->where('id', $layerId)
->where('phase_id', $this->phase->id)
->first();
if (!$layer) return;
if (! $layer) {
return;
}
$fc = [
'type' => 'FeatureCollection',
'name' => $layer->name,
'features' => $layer->features->map(fn($f) => [
'type' => 'Feature',
'geometry' => $f->geometry,
'type' => 'FeatureCollection',
'name' => $layer->name,
'features' => $layer->features->map(fn ($f) => [
'type' => 'Feature',
'geometry' => $f->geometry,
'properties' => array_merge($f->properties ?? [], [
'name' => $f->name,
'progress' => $f->progress,
'status' => $f->status,
'name' => $f->name,
'progress' => $f->progress,
'status' => $f->status,
'responsible' => $f->responsible,
'template_id' => $f->template_id,
]),
])->values()->toArray(),
];
$filename = preg_replace('/[^a-z0-9_\-]/i', '_', $layer->name) . '.geojson';
$filename = preg_replace('/[^a-z0-9_\-]/i', '_', $layer->name).'.geojson';
return response()->streamDownload(function () use ($fc) {
echo json_encode($fc, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
@@ -367,7 +400,9 @@ class LayerManager extends Component
public function batchAssign($layerId)
{
$layer = Layer::where('id', $layerId)->where('phase_id', $this->phase->id)->first();
if (!$layer) return;
if (! $layer) {
return;
}
$data = [];
if ($this->batchStatus && in_array($this->batchStatus, Feature::STATUSES)) {
@@ -378,6 +413,7 @@ class LayerManager extends Component
}
if (empty($data)) {
$this->dispatch('notify', 'Selecciona un estado o template para asignar');
return;
}
+18 -16
View File
@@ -2,17 +2,10 @@
namespace App\Livewire\Media;
use App\Models\Media;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
use Livewire\WithFileUploads;
use Livewire\Attributes\On;
use App\Models\Media;
use App\Models\Project;
use App\Models\Phase;
use App\Models\Layer;
use App\Models\Feature;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class MediaManager extends Component
{
@@ -20,18 +13,23 @@ class MediaManager extends Component
// Polimórfico: a qué entidad pertenece
public $mediableType;
public $mediableId;
public $entity; // instancia cargada
public $mediaItems = [];
// Subida
public $uploadFiles = [];
public $uploadDescription = '';
public $uploadCategory = 'image';
// Modal visor
public $showViewer = false;
public $viewingMedia = null;
protected $rules = [
@@ -65,8 +63,9 @@ class MediaManager extends Component
public function upload()
{
$user = Auth::user();
if (!$user->can('upload layers')) {
if (! $user->can('upload layers')) {
session()->flash('error', 'Sin permisos.');
return;
}
@@ -74,6 +73,7 @@ class MediaManager extends Component
if (empty($this->uploadFiles)) {
session()->flash('error', 'Selecciona al menos un archivo.');
return;
}
@@ -130,8 +130,9 @@ class MediaManager extends Component
$media = Media::findOrFail($mediaId);
$user = Auth::user();
if (!$user->can('delete media') && $media->uploaded_by !== $user->id) {
if (! $user->can('delete media') && $media->uploaded_by !== $user->id) {
session()->flash('error', 'No puedes borrar archivos de otro usuario.');
return;
}
@@ -143,9 +144,10 @@ class MediaManager extends Component
public function viewMedia($mediaId)
{
$media = Media::findOrFail($mediaId);
if (!$media->is_image) {
if (! $media->is_image) {
// Si no es imagen, abrir en nueva pestaña
$this->dispatch('openUrl', $media->url);
return;
}
$this->viewingMedia = $media;
@@ -161,9 +163,9 @@ class MediaManager extends Component
public function render()
{
return view('livewire.media.media-manager', [
'entityName' => class_basename($this->entity) . ': ' . ($this->entity->name ?? $this->entity->id),
'images' => $this->mediaItems->filter(fn($m) => $m->is_image),
'documents' => $this->mediaItems->filter(fn($m) => !$m->is_image),
'entityName' => class_basename($this->entity).': '.($this->entity->name ?? $this->entity->id),
'images' => $this->mediaItems->filter(fn ($m) => $m->is_image),
'documents' => $this->mediaItems->filter(fn ($m) => ! $m->is_image),
]);
}
}
}
+37 -33
View File
@@ -1,20 +1,23 @@
<?php
namespace App\Livewire\Phases;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\Project;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class PhaseGantt extends Component
{
public Project $project;
public $ganttData = [];
public function mount(Project $project)
{
$user = Auth::user();
if (!$user->can('manage all') && !$project->users()->where('user_id', $user->id)->exists()) {
if (! $user->can('manage all') && ! $project->users()->where('user_id', $user->id)->exists()) {
abort(403);
}
$this->project = $project;
@@ -25,47 +28,48 @@ class PhaseGantt extends Component
{
$phases = $this->project->phases()->with(['layers.features'])->orderBy('order')->get();
$projectStart = $this->project->start_date ?? now()->startOfMonth();
$projectEnd = $this->project->end_date_estimated ?? now()->addMonths(6);
$projectEnd = $this->project->end_date_estimated ?? now()->addMonths(6);
$this->ganttData = $phases->map(function($phase) use ($projectStart, $projectEnd) {
$this->ganttData = $phases->map(function ($phase) use ($projectStart, $projectEnd) {
$planned_start = $phase->planned_start ?? $projectStart;
$planned_end = $phase->planned_end ?? $projectEnd;
$actual_start = $phase->actual_start;
$actual_end = $phase->actual_end;
$planned_end = $phase->planned_end ?? $projectEnd;
$actual_start = $phase->actual_start;
$actual_end = $phase->actual_end;
$totalDays = max(1, $projectStart->diffInDays($projectEnd));
$pStartOffset = max(0, $projectStart->diffInDays($planned_start));
$pDuration = max(1, $planned_start->diffInDays($planned_end));
$pStartPct = round(($pStartOffset / $totalDays) * 100, 2);
$pWidthPct = round(($pDuration / $totalDays) * 100, 2);
$pDuration = max(1, $planned_start->diffInDays($planned_end));
$pStartPct = round(($pStartOffset / $totalDays) * 100, 2);
$pWidthPct = round(($pDuration / $totalDays) * 100, 2);
$aStartPct = null; $aWidthPct = null;
$aStartPct = null;
$aWidthPct = null;
if ($actual_start) {
$aStart = max(0, $projectStart->diffInDays($actual_start));
$aEnd = $actual_end ?? now();
$aStart = max(0, $projectStart->diffInDays($actual_start));
$aEnd = $actual_end ?? now();
$aDuration = max(1, $actual_start->diffInDays($aEnd));
$aStartPct = round(($aStart / $totalDays) * 100, 2);
$aStartPct = round(($aStart / $totalDays) * 100, 2);
$aWidthPct = round(($aDuration / $totalDays) * 100, 2);
}
$isDelayed = $phase->planned_end && $phase->planned_end->isPast() && $phase->progress_percent < 100;
return [
'id' => $phase->id,
'name' => $phase->name,
'color' => $phase->color ?? '#3b82f6',
'progress' => $phase->progress_percent,
'planned_start' => $planned_start->format('d/m/Y'),
'planned_end' => $planned_end->format('d/m/Y'),
'actual_start' => $actual_start?->format('d/m/Y'),
'actual_end' => $actual_end?->format('d/m/Y'),
'p_start_pct' => $pStartPct,
'p_width_pct' => min($pWidthPct, 100 - $pStartPct),
'a_start_pct' => $aStartPct,
'a_width_pct' => $aWidthPct ? min($aWidthPct, 100 - $aStartPct) : null,
'is_delayed' => $isDelayed,
'features_count' => $phase->layers->sum(fn($l) => $l->features->count()),
'id' => $phase->id,
'name' => $phase->name,
'color' => $phase->color ?? '#3b82f6',
'progress' => $phase->progress_percent,
'planned_start' => $planned_start->format('d/m/Y'),
'planned_end' => $planned_end->format('d/m/Y'),
'actual_start' => $actual_start?->format('d/m/Y'),
'actual_end' => $actual_end?->format('d/m/Y'),
'p_start_pct' => $pStartPct,
'p_width_pct' => min($pWidthPct, 100 - $pStartPct),
'a_start_pct' => $aStartPct,
'a_width_pct' => $aWidthPct ? min($aWidthPct, 100 - $aStartPct) : null,
'is_delayed' => $isDelayed,
'features_count' => $phase->layers->sum(fn ($l) => $l->features->count()),
];
})->toArray();
}
@@ -75,9 +79,9 @@ class PhaseGantt extends Component
$phase = $this->project->phases()->findOrFail($phaseId);
$phase->update([
'planned_start' => $plannedStart ?: null,
'planned_end' => $plannedEnd ?: null,
'actual_start' => $actualStart ?: null,
'actual_end' => $actualEnd ?: null,
'planned_end' => $plannedEnd ?: null,
'actual_start' => $actualStart ?: null,
'actual_end' => $actualEnd ?: null,
]);
$this->loadGanttData();
$this->dispatch('notify', 'Fechas actualizadas');
@@ -87,7 +91,7 @@ class PhaseGantt extends Component
{
return view('livewire.projects.phase-gantt', [
'project' => $this->project,
'phases' => $this->project->phases()->orderBy('order')->get(),
'phases' => $this->project->phases()->orderBy('order')->get(),
]);
}
}
+42 -34
View File
@@ -2,7 +2,6 @@
namespace App\Livewire\Phases;
use App\Models\Phase;
use App\Models\Project;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\On;
@@ -14,17 +13,26 @@ class PhaseList extends Component
// Modal state
public bool $showForm = false;
public $editingId = null;
// Form fields
public string $name = '';
public string $description = '';
public string $color = '#3b82f6';
public int $order = 1;
public int $progressPercent = 0;
public string $plannedStart = '';
public string $plannedEnd = '';
public string $actualStart = '';
public string $actualEnd = '';
public function mount(Project $project)
@@ -35,27 +43,27 @@ class PhaseList extends Component
protected function rules(): array
{
return [
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'color' => 'required|string|max:7',
'order' => 'required|integer|min:0',
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'color' => 'required|string|max:7',
'order' => 'required|integer|min:0',
'progressPercent' => 'required|integer|min:0|max:100',
'plannedStart' => 'nullable|date',
'plannedEnd' => 'nullable|date|after_or_equal:plannedStart',
'actualStart' => 'nullable|date',
'actualEnd' => 'nullable|date|after_or_equal:actualStart',
'plannedStart' => 'nullable|date',
'plannedEnd' => 'nullable|date|after_or_equal:plannedStart',
'actualStart' => 'nullable|date',
'actualEnd' => 'nullable|date|after_or_equal:actualStart',
];
}
protected $validationAttributes = [
'name' => 'nombre',
'color' => 'color',
'order' => 'orden',
'name' => 'nombre',
'color' => 'color',
'order' => 'orden',
'progressPercent' => 'progreso',
'plannedStart' => 'inicio previsto',
'plannedEnd' => 'fin previsto',
'actualStart' => 'inicio real',
'actualEnd' => 'fin real',
'plannedStart' => 'inicio previsto',
'plannedEnd' => 'fin previsto',
'actualStart' => 'inicio real',
'actualEnd' => 'fin real',
];
public function openForm($phaseId = null): void
@@ -65,19 +73,19 @@ class PhaseList extends Component
if ($phaseId) {
$phase = $this->project->phases()->findOrFail($phaseId);
$this->editingId = $phase->id;
$this->name = $phase->name;
$this->description = $phase->description ?? '';
$this->color = $phase->color ?? '#3b82f6';
$this->order = (int) $phase->order;
$this->editingId = $phase->id;
$this->name = $phase->name;
$this->description = $phase->description ?? '';
$this->color = $phase->color ?? '#3b82f6';
$this->order = (int) $phase->order;
$this->progressPercent = (int) $phase->progress_percent;
$this->plannedStart = $phase->planned_start?->format('Y-m-d') ?? '';
$this->plannedEnd = $phase->planned_end?->format('Y-m-d') ?? '';
$this->actualStart = $phase->actual_start?->format('Y-m-d') ?? '';
$this->actualEnd = $phase->actual_end?->format('Y-m-d') ?? '';
$this->plannedStart = $phase->planned_start?->format('Y-m-d') ?? '';
$this->plannedEnd = $phase->planned_end?->format('Y-m-d') ?? '';
$this->actualStart = $phase->actual_start?->format('Y-m-d') ?? '';
$this->actualEnd = $phase->actual_end?->format('Y-m-d') ?? '';
} else {
$this->order = (int) $this->project->phases()->max('order') + 1;
$this->color = '#' . substr(md5((string) rand()), 0, 6);
$this->color = '#'.substr(md5((string) rand()), 0, 6);
}
$this->showForm = true;
@@ -113,15 +121,15 @@ class PhaseList extends Component
$this->validate();
$data = [
'name' => $this->name,
'description' => $this->description ?: null,
'color' => $this->color,
'order' => $this->order,
'name' => $this->name,
'description' => $this->description ?: null,
'color' => $this->color,
'order' => $this->order,
'progress_percent' => $this->progressPercent,
'planned_start' => $this->plannedStart ?: null,
'planned_end' => $this->plannedEnd ?: null,
'actual_start' => $this->actualStart ?: null,
'actual_end' => $this->actualEnd ?: null,
'planned_start' => $this->plannedStart ?: null,
'planned_end' => $this->plannedEnd ?: null,
'actual_start' => $this->actualStart ?: null,
'actual_end' => $this->actualEnd ?: null,
];
if ($this->editingId) {
+5 -3
View File
@@ -2,15 +2,17 @@
namespace App\Livewire\Phases;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\Phase;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class PhaseProgress extends Component
{
public Phase $phase;
public $progress;
public $comment = '';
public function mount(Phase $phase)
@@ -39,4 +41,4 @@ class PhaseProgress extends Component
{
return view('livewire.phases.phase-progress');
}
}
}
+44 -42
View File
@@ -6,6 +6,7 @@ use App\Models\Phase;
use App\Models\Project;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Livewire\Attributes\On;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
@@ -19,9 +20,9 @@ class PhaseTable extends DataTableComponent
public function configure(): void
{
$this->setPrimaryKey('id')
->setDefaultSort('order', 'asc')
->setSortingPillsEnabled(false)
->setAdditionalSelects(['phases.id as id', 'phases.order as order']);
->setDefaultSort('order', 'asc')
->setSortingPillsEnabled(false)
->setAdditionalSelects(['phases.id as id', 'phases.order as order']);
}
/** Re-render when the parent (PhaseList) creates/edits a phase. */
@@ -49,64 +50,65 @@ class PhaseTable extends DataTableComponent
Column::make('Orden', 'order')->sortable(),
Column::make('Nombre', 'name')
->sortable()
->searchable()
->format(function ($value, $row) {
$html = '<span class="font-medium">'.e($value).'</span>';
if ($row->description) {
$html .= '<div class="text-xs text-base-content/50 truncate max-w-xs">'.e(\Illuminate\Support\Str::limit($row->description, 60)).'</div>';
}
return $html;
})
->html(),
->sortable()
->searchable()
->format(function ($value, $row) {
$html = '<span class="font-medium">'.e($value).'</span>';
if ($row->description) {
$html .= '<div class="text-xs text-base-content/50 truncate max-w-xs">'.e(Str::limit($row->description, 60)).'</div>';
}
return $html;
})
->html(),
Column::make('Progreso', 'progress_percent')
->sortable()
->format(fn ($value) =>
'<div class="flex items-center gap-2 min-w-[110px]">
->sortable()
->format(fn ($value) => '<div class="flex items-center gap-2 min-w-[110px]">
<progress class="progress progress-primary w-24 h-2" value="'.(int) $value.'" max="100"></progress>
<span class="text-xs text-base-content/60 w-8 text-right">'.(int) $value.'%</span>
</div>')
->html(),
->html(),
Column::make('Fechas')
->label(function ($row) {
$ps = $row->planned_start?->format('d/m/Y');
$pe = $row->planned_end?->format('d/m/Y');
if (! $ps && ! $pe) {
return '<span class="text-base-content/30 text-xs">—</span>';
}
return '<span class="text-xs">'.($ps ?: '?').' → '.($pe ?: '?').'</span>';
})
->html(),
->label(function ($row) {
$ps = $row->planned_start?->format('d/m/Y');
$pe = $row->planned_end?->format('d/m/Y');
if (! $ps && ! $pe) {
return '<span class="text-base-content/30 text-xs">—</span>';
}
return '<span class="text-xs">'.($ps ?: '?').' → '.($pe ?: '?').'</span>';
})
->html(),
Column::make('Color', 'color')
->format(fn ($value) =>
'<div class="w-6 h-6 rounded border border-base-300" style="background:'.e($value).'" title="'.e($value).'"></div>')
->html(),
->format(fn ($value) => '<div class="w-6 h-6 rounded border border-base-300" style="background:'.e($value).'" title="'.e($value).'"></div>')
->html(),
Column::make('Acciones')
->label(function ($row) {
$user = Auth::user();
$progress = route('phases.progress', $row->id);
->label(function ($row) {
$user = Auth::user();
$progress = route('phases.progress', $row->id);
$html = '<div class="flex items-center justify-end gap-1">';
$html .= '<a href="'.$progress.'" class="btn btn-xs btn-outline btn-info" title="Actualizar progreso" wire:navigate>
$html = '<div class="flex items-center justify-end gap-1">';
$html .= '<a href="'.$progress.'" class="btn btn-xs btn-outline btn-info" title="Actualizar progreso" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
</a>';
if ($user->can('manage phases')) {
$html .= '<button wire:click="$dispatch(\'phase-edit\', { id: '.$row->id.' })" class="btn btn-xs btn-ghost" title="Editar">
if ($user->can('manage phases')) {
$html .= '<button wire:click="$dispatch(\'phase-edit\', { id: '.$row->id.' })" class="btn btn-xs btn-ghost" title="Editar">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</button>';
$html .= '<button wire:click="deletePhase('.$row->id.')" wire:confirm="¿Eliminar la fase \''.e($row->name).'\'? Esta acción no se puede deshacer."
$html .= '<button wire:click="deletePhase('.$row->id.')" wire:confirm="¿Eliminar la fase \''.e($row->name).'\'? Esta acción no se puede deshacer."
class="btn btn-xs btn-error btn-outline" title="Eliminar">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>';
}
$html .= '</div>';
return $html;
})
->html(),
}
$html .= '</div>';
return $html;
})
->html(),
];
}
+12 -6
View File
@@ -14,13 +14,18 @@ use Livewire\Component;
class FeatureManager extends Component
{
public Project $project;
public $featureTypes = [];
// Edit modal
public bool $showForm = false;
public $editingId = null;
public string $name = '';
public $featureTypeId = '';
public bool $isActive = true;
public function mount(Project $project)
@@ -33,6 +38,7 @@ class FeatureManager extends Component
private function canManage(): bool
{
$user = Auth::user();
return $user->can('manage all')
|| ($user->can('edit layers') && $this->project->users()->where('user_id', $user->id)->exists());
}
@@ -42,10 +48,10 @@ class FeatureManager extends Component
{
abort_unless($this->canManage(), 403);
$feature = $this->findFeature($id);
$this->editingId = $feature->id;
$this->name = $feature->name ?? '';
$this->editingId = $feature->id;
$this->name = $feature->name ?? '';
$this->featureTypeId = $feature->feature_type_id ?? '';
$this->isActive = (bool) $feature->is_active;
$this->isActive = (bool) $feature->is_active;
$this->resetErrorBag();
$this->showForm = true;
}
@@ -54,14 +60,14 @@ class FeatureManager extends Component
{
abort_unless($this->canManage(), 403);
$this->validate([
'name' => 'required|string|max:255',
'name' => 'required|string|max:255',
'featureTypeId' => 'nullable|exists:feature_types,id',
]);
$this->findFeature($this->editingId)->update([
'name' => $this->name,
'name' => $this->name,
'feature_type_id' => $this->featureTypeId ?: null,
'is_active' => $this->isActive,
'is_active' => $this->isActive,
]);
$this->showForm = false;
+34 -32
View File
@@ -22,10 +22,10 @@ class FeatureTable extends DataTableComponent
public function configure(): void
{
$this->setPrimaryKey('id')
->setDefaultSort('name', 'asc')
->setSortingPillsEnabled(false)
->setSecondaryHeaderEnabled()
->setAdditionalSelects(['features.id as id', 'features.layer_id as layer_id']);
->setDefaultSort('name', 'asc')
->setSortingPillsEnabled(false)
->setSecondaryHeaderEnabled()
->setAdditionalSelects(['features.id as id', 'features.layer_id as layer_id']);
}
public function builder(): Builder
@@ -46,40 +46,42 @@ class FeatureTable extends DataTableComponent
{
return [
Column::make('Elemento', 'name')
->sortable()
->searchable()
->secondaryHeaderFilter('name')
->format(fn ($value) => '<span class="font-medium">' . e($value) . '</span>')
->html(),
->sortable()
->searchable()
->secondaryHeaderFilter('name')
->format(fn ($value) => '<span class="font-medium">'.e($value).'</span>')
->html(),
Column::make('Capa')
->secondaryHeaderFilter('layer')
->label(fn ($row) => e($row->layer?->name ?? '—')),
->secondaryHeaderFilter('layer')
->label(fn ($row) => e($row->layer?->name ?? '—')),
Column::make('Fase')
->secondaryHeaderFilter('phase')
->label(fn ($row) => e($row->layer?->phase?->name ?? '—')),
->secondaryHeaderFilter('phase')
->label(fn ($row) => e($row->layer?->phase?->name ?? '—')),
Column::make('Progreso', 'progress')
->sortable()
->format(function ($value) {
$cls = $value >= 100 ? 'badge-success' : ($value > 0 ? 'badge-warning' : 'badge-ghost');
return '<span class="badge badge-sm ' . $cls . '">' . (int) $value . '%</span>';
})
->html(),
->sortable()
->format(function ($value) {
$cls = $value >= 100 ? 'badge-success' : ($value > 0 ? 'badge-warning' : 'badge-ghost');
return '<span class="badge badge-sm '.$cls.'">'.(int) $value.'%</span>';
})
->html(),
Column::make('Acciones')
->label(function ($row) {
$id = $row->id;
return '<div class="flex justify-end">'
. '<button wire:click="$dispatch(\'map-select-feature\', { featureId: ' . $id . ' })"'
. 'class="btn btn-xs btn-primary gap-1" title="' . e(__('Editar elemento')) . '">'
. '<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>'
. e(__('Abrir'))
. '</button>'
. '</div>';
})
->html(),
->label(function ($row) {
$id = $row->id;
return '<div class="flex justify-end">'
.'<button wire:click="$dispatch(\'map-select-feature\', { featureId: '.$id.' })"'
.'class="btn btn-xs btn-primary gap-1" title="'.e(__('Editar elemento')).'">'
.'<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>'
.e(__('Abrir'))
.'</button>'
.'</div>';
})
->html(),
];
}
@@ -93,7 +95,7 @@ class FeatureTable extends DataTableComponent
return [
TextFilter::make('Elemento', 'name')
->config(['placeholder' => 'Buscar elemento…'])
->filter(fn (Builder $query, string $value) => $query->where('features.name', 'like', '%' . $value . '%')),
->filter(fn (Builder $query, string $value) => $query->where('features.name', 'like', '%'.$value.'%')),
SelectFilter::make('Capa', 'layer')
->options(['' => 'Todas'] + $layers)
@@ -104,4 +106,4 @@ class FeatureTable extends DataTableComponent
->filter(fn (Builder $query, string $value) => $query->whereHas('layer', fn ($l) => $l->where('phase_id', $value))),
];
}
}
}
+9 -5
View File
@@ -13,9 +13,13 @@ class FeatureTypeManager extends Component
public $types = [];
public bool $showForm = false;
public $editingId = null;
public string $name = '';
public string $description = '';
public string $color = '#6b7280';
public function mount()
@@ -32,9 +36,9 @@ class FeatureTypeManager extends Component
protected function rules(): array
{
return [
'name' => 'required|string|max:255|unique:feature_types,name,' . ($this->editingId ?? 'NULL'),
'name' => 'required|string|max:255|unique:feature_types,name,'.($this->editingId ?? 'NULL'),
'description' => 'nullable|string|max:255',
'color' => 'required|string|max:7',
'color' => 'required|string|max:7',
];
}
@@ -49,10 +53,10 @@ class FeatureTypeManager extends Component
public function edit($id): void
{
$t = FeatureType::findOrFail($id);
$this->editingId = $t->id;
$this->name = $t->name;
$this->editingId = $t->id;
$this->name = $t->name;
$this->description = $t->description ?? '';
$this->color = $t->color ?? '#6b7280';
$this->color = $t->color ?? '#6b7280';
$this->resetErrorBag();
$this->showForm = true;
}
+57 -59
View File
@@ -22,16 +22,16 @@ class InspectionTable extends DataTableComponent
public function configure(): void
{
$this->setPrimaryKey('id')
->setDefaultSort('inspections.created_at', 'desc')
->setSortingPillsEnabled(false)
->setSecondaryHeaderEnabled()
->setAdditionalSelects([
'inspections.id as id',
'inspections.created_at as created_at',
'inspections.feature_id as feature_id',
'inspections.template_id as template_id',
'inspections.user_id as user_id',
]);
->setDefaultSort('inspections.created_at', 'desc')
->setSortingPillsEnabled(false)
->setSecondaryHeaderEnabled()
->setAdditionalSelects([
'inspections.id as id',
'inspections.created_at as created_at',
'inspections.feature_id as feature_id',
'inspections.template_id as template_id',
'inspections.user_id as user_id',
]);
}
public function builder(): Builder
@@ -52,58 +52,57 @@ class InspectionTable extends DataTableComponent
{
return [
Column::make('Fecha', 'created_at')
->sortable()
->secondaryHeaderFilter('fecha')
->format(fn ($value, $row) => $row->created_at?->format('d/m/Y') ?? '—'),
->sortable()
->secondaryHeaderFilter('fecha')
->format(fn ($value, $row) => $row->created_at?->format('d/m/Y') ?? '—'),
Column::make('Elemento')
->secondaryHeaderFilter('elemento')
->label(fn ($row) => $row->feature?->name
? '<span class="font-medium">' . e($row->feature->name) . '</span>'
: '<span class="text-base-content/30 text-xs">—</span>')
->html(),
->secondaryHeaderFilter('elemento')
->label(fn ($row) => $row->feature?->name
? '<span class="font-medium">'.e($row->feature->name).'</span>'
: '<span class="text-base-content/30 text-xs">—</span>')
->html(),
Column::make('Plantilla')
->label(fn ($row) => e($row->template?->name ?? '—')),
->label(fn ($row) => e($row->template?->name ?? '—')),
Column::make('Resultado', 'result')
->sortable()
->secondaryHeaderFilter('resultado')
->format(fn ($value) => $value
? '<span class="badge badge-sm badge-outline">' . e($value) . '</span>'
: '<span class="text-base-content/30 text-xs">—</span>')
->html(),
->sortable()
->secondaryHeaderFilter('resultado')
->format(fn ($value) => $value
? '<span class="badge badge-sm badge-outline">'.e($value).'</span>'
: '<span class="text-base-content/30 text-xs">—</span>')
->html(),
Column::make('Usuario')
->secondaryHeaderFilter('usuario')
->label(fn ($row) => e($row->user?->name ?? '—')),
->secondaryHeaderFilter('usuario')
->label(fn ($row) => e($row->user?->name ?? '—')),
Column::make('Fotos')
->label(fn ($row) => $this->renderPhotosColumn($row))
->html(),
->label(fn ($row) => $this->renderPhotosColumn($row))
->html(),
Column::make('Acciones')
->label(fn ($row) =>
'<div class="flex justify-end gap-1">'
. '<button wire:click="$dispatch(\'map-view-inspection\', ' . $row->id . ')"'
. 'class="btn btn-xs btn-ghost" title="Ver inspección">'
. '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>'
. '</button>'
. '@can("edit inspections")'
. '<button wire:click="$dispatch(\'edit-inspection\', ' . $row->id . ')"'
. 'class="btn btn-xs btn-ghost" title="Editar inspección">'
. '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>'
. '</button>'
. '@endcan'
. '@can("delete inspections")'
. '<button wire:click="$dispatch(\'delete-inspection\', ' . $row->id . ')"'
. 'class="btn btn-xs btn-ghost btn-error" title="Eliminar inspección"'
. 'onclick="return confirm(\'¿Eliminar esta inspección? No se puede deshacer.\');">'
. '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16\"/></svg>'
. '</button>'
. '@endcan'
. '</div>')
->html(),
->label(fn ($row) => '<div class="flex justify-end gap-1">'
.'<button wire:click="$dispatch(\'map-view-inspection\', '.$row->id.')"'
.'class="btn btn-xs btn-ghost" title="Ver inspección">'
.'<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>'
.'</button>'
.'@can("edit inspections")'
.'<button wire:click="$dispatch(\'edit-inspection\', '.$row->id.')"'
.'class="btn btn-xs btn-ghost" title="Editar inspección">'
.'<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>'
.'</button>'
.'@endcan'
.'@can("delete inspections")'
.'<button wire:click="$dispatch(\'delete-inspection\', '.$row->id.')"'
.'class="btn btn-xs btn-ghost btn-error" title="Eliminar inspección"'
.'onclick="return confirm(\'¿Eliminar esta inspección? No se puede deshacer.\');">'
.'<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16\"/></svg>'
.'</button>'
.'@endcan'
.'</div>')
->html(),
];
}
@@ -116,15 +115,14 @@ class InspectionTable extends DataTableComponent
return '<span class="text-base-content/30 text-xs">—</span>';
}
$thumbnails = $images->take(3)->map(fn ($m) =>
'<a href="' . $m->url . '" target="_blank" class="inline-block mr-1">'
. '<img src="' . $m->url . '" class="w-8 h-8 object-cover rounded border border-base-300" alt="' . e($m->name) . '" loading="lazy" />'
. '</a>'
$thumbnails = $images->take(3)->map(fn ($m) => '<a href="'.$m->url.'" target="_blank" class="inline-block mr-1">'
.'<img src="'.$m->url.'" class="w-8 h-8 object-cover rounded border border-base-300" alt="'.e($m->name).'" loading="lazy" />'
.'</a>'
)->implode('');
$more = $count > 3 ? '<span class="text-xs text-base-content/50 ml-1">+' . ($count - 3) . '</span>' : '';
$more = $count > 3 ? '<span class="text-xs text-base-content/50 ml-1">+'.($count - 3).'</span>' : '';
return '<div class="flex items-center">' . $thumbnails . $more . '</div>';
return '<div class="flex items-center">'.$thumbnails.$more.'</div>';
}
public function filters(): array
@@ -134,7 +132,7 @@ class InspectionTable extends DataTableComponent
->pluck('result', 'result')->toArray();
$users = User::whereIn('id', Inspection::where('project_id', $this->projectId)
->whereNotNull('user_id')->distinct()->pluck('user_id'))
->whereNotNull('user_id')->distinct()->pluck('user_id'))
->orderBy('name')->pluck('name', 'id')->toArray();
return [
@@ -143,7 +141,7 @@ class InspectionTable extends DataTableComponent
TextFilter::make('Elemento', 'elemento')
->config(['placeholder' => 'Buscar elemento…'])
->filter(fn (Builder $query, string $value) => $query->whereHas('feature', fn ($f) => $f->where('name', 'like', '%' . $value . '%'))),
->filter(fn (Builder $query, string $value) => $query->whereHas('feature', fn ($f) => $f->where('name', 'like', '%'.$value.'%'))),
SelectFilter::make('Resultado', 'resultado')
->options(['' => 'Todos'] + $results)
@@ -154,4 +152,4 @@ class InspectionTable extends DataTableComponent
->filter(fn (Builder $query, string $value) => $query->where('inspections.user_id', $value)),
];
}
}
}
+4 -1
View File
@@ -11,8 +11,11 @@ use Livewire\Component;
class ProjectCompanies extends Component
{
public Project $project;
public $allCompanies = [];
public $selectedCompanyId = '';
public $selectedRole = 'other';
public function mount(Project $project)
@@ -41,7 +44,7 @@ class ProjectCompanies extends Component
$this->validate([
'selectedCompanyId' => 'required|exists:companies,id',
'selectedRole' => 'required|in:' . implode(',', array_keys(ProjectCompaniesTable::ROLES)),
'selectedRole' => 'required|in:'.implode(',', array_keys(ProjectCompaniesTable::ROLES)),
]);
$this->project->companies()->attach($this->selectedCompanyId, [
+45 -41
View File
@@ -3,6 +3,7 @@
namespace App\Livewire\Projects;
use App\Models\Company;
use App\Models\Project;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\On;
@@ -18,20 +19,20 @@ class ProjectCompaniesTable extends DataTableComponent
/** role_in_project => label */
public const ROLES = [
'owner' => 'Promotor',
'constructor' => 'Constructor',
'owner' => 'Promotor',
'constructor' => 'Constructor',
'subcontractor' => 'Subcontratista',
'consultant' => 'Consultor',
'supplier' => 'Proveedor',
'other' => 'Otro',
'consultant' => 'Consultor',
'supplier' => 'Proveedor',
'other' => 'Otro',
];
public function configure(): void
{
$this->setPrimaryKey('id')
->setDefaultSort('companies.name', 'asc')
->setSortingPillsEnabled(false)
->setAdditionalSelects(['companies.id as id', 'company_project.role_in_project as role_in_project']);
->setDefaultSort('companies.name', 'asc')
->setSortingPillsEnabled(false)
->setAdditionalSelects(['companies.id as id', 'company_project.role_in_project as role_in_project']);
}
#[On('project-companies-changed')]
@@ -51,48 +52,51 @@ class ProjectCompaniesTable extends DataTableComponent
{
return [
Column::make('Empresa', 'name')
->sortable()
->searchable()
->format(function ($value, $row) {
$initial = strtoupper(mb_substr($value ?? '?', 0, 1));
$html = '<div class="flex items-center gap-2">
->sortable()
->searchable()
->format(function ($value, $row) {
$initial = strtoupper(mb_substr($value ?? '?', 0, 1));
$html = '<div class="flex items-center gap-2">
<span class="w-7 h-7 rounded-full bg-primary text-primary-content flex items-center justify-center text-xs font-bold shrink-0">'.$initial.'</span>
<div><span class="font-medium">'.e($value).'</span>';
if ($row->tax_id) {
$html .= '<div class="text-xs text-base-content/50">'.e($row->tax_id).'</div>';
}
$html .= '</div></div>';
return $html;
})
->html(),
if ($row->tax_id) {
$html .= '<div class="text-xs text-base-content/50">'.e($row->tax_id).'</div>';
}
$html .= '</div></div>';
return $html;
})
->html(),
Column::make('Rol', 'role_in_project')
->label(function ($row) {
$current = $row->role_in_project;
if (! Auth::user()->can('assign companies')) {
return '<span class="badge badge-sm">'.(self::ROLES[$current] ?? ucfirst((string) $current)).'</span>';
}
$opts = '';
foreach (self::ROLES as $val => $label) {
$opts .= '<option value="'.$val.'"'.($current === $val ? ' selected' : '').'>'.$label.'</option>';
}
return '<select wire:change="changeRole('.$row->id.', $event.target.value)" class="select select-bordered select-xs">'.$opts.'</select>';
})
->html(),
->label(function ($row) {
$current = $row->role_in_project;
if (! Auth::user()->can('assign companies')) {
return '<span class="badge badge-sm">'.(self::ROLES[$current] ?? ucfirst((string) $current)).'</span>';
}
$opts = '';
foreach (self::ROLES as $val => $label) {
$opts .= '<option value="'.$val.'"'.($current === $val ? ' selected' : '').'>'.$label.'</option>';
}
return '<select wire:change="changeRole('.$row->id.', $event.target.value)" class="select select-bordered select-xs">'.$opts.'</select>';
})
->html(),
Column::make('Acciones')
->label(function ($row) {
if (! Auth::user()->can('assign companies')) {
return '';
}
return '<div class="flex justify-end">
->label(function ($row) {
if (! Auth::user()->can('assign companies')) {
return '';
}
return '<div class="flex justify-end">
<button wire:click="removeCompany('.$row->id.')" wire:confirm="¿Quitar a '.e($row->name).' del proyecto?"
class="btn btn-xs btn-error btn-outline" title="Quitar del proyecto">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>';
})
->html(),
})
->html(),
];
}
@@ -111,7 +115,7 @@ class ProjectCompaniesTable extends DataTableComponent
if (! array_key_exists($role, self::ROLES)) {
return;
}
\App\Models\Project::findOrFail($this->projectId)
Project::findOrFail($this->projectId)
->companies()->updateExistingPivot($companyId, ['role_in_project' => $role]);
$this->dispatch('project-companies-changed');
$this->dispatch('notify', 'Rol actualizado.');
@@ -120,7 +124,7 @@ class ProjectCompaniesTable extends DataTableComponent
public function removeCompany($companyId): void
{
abort_unless(Auth::user()->can('assign companies'), 403);
\App\Models\Project::findOrFail($this->projectId)->companies()->detach($companyId);
Project::findOrFail($this->projectId)->companies()->detach($companyId);
$this->dispatch('project-companies-changed');
$this->dispatch('notify', 'Empresa eliminada del proyecto.');
}
+38 -30
View File
@@ -2,14 +2,14 @@
namespace App\Livewire\Projects;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\Project;
use App\Models\Phase;
use App\Models\Feature;
use App\Models\Inspection;
use App\Models\Issue;
use App\Models\Phase;
use App\Models\Project;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class ProjectDashboard extends Component
@@ -17,11 +17,16 @@ class ProjectDashboard extends Component
public Project $project;
// Computed stats (cached as properties after mount)
public array $stats = [];
public array $stats = [];
public $phases;
public $recentInspections;
public $recentIssues;
public $teamMembers;
public $companies;
public function mount(Project $project): void
@@ -34,8 +39,12 @@ class ProjectDashboard extends Component
private function checkAccess(): void
{
$user = Auth::user();
if ($user->can('manage all')) return;
if (!$this->project->users()->where('user_id', $user->id)->exists()) abort(403);
if ($user->can('manage all')) {
return;
}
if (! $this->project->users()->where('user_id', $user->id)->exists()) {
abort(403);
}
}
private function loadData(): void
@@ -44,43 +53,42 @@ class ProjectDashboard extends Component
$this->phases = Phase::where('project_id', $pid)
->withCount('layers')
->with(['layers' => fn($q) => $q->withCount('features')])
->with(['layers' => fn ($q) => $q->withCount('features')])
->orderBy('order')
->get();
$totalFeatures = Feature::whereHas('layer.phase', fn($q) => $q->where('project_id', $pid))->count();
$completedFeatures = Feature::whereHas('layer.phase', fn($q) => $q->where('project_id', $pid))
$totalFeatures = Feature::whereHas('layer.phase', fn ($q) => $q->where('project_id', $pid))->count();
$completedFeatures = Feature::whereHas('layer.phase', fn ($q) => $q->where('project_id', $pid))
->where('status', 'completed')->count();
$verifiedFeatures = Feature::whereHas('layer.phase', fn($q) => $q->where('project_id', $pid))
$verifiedFeatures = Feature::whereHas('layer.phase', fn ($q) => $q->where('project_id', $pid))
->where('status', 'verified')->count();
$openIssues = Issue::where('project_id', $pid)->where('status', 'open')->count();
$closedIssues = Issue::where('project_id', $pid)->where('status', 'closed')->count();
$openIssues = Issue::where('project_id', $pid)->where('status', 'open')->count();
$closedIssues = Issue::where('project_id', $pid)->where('status', 'closed')->count();
$criticalIssues = Issue::where('project_id', $pid)->where('status', 'open')->where('priority', 'critical')->count();
$totalInspections = Inspection::where('project_id', $pid)->count();
$passedInspections = Inspection::where('project_id', $pid)->where('result', 'pass')->count();
$failedInspections = Inspection::where('project_id', $pid)->where('result', 'fail')->count();
$totalInspections = Inspection::where('project_id', $pid)->count();
$passedInspections = Inspection::where('project_id', $pid)->where('result', 'pass')->count();
$failedInspections = Inspection::where('project_id', $pid)->where('result', 'fail')->count();
$globalProgress = $this->phases->avg('progress_percent') ?? 0;
$delayedPhases = $this->phases->filter(fn($p) =>
$p->planned_end && $p->planned_end < now() && $p->progress_percent < 100
$delayedPhases = $this->phases->filter(fn ($p) => $p->planned_end && $p->planned_end < now() && $p->progress_percent < 100
)->count();
$this->stats = [
'global_progress' => round($globalProgress),
'total_phases' => $this->phases->count(),
'delayed_phases' => $delayedPhases,
'total_features' => $totalFeatures,
'completed_features' => $completedFeatures,
'verified_features' => $verifiedFeatures,
'open_issues' => $openIssues,
'closed_issues' => $closedIssues,
'critical_issues' => $criticalIssues,
'total_inspections' => $totalInspections,
'passed_inspections' => $passedInspections,
'failed_inspections' => $failedInspections,
'global_progress' => round($globalProgress),
'total_phases' => $this->phases->count(),
'delayed_phases' => $delayedPhases,
'total_features' => $totalFeatures,
'completed_features' => $completedFeatures,
'verified_features' => $verifiedFeatures,
'open_issues' => $openIssues,
'closed_issues' => $closedIssues,
'critical_issues' => $criticalIssues,
'total_inspections' => $totalInspections,
'passed_inspections' => $passedInspections,
'failed_inspections' => $failedInspections,
];
$this->recentInspections = Inspection::where('project_id', $pid)
+33 -31
View File
@@ -4,6 +4,7 @@ namespace App\Livewire\Projects;
use App\Models\Feature;
use App\Models\FeatureType;
use App\Models\Layer;
use App\Models\Project;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
@@ -22,10 +23,10 @@ class ProjectFeaturesTable extends DataTableComponent
public function configure(): void
{
$this->setPrimaryKey('id')
->setDefaultSort('name', 'asc')
->setSortingPillsEnabled(false)
->setSecondaryHeaderEnabled()
->setAdditionalSelects(['features.id as id', 'features.layer_id as layer_id', 'features.feature_type_id as feature_type_id']);
->setDefaultSort('name', 'asc')
->setSortingPillsEnabled(false)
->setSecondaryHeaderEnabled()
->setAdditionalSelects(['features.id as id', 'features.layer_id as layer_id', 'features.feature_type_id as feature_type_id']);
}
#[On('features-changed')]
@@ -37,6 +38,7 @@ class ProjectFeaturesTable extends DataTableComponent
private function canManage(): bool
{
$user = Auth::user();
return $user->can('manage all') ||
($user->can('edit layers') &&
Project::whereKey($this->projectId)->whereHas('users', fn ($q) => $q->where('user_id', $user->id))->exists());
@@ -55,47 +57,47 @@ class ProjectFeaturesTable extends DataTableComponent
{
return [
Column::make('Elemento', 'name')
->sortable()->searchable()
->secondaryHeaderFilter('name')
->format(fn ($value) => '<span class="font-medium">' . e($value) . '</span>')
->html(),
->sortable()->searchable()
->secondaryHeaderFilter('name')
->format(fn ($value) => '<span class="font-medium">'.e($value).'</span>')
->html(),
Column::make('Capa')
->secondaryHeaderFilter('layer')
->label(fn ($row) => e($row->layer?->name ?? '—')),
->secondaryHeaderFilter('layer')
->label(fn ($row) => e($row->layer?->name ?? '—')),
Column::make('Fase')
->label(fn ($row) => e($row->layer?->phase?->name ?? '—')),
->label(fn ($row) => e($row->layer?->phase?->name ?? '—')),
Column::make('Tipo')
->secondaryHeaderFilter('type')
->label(fn ($row) => $row->featureType
? '<span class="badge badge-sm" style="background-color:' . e($row->featureType->color) . ';color:#fff;border:0;">' . e($row->featureType->name) . '</span>'
: '<span class="text-base-content/30 text-xs">—</span>')
->html(),
->secondaryHeaderFilter('type')
->label(fn ($row) => $row->featureType
? '<span class="badge badge-sm" style="background-color:'.e($row->featureType->color).';color:#fff;border:0;">'.e($row->featureType->name).'</span>'
: '<span class="text-base-content/30 text-xs">—</span>')
->html(),
Column::make('Activo', 'is_active')
->sortable()
->label(function ($row) {
if ($row->is_active) {
return '<button wire:click="toggleActive(' . $row->id . ')" class="badge badge-success badge-sm" title="Desactivar">Activo</button>';
}
return '<button wire:click="toggleActive(' . $row->id . ')" class="badge badge-ghost badge-sm" title="Activar">Inactivo</button>';
})
->html(),
->sortable()
->label(function ($row) {
if ($row->is_active) {
return '<button wire:click="toggleActive('.$row->id.')" class="badge badge-success badge-sm" title="Desactivar">Activo</button>';
}
return '<button wire:click="toggleActive('.$row->id.')" class="badge badge-ghost badge-sm" title="Activar">Inactivo</button>';
})
->html(),
Column::make('Acciones')
->label(fn ($row) =>
'<div class="flex justify-end gap-1">
<button wire:click="$dispatch(\'feature-edit\', { id: ' . $row->id . ' })" class="btn btn-xs btn-ghost" title="Editar">
->label(fn ($row) => '<div class="flex justify-end gap-1">
<button wire:click="$dispatch(\'feature-edit\', { id: '.$row->id.' })" class="btn btn-xs btn-ghost" title="Editar">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</button>
<button wire:click="deleteFeature(' . $row->id . ')" wire:confirm="¿Eliminar el elemento \'' . e($row->name) . '\'? Esta acción no se puede deshacer."
<button wire:click="deleteFeature('.$row->id.')" wire:confirm="¿Eliminar el elemento \''.e($row->name).'\'? Esta acción no se puede deshacer."
class="btn btn-xs btn-error btn-outline" title="Eliminar">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>')
->html(),
->html(),
];
}
@@ -105,10 +107,10 @@ class ProjectFeaturesTable extends DataTableComponent
return [
TextFilter::make('Elemento', 'name')
->filter(fn (Builder $q, string $v) => $q->where('features.name', 'like', '%' . $v . '%')),
->filter(fn (Builder $q, string $v) => $q->where('features.name', 'like', '%'.$v.'%')),
SelectFilter::make('Capa', 'layer')
->options(['' => 'Todas'] + \App\Models\Layer::whereHas('phase', fn ($q) => $q->where('project_id', $this->projectId))->orderBy('name')->pluck('name', 'id')->toArray())
->options(['' => 'Todas'] + Layer::whereHas('phase', fn ($q) => $q->where('project_id', $this->projectId))->orderBy('name')->pluck('name', 'id')->toArray())
->filter(fn (Builder $q, string $v) => $q->where('features.layer_id', $v)),
SelectFilter::make('Tipo', 'type')
+54 -44
View File
@@ -2,12 +2,12 @@
namespace App\Livewire\Projects;
use Livewire\Component;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use App\Models\Project;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
#[Layout('layouts.app')]
class ProjectForm extends Component
@@ -15,33 +15,39 @@ class ProjectForm extends Component
public ?Project $project = null;
// Identification
public string $name = '';
public string $name = '';
public string $reference = '';
public string $status = 'planning';
public string $status = 'planning';
// Location
public string $address = '';
public string $country = '';
public string $lat = '';
public string $lng = '';
public string $lat = '';
public string $lng = '';
// Planning
public string $startDate = '';
public string $endDateEstimated = '';
public string $startDate = '';
public string $endDateEstimated = '';
public function mount(?Project $project = null): void
{
if ($project && $project->exists) {
Gate::authorize('edit projects', $project);
$this->project = $project;
$this->name = $project->name;
$this->reference = $project->reference ?? '';
$this->status = $project->status;
$this->address = $project->address;
$this->country = $project->country ?? '';
$this->lat = (string) ($project->lat ?? '');
$this->lng = (string) ($project->lng ?? '');
$this->startDate = $project->start_date->format('Y-m-d');
$this->project = $project;
$this->name = $project->name;
$this->reference = $project->reference ?? '';
$this->status = $project->status;
$this->address = $project->address;
$this->country = $project->country ?? '';
$this->lat = (string) ($project->lat ?? '');
$this->lng = (string) ($project->lng ?? '');
$this->startDate = $project->start_date->format('Y-m-d');
$this->endDateEstimated = $project->end_date_estimated?->format('Y-m-d') ?? '';
} else {
Gate::authorize('create projects');
@@ -56,34 +62,38 @@ class ProjectForm extends Component
{
$this->lat = $lat;
$this->lng = $lng;
if ($address) $this->address = $address;
if ($country) $this->country = strtolower($country);
if ($address) {
$this->address = $address;
}
if ($country) {
$this->country = strtolower($country);
}
}
protected function rules(): array
{
return [
'name' => 'required|string|max:255',
'reference' => 'nullable|string|max:100',
'status' => 'required|in:planning,in_progress,paused,completed',
'address' => 'required|string',
'country' => 'nullable|string|size:2',
'lat' => 'nullable|numeric|between:-90,90',
'lng' => 'nullable|numeric|between:-180,180',
'startDate' => 'required|date',
'name' => 'required|string|max:255',
'reference' => 'nullable|string|max:100',
'status' => 'required|in:planning,in_progress,paused,completed',
'address' => 'required|string',
'country' => 'nullable|string|size:2',
'lat' => 'nullable|numeric|between:-90,90',
'lng' => 'nullable|numeric|between:-180,180',
'startDate' => 'required|date',
'endDateEstimated' => 'nullable|date|after_or_equal:startDate',
];
}
protected $validationAttributes = [
'name' => 'nombre',
'reference' => 'referencia',
'status' => 'estado',
'address' => 'dirección',
'country' => 'país',
'lat' => 'latitud',
'lng' => 'longitud',
'startDate' => 'fecha de inicio',
'name' => 'nombre',
'reference' => 'referencia',
'status' => 'estado',
'address' => 'dirección',
'country' => 'país',
'lat' => 'latitud',
'lng' => 'longitud',
'startDate' => 'fecha de inicio',
'endDateEstimated' => 'fecha de fin estimada',
];
@@ -92,14 +102,14 @@ class ProjectForm extends Component
$this->validate();
$data = [
'name' => $this->name,
'reference' => $this->reference ?: null,
'status' => $this->status,
'address' => $this->address,
'country' => $this->country ?: null,
'lat' => $this->lat ?: null,
'lng' => $this->lng ?: null,
'start_date' => $this->startDate,
'name' => $this->name,
'reference' => $this->reference ?: null,
'status' => $this->status,
'address' => $this->address,
'country' => $this->country ?: null,
'lat' => $this->lat ?: null,
'lng' => $this->lng ?: null,
'start_date' => $this->startDate,
'end_date_estimated' => $this->endDateEstimated ?: null,
];
+7 -5
View File
@@ -2,11 +2,11 @@
namespace App\Livewire\Projects;
use Livewire\Component;
use Livewire\WithPagination;
use Livewire\Attributes\Layout;
use App\Models\Project;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithPagination;
#[Layout('layouts.app')]
class ProjectList extends Component
@@ -14,6 +14,7 @@ class ProjectList extends Component
use WithPagination;
public $search = '';
public $statusFilter = '';
public function deleteProject($id)
@@ -29,12 +30,13 @@ class ProjectList extends Component
{
$query = Project::accessibleBy(Auth::user());
if ($this->search) {
$query->where('name', 'like', '%' . $this->search . '%');
$query->where('name', 'like', '%'.$this->search.'%');
}
if ($this->statusFilter) {
$query->where('status', $this->statusFilter);
}
$projects = $query->with('phases')->latest()->paginate(10);
return view('livewire.projects.project-list', ['projects' => $projects]);
}
}
}
+252 -136
View File
@@ -2,63 +2,86 @@
namespace App\Livewire\Projects;
use Livewire\Component;
use Livewire\Attributes\On;
use Livewire\WithFileUploads;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use App\Models\Project;
use App\Models\Phase;
use App\Models\Layer;
use App\Models\Feature;
use App\Models\Inspection;
use App\Models\InspectionTemplate;
use App\Models\Issue;
use App\Models\Layer;
use App\Models\Media;
use App\Models\Phase;
use App\Models\Project;
use App\Notifications\InspectionCompletedNotification;
use App\Notifications\InspectionDeletedNotification;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Livewire\Attributes\On;
use Livewire\Component;
use Livewire\WithFileUploads;
class ProjectMap extends Component
{
use WithFileUploads;
public Project $project;
public $phases;
public $activeLayers = []; // Now stores Layer IDs (not Phase IDs)
public $showLayerModal = false;
// Editor properties
public $selectedFeature = null;
public $selectedPhaseId = null;
public $editProgress = 0;
public $editComment = '';
public $editResponsible = '';
public $editPhotos = [];
public $formFullscreen = false;
// Tab management
public $activeTab = 'edit';
public $allFeatures;
public $allInspections;
// Templates e inspecciones
public $templates = [];
public $selectedTemplateId = null;
public $inspectionFormData = [];
public $inspectionHistory = [];
// Imágenes en mapa
public $showFeatureImages = false;
public $featureImageMarkers = [];
// Filters
public $filterStatus = '';
public $filterResponsible = '';
public $filterProgressMin = 0;
public $filterProgressMax = 100;
public $showFilters = false;
// Inspection workflow
public $inspectionResult = '';
public $inspectionNotes = '';
public $inspectionPhotos = [];
// Issues
@@ -69,10 +92,15 @@ class ProjectMap extends Component
// Inspection editor (para editar inspecciones existentes)
public $editingInspection = null;
public $editInspectionFormData = [];
public $editInspectionResult = '';
public $editInspectionNotes = '';
public $editInspectionPhotos = [];
public $editInspectionPhotosToDelete = [];
public function mount(Project $project)
@@ -81,20 +109,20 @@ class ProjectMap extends Component
$this->authorizeProjectAccess();
$this->phases = $project->phases()->with([
'layers' => fn($q) => $q->withCount('features'),
'layers' => fn ($q) => $q->withCount('features'),
'layers.features',
'layers.features.images',
])->get();
// Initialize activeLayers with ALL layer IDs (not phase IDs)
$this->activeLayers = $this->phases
->flatMap(fn($p) => $p->layers->pluck('id'))
->map(fn($id) => (int) $id)
->flatMap(fn ($p) => $p->layers->pluck('id'))
->map(fn ($id) => (int) $id)
->toArray();
$this->loadTemplates();
$this->allFeatures = Feature::whereHas('layer.phase', function($q) use ($project) {
$this->allFeatures = Feature::whereHas('layer.phase', function ($q) use ($project) {
$q->where('project_id', $project->id);
})->with(['layer.phase', 'template'])->get();
@@ -111,8 +139,12 @@ class ProjectMap extends Component
private function authorizeProjectAccess(): void
{
$user = Auth::user();
if ($user->can('manage all')) return;
if (!$this->project->users()->where('user_id', $user->id)->exists()) abort(403);
if ($user->can('manage all')) {
return;
}
if (! $this->project->users()->where('user_id', $user->id)->exists()) {
abort(403);
}
}
public function loadTemplates()
@@ -139,9 +171,11 @@ class ProjectMap extends Component
public function togglePhase($phaseId)
{
$phase = $this->phases->find($phaseId);
if (!$phase) return;
$layerIds = $phase->layers->pluck('id')->map(fn($id) => (int) $id)->toArray();
$allActive = !empty($layerIds) && collect($layerIds)->every(fn($id) => in_array($id, $this->activeLayers));
if (! $phase) {
return;
}
$layerIds = $phase->layers->pluck('id')->map(fn ($id) => (int) $id)->toArray();
$allActive = ! empty($layerIds) && collect($layerIds)->every(fn ($id) => in_array($id, $this->activeLayers));
if ($allActive) {
$this->activeLayers = array_values(array_diff($this->activeLayers, $layerIds));
} else {
@@ -150,22 +184,51 @@ class ProjectMap extends Component
$this->dispatch('layersUpdated', $this->activeLayers);
}
public function openLayerModal() { $this->showLayerModal = true; }
public function closeLayerModal() { $this->showLayerModal = false; }
public function openLayerModal()
{
$this->showLayerModal = true;
}
public function closeLayerModal()
{
$this->showLayerModal = false;
}
// ─── Filters ────────────────────────────────────────────────────────────────
public function updatedFilterStatus() { $this->applyFilters(); }
public function updatedFilterResponsible() { $this->applyFilters(); }
public function updatedFilterProgressMin() { $this->applyFilters(); }
public function updatedFilterProgressMax() { $this->applyFilters(); }
public function updatedFilterStatus()
{
$this->applyFilters();
}
public function updatedFilterResponsible()
{
$this->applyFilters();
}
public function updatedFilterProgressMin()
{
$this->applyFilters();
}
public function updatedFilterProgressMax()
{
$this->applyFilters();
}
public function applyFilters()
{
$filtered = $this->allFeatures->filter(function($f) {
if ($this->filterStatus && $f->status !== $this->filterStatus) return false;
if ($this->filterResponsible && !str_contains(strtolower($f->responsible ?? ''), strtolower($this->filterResponsible))) return false;
if ($f->progress < $this->filterProgressMin || $f->progress > $this->filterProgressMax) return false;
$filtered = $this->allFeatures->filter(function ($f) {
if ($this->filterStatus && $f->status !== $this->filterStatus) {
return false;
}
if ($this->filterResponsible && ! str_contains(strtolower($f->responsible ?? ''), strtolower($this->filterResponsible))) {
return false;
}
if ($f->progress < $this->filterProgressMin || $f->progress > $this->filterProgressMax) {
return false;
}
return true;
});
$this->dispatch('filtersChanged', $filtered->pluck('id')->values()->toArray());
@@ -184,16 +247,24 @@ class ProjectMap extends Component
public function editFeatureStatus($status)
{
if (!$this->selectedFeature) return;
if (! $this->selectedFeature) {
return;
}
$feature = Feature::with('layer.phase')->findOrFail($this->selectedFeature->id);
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
if ($feature->layer->phase->project_id !== $this->project->id) {
abort(403);
}
$feature->status = $status;
if ($status === 'completed') $feature->progress = 100;
if ($status === 'planned') $feature->progress = 0;
if ($status === 'completed') {
$feature->progress = 100;
}
if ($status === 'planned') {
$feature->progress = 0;
}
$feature->save();
$this->selectedFeature = $feature;
$this->editProgress = $feature->progress;
$this->allFeatures = $this->allFeatures->map(fn($f) => $f->id === $feature->id ? $feature : $f);
$this->allFeatures = $this->allFeatures->map(fn ($f) => $f->id === $feature->id ? $feature : $f);
$this->dispatch('featureStatusChanged', $feature->id, $feature->status, $feature->status_color);
$this->dispatch('notify', 'Estado actualizado');
}
@@ -202,20 +273,23 @@ class ProjectMap extends Component
{
$feature = Feature::with('layer.phase')->findOrFail($featureId);
$user = Auth::user();
if (!$user->can('update progress')) {
if (! $user->can('update progress')) {
$this->dispatch('notify', 'Sin permisos');
return;
}
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
if ($feature->layer->phase->project_id !== $this->project->id) {
abort(403);
}
$feature->progress = min(100, max(0, $newProgress));
$feature->save();
$phase = $feature->layer->phase;
$phase->progress_percent = $phase->features()->avg('progress') ?: 0;
$phase->save();
$phase->progressUpdates()->create([
'user_id' => $user->id,
'user_id' => $user->id,
'progress_percent' => $phase->progress_percent,
'comment' => $comment,
'comment' => $comment,
]);
$this->dispatch('progressUpdated', $featureId, $feature->progress);
$this->dispatch('notify', 'Progreso actualizado');
@@ -228,25 +302,33 @@ class ProjectMap extends Component
#[On('map-select-feature')]
public function selectFeature($featureId)
{
\Log::info('map-select-feature received', ['payload' => $featureId]);
\Log::info('[ProjectMap] map-select-feature received', ['payload' => $featureId, 'type' => gettype($featureId)]);
// Handle both formats: direct ID or { featureId: X }
if (is_array($featureId) && isset($featureId['featureId'])) {
$featureId = $featureId['featureId'];
\Log::info('[ProjectMap] Extracted featureId from array', ['featureId' => $featureId]);
}
$this->selectedFeature = null;
$feature = Feature::with(['template', 'layer.phase'])->find($featureId);
if (!$feature) return;
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
if (! $feature) {
\Log::warning('[ProjectMap] Feature not found', ['featureId' => $featureId]);
$this->selectedFeature = $feature;
$this->selectedPhaseId = $feature->layer->phase_id;
$this->editProgress = $feature->progress;
$this->editResponsible = $feature->responsible ?? '';
$this->editPhotos = $feature->properties['photos'] ?? [];
return;
}
if ($feature->layer->phase->project_id !== $this->project->id) {
\Log::warning('[ProjectMap] Feature not in project', ['featureId' => $featureId, 'projectId' => $this->project->id]);
abort(403);
}
$this->selectedFeature = $feature;
$this->selectedPhaseId = $feature->layer->phase_id;
$this->editProgress = $feature->progress;
$this->editResponsible = $feature->responsible ?? '';
$this->editPhotos = $feature->properties['photos'] ?? [];
$this->selectedTemplateId = $feature->template_id;
$this->activeTab = 'edit';
$this->activeTab = 'edit';
$this->loadInspectionHistory();
$this->resetInspectionForm();
@@ -256,8 +338,9 @@ class ProjectMap extends Component
public function loadInspectionHistory()
{
if (!$this->selectedFeature) {
if (! $this->selectedFeature) {
$this->inspectionHistory = [];
return;
}
$this->inspectionHistory = Inspection::where('feature_id', $this->selectedFeature->id)
@@ -269,9 +352,9 @@ class ProjectMap extends Component
public function resetInspectionForm()
{
$this->inspectionFormData = [];
$this->inspectionResult = '';
$this->inspectionNotes = '';
$this->inspectionPhotos = [];
$this->inspectionResult = '';
$this->inspectionNotes = '';
$this->inspectionPhotos = [];
if ($this->selectedTemplateId) {
$template = InspectionTemplate::find($this->selectedTemplateId);
if ($template) {
@@ -284,45 +367,50 @@ class ProjectMap extends Component
public function saveInspection()
{
if (!$this->selectedFeature || !$this->selectedTemplateId) {
if (! $this->selectedFeature || ! $this->selectedTemplateId) {
$this->dispatch('notify', 'Selecciona un elemento y un template.');
return;
}
// Verificar permiso
if (!auth()->user()->can('create inspections')) {
if (! auth()->user()->can('create inspections')) {
$this->dispatch('notify', 'Sin permisos para crear inspecciones.');
return;
}
$feature = Feature::with('layer.phase')->find($this->selectedFeature->id);
if (!$feature || $feature->layer->phase->project_id !== $this->project->id) abort(403);
if (! $feature || $feature->layer->phase->project_id !== $this->project->id) {
abort(403);
}
$this->validate([
'selectedTemplateId' => 'required|exists:inspection_templates,id',
'inspectionPhotos.*' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:10240',
'selectedTemplateId' => 'required|exists:inspection_templates,id',
'inspectionPhotos.*' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:10240',
]);
$template = InspectionTemplate::find($this->selectedTemplateId);
foreach ($template->fields as $field) {
if (($field['required'] ?? false) && empty($this->inspectionFormData[$field['name']])) {
$this->dispatch('notify', "El campo {$field['label']} es obligatorio.");
return;
}
}
$inspection = Inspection::create([
'project_id' => $this->project->id,
'layer_id' => $this->selectedFeature->layer_id,
'feature_id' => $this->selectedFeature->id,
'template_id' => $this->selectedTemplateId,
'user_id' => auth()->id(),
'project_id' => $this->project->id,
'layer_id' => $this->selectedFeature->layer_id,
'feature_id' => $this->selectedFeature->id,
'template_id' => $this->selectedTemplateId,
'user_id' => auth()->id(),
'inspector_user_id' => auth()->id(),
'status' => 'completed',
'completed_at' => now(),
'result' => $this->inspectionResult ?: null,
'notes' => $this->inspectionNotes ?: null,
'data' => $this->inspectionFormData,
'status' => 'completed',
'completed_at' => now(),
'result' => $this->inspectionResult ?: null,
'notes' => $this->inspectionNotes ?: null,
'data' => $this->inspectionFormData,
]);
// Fotos adjuntas a la inspección
@@ -330,34 +418,34 @@ class ProjectMap extends Component
$mime = $photo->getMimeType();
$path = $photo->store("uploads/inspections/{$inspection->id}", 'public');
$inspection->media()->create([
'name' => $photo->getClientOriginalName(),
'file_path' => $path,
'file_type' => $mime,
'name' => $photo->getClientOriginalName(),
'file_path' => $path,
'file_type' => $mime,
'file_extension' => $photo->getClientOriginalExtension(),
'file_size' => $photo->getSize(),
'category' => str_starts_with($mime, 'image/') ? 'image' : 'document',
'uploaded_by' => auth()->id(),
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'file_size' => $photo->getSize(),
'category' => str_starts_with($mime, 'image/') ? 'image' : 'document',
'uploaded_by' => auth()->id(),
'uuid' => (string) Str::uuid(),
]);
}
if ($this->inspectionResult === 'fail') {
Issue::create([
'project_id' => $this->project->id,
'feature_id' => $this->selectedFeature->id,
'project_id' => $this->project->id,
'feature_id' => $this->selectedFeature->id,
'inspection_id' => $inspection->id,
'title' => 'Fallo en inspección: ' . ($template->name ?? 'Sin nombre'),
'description' => $this->inspectionNotes,
'priority' => 'high',
'status' => 'open',
'reported_by' => auth()->id(),
'title' => 'Fallo en inspección: '.($template->name ?? 'Sin nombre'),
'description' => $this->inspectionNotes,
'priority' => 'high',
'status' => 'open',
'reported_by' => auth()->id(),
]);
$this->openIssuesCount = Issue::where('project_id', $this->project->id)
->where('status', 'open')->count();
$this->dispatch('notify', 'Inspección fallida — Issue creado automáticamente');
} else {
if (isset($this->inspectionFormData['progress'])) {
$this->updateProgress($this->selectedFeature->id, (int)$this->inspectionFormData['progress'], 'Inspección registrada');
$this->updateProgress($this->selectedFeature->id, (int) $this->inspectionFormData['progress'], 'Inspección registrada');
}
$this->dispatch('notify', 'Inspección guardada correctamente');
}
@@ -367,7 +455,7 @@ class ProjectMap extends Component
->where('user_id', '!=', auth()->id())
->get();
foreach ($usersToNotify as $user) {
$user->notify(new \App\Notifications\InspectionCompletedNotification($inspection));
$user->notify(new InspectionCompletedNotification($inspection));
}
// Reload global list
@@ -382,14 +470,18 @@ class ProjectMap extends Component
public function assignTemplateToFeature($templateId)
{
if (!$this->selectedFeature) return;
if (! $this->selectedFeature) {
return;
}
$template = InspectionTemplate::where('id', $templateId)
->where('project_id', $this->project->id)->first();
if (!$template) abort(403);
if (! $template) {
abort(403);
}
$feature = Feature::findOrFail($this->selectedFeature->id);
$feature->template_id = $templateId;
$feature->save();
$this->selectedFeature = $feature;
$this->selectedFeature = $feature;
$this->selectedTemplateId = $templateId;
$this->resetInspectionForm();
$this->dispatch('notify', 'Template asignado al elemento');
@@ -397,10 +489,14 @@ class ProjectMap extends Component
public function saveFeatureProgress()
{
if (!$this->selectedFeature) return;
if (! $this->selectedFeature) {
return;
}
$feature = Feature::with('layer.phase')->findOrFail($this->selectedFeature->id);
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
$feature->progress = min(100, max(0, (int)$this->editProgress));
if ($feature->layer->phase->project_id !== $this->project->id) {
abort(403);
}
$feature->progress = min(100, max(0, (int) $this->editProgress));
$feature->responsible = $this->editResponsible;
$feature->save();
$this->selectedFeature = $feature;
@@ -424,21 +520,23 @@ class ProjectMap extends Component
$ins = Inspection::where('project_id', $this->project->id)
->with(['feature.layer.phase', 'template', 'user', 'media'])
->find($id);
if (!$ins) return;
if (! $ins) {
return;
}
$this->viewingInspection = [
'id' => $ins->id,
'feature_name' => $ins->feature?->name ?? '—',
'layer_name' => $ins->feature?->layer?->name ?? '—',
'phase_name' => $ins->feature?->layer?->phase?->name ?? '—',
'id' => $ins->id,
'feature_name' => $ins->feature?->name ?? '—',
'layer_name' => $ins->feature?->layer?->name ?? '—',
'phase_name' => $ins->feature?->layer?->phase?->name ?? '—',
'template_name' => $ins->template?->name ?? '—',
'user_name' => $ins->user?->name ?? '—',
'date' => $ins->created_at->format('d/m/Y H:i'),
'status' => $ins->status,
'result' => $ins->result,
'notes' => $ins->notes,
'data' => $ins->data ?? [],
'fields' => $ins->template?->fields ?? [],
'photos' => $ins->media->map(fn ($m) => ['url' => $m->url, 'name' => $m->name, 'id' => $m->id])->values()->all(),
'user_name' => $ins->user?->name ?? '—',
'date' => $ins->created_at->format('d/m/Y H:i'),
'status' => $ins->status,
'result' => $ins->result,
'notes' => $ins->notes,
'data' => $ins->data ?? [],
'fields' => $ins->template?->fields ?? [],
'photos' => $ins->media->map(fn ($m) => ['url' => $m->url, 'name' => $m->name, 'id' => $m->id])->values()->all(),
];
}
@@ -455,7 +553,9 @@ class ProjectMap extends Component
$ins = Inspection::where('project_id', $this->project->id)
->with(['feature.layer.phase', 'template', 'user', 'media'])
->find($id);
if (!$ins) return;
if (! $ins) {
return;
}
$this->editingInspection = $ins;
$this->editInspectionFormData = $ins->data ?? [];
@@ -480,11 +580,13 @@ class ProjectMap extends Component
public function deleteEditPhoto($mediaIndex)
{
if (!$this->editingInspection) return;
if (! $this->editingInspection) {
return;
}
$media = $this->editingInspection->media;
if (isset($media[$mediaIndex])) {
$m = $media[$mediaIndex];
if (!in_array($m->id, $this->editInspectionPhotosToDelete)) {
if (! in_array($m->id, $this->editInspectionPhotosToDelete)) {
$this->editInspectionPhotosToDelete[] = $m->id;
}
}
@@ -492,34 +594,40 @@ class ProjectMap extends Component
public function saveEditInspection()
{
if (!$this->editingInspection) return;
if (! $this->editingInspection) {
return;
}
// Verificar permiso
if (!auth()->user()->can('edit inspections')) {
if (! auth()->user()->can('edit inspections')) {
$this->dispatch('notify', 'Sin permisos para editar inspecciones.');
return;
}
$this->validate([
'selectedTemplateId' => 'required|exists:inspection_templates,id',
'editInspectionPhotos.*' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:10240',
'selectedTemplateId' => 'required|exists:inspection_templates,id',
'editInspectionPhotos.*' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:10240',
'editInspectionPhotosToDelete' => 'array',
'editInspectionPhotosToDelete.*' => 'exists:media,id',
]);
$ins = $this->editingInspection;
if ($ins->project_id !== $this->project->id) abort(403);
if ($ins->project_id !== $this->project->id) {
abort(403);
}
$template = InspectionTemplate::find($this->selectedTemplateId);
foreach ($template->fields as $field) {
if (($field['required'] ?? false) && empty($this->editInspectionFormData[$field['name']])) {
$this->dispatch('notify', "El campo {$field['label']} es obligatorio.");
return;
}
}
// Eliminar fotos marcadas
if (!empty($this->editInspectionPhotosToDelete)) {
if (! empty($this->editInspectionPhotosToDelete)) {
$mediaToDelete = Media::whereIn('id', $this->editInspectionPhotosToDelete)
->where('mediable_type', Inspection::class)
->where('mediable_id', $ins->id)
@@ -532,9 +640,9 @@ class ProjectMap extends Component
// Actualizar datos
$ins->update([
'template_id' => $this->selectedTemplateId,
'result' => $this->editInspectionResult ?: null,
'notes' => $this->editInspectionNotes ?: null,
'data' => $this->editInspectionFormData,
'result' => $this->editInspectionResult ?: null,
'notes' => $this->editInspectionNotes ?: null,
'data' => $this->editInspectionFormData,
]);
// Añadir nuevas fotos
@@ -542,14 +650,14 @@ class ProjectMap extends Component
$mime = $photo->getMimeType();
$path = $photo->store("uploads/inspections/{$ins->id}", 'public');
$ins->media()->create([
'name' => $photo->getClientOriginalName(),
'file_path' => $path,
'file_type' => $mime,
'name' => $photo->getClientOriginalName(),
'file_path' => $path,
'file_type' => $mime,
'file_extension' => $photo->getClientOriginalExtension(),
'file_size' => $photo->getSize(),
'category' => str_starts_with($mime, 'image/') ? 'image' : 'document',
'uploaded_by' => auth()->id(),
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'file_size' => $photo->getSize(),
'category' => str_starts_with($mime, 'image/') ? 'image' : 'document',
'uploaded_by' => auth()->id(),
'uuid' => (string) Str::uuid(),
]);
}
@@ -570,19 +678,21 @@ class ProjectMap extends Component
public function deleteInspection($id)
{
\Log::info('deleteInspection: START', ['id' => $id]);
if (!auth()->user()->can('delete inspections')) {
if (! auth()->user()->can('delete inspections')) {
$this->dispatch('notify', 'Sin permisos para eliminar inspecciones.');
\Log::info('deleteInspection: permission denied');
return;
}
$ins = Inspection::where('project_id', $this->project->id)
->with(['feature', 'media'])
->find($id);
if (!$ins) {
if (! $ins) {
\Log::info('deleteInspection: inspection not found', ['id' => $id]);
$this->dispatch('notify', 'Inspección no encontrada');
return;
}
@@ -612,7 +722,7 @@ class ProjectMap extends Component
->where('user_id', '!=', auth()->id())
->get();
foreach ($usersToNotify as $user) {
$user->notify(new \App\Notifications\InspectionDeletedNotification($ins));
$user->notify(new InspectionDeletedNotification($ins));
}
}
@@ -620,21 +730,25 @@ class ProjectMap extends Component
public function toggleFeatureImages()
{
$this->showFeatureImages = !$this->showFeatureImages;
$this->showFeatureImages = ! $this->showFeatureImages;
$this->loadFeatureImageMarkers();
$this->dispatch('featureImagesToggled', $this->showFeatureImages, $this->featureImageMarkers);
}
public function loadFeatureImageMarkers()
{
if (!$this->showFeatureImages) { $this->featureImageMarkers = []; return; }
if (! $this->showFeatureImages) {
$this->featureImageMarkers = [];
return;
}
$markers = [];
foreach ($this->phases as $phase) {
foreach ($phase->layers as $layer) {
foreach ($layer->features as $feature) {
$image = $feature->images->first();
if ($image) {
$geo = $feature->geometry;
$geo = $feature->geometry;
$coords = null;
if ($geo && isset($geo['coordinates'])) {
if ($geo['type'] === 'Point') {
@@ -646,10 +760,10 @@ class ProjectMap extends Component
if ($coords && $coords['lat'] && $coords['lng']) {
$markers[] = [
'feature_id' => $feature->id,
'name' => $feature->name,
'lat' => $coords['lat'],
'lng' => $coords['lng'],
'image_url' => $image->url,
'name' => $feature->name,
'lat' => $coords['lat'],
'lng' => $coords['lng'],
'image_url' => $image->url,
'image_name' => $image->name,
];
}
@@ -662,8 +776,10 @@ class ProjectMap extends Component
public function toggleFullscreen()
{
$this->formFullscreen = !$this->formFullscreen;
if (!$this->formFullscreen) $this->dispatch('mapResize');
$this->formFullscreen = ! $this->formFullscreen;
if (! $this->formFullscreen) {
$this->dispatch('mapResize');
}
}
public function setActiveTab($tab)
@@ -675,7 +791,7 @@ class ProjectMap extends Component
{
return view('livewire.projects.project-map', [
'project' => $this->project,
'phases' => $this->phases,
'phases' => $this->phases,
]);
}
}
+64 -60
View File
@@ -2,11 +2,11 @@
namespace App\Livewire\Projects;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
use App\Models\Project;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use App\Models\Project;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
class ProjectTable extends DataTableComponent
{
@@ -15,9 +15,9 @@ class ProjectTable extends DataTableComponent
public function configure(): void
{
$this->setPrimaryKey('id')
->setDefaultSort('created_at', 'desc')
->setSortingPillsEnabled(false)
->setAdditionalSelects(['projects.id as id', 'projects.created_at as created_at']);
->setDefaultSort('created_at', 'desc')
->setSortingPillsEnabled(false)
->setAdditionalSelects(['projects.id as id', 'projects.created_at as created_at']);
}
public function builder(): Builder
@@ -30,88 +30,92 @@ class ProjectTable extends DataTableComponent
{
return [
Column::make('Referencia', 'reference')
->sortable()
->searchable()
->format(function ($value, $row) {
$url = route('projects.dashboard', $row->id);
return $value
? '<a href="'.$url.'" class="font-mono text-xs text-primary hover:underline" wire:navigate>'.e($value).'</a>'
: '<span class="text-gray-300">—</span>';
})
->html(),
->sortable()
->searchable()
->format(function ($value, $row) {
$url = route('projects.dashboard', $row->id);
return $value
? '<a href="'.$url.'" class="font-mono text-xs text-primary hover:underline" wire:navigate>'.e($value).'</a>'
: '<span class="text-gray-300">—</span>';
})
->html(),
Column::make(__('Name'), 'name')
->sortable()
->searchable(),
->sortable()
->searchable(),
Column::make(__('Address'), 'address')
->sortable()
->searchable()
->format(fn ($value) => $value
? '<span class="truncate block max-w-xs" title="'.e($value).'">'.e($value).'</span>'
: '<span class="text-gray-400">—</span>')
->html(),
->sortable()
->searchable()
->format(fn ($value) => $value
? '<span class="truncate block max-w-xs" title="'.e($value).'">'.e($value).'</span>'
: '<span class="text-gray-400">—</span>')
->html(),
Column::make(__('Status'), 'status')
->sortable()
->format(function ($value) {
$map = [
'planning' => ['badge-ghost', 'Planificación'],
'in_progress' => ['badge-primary', 'En progreso'],
'paused' => ['badge-warning', 'Pausado'],
'completed' => ['badge-success', 'Completado'],
];
[$cls, $label] = $map[$value] ?? ['badge-ghost', ucfirst($value)];
return '<span class="badge '.$cls.'">'.$label.'</span>';
})
->html(),
->sortable()
->format(function ($value) {
$map = [
'planning' => ['badge-ghost', 'Planificación'],
'in_progress' => ['badge-primary', 'En progreso'],
'paused' => ['badge-warning', 'Pausado'],
'completed' => ['badge-success', 'Completado'],
];
[$cls, $label] = $map[$value] ?? ['badge-ghost', ucfirst($value)];
return '<span class="badge '.$cls.'">'.$label.'</span>';
})
->html(),
Column::make(__('Progress'))
->label(function ($row) {
$avg = $row->phases->avg('progress_percent') ?? 0;
$pct = round($avg);
return '
->label(function ($row) {
$avg = $row->phases->avg('progress_percent') ?? 0;
$pct = round($avg);
return '
<div class="flex items-center gap-2 min-w-[100px]">
<div class="flex-1 bg-gray-200 rounded-full h-2">
<div class="bg-primary h-2 rounded-full" style="width:'.$pct.'%"></div>
</div>
<span class="text-xs text-gray-500 w-8 text-right">'.$pct.'%</span>
</div>';
})
->html(),
})
->html(),
Column::make(__('Start Date'), 'start_date')
->sortable()
->format(fn ($value) => $value ? $value->format('d/m/Y') : '—'),
->sortable()
->format(fn ($value) => $value ? $value->format('d/m/Y') : '—'),
Column::make(__('Est. End'), 'end_date_estimated')
->sortable()
->format(fn ($value) => $value ? $value->format('d/m/Y') : '—'),
->sortable()
->format(fn ($value) => $value ? $value->format('d/m/Y') : '—'),
Column::make(__('Actions'))
->label(function ($row) {
$dashboard = route('projects.dashboard', $row->id);
$map = route('projects.map', $row->id);
$edit = route('projects.edit', $row->id);
->label(function ($row) {
$dashboard = route('projects.dashboard', $row->id);
$map = route('projects.map', $row->id);
$edit = route('projects.edit', $row->id);
$canEdit = Auth::user()->can('edit projects');
$canEdit = Auth::user()->can('edit projects');
$html = '<div class="flex items-center gap-1">';
$html .= '<a href="'.$dashboard.'" class="btn btn-xs btn-outline" title="Dashboard" wire:navigate>
$html = '<div class="flex items-center gap-1">';
$html .= '<a href="'.$dashboard.'" class="btn btn-xs btn-outline" title="Dashboard" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg>
</a>';
$html .= '<a href="'.$map.'" class="btn btn-xs btn-outline" title="Mapa" wire:navigate>
$html .= '<a href="'.$map.'" class="btn btn-xs btn-outline" title="Mapa" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>
</a>';
if ($canEdit) {
$html .= '<a href="'.$edit.'" class="btn btn-xs btn-warning" title="Editar" wire:navigate>
if ($canEdit) {
$html .= '<a href="'.$edit.'" class="btn btn-xs btn-warning" title="Editar" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</a>';
}
$html .= '</div>';
return $html;
})
->html(),
}
$html .= '</div>';
return $html;
})
->html(),
];
}
@@ -12,7 +12,9 @@ use Livewire\Component;
class ProjectTemplatesPicker extends Component
{
public Project $project;
public array $assignedIds = [];
public string $search = '';
public function mount(Project $project)
@@ -41,7 +43,7 @@ class ProjectTemplatesPicker extends Component
public function render()
{
$templates = InspectionTemplate::query()
->when($this->search !== '', fn ($q) => $q->where('name', 'like', '%' . $this->search . '%'))
->when($this->search !== '', fn ($q) => $q->where('name', 'like', '%'.$this->search.'%'))
->orderBy('name')->get();
return view('livewire.projects.project-templates-picker', [
+4 -1
View File
@@ -11,8 +11,11 @@ use Livewire\Component;
class ProjectUsers extends Component
{
public Project $project;
public $allUsers = [];
public $selectedUserId = '';
public $selectedRole = 'viewer';
public function mount(Project $project)
@@ -41,7 +44,7 @@ class ProjectUsers extends Component
$this->validate([
'selectedUserId' => 'required|exists:users,id',
'selectedRole' => 'required|in:' . implode(',', array_keys(ProjectUsersTable::ROLES)),
'selectedRole' => 'required|in:'.implode(',', array_keys(ProjectUsersTable::ROLES)),
]);
$this->project->users()->attach($this->selectedUserId, [
+39 -35
View File
@@ -2,6 +2,7 @@
namespace App\Livewire\Projects;
use App\Models\Project;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
@@ -20,16 +21,16 @@ class ProjectUsersTable extends DataTableComponent
public const ROLES = [
'supervisor' => 'Supervisor',
'consultant' => 'Consultor',
'client' => 'Cliente',
'viewer' => 'Observador',
'client' => 'Cliente',
'viewer' => 'Observador',
];
public function configure(): void
{
$this->setPrimaryKey('id')
->setDefaultSort('users.name', 'asc')
->setSortingPillsEnabled(false)
->setAdditionalSelects(['users.id as id', 'project_user.role_in_project as role_in_project']);
->setDefaultSort('users.name', 'asc')
->setSortingPillsEnabled(false)
->setAdditionalSelects(['users.id as id', 'project_user.role_in_project as role_in_project']);
}
#[On('project-users-changed')]
@@ -49,48 +50,51 @@ class ProjectUsersTable extends DataTableComponent
{
return [
Column::make('Nombre', 'name')
->sortable()
->searchable()
->format(function ($value, $row) {
$initial = strtoupper(mb_substr($value ?? '?', 0, 1));
return '<div class="flex items-center gap-2">
->sortable()
->searchable()
->format(function ($value, $row) {
$initial = strtoupper(mb_substr($value ?? '?', 0, 1));
return '<div class="flex items-center gap-2">
<span class="w-7 h-7 rounded-full bg-primary text-primary-content flex items-center justify-center text-xs font-bold shrink-0">'.$initial.'</span>
<span class="font-medium">'.e($value).'</span>
</div>';
})
->html(),
})
->html(),
Column::make('Email', 'email')
->sortable()
->searchable(),
->sortable()
->searchable(),
Column::make('Rol', 'role_in_project')
->label(function ($row) {
$current = $row->role_in_project;
if (! Auth::user()->can('assign users')) {
return '<span class="badge badge-sm">'.(self::ROLES[$current] ?? ucfirst((string) $current)).'</span>';
}
$opts = '';
foreach (self::ROLES as $val => $label) {
$opts .= '<option value="'.$val.'"'.($current === $val ? ' selected' : '').'>'.$label.'</option>';
}
return '<select wire:change="changeRole('.$row->id.', $event.target.value)" class="select select-bordered select-xs">'.$opts.'</select>';
})
->html(),
->label(function ($row) {
$current = $row->role_in_project;
if (! Auth::user()->can('assign users')) {
return '<span class="badge badge-sm">'.(self::ROLES[$current] ?? ucfirst((string) $current)).'</span>';
}
$opts = '';
foreach (self::ROLES as $val => $label) {
$opts .= '<option value="'.$val.'"'.($current === $val ? ' selected' : '').'>'.$label.'</option>';
}
return '<select wire:change="changeRole('.$row->id.', $event.target.value)" class="select select-bordered select-xs">'.$opts.'</select>';
})
->html(),
Column::make('Acciones')
->label(function ($row) {
if (! Auth::user()->can('assign users')) {
return '';
}
return '<div class="flex justify-end">
->label(function ($row) {
if (! Auth::user()->can('assign users')) {
return '';
}
return '<div class="flex justify-end">
<button wire:click="removeUser('.$row->id.')" wire:confirm="¿Quitar a '.e($row->name).' del proyecto?"
class="btn btn-xs btn-error btn-outline" title="Quitar del proyecto">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>';
})
->html(),
})
->html(),
];
}
@@ -109,7 +113,7 @@ class ProjectUsersTable extends DataTableComponent
if (! array_key_exists($role, self::ROLES)) {
return;
}
\App\Models\Project::findOrFail($this->projectId)
Project::findOrFail($this->projectId)
->users()->updateExistingPivot($userId, ['role_in_project' => $role]);
$this->dispatch('project-users-changed');
$this->dispatch('notify', 'Rol actualizado.');
@@ -118,7 +122,7 @@ class ProjectUsersTable extends DataTableComponent
public function removeUser($userId): void
{
abort_unless(Auth::user()->can('assign users'), 403);
\App\Models\Project::findOrFail($this->projectId)->users()->detach($userId);
Project::findOrFail($this->projectId)->users()->detach($userId);
$this->dispatch('project-users-changed');
$this->dispatch('notify', 'Usuario eliminado del proyecto.');
}
+5 -4
View File
@@ -10,7 +10,7 @@ use Livewire\Component;
class ReportBuilder extends Component
{
public Project $project;
public array $filters = [
'date_from' => null,
'date_to' => null,
@@ -19,8 +19,9 @@ class ReportBuilder extends Component
'include_charts' => false,
'format' => 'html',
];
public bool $showPreview = false;
public ?array $previewData = null;
public function mount(Project $project)
@@ -32,7 +33,7 @@ class ReportBuilder extends Component
public function authorizeAccess(): void
{
$user = auth()->guard()->user();
if (!$user->can('manage all') && !$this->project->users()->where('user_id', $user->id)->exists()) {
if (! $user->can('manage all') && ! $this->project->users()->where('user_id', $user->id)->exists()) {
abort(403);
}
}
@@ -122,4 +123,4 @@ class ReportBuilder extends Component
'availableEntities' => (new ReportFilters)->getAvailableEntities(),
]);
}
}
}
+25 -24
View File
@@ -2,31 +2,32 @@
namespace App\Livewire\Reports;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\Project;
use App\Models\Phase;
use App\Models\Inspection;
use App\Models\Phase;
use App\Models\Project;
use Carbon\Carbon;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class ReportsDashboard extends Component
{
public $dateRange = 'month'; // week, month, quarter, year
public $chartData = [];
public function mount()
{
$this->loadChartData();
}
public function loadChartData()
{
// Project progress over time (last 6 months)
$projects = Project::with(['phases' => function($query) {
$projects = Project::with(['phases' => function ($query) {
$query->select('project_id', 'progress_percent', 'updated_at');
}])->get();
// Simulate monthly progress data (since we don't have historical stored)
// In a real app, we'd have a progress_history table or similar
$months = [];
@@ -35,7 +36,7 @@ class ReportsDashboard extends Component
$month = $current->copy()->subMonths($i);
$months[] = $month->format('M Y');
}
$projectProgress = [];
foreach ($projects as $project) {
$progressData = [];
@@ -49,54 +50,54 @@ class ReportsDashboard extends Component
}
$projectProgress[] = [
'name' => $project->name,
'data' => $progressData
'data' => $progressData,
];
}
// Inspections by type (last 6 months)
$inspections = Inspection::with(['template', 'feature'])
->whereDate('created_at', '>=', Carbon::now()->subMonths(6))
->get();
$inspectionTypes = $inspections->groupBy(function($inspection) {
$inspectionTypes = $inspections->groupBy(function ($inspection) {
return $inspection->template ? $inspection->template->name : 'Sin plantilla';
})->map(function($group) {
})->map(function ($group) {
return $group->count();
});
// Projects by status
$projectsByStatus = Project::selectRaw('status, count(*) as count')
->groupBy('status')
->pluck('count', 'status')
->toArray();
// Average phase progress by project
$projectPhaseProgress = Project::with(['phases'])
->get()
->map(function($project) {
->map(function ($project) {
return [
'name' => $project->name,
'progress' => $project->phases->avg('progress_percent') ?? 0
'progress' => $project->phases->avg('progress_percent') ?? 0,
];
});
$this->chartData = [
'months' => $months,
'projectProgress' => $projectProgress,
'inspectionTypes' => [
'labels' => $inspectionTypes->keys()->toArray(),
'data' => $inspectionTypes->values()->toArray()
'data' => $inspectionTypes->values()->toArray(),
],
'projectsByStatus' => [
'labels' => array_map(function($status) {
'labels' => array_map(function ($status) {
return ucfirst(str_replace('_', ' ', $status));
}, array_keys($projectsByStatus)),
'data' => array_values($projectsByStatus)
'data' => array_values($projectsByStatus),
],
'projectPhaseProgress' => $projectPhaseProgress
'projectPhaseProgress' => $projectPhaseProgress,
];
}
public function render()
{
return view('livewire.reports.reports-dashboard');
+7 -6
View File
@@ -2,14 +2,15 @@
namespace App\Livewire\Users;
use Livewire\Component;
use App\Models\User;
use Spatie\Permission\Models\Role;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
use Spatie\Permission\Models\Role;
class AdminUsers extends Component
{
public string $search = '';
public $roles;
public function mount(): void
@@ -21,10 +22,9 @@ class AdminUsers extends Component
public function getUsersProperty()
{
return User::with('roles')
->when($this->search, fn($q) =>
$q->where(fn($q2) => $q2
->where('name', 'like', '%' . $this->search . '%')
->orWhere('email', 'like', '%' . $this->search . '%')))
->when($this->search, fn ($q) => $q->where(fn ($q2) => $q2
->where('name', 'like', '%'.$this->search.'%')
->orWhere('email', 'like', '%'.$this->search.'%')))
->orderBy('name')
->get();
}
@@ -33,6 +33,7 @@ class AdminUsers extends Component
{
if ($userId === Auth::id()) {
$this->dispatch('notify', 'No puedes eliminarte a ti mismo.');
return;
}
User::findOrFail($userId)->delete();
+72 -60
View File
@@ -2,14 +2,14 @@
namespace App\Livewire\Users;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\User;
use App\Models\Company;
use Spatie\Permission\Models\Role;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Spatie\Permission\Models\Role;
#[Layout('layouts.app')]
class UserForm extends Component
@@ -17,21 +17,29 @@ class UserForm extends Component
public ?User $user = null;
// Información personal
public string $title = '';
public string $lastName = '';
public string $title = '';
public string $lastName = '';
public string $firstName = '';
// Validación
public string $userStatus = 'active';
public string $validFrom = '';
public string $validFrom = '';
public string $validUntil = '';
public string $formPassword = '';
// Contacto
public ?int $companyId = null;
public string $address = '';
public string $phone = '';
public string $email = '';
public ?int $companyId = null;
public string $address = '';
public string $phone = '';
public string $email = '';
// Permisos
public string $formRole = '';
@@ -44,6 +52,7 @@ class UserForm extends Component
// Catálogos
public $roles;
public $companies;
/** Idiomas disponibles (código => nombre + archivo de bandera). */
@@ -58,47 +67,47 @@ class UserForm extends Component
{
abort_unless(Auth::user()->can('create users') || Auth::user()->can('edit users'), 403);
$this->roles = Role::orderBy('name')->get();
$this->roles = Role::orderBy('name')->get();
$this->companies = Company::where('estado', 'activo')->orderBy('name')->get();
$this->formRole = $this->roles->first()?->name ?? '';
$this->formRole = $this->roles->first()?->name ?? '';
if ($user && $user->exists) {
$this->user = $user;
$this->title = $user->title ?? '';
$this->lastName = $user->last_name ?? '';
$this->firstName = $user->first_name ?? '';
$this->userStatus = $user->status ?? 'active';
$this->validFrom = $user->valid_from?->format('Y-m-d') ?? '';
$this->user = $user;
$this->title = $user->title ?? '';
$this->lastName = $user->last_name ?? '';
$this->firstName = $user->first_name ?? '';
$this->userStatus = $user->status ?? 'active';
$this->validFrom = $user->valid_from?->format('Y-m-d') ?? '';
$this->validUntil = $user->valid_until?->format('Y-m-d') ?? '';
$this->companyId = $user->company_id;
$this->address = $user->address ?? '';
$this->phone = $user->phone ?? '';
$this->email = $user->email;
$this->notes = $user->notes ?? '';
$this->formRole = $user->roles->first()?->name ?? $this->formRole;
$this->locale = $user->locale ?? $this->locale;
$this->companyId = $user->company_id;
$this->address = $user->address ?? '';
$this->phone = $user->phone ?? '';
$this->email = $user->email;
$this->notes = $user->notes ?? '';
$this->formRole = $user->roles->first()?->name ?? $this->formRole;
$this->locale = $user->locale ?? $this->locale;
}
}
protected function rules(): array
{
$id = $this->user?->id ?? 'NULL';
$id = $this->user?->id ?? 'NULL';
$rules = [
'lastName' => 'required|string|max:100',
'firstName' => 'required|string|max:100',
'title' => 'nullable|string|max:20',
'lastName' => 'required|string|max:100',
'firstName' => 'required|string|max:100',
'title' => 'nullable|string|max:20',
'userStatus' => 'required|in:active,inactive,suspended',
'validFrom' => 'nullable|date',
'validFrom' => 'nullable|date',
'validUntil' => 'nullable|date|after_or_equal:validFrom',
'companyId' => 'required|exists:companies,id',
'address' => 'nullable|string',
'phone' => 'nullable|string|max:30',
'email' => "required|email|max:255|unique:users,email,{$id}",
'formRole' => 'required|exists:roles,name',
'locale' => 'required|in:' . implode(',', array_keys($this->languages)),
'companyId' => 'required|exists:companies,id',
'address' => 'nullable|string',
'phone' => 'nullable|string|max:30',
'email' => "required|email|max:255|unique:users,email,{$id}",
'formRole' => 'required|exists:roles,name',
'locale' => 'required|in:'.implode(',', array_keys($this->languages)),
];
if (!$this->user) {
if (! $this->user) {
$rules['formPassword'] = ['required', Password::min(8)->letters()->mixedCase()->numbers()];
} elseif ($this->formPassword !== '') {
$rules['formPassword'] = [Password::min(8)->letters()->mixedCase()->numbers()];
@@ -108,20 +117,22 @@ class UserForm extends Component
}
protected $validationAttributes = [
'lastName' => 'apellidos',
'firstName' => 'nombre',
'userStatus' => 'estado',
'validFrom' => 'fecha de inicio',
'validUntil' => 'fecha de fin',
'companyId' => 'empresa',
'formPassword'=> 'contraseña',
'formRole' => 'rol',
'locale' => 'idioma',
'lastName' => 'apellidos',
'firstName' => 'nombre',
'userStatus' => 'estado',
'validFrom' => 'fecha de inicio',
'validUntil' => 'fecha de fin',
'companyId' => 'empresa',
'formPassword' => 'contraseña',
'formRole' => 'rol',
'locale' => 'idioma',
];
public function copyCompanyAddress(): void
{
if (!$this->companyId) return;
if (! $this->companyId) {
return;
}
$company = Company::find($this->companyId);
if ($company?->address) {
$this->address = $company->address;
@@ -135,25 +146,26 @@ class UserForm extends Component
if ($this->user && $this->user->id === Auth::id()
&& $this->user->hasRole('Admin') && $this->formRole !== 'Admin') {
$this->addError('formRole', 'No puedes quitarte el rol Admin a ti mismo.');
return;
}
$fullName = trim($this->firstName . ' ' . $this->lastName);
$fullName = trim($this->firstName.' '.$this->lastName);
$data = [
'name' => $fullName,
'title' => $this->title ?: null,
'name' => $fullName,
'title' => $this->title ?: null,
'first_name' => $this->firstName,
'last_name' => $this->lastName,
'status' => $this->userStatus,
'valid_from' => $this->validFrom ?: null,
'valid_until'=> $this->validUntil ?: null,
'last_name' => $this->lastName,
'status' => $this->userStatus,
'valid_from' => $this->validFrom ?: null,
'valid_until' => $this->validUntil ?: null,
'company_id' => $this->companyId,
'address' => $this->address ?: null,
'phone' => $this->phone ?: null,
'email' => $this->email,
'notes' => $this->notes ?: null,
'locale' => $this->locale,
'address' => $this->address ?: null,
'phone' => $this->phone ?: null,
'email' => $this->email,
'notes' => $this->notes ?: null,
'locale' => $this->locale,
];
if ($this->formPassword !== '') {
+84 -82
View File
@@ -2,13 +2,13 @@
namespace App\Livewire\Users;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
use Rappasoft\LaravelLivewireTables\Views\Filters\SelectFilter;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Spatie\Permission\Models\Role;
use App\Models\User;
class UserTable extends DataTableComponent
{
@@ -17,17 +17,17 @@ class UserTable extends DataTableComponent
public function configure(): void
{
$this->setPrimaryKey('id')
->setDefaultSort('name', 'asc')
->setSortingPillsEnabled(false)
->setAdditionalSelects([
'users.id as id',
'users.email as email',
'users.email_verified_at as email_verified_at',
'users.status as status',
'users.phone as phone',
'users.company_id as company_id',
'users.created_at as created_at',
]);
->setDefaultSort('name', 'asc')
->setSortingPillsEnabled(false)
->setAdditionalSelects([
'users.id as id',
'users.email as email',
'users.email_verified_at as email_verified_at',
'users.status as status',
'users.phone as phone',
'users.company_id as company_id',
'users.created_at as created_at',
]);
}
public function builder(): Builder
@@ -39,90 +39,91 @@ class UserTable extends DataTableComponent
{
return [
Column::make('Usuario', 'name')
->sortable()
->searchable()
->format(function ($value, $row) {
$initial = strtoupper(mb_substr($value, 0, 1));
$html = '<div class="flex items-center gap-3">';
$html .= '<div class="avatar placeholder shrink-0">
->sortable()
->searchable()
->format(function ($value, $row) {
$initial = strtoupper(mb_substr($value, 0, 1));
$html = '<div class="flex items-center gap-3">';
$html .= '<div class="avatar placeholder shrink-0">
<div class="bg-neutral text-neutral-content rounded-full w-8">
<span class="text-xs font-semibold">'.$initial.'</span>
</div>
</div>';
$html .= '<div>';
$html .= '<p class="font-semibold text-sm leading-tight">'.e($value).'</p>';
$html .= '<p class="text-xs text-gray-500">'.e($row->email).'</p>';
$html .= '</div></div>';
return $html;
})
->html(),
$html .= '<div>';
$html .= '<p class="font-semibold text-sm leading-tight">'.e($value).'</p>';
$html .= '<p class="text-xs text-gray-500">'.e($row->email).'</p>';
$html .= '</div></div>';
return $html;
})
->html(),
Column::make('Empresa')
->label(fn ($row) =>
$row->company
? '<span class="text-sm">'.e($row->company->name).'</span>'
: '<span class="text-gray-300 text-sm">—</span>'
)
->html(),
->label(fn ($row) => $row->company
? '<span class="text-sm">'.e($row->company->name).'</span>'
: '<span class="text-gray-300 text-sm">—</span>'
)
->html(),
Column::make('Rol')
->label(function ($row) {
if ($row->roles->isEmpty()) {
return '<span class="badge badge-sm badge-ghost">Sin rol</span>';
}
return $row->roles->map(fn ($role) =>
'<span class="badge badge-sm '.($role->name === 'Admin' ? 'badge-error' : 'badge-primary').'">'.e($role->name).'</span>'
)->implode(' ');
})
->html(),
->label(function ($row) {
if ($row->roles->isEmpty()) {
return '<span class="badge badge-sm badge-ghost">Sin rol</span>';
}
return $row->roles->map(fn ($role) => '<span class="badge badge-sm '.($role->name === 'Admin' ? 'badge-error' : 'badge-primary').'">'.e($role->name).'</span>'
)->implode(' ');
})
->html(),
Column::make('Estado', 'status')
->sortable()
->format(function ($value) {
$map = [
'active' => ['badge-success', 'Activo'],
'inactive' => ['badge-ghost', 'Inactivo'],
'suspended' => ['badge-error', 'Suspendido'],
];
[$cls, $label] = $map[$value ?? 'active'] ?? ['badge-ghost', ucfirst($value ?? '')];
return '<span class="badge badge-sm '.$cls.'">'.$label.'</span>';
})
->html(),
->sortable()
->format(function ($value) {
$map = [
'active' => ['badge-success', 'Activo'],
'inactive' => ['badge-ghost', 'Inactivo'],
'suspended' => ['badge-error', 'Suspendido'],
];
[$cls, $label] = $map[$value ?? 'active'] ?? ['badge-ghost', ucfirst($value ?? '')];
return '<span class="badge badge-sm '.$cls.'">'.$label.'</span>';
})
->html(),
Column::make('Verificado', 'email_verified_at')
->sortable()
->format(fn ($value) =>
$value
? '<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>'
: '<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>'
)
->html(),
->sortable()
->format(fn ($value) => $value
? '<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>'
: '<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>'
)
->html(),
Column::make('Acciones')
->label(function ($row) {
$ver = route('admin.users.show', $row->id);
$editar = route('admin.users.edit', $row->id);
$name = addslashes($row->name);
$isSelf = $row->id === Auth::id();
->label(function ($row) {
$ver = route('admin.users.show', $row->id);
$editar = route('admin.users.edit', $row->id);
$name = addslashes($row->name);
$isSelf = $row->id === Auth::id();
$html = '<div class="flex items-center justify-end gap-1">';
$html .= '<a href="'.$ver.'" class="btn btn-xs btn-outline" title="Ver" wire:navigate>
$html = '<div class="flex items-center justify-end gap-1">';
$html .= '<a href="'.$ver.'" class="btn btn-xs btn-outline" title="Ver" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</a>';
$html .= '<a href="'.$editar.'" class="btn btn-xs btn-outline btn-info" title="Editar" wire:navigate>
$html .= '<a href="'.$editar.'" class="btn btn-xs btn-outline btn-info" title="Editar" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</a>';
if (! $isSelf) {
$html .= '<button wire:click="deleteUser('.$row->id.')"
if (! $isSelf) {
$html .= '<button wire:click="deleteUser('.$row->id.')"
wire:confirm="¿Eliminar a \''.$name.'\'? Se perderán todos sus datos."
class="btn btn-xs btn-outline btn-error" title="Eliminar">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>';
}
$html .= '</div>';
return $html;
})
->html(),
}
$html .= '</div>';
return $html;
})
->html(),
];
}
@@ -133,16 +134,15 @@ class UserTable extends DataTableComponent
return [
SelectFilter::make('Rol')
->options($roleOptions)
->filter(fn (Builder $query, string $value) =>
$query->whereHas('roles', fn ($q) => $q->where('name', $value))
->filter(fn (Builder $query, string $value) => $query->whereHas('roles', fn ($q) => $q->where('name', $value))
),
SelectFilter::make('Estado', 'status')
->options([
'' => 'Estado: todos',
'active' => 'Activo',
'inactive' => 'Inactivo',
'suspended' => 'Suspendido',
'' => 'Estado: todos',
'active' => 'Activo',
'inactive' => 'Inactivo',
'suspended' => 'Suspendido',
])
->filter(fn (Builder $query, string $value) => $query->where('status', $value)),
];
@@ -150,7 +150,9 @@ class UserTable extends DataTableComponent
public function deleteUser(int $id): void
{
if ($id === Auth::id()) return;
if ($id === Auth::id()) {
return;
}
User::findOrFail($id)->delete();
}
}
+19 -13
View File
@@ -2,40 +2,45 @@
namespace App\Livewire\Users;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\User;
use App\Models\Project;
use App\Models\Inspection;
use App\Models\Issue;
use App\Models\Project;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\PermissionRegistrar;
#[Layout('layouts.app')]
class UserView extends Component
{
public User $user;
public User $user;
public string $activeTab = 'ficha';
// Projects tab
public ?int $addProjectId = null;
public ?int $addProjectId = null;
public string $addProjectRole = '';
public $availableProjects;
// Notes tab
public string $notes = '';
public bool $editingNotes = false;
public string $notes = '';
public bool $editingNotes = false;
// Recent activity (loaded once)
public $recentInspections;
public $recentIssues;
public function mount(User $user): void
{
abort_unless(Auth::user()->can('view users'), 403);
$this->user = $user->load(['roles', 'company', 'projects.phases']);
$this->user = $user->load(['roles', 'company', 'projects.phases']);
$this->notes = $user->notes ?? '';
$this->loadAvailableProjects();
@@ -72,7 +77,7 @@ class UserView extends Component
public function assignProject(): void
{
$this->validate([
'addProjectId' => 'required|exists:projects,id',
'addProjectId' => 'required|exists:projects,id',
'addProjectRole' => 'nullable|string|max:100',
], [], ['addProjectId' => 'proyecto', 'addProjectRole' => 'rol en proyecto']);
@@ -81,7 +86,7 @@ class UserView extends Component
]);
$this->user->load('projects.phases');
$this->addProjectId = null;
$this->addProjectId = null;
$this->addProjectRole = '';
$this->loadAvailableProjects();
$this->dispatch('notify', 'Proyecto asignado.');
@@ -146,13 +151,14 @@ class UserView extends Component
->groupBy(fn ($perm) => $perm->group ?: 'General')
->sortBy(function ($perms, $section) use ($order) {
$i = array_search($section, $order, true);
return $i === false ? 999 : $i;
});
return view('livewire.users.user-view', [
'grouped' => $grouped,
'grouped' => $grouped,
'directPerms' => $this->user->getDirectPermissions()->pluck('name')->toArray(),
'rolePerms' => $this->user->getPermissionsViaRoles()->pluck('name')->toArray(),
'rolePerms' => $this->user->getPermissionsViaRoles()->pluck('name')->toArray(),
]);
}
}
+5 -5
View File
@@ -12,18 +12,18 @@ class ActivityLog extends Model
protected $fillable = ['action', 'model_type', 'model_id', 'user_id', 'changes', 'created_at'];
protected $casts = [
'changes' => 'array',
'changes' => 'array',
'created_at' => 'datetime',
];
public static function record(string $action, Model $model, array $changes = []): void
{
static::create([
'action' => $action,
'action' => $action,
'model_type' => class_basename($model),
'model_id' => $model->getKey(),
'user_id' => Auth::id(),
'changes' => empty($changes) ? null : $changes,
'model_id' => $model->getKey(),
'user_id' => Auth::id(),
'changes' => empty($changes) ? null : $changes,
'created_at' => now(),
]);
}
+2 -3
View File
@@ -2,7 +2,6 @@
namespace App\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -35,7 +34,7 @@ class Company extends Model
public function projects()
{
return $this->belongsToMany(Project::class, 'company_project')
->withPivot('role_in_project')
->withTimestamps();
->withPivot('role_in_project')
->withTimestamps();
}
}
+28 -26
View File
@@ -2,14 +2,15 @@
namespace App\Models;
use App\Traits\LogsActivity;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Traits\LogsActivity;
class Feature extends Model
{
use SoftDeletes, LogsActivity;
use LogsActivity, SoftDeletes;
const STATUSES = ['planned', 'started', 'in_progress', 'completed', 'verified'];
@@ -22,15 +23,15 @@ class Feature extends Model
];
protected $casts = [
'geometry' => 'array',
'properties' => 'array',
'is_active' => 'boolean',
'planned_start' => 'date',
'planned_end' => 'date',
'actual_start' => 'date',
'actual_end' => 'date',
'baseline_start' => 'date',
'baseline_end' => 'date',
'geometry' => 'array',
'properties' => 'array',
'is_active' => 'boolean',
'planned_start' => 'date',
'planned_end' => 'date',
'actual_start' => 'date',
'actual_end' => 'date',
'baseline_start' => 'date',
'baseline_end' => 'date',
];
public function featureType()
@@ -80,13 +81,13 @@ class Feature extends Model
public function getStatusColorAttribute(): string
{
return match($this->status) {
'planned' => '#6b7280',
'started' => '#3b82f6',
return match ($this->status) {
'planned' => '#6b7280',
'started' => '#3b82f6',
'in_progress' => '#f59e0b',
'completed' => '#10b981',
'verified' => '#8b5cf6',
default => '#6b7280',
'completed' => '#10b981',
'verified' => '#8b5cf6',
default => '#6b7280',
};
}
@@ -99,7 +100,7 @@ class Feature extends Model
return null;
}
$end = $this->actual_end ?? now()->toDateString();
return $this->planned_end->diffInDays($end, false);
}
@@ -109,28 +110,28 @@ class Feature extends Model
return null;
}
$start = $this->actual_start ?? now()->toDateString();
return $this->planned_start->diffInDays($start, false);
}
public function getPlannedProgressAtAttribute(?\Carbon\Carbon $date = null): float
public function getPlannedProgressAtAttribute(?Carbon $date = null): float
{
$date = $date ?? now();
if (! $this->planned_start || ! $this->planned_end) {
return 0;
}
if ($date <= $this->planned_start) {
return 0;
}
if ($date >= $this->planned_end) {
return 100;
}
$totalDays = $this->planned_start->diffInDays($this->planned_end);
$elapsedDays = $this->planned_start->diffInDays($date);
return round(($elapsedDays / $totalDays) * 100, 2);
}
@@ -138,11 +139,11 @@ class Feature extends Model
{
$pv = $this->planned_progress_at;
$ev = $this->progress;
if ($pv <= 0) {
return null;
}
return round($ev / $pv, 2);
}
@@ -152,6 +153,7 @@ class Feature extends Model
if ($spi === null) {
return null;
}
return $spi >= 0.95;
}
}
+19 -7
View File
@@ -2,13 +2,13 @@
namespace App\Models;
use App\Traits\LogsActivity;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Traits\LogsActivity;
class Inspection extends Model
{
use SoftDeletes, LogsActivity;
use LogsActivity, SoftDeletes;
protected static function booted(): void
{
@@ -19,7 +19,8 @@ class Inspection extends Model
}
const STATUSES = ['pending', 'in_progress', 'completed', 'approved', 'rejected'];
const RESULTS = ['pass', 'fail', 'conditional'];
const RESULTS = ['pass', 'fail', 'conditional'];
protected $fillable = [
'project_id', 'layer_id', 'feature_id', 'template_id', 'user_id',
@@ -28,7 +29,7 @@ class Inspection extends Model
];
protected $casts = [
'data' => 'array',
'data' => 'array',
'completed_at' => 'datetime',
];
@@ -72,7 +73,18 @@ class Inspection extends Model
return $this->hasMany(Issue::class);
}
public function scopePending($q) { return $q->where('status', 'pending'); }
public function scopeCompleted($q) { return $q->where('status', 'completed'); }
public function scopeRejected($q) { return $q->where('status', 'rejected'); }
public function scopePending($q)
{
return $q->where('status', 'pending');
}
public function scopeCompleted($q)
{
return $q->where('status', 'completed');
}
public function scopeRejected($q)
{
return $q->where('status', 'rejected');
}
}
+74 -33
View File
@@ -2,17 +2,19 @@
namespace App\Models;
use App\Traits\LogsActivity;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Traits\LogsActivity;
class Issue extends Model
{
use SoftDeletes, LogsActivity;
use LogsActivity, SoftDeletes;
const STATUSES = ['open', 'in_review', 'resolved', 'closed'];
const STATUSES = ['open', 'in_review', 'resolved', 'closed'];
const PRIORITIES = ['low', 'medium', 'high', 'critical'];
const TYPES = ['defect', 'safety', 'quality', 'documentation', 'other'];
const TYPES = ['defect', 'safety', 'quality', 'documentation', 'other'];
protected $fillable = [
'project_id', 'feature_id', 'inspection_id',
@@ -23,17 +25,55 @@ class Issue extends Model
protected $casts = ['resolved_at' => 'datetime'];
public function project() { return $this->belongsTo(Project::class); }
public function feature() { return $this->belongsTo(Feature::class); }
public function inspection() { return $this->belongsTo(Inspection::class); }
public function reporter() { return $this->belongsTo(User::class, 'reported_by'); }
public function assignee() { return $this->belongsTo(User::class, 'assigned_to'); }
public function media() { return $this->morphMany(Media::class, 'mediable'); }
public function tasks() { return $this->hasMany(IssueTask::class)->orderBy('order')->orderBy('id'); }
public function comments() { return $this->hasMany(IssueComment::class)->orderBy('created_at'); }
public function project()
{
return $this->belongsTo(Project::class);
}
public function scopeOpen($q) { return $q->where('status', 'open'); }
public function scopeCritical($q) { return $q->where('priority', 'critical'); }
public function feature()
{
return $this->belongsTo(Feature::class);
}
public function inspection()
{
return $this->belongsTo(Inspection::class);
}
public function reporter()
{
return $this->belongsTo(User::class, 'reported_by');
}
public function assignee()
{
return $this->belongsTo(User::class, 'assigned_to');
}
public function media()
{
return $this->morphMany(Media::class, 'mediable');
}
public function tasks()
{
return $this->hasMany(IssueTask::class)->orderBy('order')->orderBy('id');
}
public function comments()
{
return $this->hasMany(IssueComment::class)->orderBy('created_at');
}
public function scopeOpen($q)
{
return $q->where('status', 'open');
}
public function scopeCritical($q)
{
return $q->where('priority', 'critical');
}
/** Resolution progress derived from the checklist: done tasks / total. */
public function getProgressAttribute(): int
@@ -42,6 +82,7 @@ class Issue extends Model
if ($total === 0) {
return in_array($this->status, ['resolved', 'closed'], true) ? 100 : 0;
}
return (int) round($this->tasks->where('is_done', true)->count() / $total * 100);
}
@@ -53,23 +94,23 @@ class Issue extends Model
public function getPriorityColorAttribute(): string
{
return match($this->priority) {
'low' => '#6b7280',
'medium' => '#f59e0b',
'high' => '#ef4444',
return match ($this->priority) {
'low' => '#6b7280',
'medium' => '#f59e0b',
'high' => '#ef4444',
'critical' => '#7c3aed',
default => '#6b7280',
default => '#6b7280',
};
}
public function getStatusColorAttribute(): string
{
return match($this->status) {
'open' => '#ef4444',
return match ($this->status) {
'open' => '#ef4444',
'in_review' => '#f59e0b',
'resolved' => '#10b981',
'closed' => '#6b7280',
default => '#6b7280',
'resolved' => '#10b981',
'closed' => '#6b7280',
default => '#6b7280',
};
}
@@ -77,11 +118,11 @@ class Issue extends Model
public static function typeLabels(): array
{
return [
'defect' => 'Defecto',
'safety' => 'Seguridad',
'quality' => 'Calidad',
'defect' => 'Defecto',
'safety' => 'Seguridad',
'quality' => 'Calidad',
'documentation' => 'Documentación',
'other' => 'Otro',
'other' => 'Otro',
];
}
@@ -92,12 +133,12 @@ class Issue extends Model
public function getTypeColorAttribute(): string
{
return match($this->type) {
'defect' => '#ef4444',
'safety' => '#f97316',
'quality' => '#0ea5e9',
return match ($this->type) {
'defect' => '#ef4444',
'safety' => '#f97316',
'quality' => '#0ea5e9',
'documentation' => '#8b5cf6',
default => '#6b7280',
default => '#6b7280',
};
}
}
+14 -3
View File
@@ -14,7 +14,18 @@ class IssueComment extends Model
'uuid', 'client_updated_at',
];
public function issue() { return $this->belongsTo(Issue::class); }
public function user() { return $this->belongsTo(User::class); }
public function media() { return $this->morphMany(Media::class, 'mediable'); }
public function issue()
{
return $this->belongsTo(Issue::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
public function media()
{
return $this->morphMany(Media::class, 'mediable');
}
}
+24 -9
View File
@@ -16,9 +16,9 @@ class IssueTask extends Model
];
protected $casts = [
'is_done' => 'boolean',
'done_at' => 'datetime',
'due_date' => 'date',
'is_done' => 'boolean',
'done_at' => 'datetime',
'due_date' => 'date',
'overdue_notified_at' => 'datetime',
];
@@ -26,14 +26,29 @@ class IssueTask extends Model
public function scopeOverdue($q)
{
return $q->where('is_done', false)
->whereNotNull('due_date')
->whereDate('due_date', '<', now()->toDateString());
->whereNotNull('due_date')
->whereDate('due_date', '<', now()->toDateString());
}
public function issue() { return $this->belongsTo(Issue::class); }
public function assignee() { return $this->belongsTo(User::class, 'assigned_to'); }
public function completer() { return $this->belongsTo(User::class, 'done_by'); }
public function media() { return $this->morphMany(Media::class, 'mediable'); }
public function issue()
{
return $this->belongsTo(Issue::class);
}
public function assignee()
{
return $this->belongsTo(User::class, 'assigned_to');
}
public function completer()
{
return $this->belongsTo(User::class, 'done_by');
}
public function media()
{
return $this->morphMany(Media::class, 'mediable');
}
/** Overdue = has a due date in the past and not yet done. */
public function getIsOverdueAttribute(): bool
+3 -3
View File
@@ -5,13 +5,12 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Layer extends Model
{
use SoftDeletes;
protected $fillable = [
'project_id', 'phase_id', 'name', 'color', 'geojson_data', 'original_file', 'uploaded_by'
'project_id', 'phase_id', 'name', 'color', 'geojson_data', 'original_file', 'uploaded_by',
];
protected $casts = [
@@ -32,6 +31,7 @@ class Layer extends Model
{
return $this->belongsTo(User::class, 'uploaded_by');
}
public function features()
{
return $this->hasMany(Feature::class);
@@ -46,4 +46,4 @@ class Layer extends Model
{
return $this->morphMany(Media::class, 'mediable');
}
}
}
+12 -5
View File
@@ -48,10 +48,17 @@ class Media extends Model
public function getFormattedSizeAttribute()
{
$bytes = $this->file_size;
if ($bytes >= 1073741824) return round($bytes / 1073741824, 2) . ' GB';
if ($bytes >= 1048576) return round($bytes / 1048576, 1) . ' MB';
if ($bytes >= 1024) return round($bytes / 1024) . ' KB';
return $bytes . ' B';
if ($bytes >= 1073741824) {
return round($bytes / 1073741824, 2).' GB';
}
if ($bytes >= 1048576) {
return round($bytes / 1048576, 1).' MB';
}
if ($bytes >= 1024) {
return round($bytes / 1024).' KB';
}
return $bytes.' B';
}
// Scopes
@@ -72,4 +79,4 @@ class Media extends Model
Storage::delete($media->file_path);
});
}
}
}
+1 -1
View File
@@ -12,4 +12,4 @@ class PendingSync extends Model
'payload' => 'array',
'synced_at' => 'datetime',
];
}
}
+11 -9
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -79,7 +80,7 @@ class Phase extends Model
return null;
}
$end = $this->actual_end ?? now()->toDateString();
return $this->planned_end->diffInDays($end, false);
}
@@ -92,31 +93,31 @@ class Phase extends Model
return null;
}
$start = $this->actual_start ?? now()->toDateString();
return $this->planned_start->diffInDays($start, false);
}
/**
* Planned progress at a given date (linear interpolation)
*/
public function getPlannedProgressAtAttribute(?\Carbon\Carbon $date = null): float
public function getPlannedProgressAtAttribute(?Carbon $date = null): float
{
$date = $date ?? now();
if (! $this->planned_start || ! $this->planned_end) {
return 0;
}
if ($date <= $this->planned_start) {
return 0;
}
if ($date >= $this->planned_end) {
return 100;
}
$totalDays = $this->planned_start->diffInDays($this->planned_end);
$elapsedDays = $this->planned_start->diffInDays($date);
return round(($elapsedDays / $totalDays) * 100, 2);
}
@@ -127,11 +128,11 @@ class Phase extends Model
{
$pv = $this->planned_progress_at; // Planned Value
$ev = $this->progress_percent; // Earned Value (current progress)
if ($pv <= 0) {
return null;
}
return round($ev / $pv, 2);
}
@@ -144,6 +145,7 @@ class Phase extends Model
if ($spi === null) {
return null;
}
return $spi >= 0.95;
}
}
+2 -1
View File
@@ -52,6 +52,7 @@ class ProgressSnapshot extends Model
if ($id) {
$query->where('trackable_id', $id);
}
return $query;
}
@@ -76,4 +77,4 @@ class ProgressSnapshot extends Model
->get(['snapshot_date', 'progress', 'method'])
->toArray();
}
}
}
+2 -2
View File
@@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Model;
class ProgressUpdate extends Model
{
protected $fillable = [
'uuid', 'phase_id', 'user_id', 'progress_percent', 'comment', 'location', 'client_updated_at'
'uuid', 'phase_id', 'user_id', 'progress_percent', 'comment', 'location', 'client_updated_at',
];
protected $casts = [
@@ -24,4 +24,4 @@ class ProgressUpdate extends Model
{
return $this->belongsTo(User::class);
}
}
}
@@ -2,9 +2,9 @@
namespace App\Notifications;
use App\Models\Feature;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use App\Models\Feature;
class FeatureCompletedNotification extends Notification
{
@@ -20,12 +20,12 @@ class FeatureCompletedNotification extends Notification
public function toArray($notifiable): array
{
return [
'type' => 'feature_completed',
'feature_id' => $this->feature->id,
'project_id' => $this->feature->layer?->phase?->project_id,
'type' => 'feature_completed',
'feature_id' => $this->feature->id,
'project_id' => $this->feature->layer?->phase?->project_id,
'feature_name' => $this->feature->name,
'progress' => 100,
'message' => "Elemento '{$this->feature->name}' marcado como completado",
'progress' => 100,
'message' => "Elemento '{$this->feature->name}' marcado como completado",
];
}
}
@@ -2,10 +2,9 @@
namespace App\Notifications;
use App\Models\Inspection;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
use App\Models\Inspection;
class InspectionCompletedNotification extends Notification
{
@@ -21,13 +20,13 @@ class InspectionCompletedNotification extends Notification
public function toArray($notifiable): array
{
return [
'type' => 'inspection_completed',
'type' => 'inspection_completed',
'inspection_id' => $this->inspection->id,
'project_id' => $this->inspection->project_id,
'feature_name' => $this->inspection->feature?->name ?? '—',
'project_id' => $this->inspection->project_id,
'feature_name' => $this->inspection->feature?->name ?? '—',
'template_name' => $this->inspection->template?->name ?? '—',
'result' => $this->inspection->result,
'message' => "Inspección completada en '{$this->inspection->feature?->name}': " . ($this->inspection->result ?? 'sin resultado'),
'result' => $this->inspection->result,
'message' => "Inspección completada en '{$this->inspection->feature?->name}': ".($this->inspection->result ?? 'sin resultado'),
];
}
}
@@ -2,9 +2,9 @@
namespace App\Notifications;
use App\Models\Inspection;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use App\Models\Inspection;
class InspectionDeletedNotification extends Notification
{
@@ -20,13 +20,13 @@ class InspectionDeletedNotification extends Notification
public function toArray($notifiable): array
{
return [
'type' => 'inspection_deleted',
'type' => 'inspection_deleted',
'inspection_id' => $this->inspection->id,
'project_id' => $this->inspection->project_id,
'feature_name' => $this->inspection->feature?->name ?? '—',
'project_id' => $this->inspection->project_id,
'feature_name' => $this->inspection->feature?->name ?? '—',
'template_name' => $this->inspection->template?->name ?? '—',
'deleted_by' => $this->deletedBy,
'message' => "Inspección eliminada en '{$this->inspection->feature?->name}' por {$this->deletedBy}",
'deleted_by' => $this->deletedBy,
'message' => "Inspección eliminada en '{$this->inspection->feature?->name}' por {$this->deletedBy}",
];
}
}
}
@@ -2,9 +2,9 @@
namespace App\Notifications;
use App\Models\Inspection;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use App\Models\Inspection;
class InspectionUpdatedNotification extends Notification
{
@@ -20,14 +20,14 @@ class InspectionUpdatedNotification extends Notification
public function toArray($notifiable): array
{
return [
'type' => 'inspection_updated',
'type' => 'inspection_updated',
'inspection_id' => $this->inspection->id,
'project_id' => $this->inspection->project_id,
'feature_name' => $this->inspection->feature?->name ?? '—',
'project_id' => $this->inspection->project_id,
'feature_name' => $this->inspection->feature?->name ?? '—',
'template_name' => $this->inspection->template?->name ?? '—',
'result' => $this->inspection->result,
'changes' => $this->changes,
'message' => "Inspección actualizada en '{$this->inspection->feature?->name}'",
'result' => $this->inspection->result,
'changes' => $this->changes,
'message' => "Inspección actualizada en '{$this->inspection->feature?->name}'",
];
}
}
}
@@ -20,11 +20,11 @@ class IssueAssignedNotification extends Notification
public function toArray($notifiable): array
{
return [
'type' => 'issue_assigned',
'issue_id' => $this->issue->id,
'type' => 'issue_assigned',
'issue_id' => $this->issue->id,
'project_id' => $this->issue->project_id,
'priority' => $this->issue->priority,
'message' => "Se te ha asignado la incidencia '{$this->issue->title}'",
'priority' => $this->issue->priority,
'message' => "Se te ha asignado la incidencia '{$this->issue->title}'",
];
}
}
@@ -23,11 +23,11 @@ class IssueCommentedNotification extends Notification
$issue = $this->comment->issue;
return [
'type' => 'issue_commented',
'issue_id' => $this->comment->issue_id,
'type' => 'issue_commented',
'issue_id' => $this->comment->issue_id,
'project_id' => $issue?->project_id,
'author' => $this->comment->user?->name,
'message' => "{$this->comment->user?->name} comentó en '{$issue?->title}': " . Str::limit($this->comment->body, 60),
'author' => $this->comment->user?->name,
'message' => "{$this->comment->user?->name} comentó en '{$issue?->title}': ".Str::limit($this->comment->body, 60),
];
}
}
@@ -2,9 +2,9 @@
namespace App\Notifications;
use App\Models\Issue;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use App\Models\Issue;
class IssueReportedNotification extends Notification
{
@@ -20,12 +20,12 @@ class IssueReportedNotification extends Notification
public function toArray($notifiable): array
{
return [
'type' => 'issue_reported',
'issue_id' => $this->issue->id,
'project_id' => $this->issue->project_id,
'type' => 'issue_reported',
'issue_id' => $this->issue->id,
'project_id' => $this->issue->project_id,
'feature_name' => $this->issue->feature?->name ?? '—',
'priority' => $this->issue->priority,
'message' => "Nuevo issue '{$this->issue->title}' (prioridad: {$this->issue->priority})",
'priority' => $this->issue->priority,
'message' => "Nuevo issue '{$this->issue->title}' (prioridad: {$this->issue->priority})",
];
}
}
@@ -20,18 +20,18 @@ class IssueStatusChangedNotification extends Notification
public function toArray($notifiable): array
{
$label = [
'open' => 'reabierta',
'open' => 'reabierta',
'in_review' => 'enviada a revisión',
'resolved' => 'resuelta',
'closed' => 'cerrada',
'resolved' => 'resuelta',
'closed' => 'cerrada',
][$this->status] ?? $this->status;
return [
'type' => 'issue_status_changed',
'issue_id' => $this->issue->id,
'type' => 'issue_status_changed',
'issue_id' => $this->issue->id,
'project_id' => $this->issue->project_id,
'status' => $this->status,
'message' => "La incidencia '{$this->issue->title}' ha sido {$label}",
'status' => $this->status,
'message' => "La incidencia '{$this->issue->title}' ha sido {$label}",
];
}
}
@@ -20,12 +20,12 @@ class IssueTaskAssignedNotification extends Notification
public function toArray($notifiable): array
{
return [
'type' => 'issue_task_assigned',
'issue_id' => $this->task->issue_id,
'task_id' => $this->task->id,
'type' => 'issue_task_assigned',
'issue_id' => $this->task->issue_id,
'task_id' => $this->task->id,
'project_id' => $this->task->issue?->project_id,
'due_date' => $this->task->due_date?->toDateString(),
'message' => "Se te ha asignado la tarea '{$this->task->title}'",
'due_date' => $this->task->due_date?->toDateString(),
'message' => "Se te ha asignado la tarea '{$this->task->title}'",
];
}
}
@@ -20,12 +20,12 @@ class IssueTaskOverdueNotification extends Notification
public function toArray($notifiable): array
{
return [
'type' => 'issue_task_overdue',
'issue_id' => $this->task->issue_id,
'task_id' => $this->task->id,
'type' => 'issue_task_overdue',
'issue_id' => $this->task->issue_id,
'task_id' => $this->task->id,
'project_id' => $this->task->issue?->project_id,
'due_date' => $this->task->due_date?->toDateString(),
'message' => "Tarea vencida: '{$this->task->title}' (venció el {$this->task->due_date?->format('d/m/Y')})",
'due_date' => $this->task->due_date?->toDateString(),
'message' => "Tarea vencida: '{$this->task->title}' (venció el {$this->task->due_date?->format('d/m/Y')})",
];
}
}
+31 -28
View File
@@ -8,16 +8,15 @@ use App\Models\Inspection;
use App\Models\Issue;
use App\Models\Media;
use App\Models\Phase;
use App\Models\Project;
use App\Models\ProgressSnapshot;
use App\Models\Project;
use App\Models\Task;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
class ReportGenerator
{
protected Project $project;
protected ReportFilters $filters;
public function __construct(Project $project, ReportFilters $filters)
@@ -82,7 +81,7 @@ class ReportGenerator
->with(['layers.features'])
->get();
$allFeatures = $phases->flatMap(fn($p) => $p->layers->flatMap(fn($l) => $l->features));
$allFeatures = $phases->flatMap(fn ($p) => $p->layers->flatMap(fn ($l) => $l->features));
$completedFeatures = $allFeatures->where('status', 'completed')->count();
$inspectionsQuery = Inspection::where('project_id', $this->project->id);
@@ -136,8 +135,8 @@ class ReportGenerator
->get();
return $phases->map(function ($phase) {
$phaseFeatures = $phase->layers->flatMap(fn($l) => $l->features);
$phaseFeatures = $phase->layers->flatMap(fn ($l) => $l->features);
return [
'id' => $phase->id,
'name' => $phase->name,
@@ -158,7 +157,7 @@ class ReportGenerator
'is_on_track' => $phase->is_on_track,
'features_count' => $phaseFeatures->count(),
'completed_features' => $phaseFeatures->where('status', 'completed')->count(),
'layers' => $phase->layers->map(fn($l) => [
'layers' => $phase->layers->map(fn ($l) => [
'id' => $l->id,
'name' => $l->name,
'features_count' => $l->features->count(),
@@ -169,7 +168,7 @@ class ReportGenerator
protected function buildFeaturesData(): array
{
$query = Feature::whereHas('layer.phase', fn($q) => $q->where('project_id', $this->project->id))
$query = Feature::whereHas('layer.phase', fn ($q) => $q->where('project_id', $this->project->id))
->with(['layer.phase', 'template', 'inspections', 'issues']);
// Apply date filter on feature created_at if date range provided
@@ -179,7 +178,7 @@ class ReportGenerator
return $features->map(function ($feature) {
$lastInspection = $feature->inspections->sortByDesc('created_at')->first();
return [
'id' => $feature->id,
'name' => $feature->name,
@@ -227,7 +226,7 @@ class ReportGenerator
'date' => $inspection->created_at->format('d/m/Y H:i'),
'status' => $inspection->status,
'result' => $inspection->result,
'result_label' => match($inspection->result) {
'result_label' => match ($inspection->result) {
'pass' => 'Aprobada',
'fail' => 'Fallida',
'conditional' => 'Condicional',
@@ -256,7 +255,7 @@ class ReportGenerator
'feature' => $issue->feature?->name ?? '—',
'phase' => $issue->feature?->layer?->phase?->name ?? '—',
'priority' => $issue->priority,
'priority_label' => match($issue->priority) {
'priority_label' => match ($issue->priority) {
'low' => 'Baja',
'medium' => 'Media',
'high' => 'Alta',
@@ -264,7 +263,7 @@ class ReportGenerator
default => ucfirst($issue->priority ?? ''),
},
'status' => $issue->status,
'status_label' => match($issue->status) {
'status_label' => match ($issue->status) {
'open' => 'Abierta',
'in_review' => 'En revisión',
'closed' => 'Cerrada',
@@ -308,7 +307,7 @@ class ReportGenerator
'actual_hours' => $task->actual_hours,
'progress' => $task->progress,
'is_overdue' => $task->is_overdue,
'subtasks' => $task->subtasks->map(fn($st) => [
'subtasks' => $task->subtasks->map(fn ($st) => [
'id' => $st->id,
'title' => $st->title,
'status' => $st->status,
@@ -341,8 +340,10 @@ class ReportGenerator
// Filter by project in PHP (polymorphic complexity)
$filtered = $media->filter(function ($m) {
$mediable = $m->mediable;
if (!$mediable) return false;
if (! $mediable) {
return false;
}
if ($mediable instanceof Phase) {
return $mediable->project_id === $this->project->id;
}
@@ -358,6 +359,7 @@ class ReportGenerator
if ($mediable instanceof Task) {
return $mediable->project_id === $this->project->id;
}
return false;
});
@@ -398,9 +400,9 @@ class ReportGenerator
'spi' => $phase->spi,
'is_on_track' => $phase->is_on_track,
];
})->filter(fn($p) => $p['planned_start'] || $p['planned_end'])->toArray();
})->filter(fn ($p) => $p['planned_start'] || $p['planned_end'])->toArray();
$featureDeviations = Feature::whereHas('layer.phase', fn($q) => $q->where('project_id', $this->project->id))
$featureDeviations = Feature::whereHas('layer.phase', fn ($q) => $q->where('project_id', $this->project->id))
->whereNotNull('planned_end')
->with(['layer.phase'])
->get()
@@ -428,12 +430,12 @@ class ReportGenerator
'phases' => $phaseDeviations,
'features' => $featureDeviations,
'summary' => [
'phases_delayed' => count(array_filter($phaseDeviations, fn($p) => ($p['end_deviation'] ?? 0) > 0)),
'phases_early' => count(array_filter($phaseDeviations, fn($p) => ($p['end_deviation'] ?? 0) < 0)),
'phases_on_time' => count(array_filter($phaseDeviations, fn($p) => ($p['end_deviation'] ?? 0) === 0)),
'features_delayed' => count(array_filter($featureDeviations, fn($f) => ($f['end_deviation'] ?? 0) > 0)),
'features_early' => count(array_filter($featureDeviations, fn($f) => ($f['end_deviation'] ?? 0) < 0)),
'features_on_time' => count(array_filter($featureDeviations, fn($f) => ($f['end_deviation'] ?? 0) === 0)),
'phases_delayed' => count(array_filter($phaseDeviations, fn ($p) => ($p['end_deviation'] ?? 0) > 0)),
'phases_early' => count(array_filter($phaseDeviations, fn ($p) => ($p['end_deviation'] ?? 0) < 0)),
'phases_on_time' => count(array_filter($phaseDeviations, fn ($p) => ($p['end_deviation'] ?? 0) === 0)),
'features_delayed' => count(array_filter($featureDeviations, fn ($f) => ($f['end_deviation'] ?? 0) > 0)),
'features_early' => count(array_filter($featureDeviations, fn ($f) => ($f['end_deviation'] ?? 0) < 0)),
'features_on_time' => count(array_filter($featureDeviations, fn ($f) => ($f['end_deviation'] ?? 0) === 0)),
],
];
}
@@ -442,7 +444,7 @@ class ReportGenerator
{
// Get snapshots for this project's phases and features
$phaseIds = $this->project->phases->pluck('id')->toArray();
$featureIds = Feature::whereHas('layer.phase', fn($q) => $q->whereIn('phase_id', $phaseIds))
$featureIds = Feature::whereHas('layer.phase', fn ($q) => $q->whereIn('phase_id', $phaseIds))
->pluck('id')->toArray();
$taskIds = Task::where('project_id', $this->project->id)->pluck('id')->toArray();
@@ -475,7 +477,7 @@ class ReportGenerator
foreach ($grouped as $date => $snaps) {
$dates[] = Carbon::parse($date)->format('d/m/Y');
$phaseSnaps = $snaps->where('trackable_type', Phase::class);
$featureSnaps = $snaps->where('trackable_type', Feature::class);
$taskSnaps = $snaps->where('trackable_type', Task::class);
@@ -523,7 +525,7 @@ class ReportGenerator
protected function getFeatureStatusLabel(string $status): string
{
return match($status) {
return match ($status) {
'planned' => 'Planificado',
'started' => 'Iniciado',
'in_progress' => 'En progreso',
@@ -541,6 +543,7 @@ class ReportGenerator
$bytes /= 1024;
$i++;
}
return round($bytes, 1) . ' ' . $units[$i];
return round($bytes, 1).' '.$units[$i];
}
}
}
+83 -37
View File
@@ -15,14 +15,16 @@ class SpatialFileConverter
$geojson = match ($ext) {
'geojson' => self::parseGeoJson($path),
'kml' => self::kmlToGeoJson($path),
'kmz' => self::kmzToGeoJson($path),
'shp' => self::shapefileToGeoJson($path),
'zip' => self::handleZip($path),
default => null,
'kml' => self::kmlToGeoJson($path),
'kmz' => self::kmzToGeoJson($path),
'shp' => self::shapefileToGeoJson($path),
'zip' => self::handleZip($path),
default => null,
};
if (!$geojson) return null;
if (! $geojson) {
return null;
}
return self::postProcess($geojson);
}
@@ -37,15 +39,19 @@ class SpatialFileConverter
foreach ($geojson['features'] ?? [] as $feature) {
if (!isset($feature['geometry'])) continue;
if (! isset($feature['geometry'])) {
continue;
}
$geometry = self::cleanGeometry($feature['geometry']);
if (!$geometry) continue;
if (! $geometry) {
continue;
}
$features[] = [
'type' => 'Feature',
'geometry' => $geometry,
'properties' => self::normalizeProperties($feature['properties'] ?? [])
'properties' => self::normalizeProperties($feature['properties'] ?? []),
];
}
@@ -53,7 +59,7 @@ class SpatialFileConverter
'type' => 'FeatureCollection',
'features' => $features,
'bbox' => self::calculateBBox($features),
'centroid' => self::calculateCentroid($features)
'centroid' => self::calculateCentroid($features),
];
}
@@ -75,13 +81,16 @@ class SpatialFileConverter
private static function cleanGeometry(array $geom): ?array
{
if (!isset($geom['type'], $geom['coordinates'])) return null;
if (! isset($geom['type'], $geom['coordinates'])) {
return null;
}
if ($geom['type'] === 'Polygon') {
$geom['coordinates'] = array_map(function ($ring) {
if ($ring[0] !== end($ring)) {
$ring[] = $ring[0];
}
return $ring;
}, $geom['coordinates']);
}
@@ -101,7 +110,9 @@ class SpatialFileConverter
$coords = array_merge($coords, self::flattenCoords($f['geometry']['coordinates']));
}
if (empty($coords)) return null;
if (empty($coords)) {
return null;
}
$lons = array_column($coords, 0);
$lats = array_column($coords, 1);
@@ -110,7 +121,7 @@ class SpatialFileConverter
min($lons),
min($lats),
max($lons),
max($lats)
max($lats),
];
}
@@ -126,7 +137,9 @@ class SpatialFileConverter
$coords = array_merge($coords, self::flattenCoords($f['geometry']['coordinates']));
}
if (empty($coords)) return null;
if (empty($coords)) {
return null;
}
$x = array_sum(array_column($coords, 0)) / count($coords);
$y = array_sum(array_column($coords, 1)) / count($coords);
@@ -139,10 +152,13 @@ class SpatialFileConverter
$result = [];
$iterator = function ($c) use (&$result, &$iterator) {
if (!is_array($c)) return;
if (! is_array($c)) {
return;
}
if (isset($c[0]) && isset($c[1]) && is_numeric($c[0])) {
$result[] = [$c[0], $c[1]];
return;
}
@@ -163,6 +179,7 @@ class SpatialFileConverter
private static function parseGeoJson($path): ?array
{
$data = json_decode(file_get_contents($path), true);
return json_last_error() === JSON_ERROR_NONE ? $data : null;
}
@@ -175,18 +192,24 @@ class SpatialFileConverter
libxml_use_internal_errors(true);
$xml = simplexml_load_file($path);
if (!$xml) return null;
if (! $xml) {
return null;
}
// Namespace-agnostic: usamos local-name() en el XPath para aceptar KMLs
// con cualquier xmlns (opengis 2.2, earth.google 2.1/2.0, o sin xmlns).
$placemarks = $xml->xpath('//*[local-name()="Placemark"]');
if ($placemarks === false) $placemarks = [];
if ($placemarks === false) {
$placemarks = [];
}
$features = [];
foreach ($placemarks as $pm) {
$geom = self::parseKmlGeometry($pm);
if (!$geom) continue;
if (! $geom) {
continue;
}
$features[] = [
'type' => 'Feature',
@@ -204,12 +227,15 @@ class SpatialFileConverter
/** Descomprime un KMZ y parsea el .kml interno. */
private static function kmzToGeoJson(string $path): ?array
{
$zip = new \ZipArchive();
if ($zip->open($path) !== true) return null;
$zip = new \ZipArchive;
if ($zip->open($path) !== true) {
return null;
}
$tmp = sys_get_temp_dir() . '/kmz_' . uniqid();
$tmp = sys_get_temp_dir().'/kmz_'.uniqid();
if (! @mkdir($tmp, 0777, true) && ! is_dir($tmp)) {
$zip->close();
return null;
}
$zip->extractTo($tmp);
@@ -228,15 +254,20 @@ class SpatialFileConverter
// Limpieza
self::rrmdir($tmp);
return $result;
}
private static function rrmdir(string $dir): void
{
if (! is_dir($dir)) return;
if (! is_dir($dir)) {
return;
}
foreach (scandir($dir) ?: [] as $item) {
if ($item === '.' || $item === '..') continue;
$p = $dir . DIRECTORY_SEPARATOR . $item;
if ($item === '.' || $item === '..') {
continue;
}
$p = $dir.DIRECTORY_SEPARATOR.$item;
is_dir($p) ? self::rrmdir($p) : @unlink($p);
}
@rmdir($dir);
@@ -245,38 +276,45 @@ class SpatialFileConverter
/** Devuelve el primer hijo cuyo local-name coincida (sin depender del prefijo). */
private static function kmlChild(\SimpleXMLElement $node, string $name): \SimpleXMLElement|string
{
$matches = $node->xpath('./*[local-name()="' . $name . '"]');
$matches = $node->xpath('./*[local-name()="'.$name.'"]');
return $matches ? $matches[0] : '';
}
private static function parseKmlGeometry($pm): ?array
{
// Todos los accesos van por xpath local-name para tolerar cualquier xmlns.
$find = fn (string $tag) => $pm->xpath('./*[local-name()="' . $tag . '"]');
$findDeep = fn (\SimpleXMLElement $n, string $tag) => $n->xpath('.//*[local-name()="' . $tag . '"]');
$find = fn (string $tag) => $pm->xpath('./*[local-name()="'.$tag.'"]');
$findDeep = fn (\SimpleXMLElement $n, string $tag) => $n->xpath('.//*[local-name()="'.$tag.'"]');
if ($multi = $find('MultiGeometry')) {
$geoms = [];
foreach ($multi[0]->children() as $g) {
$parsed = self::parseKmlGeometry($g);
if ($parsed) $geoms[] = $parsed;
if ($parsed) {
$geoms[] = $parsed;
}
}
return ['type' => 'GeometryCollection', 'geometries' => $geoms];
}
if ($point = $find('Point')) {
$coords = self::parseKmlCoords((string) ($findDeep($point[0], 'coordinates')[0] ?? ''));
return $coords ? ['type' => 'Point', 'coordinates' => $coords[0]] : null;
}
if ($line = $find('LineString')) {
$coords = self::parseKmlCoords((string) ($findDeep($line[0], 'coordinates')[0] ?? ''));
return $coords ? ['type' => 'LineString', 'coordinates' => $coords] : null;
}
if ($poly = $find('Polygon')) {
$outer = $findDeep($poly[0], 'coordinates')[0] ?? '';
$coords = self::parseKmlCoords((string) $outer);
return $coords ? ['type' => 'Polygon', 'coordinates' => [$coords]] : null;
}
@@ -289,9 +327,10 @@ class SpatialFileConverter
foreach (preg_split('/\s+/', trim($text)) as $pair) {
$p = explode(',', $pair);
if (count($p) >= 2) {
$coords[] = [(float)$p[0], (float)$p[1]];
$coords[] = [(float) $p[0], (float) $p[1]];
}
}
return $coords;
}
@@ -307,16 +346,20 @@ class SpatialFileConverter
$features = [];
while ($record = $reader->fetchRecord()) {
if ($record->isDeleted()) continue;
if ($record->isDeleted()) {
continue;
}
$geom = json_decode($record->getGeometry()->toGeoJSON(), true);
if (!$geom) continue;
if (! $geom) {
continue;
}
$features[] = [
'type' => 'Feature',
'geometry' => $geom,
'properties' => $record->getDataArray()
'properties' => $record->getDataArray(),
];
}
@@ -324,6 +367,7 @@ class SpatialFileConverter
} catch (\Exception $e) {
Log::error($e->getMessage());
return null;
}
}
@@ -334,11 +378,13 @@ class SpatialFileConverter
private static function handleZip($zipPath): ?array
{
$zip = new \ZipArchive();
$zip = new \ZipArchive;
if ($zip->open($zipPath) !== true) return null;
if ($zip->open($zipPath) !== true) {
return null;
}
$dir = sys_get_temp_dir() . '/geo_' . uniqid();
$dir = sys_get_temp_dir().'/geo_'.uniqid();
mkdir($dir);
$zip->extractTo($dir);
@@ -347,7 +393,7 @@ class SpatialFileConverter
$result = null;
foreach (scandir($dir) as $file) {
$full = $dir . '/' . $file;
$full = $dir.'/'.$file;
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
if ($ext === 'shp') {
@@ -373,4 +419,4 @@ class SpatialFileConverter
}
rmdir($dir);
}
}
}
-1
View File
@@ -2,7 +2,6 @@
namespace App\Traits;
use Illuminate\Support\Facades\Auth;
use App\Models\ActivityLog;
trait LogsActivity
+20 -13
View File
@@ -1,8 +1,16 @@
<?php
use App\Http\Middleware\ForceHttpsScheme;
use App\Http\Middleware\SetLocale;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
use Spatie\Permission\Middleware\PermissionMiddleware;
use Spatie\Permission\Middleware\RoleMiddleware;
use Spatie\Permission\Middleware\RoleOrPermissionMiddleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
@@ -12,32 +20,31 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->appendToGroup('web', \App\Http\Middleware\SetLocale::class);
$middleware->appendToGroup('web', SetLocale::class);
// Si APP_URL es https, forzar el scheme percibido en TODAS las peticiones
// (algunos proxies no reenvían X-Forwarded-Proto en todas las rutas, y las
// URLs firmadas —p. ej. /livewire/upload-file— se firman con un scheme y
// se validan con otro → 401). Debe ir ANTES de TrustProxies.
$middleware->prepend(\App\Http\Middleware\ForceHttpsScheme::class);
$middleware->prepend(ForceHttpsScheme::class);
// Confiar en el proxy inverso (OpenResty/Nginx) para leer X-Forwarded-Proto:
// sin esto, tras HTTPS el proxy Laravel genera URLs con http:// y las cookies
// "secure" no viajan → 419 en /livewire/update.
$middleware->trustProxies(at: '*', headers:
Illuminate\Http\Request::HEADER_X_FORWARDED_FOR |
Illuminate\Http\Request::HEADER_X_FORWARDED_HOST |
Illuminate\Http\Request::HEADER_X_FORWARDED_PORT |
Illuminate\Http\Request::HEADER_X_FORWARDED_PROTO |
Illuminate\Http\Request::HEADER_X_FORWARDED_AWS_ELB,
$middleware->trustProxies(at: '*', headers: Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO |
Request::HEADER_X_FORWARDED_AWS_ELB,
);
// Spatie permission + Sanctum ability middleware aliases
$middleware->alias([
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class,
'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class,
'role' => RoleMiddleware::class,
'permission' => PermissionMiddleware::class,
'role_or_permission' => RoleOrPermissionMiddleware::class,
'abilities' => CheckAbilities::class,
'ability' => CheckForAnyAbility::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
+5 -2
View File
@@ -1,6 +1,9 @@
<?php
use App\Providers\AppServiceProvider;
use App\Providers\VoltServiceProvider;
return [
App\Providers\AppServiceProvider::class,
App\Providers\VoltServiceProvider::class,
AppServiceProvider::class,
VoltServiceProvider::class,
];
+6 -6
View File
@@ -32,17 +32,17 @@ return [
| Esto permite subir .kml/.kmz/.shp/.dwg/.zip grandes.
*/
'temporary_file_upload' => [
'disk' => null,
'rules' => ['required', 'file', 'max:51200'], // 50 MB
'directory' => null,
'middleware' => null,
'disk' => null,
'rules' => ['required', 'file', 'max:51200'], // 50 MB
'directory' => null,
'middleware' => null,
'preview_mimes' => [
'png', 'gif', 'bmp', 'svg', 'wav', 'mp4',
'mov', 'avi', 'wmv', 'mp3', 'm4a',
'jpg', 'jpeg', 'mpga', 'webp', 'wma',
],
'max_upload_time' => 5,
'cleanup' => true,
'cleanup' => true,
],
/*
@@ -64,7 +64,7 @@ return [
| Navegación SPA (wire:navigate).
*/
'navigate' => [
'show_progress_bar' => true,
'show_progress_bar' => true,
'progress_bar_color' => '#2299dd',
],
@@ -30,4 +30,4 @@ return new class extends Migration
{
Schema::dropIfExists('projects');
}
};
};
@@ -29,4 +29,4 @@ return new class extends Migration
{
Schema::dropIfExists('phases');
}
};
};
@@ -25,4 +25,4 @@ return new class extends Migration
{
Schema::dropIfExists('layers');
}
};
};
@@ -25,4 +25,4 @@ return new class extends Migration
{
Schema::dropIfExists('progress_updates');
}
};
};
@@ -22,4 +22,4 @@ return new class extends Migration
{
Schema::dropIfExists('pending_syncs');
}
};
};
@@ -21,4 +21,4 @@ return new class extends Migration
{
Schema::dropIfExists('project_user');
}
};
};

Some files were not shown because too many files have changed in this diff Show More