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}");
}
}
+7 -7
View File
@@ -3,7 +3,6 @@
namespace App\DTO; namespace App\DTO;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Collection;
class ReportFilters class ReportFilters
{ {
@@ -21,10 +20,10 @@ class ReportFilters
return new self( return new self(
dateFrom: isset($data['date_from']) ? Carbon::parse($data['date_from']) : null, dateFrom: isset($data['date_from']) ? Carbon::parse($data['date_from']) : null,
dateTo: isset($data['date_to']) ? Carbon::parse($data['date_to']) : null, dateTo: isset($data['date_to']) ? Carbon::parse($data['date_to']) : null,
entityTypes: $data['entity_types'] ?? ['phases','features','inspections','issues','tasks'], entityTypes: $data['entity_types'] ?? ['phases', 'features', 'inspections', 'issues', 'tasks'],
includePhotos: (bool)($data['include_photos'] ?? false), includePhotos: (bool) ($data['include_photos'] ?? false),
format: $data['format'] ?? 'html', 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 public function getDateRangeLabel(): string
{ {
if ($this->dateFrom && $this->dateTo) { 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) { if ($this->dateFrom) {
return 'Desde ' . $this->dateFrom->format('d/m/Y'); return 'Desde '.$this->dateFrom->format('d/m/Y');
} }
if ($this->dateTo) { if ($this->dateTo) {
return 'Hasta ' . $this->dateTo->format('d/m/Y'); return 'Hasta '.$this->dateTo->format('d/m/Y');
} }
return 'Todo el período'; return 'Todo el período';
} }
} }
+2 -2
View File
@@ -18,7 +18,7 @@ class InspectionsExport implements FromCollection, WithHeadings
'status', 'status',
'notes', 'notes',
'created_at', 'created_at',
'updated_at' 'updated_at',
])->get(); ])->get();
} }
@@ -32,7 +32,7 @@ class InspectionsExport implements FromCollection, WithHeadings
'Estado', 'Estado',
'Notas', 'Notas',
'Creado el', 'Creado el',
'Actualizado el' 'Actualizado el',
]; ];
} }
} }
+2 -2
View File
@@ -18,7 +18,7 @@ class PhasesExport implements FromCollection, WithHeadings
'start_date', 'start_date',
'end_date', 'end_date',
'created_at', 'created_at',
'updated_at' 'updated_at',
])->get(); ])->get();
} }
@@ -32,7 +32,7 @@ class PhasesExport implements FromCollection, WithHeadings
'Fecha de inicio', 'Fecha de inicio',
'Fecha de fin', 'Fecha de fin',
'Creado el', 'Creado el',
'Actualizado el' 'Actualizado el',
]; ];
} }
} }
+29 -24
View File
@@ -5,20 +5,22 @@ namespace App\Exports;
use App\DTO\ReportFilters; use App\DTO\ReportFilters;
use App\Models\Project; use App\Models\Project;
use Maatwebsite\Excel\Concerns\FromArray; 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 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\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border; use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill; use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
class ProjectReportExport implements WithMultipleSheets class ProjectReportExport implements WithMultipleSheets
{ {
protected Project $project; protected Project $project;
protected ReportFilters $filters; protected ReportFilters $filters;
protected array $data; protected array $data;
public function __construct(Project $project, ReportFilters $filters, 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), 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']); $sheets[] = new PhasesSheet($this->data['phases']);
} }
if (!empty($this->data['features'])) { if (! empty($this->data['features'])) {
$sheets[] = new FeaturesSheet($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']); $sheets[] = new InspectionsSheet($this->data['inspections']);
} }
if (!empty($this->data['issues'])) { if (! empty($this->data['issues'])) {
$sheets[] = new IssuesSheet($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']); $sheets[] = new TasksSheet($this->data['tasks']);
} }
if (!empty($this->data['deviations'])) { if (! empty($this->data['deviations'])) {
$sheets[] = new DeviationsSheet($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']); $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']); $sheets[] = new ProgressCurveSheet($this->data['progress_curve']);
} }
@@ -77,7 +79,7 @@ class ProjectReportExport implements WithMultipleSheets
// Base Sheet with common styling // 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 = []; protected array $rows = [];
@@ -147,6 +149,7 @@ abstract class BaseSheet implements FromArray, WithHeadings, WithStyles, WithCol
class SummarySheet extends BaseSheet class SummarySheet extends BaseSheet
{ {
protected Project $project; protected Project $project;
protected ReportFilters $filters; protected ReportFilters $filters;
public function __construct(array $summary, Project $project, ReportFilters $filters) public function __construct(array $summary, Project $project, ReportFilters $filters)
@@ -221,7 +224,7 @@ class PhasesSheet extends BaseSheet
'ID', 'Fase', 'Orden', 'Color', 'Inicio Plan', 'Fin Plan', 'ID', 'Fase', 'Orden', 'Color', 'Inicio Plan', 'Fin Plan',
'Inicio Real', 'Fin Real', 'Progreso (%)', 'Progreso Plan (%)', 'Inicio Real', 'Fin Real', 'Progreso (%)', 'Progreso Plan (%)',
'Desvío Fin (días)', 'Desvío Inicio (días)', 'SPI', 'En Plazo', 'Desvío Fin (días)', 'Desvío Inicio (días)', 'SPI', 'En Plazo',
'Elementos', 'Completados', 'Capas' 'Elementos', 'Completados', 'Capas',
]; ];
} }
@@ -270,7 +273,7 @@ class FeaturesSheet extends BaseSheet
'ID', 'Elemento', 'Fase', 'Capa', 'Estado', 'Progreso (%)', 'Progreso Plan (%)', 'ID', 'Elemento', 'Fase', 'Capa', 'Estado', 'Progreso (%)', 'Progreso Plan (%)',
'Inicio Plan', 'Fin Plan', 'Inicio Real', 'Fin Real', 'Desvío Fin (días)', 'Inicio Plan', 'Fin Plan', 'Inicio Real', 'Fin Real', 'Desvío Fin (días)',
'Desvío Inicio (días)', 'SPI', 'En Plazo', 'Responsable', 'Template', 'Desvío Inicio (días)', 'SPI', 'En Plazo', 'Responsable', 'Template',
'Última Inspección', 'Resultado', 'Inspecciones', 'Issues Abiertos' 'Última Inspección', 'Resultado', 'Inspecciones', 'Issues Abiertos',
]; ];
} }
@@ -322,7 +325,7 @@ class InspectionsSheet extends BaseSheet
{ {
return [ return [
'ID', 'Elemento', 'Fase', 'Template', 'Inspector', 'Fecha', 'ID', 'Elemento', 'Fase', 'Template', 'Inspector', 'Fecha',
'Estado', 'Resultado', 'Notas', 'Fotos' 'Estado', 'Resultado', 'Notas', 'Fotos',
]; ];
} }
@@ -362,7 +365,7 @@ class IssuesSheet extends BaseSheet
return [ return [
'ID', 'Título', 'Elemento', 'Fase', 'Prioridad', 'Estado', 'ID', 'Título', 'Elemento', 'Fase', 'Prioridad', 'Estado',
'Reportado por', 'Asignado a', 'Creado', 'Cerrado', 'Días abierto', 'Reportado por', 'Asignado a', 'Creado', 'Cerrado', 'Días abierto',
'Tareas total', 'Tareas completadas' 'Tareas total', 'Tareas completadas',
]; ];
} }
@@ -405,14 +408,15 @@ class TasksSheet extends BaseSheet
return [ return [
'ID', 'Tarea', 'Fase', 'Estado', 'Prioridad', 'Asignado', 'Creador', 'ID', 'Tarea', 'Fase', 'Estado', 'Prioridad', 'Asignado', 'Creador',
'Fecha inicio', 'Fecha fin', 'Completada', 'Horas est.', 'Horas real', 'Fecha inicio', 'Fecha fin', 'Completada', 'Horas est.', 'Horas real',
'Progreso (%)', 'Vencida', 'Subtareas' 'Progreso (%)', 'Vencida', 'Subtareas',
]; ];
} }
public function array(): array public function array(): array
{ {
return array_map(function ($task) { 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 [ return [
$task['id'], $task['id'],
$task['title'], $task['title'],
@@ -455,7 +459,7 @@ class DeviationsSheet extends BaseSheet
$rows = []; $rows = [];
// Phase deviations // Phase deviations
if (!empty($deviations['phases'])) { if (! empty($deviations['phases'])) {
$rows[] = ['=== DESVÍOS POR FASE ===', '', '', '', '', '', '', '', '', '', '']; $rows[] = ['=== DESVÍOS POR FASE ===', '', '', '', '', '', '', '', '', '', ''];
$rows[] = ['ID', 'Fase', 'Inicio Plan', 'Fin Plan', 'Inicio Real', 'Fin Real', $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 Inicio (d)', 'Desvío Fin (d)', 'Prog. Plan (%)', 'Prog. Real (%)',
@@ -475,7 +479,7 @@ class DeviationsSheet extends BaseSheet
} }
// Feature deviations // Feature deviations
if (!empty($deviations['features'])) { if (! empty($deviations['features'])) {
$rows[] = ['=== DESVÍOS POR ELEMENTO ===', '', '', '', '', '', '', '', '', '', '', '', '']; $rows[] = ['=== DESVÍOS POR ELEMENTO ===', '', '', '', '', '', '', '', '', '', '', '', ''];
$rows[] = ['ID', 'Elemento', 'Fase', 'Inicio Plan', 'Fin Plan', 'Inicio Real', 'Fin Real', $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 Inicio (d)', 'Desvío Fin (d)', 'Prog. Plan (%)', 'Prog. Real (%)',
@@ -497,7 +501,7 @@ class DeviationsSheet extends BaseSheet
} }
// Summary // Summary
if (!empty($deviations['summary'])) { if (! empty($deviations['summary'])) {
$rows[] = ['=== RESUMEN DESVÍOS ===', '']; $rows[] = ['=== RESUMEN DESVÍOS ===', ''];
$rows[] = ['Fases retrasadas', $deviations['summary']['phases_delayed'] ?? 0]; $rows[] = ['Fases retrasadas', $deviations['summary']['phases_delayed'] ?? 0];
$rows[] = ['Fases adelantadas', $deviations['summary']['phases_early'] ?? 0]; $rows[] = ['Fases adelantadas', $deviations['summary']['phases_early'] ?? 0];
@@ -611,6 +615,7 @@ class ProgressCurveSheet extends BaseSheet
class ParametersSheet extends BaseSheet class ParametersSheet extends BaseSheet
{ {
protected ReportFilters $filters; protected ReportFilters $filters;
protected Project $project; protected Project $project;
public function __construct(ReportFilters $filters, Project $project) public function __construct(ReportFilters $filters, Project $project)
@@ -625,7 +630,7 @@ class ParametersSheet extends BaseSheet
['Fecha desde', $filters->dateFrom?->format('d/m/Y') ?? '—'], ['Fecha desde', $filters->dateFrom?->format('d/m/Y') ?? '—'],
['Fecha hasta', $filters->dateTo?->format('d/m/Y') ?? '—'], ['Fecha hasta', $filters->dateTo?->format('d/m/Y') ?? '—'],
['Entidades incluidas', implode(', ', array_map( ['Entidades incluidas', implode(', ', array_map(
fn($e) => $filters->getAvailableEntities()[$e] ?? $e, fn ($e) => $filters->getAvailableEntities()[$e] ?? $e,
$filters->entityTypes $filters->entityTypes
))], ))],
['Incluir fotos', $filters->includePhotos ? 'Sí' : 'No'], ['Incluir fotos', $filters->includePhotos ? 'Sí' : 'No'],
+2 -2
View File
@@ -18,7 +18,7 @@ class ProjectsExport implements FromCollection, WithHeadings
'end_date', 'end_date',
'status', 'status',
'created_at', 'created_at',
'updated_at' 'updated_at',
])->get(); ])->get();
} }
@@ -32,7 +32,7 @@ class ProjectsExport implements FromCollection, WithHeadings
'Fecha de fin', 'Fecha de fin',
'Estado', 'Estado',
'Creado el', 'Creado el',
'Actualizado el' 'Actualizado el',
]; ];
} }
} }
@@ -13,8 +13,8 @@ use App\Models\Phase;
use App\Models\Project; use App\Models\Project;
use App\Models\User; use App\Models\User;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class MediaController extends Controller class MediaController extends Controller
{ {
@@ -93,6 +93,7 @@ class MediaController extends Controller
if (! $project) { if (! $project) {
return false; return false;
} }
return $user->can('manage all') return $user->can('manage all')
|| $project->users()->where('user_id', $user->id)->exists(); || $project->users()->where('user_id', $user->id)->exists();
} }
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Feature; use App\Models\Feature;
use App\Models\FeatureType;
use App\Models\Inspection; use App\Models\Inspection;
use App\Models\InspectionTemplate; use App\Models\InspectionTemplate;
use App\Models\Issue; use App\Models\Issue;
@@ -74,7 +75,7 @@ class ProjectApiController extends Controller
'phases' => $phases->map(fn ($p) => $this->mapPhase($p))->values(), 'phases' => $phases->map(fn ($p) => $this->mapPhase($p))->values(),
'layers' => $layers->map(fn ($l) => $this->mapLayer($l))->values(), 'layers' => $layers->map(fn ($l) => $this->mapLayer($l))->values(),
'features' => $features->map(fn ($f) => $this->mapFeature($f))->values(), 'features' => $features->map(fn ($f) => $this->mapFeature($f))->values(),
'feature_types' => \App\Models\FeatureType::orderBy('name')->get(['id', 'name', 'color'])->values(), 'feature_types' => FeatureType::orderBy('name')->get(['id', 'name', 'color'])->values(),
'inspections' => $inspections->map(fn ($i) => $this->mapInspection($i))->values(), 'inspections' => $inspections->map(fn ($i) => $this->mapInspection($i))->values(),
'issues' => $issues->map(fn ($i) => $this->mapIssue($i))->values(), 'issues' => $issues->map(fn ($i) => $this->mapIssue($i))->values(),
'issue_tasks' => $issueTasks->map(fn ($t) => $this->mapIssueTask($t))->values(), 'issue_tasks' => $issueTasks->map(fn ($t) => $this->mapIssueTask($t))->values(),
@@ -209,7 +210,7 @@ class ProjectApiController extends Controller
'id' => $t->id, 'project_id' => $t->project_id, 'phase_id' => $t->phase_id, 'id' => $t->id, 'project_id' => $t->project_id, 'phase_id' => $t->phase_id,
'name' => $t->name, 'description' => $t->description, 'fields' => $t->fields, 'name' => $t->name, 'description' => $t->description, 'fields' => $t->fields,
'version' => $t->updated_at?->timestamp, '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(), 'updated_at' => $t->updated_at?->toIso8601String(),
]; ];
} }
@@ -232,4 +233,3 @@ class ProjectApiController extends Controller
]; ];
} }
} }
+18 -16
View File
@@ -74,7 +74,7 @@ class SyncController extends Controller
} }
try { try {
$result = match ($op['entity'] . '.' . $op['op']) { $result = match ($op['entity'].'.'.$op['op']) {
'progress_update.create' => $this->progressUpdateCreate($user, $uuid, $op), 'progress_update.create' => $this->progressUpdateCreate($user, $uuid, $op),
'inspection.create' => $this->inspectionCreate($user, $uuid, $op), 'inspection.create' => $this->inspectionCreate($user, $uuid, $op),
'issue.create' => $this->issueCreate($user, $uuid, $op), 'issue.create' => $this->issueCreate($user, $uuid, $op),
@@ -83,7 +83,7 @@ class SyncController extends Controller
'issue_task.update' => $this->issueTaskUpdate($user, $uuid, $op), 'issue_task.update' => $this->issueTaskUpdate($user, $uuid, $op),
'issue_comment.create' => $this->issueCommentCreate($user, $uuid, $op), 'issue_comment.create' => $this->issueCommentCreate($user, $uuid, $op),
'feature.update' => $this->featureUpdate($user, $uuid, $op), 'feature.update' => $this->featureUpdate($user, $uuid, $op),
default => $this->error($uuid, 'unsupported entity/op: ' . $op['entity'] . '.' . $op['op']), default => $this->error($uuid, 'unsupported entity/op: '.$op['entity'].'.'.$op['op']),
}; };
} catch (\Throwable $e) { } catch (\Throwable $e) {
$result = $this->error($uuid, $e->getMessage()); $result = $this->error($uuid, $e->getMessage());
@@ -119,7 +119,7 @@ class SyncController extends Controller
'location' => ['nullable', 'array'], 'location' => ['nullable', 'array'],
]); ]);
if ($v->fails()) { if ($v->fails()) {
return $this->error($uuid, 'validation: ' . $v->errors()->first()); return $this->error($uuid, 'validation: '.$v->errors()->first());
} }
$d = $v->validated(); $d = $v->validated();
@@ -161,7 +161,7 @@ class SyncController extends Controller
'notes' => ['nullable', 'string'], 'notes' => ['nullable', 'string'],
]); ]);
if ($v->fails()) { if ($v->fails()) {
return $this->error($uuid, 'validation: ' . $v->errors()->first()); return $this->error($uuid, 'validation: '.$v->errors()->first());
} }
$d = $v->validated(); $d = $v->validated();
@@ -201,12 +201,12 @@ class SyncController extends Controller
'feature_id' => ['nullable', 'integer', 'exists:features,id'], 'feature_id' => ['nullable', 'integer', 'exists:features,id'],
'title' => ['required', 'string', 'max:255'], 'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'], 'description' => ['nullable', 'string'],
'priority' => ['nullable', 'in:' . implode(',', Issue::PRIORITIES)], 'priority' => ['nullable', 'in:'.implode(',', Issue::PRIORITIES)],
'status' => ['nullable', 'in:' . implode(',', Issue::STATUSES)], 'status' => ['nullable', 'in:'.implode(',', Issue::STATUSES)],
'type' => ['nullable', 'in:' . implode(',', Issue::TYPES)], 'type' => ['nullable', 'in:'.implode(',', Issue::TYPES)],
]); ]);
if ($v->fails()) { if ($v->fails()) {
return $this->error($uuid, 'validation: ' . $v->errors()->first()); return $this->error($uuid, 'validation: '.$v->errors()->first());
} }
$d = $v->validated(); $d = $v->validated();
@@ -237,14 +237,14 @@ class SyncController extends Controller
'id' => ['required', 'integer', 'exists:issues,id'], 'id' => ['required', 'integer', 'exists:issues,id'],
'title' => ['nullable', 'string', 'max:255'], 'title' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string'], 'description' => ['nullable', 'string'],
'priority' => ['nullable', 'in:' . implode(',', Issue::PRIORITIES)], 'priority' => ['nullable', 'in:'.implode(',', Issue::PRIORITIES)],
'status' => ['nullable', 'in:' . implode(',', Issue::STATUSES)], 'status' => ['nullable', 'in:'.implode(',', Issue::STATUSES)],
'type' => ['nullable', 'in:' . implode(',', Issue::TYPES)], 'type' => ['nullable', 'in:'.implode(',', Issue::TYPES)],
'assigned_to' => ['nullable', 'integer', 'exists:users,id'], 'assigned_to' => ['nullable', 'integer', 'exists:users,id'],
'resolution_notes' => ['nullable', 'string'], 'resolution_notes' => ['nullable', 'string'],
]); ]);
if ($v->fails()) { if ($v->fails()) {
return $this->error($uuid, 'validation: ' . $v->errors()->first()); return $this->error($uuid, 'validation: '.$v->errors()->first());
} }
$d = $v->validated(); $d = $v->validated();
@@ -283,7 +283,7 @@ class SyncController extends Controller
'is_done' => ['nullable', 'boolean'], 'is_done' => ['nullable', 'boolean'],
]); ]);
if ($v->fails()) { if ($v->fails()) {
return $this->error($uuid, 'validation: ' . $v->errors()->first()); return $this->error($uuid, 'validation: '.$v->errors()->first());
} }
$d = $v->validated(); $d = $v->validated();
@@ -319,7 +319,7 @@ class SyncController extends Controller
'is_done' => ['nullable', 'boolean'], 'is_done' => ['nullable', 'boolean'],
]); ]);
if ($v->fails()) { if ($v->fails()) {
return $this->error($uuid, 'validation: ' . $v->errors()->first()); return $this->error($uuid, 'validation: '.$v->errors()->first());
} }
$d = $v->validated(); $d = $v->validated();
@@ -357,7 +357,7 @@ class SyncController extends Controller
'body' => ['required', 'string', 'max:5000'], 'body' => ['required', 'string', 'max:5000'],
]); ]);
if ($v->fails()) { if ($v->fails()) {
return $this->error($uuid, 'validation: ' . $v->errors()->first()); return $this->error($uuid, 'validation: '.$v->errors()->first());
} }
$d = $v->validated(); $d = $v->validated();
@@ -390,7 +390,7 @@ class SyncController extends Controller
'feature_type_id' => ['nullable', 'integer', 'exists:feature_types,id'], 'feature_type_id' => ['nullable', 'integer', 'exists:feature_types,id'],
]); ]);
if ($v->fails()) { if ($v->fails()) {
return $this->error($uuid, 'validation: ' . $v->errors()->first()); return $this->error($uuid, 'validation: '.$v->errors()->first());
} }
$d = $v->validated(); $d = $v->validated();
@@ -424,6 +424,7 @@ class SyncController extends Controller
if (! $project) { if (! $project) {
return false; return false;
} }
return $user->can('manage all') return $user->can('manage all')
|| $project->users()->where('user_id', $user->id)->exists(); || $project->users()->where('user_id', $user->id)->exists();
} }
@@ -445,6 +446,7 @@ class SyncController extends Controller
'server' => $model->fresh()->toArray(), 'server' => $model->fresh()->toArray(),
]; ];
} }
return null; 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)
{
//
}
}
+1 -3
View File
@@ -4,8 +4,6 @@ namespace App\Http\Controllers;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
class ProfileController extends Controller class ProfileController extends Controller
{ {
@@ -26,7 +24,7 @@ class ProfileController extends Controller
$validated = $request->validate([ $validated = $request->validate([
'name' => 'required|string|max:255', '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); $user->update($validated);
+2 -1
View File
@@ -3,7 +3,6 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Project; use App\Models\Project;
use App\Models\User;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
@@ -16,6 +15,7 @@ class ProjectController extends Controller
public function index() public function index()
{ {
Gate::authorize('view projects'); Gate::authorize('view projects');
return view('projects.index'); return view('projects.index');
} }
@@ -37,6 +37,7 @@ class ProjectController extends Controller
// Assign creator as supervisor in project // Assign creator as supervisor in project
$project->users()->attach(Auth::id(), ['role_in_project' => 'supervisor']); $project->users()->attach(Auth::id(), ['role_in_project' => 'supervisor']);
return redirect()->route('projects.map', $project)->with('success', 'Proyecto creado'); return redirect()->route('projects.map', $project)->with('success', 'Proyecto creado');
} }
@@ -1,8 +1,10 @@
<?php <?php
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Inspection;
use App\Models\Issue;
use App\Models\Project; use App\Models\Project;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
class ProjectReportController extends Controller class ProjectReportController extends Controller
@@ -10,7 +12,7 @@ class ProjectReportController extends Controller
public function show(Project $project) public function show(Project $project)
{ {
$user = Auth::user(); $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); abort(403);
} }
@@ -20,10 +22,10 @@ class ProjectReportController extends Controller
->get(); ->get();
$stats = [ $stats = [
'total_features' => $phases->flatMap(fn($p) => $p->layers)->flatMap(fn($l) => $l->features)->count(), '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(), '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(), 'total_inspections' => Inspection::where('project_id', $project->id)->count(),
'open_issues' => \App\Models\Issue::where('project_id', $project->id)->where('status', 'open')->count(), 'open_issues' => Issue::where('project_id', $project->id)->where('status', 'open')->count(),
'avg_progress' => round($phases->avg('progress_percent') ?? 0), 'avg_progress' => round($phases->avg('progress_percent') ?? 0),
]; ];
+4 -4
View File
@@ -3,12 +3,12 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\DTO\ReportFilters; use App\DTO\ReportFilters;
use App\Exports\ProjectReportExport;
use App\Models\Project; use App\Models\Project;
use App\Services\ReportGenerator; use App\Services\ReportGenerator;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Maatwebsite\Excel\Facades\Excel; use Maatwebsite\Excel\Facades\Excel;
use App\Exports\ProjectReportExport;
class ReportController extends Controller class ReportController extends Controller
{ {
@@ -19,7 +19,7 @@ class ReportController extends Controller
{ {
$this->authorizeProjectAccess($project); $this->authorizeProjectAccess($project);
return \Livewire\Livewire::mount(\App\Livewire\Reports\ReportBuilder::class, [ return view('reports.builder', [
'project' => $project, 'project' => $project,
]); ]);
} }
@@ -65,7 +65,7 @@ class ReportController extends Controller
protected function downloadExcel(Project $project, ReportFilters $filters, array $data) protected function downloadExcel(Project $project, ReportFilters $filters, array $data)
{ {
$export = new ProjectReportExport($project, $filters, $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); return Excel::download($export, $filename);
} }
@@ -81,7 +81,7 @@ class ReportController extends Controller
return; 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.'); abort(403, 'No tienes acceso a este proyecto.');
} }
} }
@@ -2,15 +2,12 @@
namespace App\Http\Controllers\Reports; 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\InspectionsExport;
use App\Exports\PhasesExport;
use App\Exports\ProjectsExport;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Maatwebsite\Excel\Facades\Excel;
class ExportController extends Controller class ExportController extends Controller
{ {
+2 -1
View File
@@ -2,9 +2,9 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Models\Project;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Models\Project;
class BindProjectModel class BindProjectModel
{ {
@@ -18,6 +18,7 @@ class BindProjectModel
$route->setParameter('project', $project); $route->setParameter('project', $project);
} }
} }
return $next($request); return $next($request);
} }
} }
+3 -3
View File
@@ -27,7 +27,7 @@ class SetLocale
} }
// 2. From session // 2. From session
if (!$locale && Session::has('locale')) { if (! $locale && Session::has('locale')) {
$sessionLocale = Session::get('locale'); $sessionLocale = Session::get('locale');
if (in_array($sessionLocale, $allowedLocales)) { if (in_array($sessionLocale, $allowedLocales)) {
$locale = $sessionLocale; $locale = $sessionLocale;
@@ -35,7 +35,7 @@ class SetLocale
} }
// 3. From browser Accept-Language // 3. From browser Accept-Language
if (!$locale) { if (! $locale) {
$browserLang = substr($request->server('HTTP_ACCEPT_LANGUAGE', 'en'), 0, 2); $browserLang = substr($request->server('HTTP_ACCEPT_LANGUAGE', 'en'), 0, 2);
if (in_array($browserLang, $allowedLocales)) { if (in_array($browserLang, $allowedLocales)) {
$locale = $browserLang; $locale = $browserLang;
@@ -43,7 +43,7 @@ class SetLocale
} }
// 4. Default to app locale // 4. Default to app locale
if (!$locale) { if (! $locale) {
$locale = config('app.locale', 'en'); $locale = config('app.locale', 'en');
} }
+5 -3
View File
@@ -2,9 +2,9 @@
namespace App\Livewire\Admin; namespace App\Livewire\Admin;
use Livewire\Component;
use Livewire\Attributes\Layout;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Spatie\Permission\Models\Role; use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar; use Spatie\Permission\PermissionRegistrar;
@@ -14,9 +14,11 @@ class RoleForm extends Component
public ?Role $role = null; public ?Role $role = null;
public string $name = ''; public string $name = '';
public string $description = ''; public string $description = '';
private const PROTECTED_ROLES = ['Admin']; private const PROTECTED_ROLES = ['Admin'];
private const CORE_PERMISSION = 'manage all'; private const CORE_PERMISSION = 'manage all';
public function mount(?Role $role = null): void public function mount(?Role $role = null): void
@@ -33,7 +35,7 @@ class RoleForm extends Component
public function save() public function save()
{ {
$this->validate([ $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', 'description' => 'nullable|string|max:255',
], [], ['name' => 'nombre', 'description' => 'descripción']); ], [], ['name' => 'nombre', 'description' => 'descripción']);
+10 -5
View File
@@ -2,21 +2,23 @@
namespace App\Livewire\Admin; namespace App\Livewire\Admin;
use Livewire\Component;
use Livewire\Attributes\Layout;
use Illuminate\Support\Facades\Auth; 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\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar; use Spatie\Permission\PermissionRegistrar;
#[Layout('layouts.app')] #[Layout('layouts.app')]
class RolePermissionManager extends Component class RolePermissionManager extends Component
{ {
public string $newRole = ''; public string $newRole = '';
public string $newPermission = ''; public string $newPermission = '';
/** Roles that must not be deleted or stripped of core powers. */ /** Roles that must not be deleted or stripped of core powers. */
private const PROTECTED_ROLES = ['Admin']; private const PROTECTED_ROLES = ['Admin'];
private const CORE_PERMISSION = 'manage all'; private const CORE_PERMISSION = 'manage all';
public function mount(): void public function mount(): void
@@ -36,7 +38,8 @@ class RolePermissionManager extends Component
if ($role->hasPermissionTo($permissionName)) { if ($role->hasPermissionTo($permissionName)) {
// Admin must always keep the core permission // Admin must always keep the core permission
if ($role->name === 'Admin' && $permissionName === self::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; return;
} }
$role->revokePermissionTo($permissionName); $role->revokePermissionTo($permissionName);
@@ -66,6 +69,7 @@ class RolePermissionManager extends Component
if (in_array($role->name, self::PROTECTED_ROLES, true)) { if (in_array($role->name, self::PROTECTED_ROLES, true)) {
$this->dispatch('notify', "El rol '{$role->name}' está protegido y no se puede borrar."); $this->dispatch('notify', "El rol '{$role->name}' está protegido y no se puede borrar.");
return; return;
} }
@@ -91,7 +95,8 @@ class RolePermissionManager extends Component
$permission = Permission::findOrFail($permissionId); $permission = Permission::findOrFail($permissionId);
if ($permission->name === self::CORE_PERMISSION) { 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; return;
} }
+7 -5
View File
@@ -2,9 +2,9 @@
namespace App\Livewire\Admin; namespace App\Livewire\Admin;
use Illuminate\Database\Eloquent\Builder;
use Rappasoft\LaravelLivewireTables\DataTableComponent; use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column; use Rappasoft\LaravelLivewireTables\Views\Column;
use Illuminate\Database\Eloquent\Builder;
use Spatie\Permission\Models\Role; use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar; use Spatie\Permission\PermissionRegistrar;
@@ -32,9 +32,8 @@ class RoleTable extends DataTableComponent
Column::make(__('Name'), 'name') Column::make(__('Name'), 'name')
->sortable() ->sortable()
->searchable() ->searchable()
->format(fn ($value, $row) => ->format(fn ($value, $row) => '<a href="'.route('admin.roles.show', $row->id).'" class="font-semibold text-primary hover:underline" wire:navigate>'.e($value).'</a>'
'<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>' : '')
. (in_array($row->name, self::PROTECTED_ROLES, true) ? ' <span class="badge badge-ghost badge-xs">protegido</span>' : '')
) )
->html(), ->html(),
@@ -69,6 +68,7 @@ class RoleTable extends DataTableComponent
$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 .= '<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>'; $html .= '</div>';
return $html; return $html;
}) })
->html(), ->html(),
@@ -84,7 +84,9 @@ class RoleTable extends DataTableComponent
{ {
$roles = Role::whereIn('id', $this->selected)->get(); $roles = Role::whereIn('id', $this->selected)->get();
foreach ($roles as $role) { 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(); $role->delete();
} }
$this->clearSelected(); $this->clearSelected();
+12 -5
View File
@@ -2,23 +2,26 @@
namespace App\Livewire\Admin; namespace App\Livewire\Admin;
use Livewire\Component; use App\Models\User;
use Livewire\Attributes\Layout;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use App\Models\User; use Livewire\Attributes\Layout;
use Spatie\Permission\Models\Role; use Livewire\Component;
use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar; use Spatie\Permission\PermissionRegistrar;
#[Layout('layouts.app')] #[Layout('layouts.app')]
class RoleView extends Component class RoleView extends Component
{ {
public Role $role; public Role $role;
public string $tab = 'ficha'; // ficha | permisos public string $tab = 'ficha'; // ficha | permisos
public $newUserId = ''; public $newUserId = '';
private const PROTECTED_ROLES = ['Admin']; private const PROTECTED_ROLES = ['Admin'];
private const CORE_PERMISSION = 'manage all'; private const CORE_PERMISSION = 'manage all';
public function mount(Role $role): void public function mount(Role $role): void
@@ -38,7 +41,8 @@ class RoleView extends Component
if ($this->role->name === 'Admin' if ($this->role->name === 'Admin'
&& $permissionName === self::CORE_PERMISSION && $permissionName === self::CORE_PERMISSION
&& $this->role->hasPermissionTo($permissionName)) { && $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; return;
} }
@@ -91,6 +95,7 @@ class RoleView extends Component
{ {
if (in_array($this->role->name, self::PROTECTED_ROLES, true)) { 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."); $this->dispatch('notify', "El rol '{$this->role->name}' está protegido y no se puede borrar.");
return; return;
} }
$this->role->delete(); $this->role->delete();
@@ -107,6 +112,7 @@ class RoleView extends Component
return 'General'; return 'General';
} }
$resource = Str::afterLast($name, ' '); $resource = Str::afterLast($name, ' ');
return Str::headline($resource ?: 'General'); return Str::headline($resource ?: 'General');
} }
@@ -126,6 +132,7 @@ class RoleView extends Component
->groupBy(fn ($perm) => $perm->group ?: $this->sectionFor($perm->name)) ->groupBy(fn ($perm) => $perm->group ?: $this->sectionFor($perm->name))
->sortBy(function ($perms, $section) use ($order) { ->sortBy(function ($perms, $section) use ($order) {
$i = array_search($section, $order, true); $i = array_search($section, $order, true);
return $i === false ? 999 : $i; return $i === false ? 999 : $i;
}); });
+18 -18
View File
@@ -2,20 +2,20 @@
namespace App\Livewire\Client; 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 App\Models\ChangeOrder;
use Carbon\Carbon; use App\Models\Project;
use Livewire\Component;
class ClientProjects extends Component class ClientProjects extends Component
{ {
public $projects = []; public $projects = [];
public $selectedProject = null; public $selectedProject = null;
public $projectDetails = []; public $projectDetails = [];
public $galleryImages = []; public $galleryImages = [];
public $changeOrders = []; public $changeOrders = [];
public function mount() public function mount()
@@ -29,7 +29,7 @@ class ClientProjects extends Component
$user = auth()->user(); $user = auth()->user();
$this->projects = $user->projects() $this->projects = $user->projects()
->wherePivot('role_in_project', 'client') ->wherePivot('role_in_project', 'client')
->with(['phases' => function($query) { ->with(['phases' => function ($query) {
$query->select('id', 'project_id', 'name', 'progress_percent'); $query->select('id', 'project_id', 'name', 'progress_percent');
}]) }])
->get() ->get()
@@ -44,17 +44,17 @@ class ClientProjects extends Component
public function loadProjectDetails() public function loadProjectDetails()
{ {
if (!$this->selectedProject) { if (! $this->selectedProject) {
return; return;
} }
$project = Project::with([ $project = Project::with([
'phases.features', 'phases.features',
'inspections.template', 'inspections.template',
'changeOrders' // Load change orders for this project 'changeOrders', // Load change orders for this project
])->find($this->selectedProject); ])->find($this->selectedProject);
if (!$project) { if (! $project) {
return; return;
} }
@@ -75,11 +75,11 @@ class ClientProjects extends Component
->latest() ->latest()
->take(3) ->take(3)
->get() ->get()
->map(function($media) { ->map(function ($media) {
return [ return [
'url' => $media->url, 'url' => $media->url,
'title' => $media->name, 'title' => $media->name,
'date' => $media->created_at->format('d/m/Y') 'date' => $media->created_at->format('d/m/Y'),
]; ];
}) })
->toArray(); ->toArray();
@@ -93,18 +93,18 @@ class ClientProjects extends Component
[ [
'url' => 'https://via.placeholder.com/400x300?text=Avance+1', 'url' => 'https://via.placeholder.com/400x300?text=Avance+1',
'title' => 'Avance inicial', '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', 'url' => 'https://via.placeholder.com/400x300?text=Avance+2',
'title' => 'Estructura levantada', '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', 'url' => 'https://via.placeholder.com/400x300?text=Avance+3',
'title' => 'Instalaciones', '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 $this->changeOrders = $project->changeOrders
->orderBy('requested_at', 'desc') ->orderBy('requested_at', 'desc')
->get() ->get()
->map(function($order) { ->map(function ($order) {
return [ return [
'id' => $order->id, 'id' => $order->id,
'title' => $order->title, 'title' => $order->title,
'description' => $order->description, 'description' => $order->description,
'status' => $order->status, 'status' => $order->status,
'requested_at' => $order->requested_at->format('d/m/Y'), 'requested_at' => $order->requested_at->format('d/m/Y'),
'amount' => $order->amount 'amount' => $order->amount,
]; ];
}) })
->toArray(); ->toArray();
+2 -2
View File
@@ -2,10 +2,10 @@
namespace App\Livewire\Common; namespace App\Livewire\Common;
use Livewire\Component;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session; use Illuminate\Support\Facades\Session;
use Livewire\Component;
class LanguageSwitcher extends Component class LanguageSwitcher extends Component
{ {
@@ -26,7 +26,7 @@ class LanguageSwitcher extends Component
public function updatedCurrentLocale(string $locale): void public function updatedCurrentLocale(string $locale): void
{ {
if (!in_array($locale, ['en', 'es', 'fr', 'ru'])) { if (! in_array($locale, ['en', 'es', 'fr', 'ru'])) {
return; return;
} }
+3 -1
View File
@@ -2,13 +2,15 @@
namespace App\Livewire\Common; namespace App\Livewire\Common;
use Livewire\Component;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class NotificationBell extends Component class NotificationBell extends Component
{ {
public $notifications = []; public $notifications = [];
public $unreadCount = 0; public $unreadCount = 0;
public $showDropdown = false; public $showDropdown = false;
public function mount() public function mount()
+14 -3
View File
@@ -2,11 +2,11 @@
namespace App\Livewire\Companies; namespace App\Livewire\Companies;
use Livewire\Component;
use Livewire\WithFileUploads;
use Livewire\Attributes\Layout;
use App\Models\Company; use App\Models\Company;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithFileUploads;
#[Layout('layouts.app')] #[Layout('layouts.app')]
class CompanyForm extends Component class CompanyForm extends Component
@@ -17,15 +17,25 @@ class CompanyForm extends Component
// Form fields // Form fields
public string $name = ''; public string $name = '';
public string $apodo = ''; public string $apodo = '';
public string $tax_id = ''; public string $tax_id = '';
public string $estado = 'activo'; public string $estado = 'activo';
public string $type = 'other'; public string $type = 'other';
public string $address = ''; public string $address = '';
public string $phone = ''; public string $phone = '';
public string $email = ''; public string $email = '';
public string $website = ''; public string $website = '';
public string $notes = ''; public string $notes = '';
public $logo = null; public $logo = null;
public function mount(?Company $company = null): void public function mount(?Company $company = null): void
@@ -48,6 +58,7 @@ class CompanyForm extends Component
protected function rules(): array protected function rules(): array
{ {
$id = $this->company?->id ?? 'NULL'; $id = $this->company?->id ?? 'NULL';
return [ return [
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'apodo' => 'nullable|string|max:100', 'apodo' => 'nullable|string|max:100',
+9 -7
View File
@@ -2,29 +2,31 @@
namespace App\Livewire\Companies; namespace App\Livewire\Companies;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\Company; use App\Models\Company;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')] #[Layout('layouts.app')]
class CompanyManagement extends Component class CompanyManagement extends Component
{ {
public string $search = ''; public string $search = '';
public string $filterType = ''; public string $filterType = '';
public string $filterEstado = ''; public string $filterEstado = '';
public function getCompaniesProperty() public function getCompaniesProperty()
{ {
return Company::when($this->search, function ($q) { return Company::when($this->search, function ($q) {
$s = '%' . $this->search . '%'; $s = '%'.$this->search.'%';
$q->where(fn($q2) => $q2 $q->where(fn ($q2) => $q2
->where('name', 'like', $s) ->where('name', 'like', $s)
->orWhere('apodo', 'like', $s) ->orWhere('apodo', 'like', $s)
->orWhere('tax_id', 'like', $s)); ->orWhere('tax_id', 'like', $s));
}) })
->when($this->filterType, fn($q) => $q->where('type', $this->filterType)) ->when($this->filterType, fn ($q) => $q->where('type', $this->filterType))
->when($this->filterEstado, fn($q) => $q->where('estado', $this->filterEstado)) ->when($this->filterEstado, fn ($q) => $q->where('estado', $this->filterEstado))
->withCount('projects') ->withCount('projects')
->orderBy('name') ->orderBy('name')
->get(); ->get();
@@ -45,7 +47,7 @@ class CompanyManagement extends Component
return response()->streamDownload(function () use ($companies) { return response()->streamDownload(function () use ($companies) {
$handle = fopen('php://output', 'w'); $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']); fputcsv($handle, ['Nombre', 'Apodo', 'NIF/Tax ID', 'Tipo', 'Estado', 'Dirección', 'Teléfono', 'Email', 'Website', 'Proyectos', 'Creación']);
foreach ($companies as $c) { foreach ($companies as $c) {
fputcsv($handle, [ fputcsv($handle, [
+15 -8
View File
@@ -2,13 +2,12 @@
namespace App\Livewire\Companies; 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\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column; use Rappasoft\LaravelLivewireTables\Views\Column;
use Rappasoft\LaravelLivewireTables\Views\Filters\SelectFilter; 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 class CompanyTable extends DataTableComponent
{ {
@@ -53,9 +52,14 @@ class CompanyTable extends DataTableComponent
} }
$html = '<div class="flex items-center gap-3">'.$logoHtml.'<div>'; $html = '<div class="flex items-center gap-3">'.$logoHtml.'<div>';
$html .= '<p class="font-semibold text-sm leading-tight">'.e($value).'</p>'; $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->apodo) {
if ($row->tax_id) $html .= '<p class="text-xs text-gray-400">NIF: '.e($row->tax_id).'</p>'; $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>'; $html .= '</div></div>';
return $html; return $html;
}) })
->html(), ->html(),
@@ -71,6 +75,7 @@ class CompanyTable extends DataTableComponent
'supplier' => ['badge-warning', 'Proveedor'], 'supplier' => ['badge-warning', 'Proveedor'],
]; ];
[$cls, $label] = $map[$value] ?? ['badge-ghost', 'Otro']; [$cls, $label] = $map[$value] ?? ['badge-ghost', 'Otro'];
return '<span class="badge badge-sm '.$cls.'">'.$label.'</span>'; return '<span class="badge badge-sm '.$cls.'">'.$label.'</span>';
}) })
->html(), ->html(),
@@ -88,6 +93,7 @@ class CompanyTable extends DataTableComponent
<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> <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>'; '.e($row->email).'</div>';
} }
return $html ?: '<span class="text-gray-300">—</span>'; return $html ?: '<span class="text-gray-300">—</span>';
}) })
->html(), ->html(),
@@ -101,13 +107,13 @@ class CompanyTable extends DataTableComponent
'suspendido' => ['badge-error', 'Suspendido'], 'suspendido' => ['badge-error', 'Suspendido'],
]; ];
[$cls, $label] = $map[$value ?? 'activo'] ?? ['badge-ghost', ucfirst($value ?? 'activo')]; [$cls, $label] = $map[$value ?? 'activo'] ?? ['badge-ghost', ucfirst($value ?? 'activo')];
return '<span class="badge badge-sm '.$cls.'">'.$label.'</span>'; return '<span class="badge badge-sm '.$cls.'">'.$label.'</span>';
}) })
->html(), ->html(),
Column::make('Proyectos') Column::make('Proyectos')
->label(fn ($row) => ->label(fn ($row) => '<span class="badge badge-outline badge-sm">'.(int) ($row->projects_count ?? 0).'</span>'
'<span class="badge badge-outline badge-sm">'.(int)($row->projects_count ?? 0).'</span>'
) )
->html(), ->html(),
@@ -130,6 +136,7 @@ class CompanyTable extends DataTableComponent
<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> <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>'; </button>';
$html .= '</div>'; $html .= '</div>';
return $html; return $html;
}) })
->html(), ->html(),
+12 -4
View File
@@ -2,37 +2,45 @@
namespace App\Livewire\Companies; namespace App\Livewire\Companies;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\Company; use App\Models\Company;
use App\Models\Issue;
use App\Models\Project; use App\Models\Project;
use App\Models\User; use App\Models\User;
use App\Models\Issue;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')] #[Layout('layouts.app')]
class CompanyView extends Component class CompanyView extends Component
{ {
public Company $company; public Company $company;
public string $activeTab = 'summary'; public string $activeTab = 'summary';
// Projects tab // Projects tab
public ?int $addProjectId = null; public ?int $addProjectId = null;
public string $addProjectRole = ''; public string $addProjectRole = '';
public $availableProjects; public $availableProjects;
// People tab // People tab
public ?int $assignUserId = null; public ?int $assignUserId = null;
public $assignableUsers; public $assignableUsers;
// Notes tab // Notes tab
public string $notes = ''; public string $notes = '';
public bool $editingNotes = false; public bool $editingNotes = false;
// Stats (computed once in mount, refreshed on mutations) // Stats (computed once in mount, refreshed on mutations)
public int $usersCount = 0; public int $usersCount = 0;
public int $projectsCount = 0; public int $projectsCount = 0;
public float $avgProgress = 0.0; public float $avgProgress = 0.0;
public int $openIssues = 0; public int $openIssues = 0;
public function mount(Company $company): void public function mount(Company $company): void
@@ -69,7 +77,7 @@ class CompanyView extends Component
$this->usersCount = $this->company->users->count(); $this->usersCount = $this->company->users->count();
$this->projectsCount = $this->company->projects->count(); $this->projectsCount = $this->company->projects->count();
$this->avgProgress = round( $this->avgProgress = round(
$this->company->projects->flatMap(fn($p) => $p->phases)->avg('progress_percent') ?? 0 $this->company->projects->flatMap(fn ($p) => $p->phases)->avg('progress_percent') ?? 0
); );
$userIds = $this->company->users->pluck('id'); $userIds = $this->company->users->pluck('id');
$this->openIssues = $userIds->isNotEmpty() $this->openIssues = $userIds->isNotEmpty()
@@ -18,7 +18,9 @@ class GlobalTemplateManager extends Component
public $templates; public $templates;
public $editingTemplate = null; public $editingTemplate = null;
public $showForm = false; public $showForm = false;
public $form = [ public $form = [
'name' => '', 'name' => '',
'description' => '', 'description' => '',
@@ -27,9 +29,13 @@ class GlobalTemplateManager extends Component
// ── Importar desde CSV/Excel ─────────────────────────────────────────── // ── Importar desde CSV/Excel ───────────────────────────────────────────
public $showImportFileModal = false; public $showImportFileModal = false;
public $importFile = null; public $importFile = null;
public $importPreviewFields = []; public $importPreviewFields = [];
public $importTemplateName = ''; public $importTemplateName = '';
public $importError = ''; public $importError = '';
public $fieldTypes = [ public $fieldTypes = [
@@ -164,11 +170,12 @@ class GlobalTemplateManager extends Component
{ {
$headers = ['Content-Type' => 'text/csv']; $headers = ['Content-Type' => 'text/csv'];
$csv = "\xEF\xBB\xBF" // BOM UTF-8 (para que Excel respete los acentos) $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" ."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,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" ."Dimensiones,material,Material,,select,1,Hormigón|Acero|Madera,,,,\n"
. "Acabados,ok,¿Acabado correcto?,,boolean,1,,,,,\n"; ."Acabados,ok,¿Acabado correcto?,,boolean,1,,,,,\n";
return response()->streamDownload(fn () => print($csv), 'plantilla_ejemplo.csv', $headers);
return response()->streamDownload(fn () => print ($csv), 'plantilla_ejemplo.csv', $headers);
} }
public function parseImportFile() public function parseImportFile()
@@ -182,13 +189,15 @@ class GlobalTemplateManager extends Component
try { try {
$rows = $this->readFileRows(); $rows = $this->readFileRows();
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->importError = 'No se pudo leer el archivo: ' . $e->getMessage(); $this->importError = 'No se pudo leer el archivo: '.$e->getMessage();
return; return;
} }
$fields = $this->parseRows($rows); $fields = $this->parseRows($rows);
if (empty($fields)) { if (empty($fields)) {
$this->importError = 'No se encontraron filas válidas.'; $this->importError = 'No se encontraron filas válidas.';
return; return;
} }
$this->importPreviewFields = $fields; $this->importPreviewFields = $fields;
@@ -196,7 +205,9 @@ class GlobalTemplateManager extends Component
public function confirmImportFile() public function confirmImportFile()
{ {
if (empty($this->importPreviewFields) || empty($this->importTemplateName)) return; if (empty($this->importPreviewFields) || empty($this->importTemplateName)) {
return;
}
InspectionTemplate::create([ InspectionTemplate::create([
'name' => $this->importTemplateName, 'name' => $this->importTemplateName,
@@ -229,18 +240,24 @@ class GlobalTemplateManager extends Component
$sheet = $spreadsheet->getActiveSheet(); $sheet = $spreadsheet->getActiveSheet();
$rows = $sheet->toArray(null, true, true, false); $rows = $sheet->toArray(null, true, true, false);
array_shift($rows); array_shift($rows);
return array_values(array_filter($rows, $notEmpty)); return array_values(array_filter($rows, $notEmpty));
} }
$rows = []; $rows = [];
$handle = fopen($path, 'r'); $handle = fopen($path, 'r');
$bom = fread($handle, 3); $bom = fread($handle, 3);
if ($bom !== "\xEF\xBB\xBF") rewind($handle); if ($bom !== "\xEF\xBB\xBF") {
rewind($handle);
}
fgetcsv($handle); fgetcsv($handle);
while (($row = fgetcsv($handle)) !== false) { while (($row = fgetcsv($handle)) !== false) {
if ($notEmpty($row)) $rows[] = $row; if ($notEmpty($row)) {
$rows[] = $row;
}
} }
fclose($handle); fclose($handle);
return $rows; return $rows;
} }
@@ -252,7 +269,9 @@ class GlobalTemplateManager extends Component
foreach ($rows as $row) { foreach ($rows as $row) {
$row = array_values((array) $row); $row = array_values((array) $row);
$rawName = trim($row[1] ?? ''); $rawName = trim($row[1] ?? '');
if ($rawName === '') continue; if ($rawName === '') {
continue;
}
$fields[] = [ $fields[] = [
'group' => trim($row[0] ?? ''), 'group' => trim($row[0] ?? ''),
@@ -268,6 +287,7 @@ class GlobalTemplateManager extends Component
'help' => trim($row[10] ?? ''), 'help' => trim($row[10] ?? ''),
]; ];
} }
return $fields; return $fields;
} }
@@ -276,6 +296,7 @@ class GlobalTemplateManager extends Component
$str = mb_strtolower(trim($str)); $str = mb_strtolower(trim($str));
$str = preg_replace('/\s+/', '_', $str); $str = preg_replace('/\s+/', '_', $str);
$str = preg_replace('/[^a-z0-9_]/i', '', $str); $str = preg_replace('/[^a-z0-9_]/i', '', $str);
return trim($str, '_') ?: 'campo'; return trim($str, '_') ?: 'campo';
} }
@@ -291,6 +312,7 @@ class GlobalTemplateManager extends Component
'date' => 'date', 'fecha' => 'date', 'date' => 'date', 'fecha' => 'date',
'select' => 'select', 'lista' => 'select', 'dropdown' => 'select', 'opciones' => 'select', 'select' => 'select', 'lista' => 'select', 'dropdown' => 'select', 'opciones' => 'select',
]; ];
return $map[strtolower(trim($type))] ?? 'text'; return $map[strtolower(trim($type))] ?? 'text';
} }
@@ -48,20 +48,19 @@ class InspectionTemplatesTable extends DataTableComponent
Column::make('Plantilla', 'name') Column::make('Plantilla', 'name')
->sortable()->searchable() ->sortable()->searchable()
->secondaryHeaderFilter('name') ->secondaryHeaderFilter('name')
->format(fn ($value) => '<span class="font-medium">' . e($value) . '</span>') ->format(fn ($value) => '<span class="font-medium">'.e($value).'</span>')
->html(), ->html(),
Column::make('Descripción', 'description') Column::make('Descripción', 'description')
->searchable() ->searchable()
->secondaryHeaderFilter('description') ->secondaryHeaderFilter('description')
->format(fn ($value) => $value ->format(fn ($value) => $value
? '<span class="text-sm text-base-content/70">' . e($value) . '</span>' ? '<span class="text-sm text-base-content/70">'.e($value).'</span>'
: '<span class="text-base-content/30 text-xs">—</span>') : '<span class="text-base-content/30 text-xs">—</span>')
->html(), ->html(),
Column::make('Campos') Column::make('Campos')
->label(fn ($row) => ->label(fn ($row) => '<span class="badge badge-ghost badge-sm">'.count($row->fields ?? []).'</span>')
'<span class="badge badge-ghost badge-sm">' . count($row->fields ?? []) . '</span>')
->html(), ->html(),
Column::make('Proyectos') Column::make('Proyectos')
@@ -69,19 +68,19 @@ class InspectionTemplatesTable extends DataTableComponent
->label(function ($row) { ->label(function ($row) {
$n = (int) ($row->projects_count ?? 0); $n = (int) ($row->projects_count ?? 0);
$cls = $n > 0 ? 'badge-info' : 'badge-ghost'; $cls = $n > 0 ? 'badge-info' : 'badge-ghost';
return '<span class="badge ' . $cls . ' badge-sm">' . $n . '</span>';
return '<span class="badge '.$cls.' badge-sm">'.$n.'</span>';
}) })
->html(), ->html(),
Column::make('Acciones') Column::make('Acciones')
->label(fn ($row) => ->label(fn ($row) => '<div class="flex justify-end gap-1">
'<div class="flex justify-end gap-1"> <button wire:click="$dispatch(\'template-edit\', { id: '.$row->id.' })"
<button wire:click="$dispatch(\'template-edit\', { id: ' . $row->id . ' })"
class="btn btn-xs btn-ghost" title="Editar"> 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> <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>
<button wire:click="$dispatch(\'template-delete\', { id: ' . $row->id . ' })" <button wire:click="$dispatch(\'template-delete\', { id: '.$row->id.' })"
wire:confirm="¿Eliminar la plantilla \'' . e($row->name) . '\'? Esta acción no se puede deshacer." 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"> 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> <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> </button>
@@ -95,17 +94,21 @@ class InspectionTemplatesTable extends DataTableComponent
return [ return [
TextFilter::make('Plantilla', 'name') TextFilter::make('Plantilla', 'name')
->config(['placeholder' => 'Buscar nombre…']) ->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') TextFilter::make('Descripción', 'description')
->config(['placeholder' => 'Buscar descripción…']) ->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') SelectFilter::make('Uso', 'usage')
->options(['' => 'Todos', 'used' => 'En uso (≥1 proyecto)', 'unused' => 'Sin uso']) ->options(['' => 'Todos', 'used' => 'En uso (≥1 proyecto)', 'unused' => 'Sin uso'])
->filter(function (Builder $q, string $v) { ->filter(function (Builder $q, string $v) {
if ($v === 'used') $q->has('projects'); if ($v === 'used') {
if ($v === 'unused') $q->doesntHave('projects'); $q->has('projects');
}
if ($v === 'unused') {
$q->doesntHave('projects');
}
}), }),
]; ];
} }
@@ -12,11 +12,15 @@ use Livewire\Component;
class IssueChecklistManager extends Component class IssueChecklistManager extends Component
{ {
public Project $project; public Project $project;
public $templates = []; public $templates = [];
public bool $showForm = false; public bool $showForm = false;
public $editingId = null; public $editingId = null;
public string $name = ''; public string $name = '';
public array $items = ['']; public array $items = [''];
public function mount(Project $project) public function mount(Project $project)
@@ -29,6 +33,7 @@ class IssueChecklistManager extends Component
private function canAccessProject(): bool private function canAccessProject(): bool
{ {
$user = Auth::user(); $user = Auth::user();
return $user->can('manage all') return $user->can('manage all')
|| $this->project->users()->where('user_id', $user->id)->exists(); || $this->project->users()->where('user_id', $user->id)->exists();
} }
@@ -82,6 +87,7 @@ class IssueChecklistManager extends Component
$items = array_values(array_filter(array_map('trim', $this->items), fn ($v) => $v !== '')); $items = array_values(array_filter(array_map('trim', $this->items), fn ($v) => $v !== ''));
if (empty($items)) { if (empty($items)) {
$this->addError('items', 'Añade al menos una tarea.'); $this->addError('items', 'Añade al menos una tarea.');
return; return;
} }
+13 -7
View File
@@ -4,13 +4,13 @@ namespace App\Livewire\Issues;
use App\Models\Issue; use App\Models\Issue;
use App\Models\IssueChecklistTemplate; use App\Models\IssueChecklistTemplate;
use App\Models\IssueComment; use App\Models\Media;
use App\Models\IssueTask;
use App\Models\Project; use App\Models\Project;
use App\Notifications\IssueCommentedNotification; use App\Notifications\IssueCommentedNotification;
use App\Notifications\IssueStatusChangedNotification; use App\Notifications\IssueStatusChangedNotification;
use App\Notifications\IssueTaskAssignedNotification; use App\Notifications\IssueTaskAssignedNotification;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Component; use Livewire\Component;
@@ -22,19 +22,24 @@ class IssueDetail extends Component
use WithFileUploads; use WithFileUploads;
public Project $project; public Project $project;
public Issue $issue; public Issue $issue;
// New task form // New task form
public string $newTaskTitle = ''; public string $newTaskTitle = '';
public $newTaskAssignee = ''; public $newTaskAssignee = '';
public $newTaskDue = ''; public $newTaskDue = '';
// Checklist templates // Checklist templates
public $checklistTemplates = []; public $checklistTemplates = [];
public $applyTemplateId = ''; public $applyTemplateId = '';
// New comment form // New comment form
public string $newComment = ''; public string $newComment = '';
public $commentPhoto = null; // single optional photo on a comment public $commentPhoto = null; // single optional photo on a comment
// Issue-level photos // Issue-level photos
@@ -61,6 +66,7 @@ class IssueDetail extends Component
private function canAccessProject(): bool private function canAccessProject(): bool
{ {
$user = Auth::user(); $user = Auth::user();
return $user->can('manage all') return $user->can('manage all')
|| $this->project->users()->where('user_id', $user->id)->exists(); || $this->project->users()->where('user_id', $user->id)->exists();
} }
@@ -107,7 +113,7 @@ class IssueDetail extends Component
'assigned_to' => $this->newTaskAssignee ?: null, 'assigned_to' => $this->newTaskAssignee ?: null,
'due_date' => $this->newTaskDue ?: null, 'due_date' => $this->newTaskDue ?: null,
'order' => ((int) $this->issue->tasks()->max('order')) + 1, 'order' => ((int) $this->issue->tasks()->max('order')) + 1,
'uuid' => (string) \Illuminate\Support\Str::uuid(), 'uuid' => (string) Str::uuid(),
]); ]);
// Notify the assignee (unless they assigned it to themselves). // Notify the assignee (unless they assigned it to themselves).
@@ -134,7 +140,7 @@ class IssueDetail extends Component
$this->issue->tasks()->create([ $this->issue->tasks()->create([
'title' => $title, 'title' => $title,
'order' => ++$order, 'order' => ++$order,
'uuid' => (string) \Illuminate\Support\Str::uuid(), 'uuid' => (string) Str::uuid(),
]); ]);
} }
@@ -183,7 +189,7 @@ class IssueDetail extends Component
$comment = $this->issue->comments()->create([ $comment = $this->issue->comments()->create([
'user_id' => Auth::id(), 'user_id' => Auth::id(),
'body' => trim($this->newComment) ?: '(foto)', 'body' => trim($this->newComment) ?: '(foto)',
'uuid' => (string) \Illuminate\Support\Str::uuid(), 'uuid' => (string) Str::uuid(),
]); ]);
if ($this->commentPhoto) { if ($this->commentPhoto) {
@@ -215,7 +221,7 @@ class IssueDetail extends Component
public function deleteMedia($mediaId): void public function deleteMedia($mediaId): void
{ {
$media = \App\Models\Media::findOrFail($mediaId); $media = Media::findOrFail($mediaId);
$user = Auth::user(); $user = Auth::user();
abort_unless($user->can('delete media') || $media->uploaded_by === $user->id, 403); abort_unless($user->can('delete media') || $media->uploaded_by === $user->id, 403);
$media->delete(); $media->delete();
@@ -237,7 +243,7 @@ class IssueDetail extends Component
'file_size' => $file->getSize(), 'file_size' => $file->getSize(),
'category' => str_starts_with($mime, 'image/') ? 'image' : 'document', 'category' => str_starts_with($mime, 'image/') ? 'image' : 'document',
'uploaded_by' => Auth::id(), 'uploaded_by' => Auth::id(),
'uuid' => (string) \Illuminate\Support\Str::uuid(), 'uuid' => (string) Str::uuid(),
]); ]);
} }
+15 -4
View File
@@ -2,6 +2,7 @@
namespace App\Livewire\Issues; namespace App\Livewire\Issues;
use App\Models\Feature;
use App\Models\Issue; use App\Models\Issue;
use App\Models\Project; use App\Models\Project;
use App\Notifications\IssueAssignedNotification; use App\Notifications\IssueAssignedNotification;
@@ -14,22 +15,31 @@ use Livewire\Component;
class IssueForm extends Component class IssueForm extends Component
{ {
public Project $project; public Project $project;
public ?Issue $issue = null; // null = create, set = edit public ?Issue $issue = null; // null = create, set = edit
public $projectUsers = []; public $projectUsers = [];
// Form fields // Form fields
public $title = ''; public $title = '';
public $description = ''; public $description = '';
public $status = 'open'; public $status = 'open';
public $priority = 'medium'; public $priority = 'medium';
public $type = 'defect'; public $type = 'defect';
public $assignedTo = ''; public $assignedTo = '';
public $resolutionNotes = ''; public $resolutionNotes = '';
// Optional context (e.g. when reporting from a map feature) // Optional context (e.g. when reporting from a map feature)
public $featureId = null; public $featureId = null;
public $inspectionId = null; public $inspectionId = null;
public $featureName = null; // shown when the issue is pre-linked to a map element public $featureName = null; // shown when the issue is pre-linked to a map element
public function mount(Project $project, ?Issue $issue = null) public function mount(Project $project, ?Issue $issue = null)
@@ -57,7 +67,7 @@ class IssueForm extends Component
$this->featureName = $issue->feature?->name; $this->featureName = $issue->feature?->name;
} elseif ($featureId = request()->integer('feature')) { } elseif ($featureId = request()->integer('feature')) {
// Pre-link to a map element when reporting from the project map. // 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) { if ($feature && $feature->layer?->phase?->project_id === $project->id) {
$this->featureId = $feature->id; $this->featureId = $feature->id;
$this->featureName = $feature->name; $this->featureName = $feature->name;
@@ -68,6 +78,7 @@ class IssueForm extends Component
private function canAccessProject(): bool private function canAccessProject(): bool
{ {
$user = Auth::user(); $user = Auth::user();
return $user->can('manage all') return $user->can('manage all')
|| $this->project->users()->where('user_id', $user->id)->exists(); || $this->project->users()->where('user_id', $user->id)->exists();
} }
@@ -77,9 +88,9 @@ class IssueForm extends Component
return [ return [
'title' => 'required|string|max:255', 'title' => 'required|string|max:255',
'description' => 'nullable|string', 'description' => 'nullable|string',
'status' => 'required|in:' . implode(',', Issue::STATUSES), 'status' => 'required|in:'.implode(',', Issue::STATUSES),
'priority' => 'required|in:' . implode(',', Issue::PRIORITIES), 'priority' => 'required|in:'.implode(',', Issue::PRIORITIES),
'type' => 'required|in:' . implode(',', Issue::TYPES), 'type' => 'required|in:'.implode(',', Issue::TYPES),
'assignedTo' => 'nullable|exists:users,id', 'assignedTo' => 'nullable|exists:users,id',
'resolutionNotes' => 'nullable|string', 'resolutionNotes' => 'nullable|string',
]; ];
+5 -4
View File
@@ -2,12 +2,12 @@
namespace App\Livewire\Issues; 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\Layout;
use Livewire\Attributes\On; use Livewire\Attributes\On;
use Illuminate\Support\Facades\Auth; use Livewire\Component;
use App\Models\Project;
use App\Models\Issue;
#[Layout('layouts.app')] #[Layout('layouts.app')]
class IssueManager extends Component class IssueManager extends Component
@@ -24,6 +24,7 @@ class IssueManager extends Component
private function canAccessProject(): bool private function canAccessProject(): bool
{ {
$user = Auth::user(); $user = Auth::user();
return $user->can('manage all') return $user->can('manage all')
|| $this->project->users()->where('user_id', $user->id)->exists(); || $this->project->users()->where('user_id', $user->id)->exists();
} }
+18 -7
View File
@@ -3,6 +3,7 @@
namespace App\Livewire\Issues; namespace App\Livewire\Issues;
use App\Models\Issue; use App\Models\Issue;
use App\Models\Project;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@@ -31,7 +32,7 @@ class IssueTable extends DataTableComponent
abort_unless( abort_unless(
$user->can('view issues') && ( $user->can('view issues') && (
$user->can('manage all') $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 403
); );
@@ -57,6 +58,7 @@ class IssueTable extends DataTableComponent
->format(function ($value, $row) { ->format(function ($value, $row) {
$label = ['low' => 'Bajo', 'medium' => 'Medio', 'high' => 'Alto', 'critical' => 'Crítico'][$value] ?? ucfirst($value); $label = ['low' => 'Bajo', 'medium' => 'Medio', 'high' => 'Alto', 'critical' => 'Crítico'][$value] ?? ucfirst($value);
$textColor = in_array($value, ['critical', 'high']) ? '#fff' : '#1f2937'; $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>'; return '<span class="badge badge-sm font-semibold" style="background-color:'.$row->priority_color.';color:'.$textColor.';border-color:transparent;">'.$label.'</span>';
}) })
->html(), ->html(),
@@ -71,9 +73,15 @@ class IssueTable extends DataTableComponent
$html .= '<div class="text-xs text-base-content/50 truncate max-w-xs">'.e(Str::limit($row->description, 60)).'</div>'; $html .= '<div class="text-xs text-base-content/50 truncate max-w-xs">'.e(Str::limit($row->description, 60)).'</div>';
} }
$meta = []; $meta = [];
if ($row->reporter) $meta[] = 'Reportado por '.e($row->reporter->name); if ($row->reporter) {
if ($row->comments_count) $meta[] = '💬 '.$row->comments_count; $meta[] = 'Reportado por '.e($row->reporter->name);
if ($row->media_count) $meta[] = '📷 '.$row->media_count; }
if ($row->comments_count) {
$meta[] = '💬 '.$row->comments_count;
}
if ($row->media_count) {
$meta[] = '📷 '.$row->media_count;
}
if ($meta) { if ($meta) {
$html .= '<div class="text-xs text-base-content/40 mt-0.5">'.implode(' · ', $meta).'</div>'; $html .= '<div class="text-xs text-base-content/40 mt-0.5">'.implode(' · ', $meta).'</div>';
} }
@@ -87,14 +95,14 @@ class IssueTable extends DataTableComponent
if ($row->overdue_tasks_count) { 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>'; $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; return $html;
}) })
->html(), ->html(),
Column::make('Tipo', 'type') Column::make('Tipo', 'type')
->sortable() ->sortable()
->format(fn ($value, $row) => ->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>')
'<span class="badge badge-sm" style="background-color:'.$row->type_color.';color:#fff;border-color:transparent;">'.e($row->type_label).'</span>')
->html(), ->html(),
Column::make('Feature') Column::make('Feature')
@@ -107,6 +115,7 @@ class IssueTable extends DataTableComponent
->sortable() ->sortable()
->format(function ($value, $row) { ->format(function ($value, $row) {
$label = ['open' => 'Abierto', 'in_review' => 'En revisión', 'resolved' => 'Resuelto', 'closed' => 'Cerrado'][$value] ?? ucfirst($value); $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>'; return '<span class="badge badge-sm" style="background-color:'.$row->status_color.';color:#fff;border-color:transparent;">'.$label.'</span>';
}) })
->html(), ->html(),
@@ -124,6 +133,7 @@ class IssueTable extends DataTableComponent
if ($row->resolved_at) { if ($row->resolved_at) {
$html .= '<div class="text-success text-xs">Res. '.$row->resolved_at->format('d/m/Y').'</div>'; $html .= '<div class="text-success text-xs">Res. '.$row->resolved_at->format('d/m/Y').'</div>';
} }
return $html; return $html;
}) })
->html(), ->html(),
@@ -163,6 +173,7 @@ class IssueTable extends DataTableComponent
} }
$html .= '</div>'; $html .= '</div>';
return $html; return $html;
}) })
->html(), ->html(),
@@ -193,7 +204,7 @@ class IssueTable extends DataTableComponent
->filter(fn (Builder $query, string $value) => $query->where('issues.priority', $value)), ->filter(fn (Builder $query, string $value) => $query->where('issues.priority', $value)),
SelectFilter::make('Tipo', 'type') 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)), ->filter(fn (Builder $query, string $value) => $query->where('issues.type', $value)),
]; ];
} }
+69 -33
View File
@@ -2,19 +2,19 @@
namespace App\Livewire\Layers; 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\Feature;
use App\Models\InspectionTemplate; use App\Models\InspectionTemplate;
use App\Models\Layer;
use App\Models\Phase;
use App\Models\Project;
use App\Services\SpatialFileConverter; use App\Services\SpatialFileConverter;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
use Livewire\WithFileUploads;
#[Layout('layouts.app')] #[Layout('layouts.app')]
class LayerManager extends Component class LayerManager extends Component
@@ -22,18 +22,26 @@ class LayerManager extends Component
use WithFileUploads; use WithFileUploads;
public Project $project; public Project $project;
public Phase $phase; public Phase $phase;
public $layers; public $layers;
public $selectedLayer = null; public $selectedLayer = null;
public $visibleLayers = []; public $visibleLayers = [];
public $uploadFile = null; public $uploadFile = null;
public $layerName = ''; public $layerName = '';
public $layerColor = '#3b82f6'; public $layerColor = '#3b82f6';
// Batch assign // Batch assign
public $templates = []; public $templates = [];
public $batchTemplateId = null; public $batchTemplateId = null;
public $batchStatus = ''; public $batchStatus = '';
public function mount(Project $project, Phase $phase) public function mount(Project $project, Phase $phase)
@@ -41,10 +49,12 @@ class LayerManager extends Component
$this->project = $project; $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(); $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); abort(403);
} }
@@ -73,7 +83,7 @@ class LayerManager extends Component
{ {
$color = $layer->color ?: '#3b82f6'; $color = $layer->color ?: '#3b82f6';
$features = ($layer->relationLoaded('features') ? $layer->features : $layer->features()->get()) $features = ($layer->relationLoaded('features') ? $layer->features : $layer->features()->get())
->map(fn($f) => [ ->map(fn ($f) => [
'type' => 'Feature', 'type' => 'Feature',
'id' => $f->id, 'id' => $f->id,
'geometry' => $f->geometry, 'geometry' => $f->geometry,
@@ -101,7 +111,7 @@ class LayerManager extends Component
{ {
$this->layers->loadMissing('features'); $this->layers->loadMissing('features');
$this->dispatch('initialLayersData', [ $this->dispatch('initialLayersData', [
'layers' => $this->layers->map(fn($l) => $this->buildLayerPayload($l)), 'layers' => $this->layers->map(fn ($l) => $this->buildLayerPayload($l)),
'visibleLayers' => $this->visibleLayers, 'visibleLayers' => $this->visibleLayers,
'selectedLayerId' => $this->selectedLayer?->id, 'selectedLayerId' => $this->selectedLayer?->id,
]); ]);
@@ -113,6 +123,7 @@ class LayerManager extends Component
{ {
if ($this->selectedLayer && $this->selectedLayer->id == $layerId) { if ($this->selectedLayer && $this->selectedLayer->id == $layerId) {
$this->dispatch('notify', 'No puedes ocultar la capa que estás editando'); $this->dispatch('notify', 'No puedes ocultar la capa que estás editando');
return; return;
} }
if (in_array($layerId, $this->visibleLayers)) { if (in_array($layerId, $this->visibleLayers)) {
@@ -128,9 +139,11 @@ class LayerManager extends Component
public function selectLayer($layerId) public function selectLayer($layerId)
{ {
$this->selectedLayer = Layer::with('features')->find($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->visibleLayers[] = $layerId;
$this->dispatch('visibilityChanged', $this->visibleLayers); $this->dispatch('visibilityChanged', $this->visibleLayers);
} }
@@ -141,7 +154,7 @@ class LayerManager extends Component
'geojson' => $payload['geojson'], '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 ─────────────────────────────────────────────────────────── // ── Import file ───────────────────────────────────────────────────────────
@@ -149,8 +162,9 @@ class LayerManager extends Component
public function importFile() public function importFile()
{ {
$user = Auth::user(); $user = Auth::user();
if (!$user->can('upload layers')) { if (! $user->can('upload layers')) {
$this->dispatch('notify', 'Sin permisos para subir capas'); $this->dispatch('notify', 'Sin permisos para subir capas');
return; return;
} }
@@ -162,14 +176,16 @@ class LayerManager extends Component
$ext = strtolower($this->uploadFile->getClientOriginalExtension()); $ext = strtolower($this->uploadFile->getClientOriginalExtension());
$allowed = ['geojson', 'json', 'kmz', 'kml', 'shp', 'dwg', 'zip']; $allowed = ['geojson', 'json', 'kmz', 'kml', 'shp', 'dwg', 'zip'];
if (!in_array($ext, $allowed)) { if (! in_array($ext, $allowed)) {
$this->dispatch('notify', 'Extensión no permitida. Válidas: ' . implode(', ', $allowed)); $this->dispatch('notify', 'Extensión no permitida. Válidas: '.implode(', ', $allowed));
return; return;
} }
$geojson = SpatialFileConverter::convertToGeoJson($this->uploadFile); $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.'); $this->dispatch('notify', 'No se pudo convertir el archivo. Comprueba que sea GeoJSON, KML o Shapefile válido.');
return; return;
} }
@@ -195,7 +211,9 @@ class LayerManager extends Component
foreach ($geojson['features'] ?? [] as $fd) { foreach ($geojson['features'] ?? [] as $fd) {
$idx++; $idx++;
$name = trim($fd['properties']['name'] ?? ''); $name = trim($fd['properties']['name'] ?? '');
if ($name === '') $name = $layerName . ' — Elemento ' . $idx; if ($name === '') {
$name = $layerName.' — Elemento '.$idx;
}
Feature::create([ Feature::create([
'layer_id' => $layer->id, 'layer_id' => $layer->id,
@@ -214,7 +232,8 @@ class LayerManager extends Component
$this->visibleLayers[] = $layer->id; $this->visibleLayers[] = $layer->id;
}); });
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->dispatch('notify', 'Error al importar: ' . $e->getMessage()); $this->dispatch('notify', 'Error al importar: '.$e->getMessage());
return; return;
} }
@@ -229,8 +248,9 @@ class LayerManager extends Component
public function createEmptyLayer() public function createEmptyLayer()
{ {
$user = Auth::user(); $user = Auth::user();
if (!$user->can('upload layers')) { if (! $user->can('upload layers')) {
$this->dispatch('notify', 'Sin permisos para crear capas'); $this->dispatch('notify', 'Sin permisos para crear capas');
return; return;
} }
@@ -255,14 +275,16 @@ class LayerManager extends Component
#[On('save-manual-geojson')] #[On('save-manual-geojson')]
public function saveManualGeojson($geojsonString) public function saveManualGeojson($geojsonString)
{ {
if (!$this->selectedLayer) { if (! $this->selectedLayer) {
$this->dispatch('notify', 'No hay capa seleccionada'); $this->dispatch('notify', 'No hay capa seleccionada');
return; return;
} }
$geojson = json_decode($geojsonString, true); $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'); $this->dispatch('notify', 'GeoJSON inválido');
return; return;
} }
@@ -278,7 +300,9 @@ class LayerManager extends Component
foreach ($geojson['features'] as $fd) { foreach ($geojson['features'] as $fd) {
$idx++; $idx++;
$name = trim($fd['properties']['name'] ?? ''); $name = trim($fd['properties']['name'] ?? '');
if ($name === '') $name = $layerName . ' — Elemento ' . $idx; if ($name === '') {
$name = $layerName.' — Elemento '.$idx;
}
Feature::create([ Feature::create([
'layer_id' => $layerId, 'layer_id' => $layerId,
@@ -295,14 +319,15 @@ class LayerManager extends Component
} }
}); });
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->dispatch('notify', 'Error al guardar: ' . $e->getMessage()); $this->dispatch('notify', 'Error al guardar: '.$e->getMessage());
return; return;
} }
$this->loadLayers(); $this->loadLayers();
$this->selectLayer($this->selectedLayer->id); $this->selectLayer($this->selectedLayer->id);
$this->emitInitialLayersData(); $this->emitInitialLayersData();
$this->dispatch('notify', count($geojson['features']) . ' elementos guardados'); $this->dispatch('notify', count($geojson['features']).' elementos guardados');
} }
// ── Delete layer ────────────────────────────────────────────────────────── // ── Delete layer ──────────────────────────────────────────────────────────
@@ -310,13 +335,19 @@ class LayerManager extends Component
public function deleteLayer($layerId) public function deleteLayer($layerId)
{ {
$user = Auth::user(); $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) // Verify it belongs to this phase (prevents cross-project deletion)
$layer = Layer::where('id', $layerId)->where('phase_id', $this->phase->id)->first(); $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->features()->delete();
$layer->delete(); $layer->delete();
@@ -337,12 +368,14 @@ class LayerManager extends Component
->where('id', $layerId) ->where('id', $layerId)
->where('phase_id', $this->phase->id) ->where('phase_id', $this->phase->id)
->first(); ->first();
if (!$layer) return; if (! $layer) {
return;
}
$fc = [ $fc = [
'type' => 'FeatureCollection', 'type' => 'FeatureCollection',
'name' => $layer->name, 'name' => $layer->name,
'features' => $layer->features->map(fn($f) => [ 'features' => $layer->features->map(fn ($f) => [
'type' => 'Feature', 'type' => 'Feature',
'geometry' => $f->geometry, 'geometry' => $f->geometry,
'properties' => array_merge($f->properties ?? [], [ 'properties' => array_merge($f->properties ?? [], [
@@ -355,7 +388,7 @@ class LayerManager extends Component
])->values()->toArray(), ])->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) { return response()->streamDownload(function () use ($fc) {
echo json_encode($fc, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); 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) public function batchAssign($layerId)
{ {
$layer = Layer::where('id', $layerId)->where('phase_id', $this->phase->id)->first(); $layer = Layer::where('id', $layerId)->where('phase_id', $this->phase->id)->first();
if (!$layer) return; if (! $layer) {
return;
}
$data = []; $data = [];
if ($this->batchStatus && in_array($this->batchStatus, Feature::STATUSES)) { if ($this->batchStatus && in_array($this->batchStatus, Feature::STATUSES)) {
@@ -378,6 +413,7 @@ class LayerManager extends Component
} }
if (empty($data)) { if (empty($data)) {
$this->dispatch('notify', 'Selecciona un estado o template para asignar'); $this->dispatch('notify', 'Selecciona un estado o template para asignar');
return; return;
} }
+17 -15
View File
@@ -2,17 +2,10 @@
namespace App\Livewire\Media; namespace App\Livewire\Media;
use App\Models\Media;
use Illuminate\Support\Facades\Auth;
use Livewire\Component; use Livewire\Component;
use Livewire\WithFileUploads; 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 class MediaManager extends Component
{ {
@@ -20,18 +13,23 @@ class MediaManager extends Component
// Polimórfico: a qué entidad pertenece // Polimórfico: a qué entidad pertenece
public $mediableType; public $mediableType;
public $mediableId; public $mediableId;
public $entity; // instancia cargada public $entity; // instancia cargada
public $mediaItems = []; public $mediaItems = [];
// Subida // Subida
public $uploadFiles = []; public $uploadFiles = [];
public $uploadDescription = ''; public $uploadDescription = '';
public $uploadCategory = 'image'; public $uploadCategory = 'image';
// Modal visor // Modal visor
public $showViewer = false; public $showViewer = false;
public $viewingMedia = null; public $viewingMedia = null;
protected $rules = [ protected $rules = [
@@ -65,8 +63,9 @@ class MediaManager extends Component
public function upload() public function upload()
{ {
$user = Auth::user(); $user = Auth::user();
if (!$user->can('upload layers')) { if (! $user->can('upload layers')) {
session()->flash('error', 'Sin permisos.'); session()->flash('error', 'Sin permisos.');
return; return;
} }
@@ -74,6 +73,7 @@ class MediaManager extends Component
if (empty($this->uploadFiles)) { if (empty($this->uploadFiles)) {
session()->flash('error', 'Selecciona al menos un archivo.'); session()->flash('error', 'Selecciona al menos un archivo.');
return; return;
} }
@@ -130,8 +130,9 @@ class MediaManager extends Component
$media = Media::findOrFail($mediaId); $media = Media::findOrFail($mediaId);
$user = Auth::user(); $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.'); session()->flash('error', 'No puedes borrar archivos de otro usuario.');
return; return;
} }
@@ -143,9 +144,10 @@ class MediaManager extends Component
public function viewMedia($mediaId) public function viewMedia($mediaId)
{ {
$media = Media::findOrFail($mediaId); $media = Media::findOrFail($mediaId);
if (!$media->is_image) { if (! $media->is_image) {
// Si no es imagen, abrir en nueva pestaña // Si no es imagen, abrir en nueva pestaña
$this->dispatch('openUrl', $media->url); $this->dispatch('openUrl', $media->url);
return; return;
} }
$this->viewingMedia = $media; $this->viewingMedia = $media;
@@ -161,9 +163,9 @@ class MediaManager extends Component
public function render() public function render()
{ {
return view('livewire.media.media-manager', [ return view('livewire.media.media-manager', [
'entityName' => class_basename($this->entity) . ': ' . ($this->entity->name ?? $this->entity->id), 'entityName' => class_basename($this->entity).': '.($this->entity->name ?? $this->entity->id),
'images' => $this->mediaItems->filter(fn($m) => $m->is_image), 'images' => $this->mediaItems->filter(fn ($m) => $m->is_image),
'documents' => $this->mediaItems->filter(fn($m) => !$m->is_image), 'documents' => $this->mediaItems->filter(fn ($m) => ! $m->is_image),
]); ]);
} }
} }
+10 -6
View File
@@ -1,20 +1,23 @@
<?php <?php
namespace App\Livewire\Phases; namespace App\Livewire\Phases;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\Project; use App\Models\Project;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')] #[Layout('layouts.app')]
class PhaseGantt extends Component class PhaseGantt extends Component
{ {
public Project $project; public Project $project;
public $ganttData = []; public $ganttData = [];
public function mount(Project $project) public function mount(Project $project)
{ {
$user = Auth::user(); $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); abort(403);
} }
$this->project = $project; $this->project = $project;
@@ -27,7 +30,7 @@ class PhaseGantt extends Component
$projectStart = $this->project->start_date ?? now()->startOfMonth(); $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_start = $phase->planned_start ?? $projectStart;
$planned_end = $phase->planned_end ?? $projectEnd; $planned_end = $phase->planned_end ?? $projectEnd;
$actual_start = $phase->actual_start; $actual_start = $phase->actual_start;
@@ -40,7 +43,8 @@ class PhaseGantt extends Component
$pStartPct = round(($pStartOffset / $totalDays) * 100, 2); $pStartPct = round(($pStartOffset / $totalDays) * 100, 2);
$pWidthPct = round(($pDuration / $totalDays) * 100, 2); $pWidthPct = round(($pDuration / $totalDays) * 100, 2);
$aStartPct = null; $aWidthPct = null; $aStartPct = null;
$aWidthPct = null;
if ($actual_start) { if ($actual_start) {
$aStart = max(0, $projectStart->diffInDays($actual_start)); $aStart = max(0, $projectStart->diffInDays($actual_start));
$aEnd = $actual_end ?? now(); $aEnd = $actual_end ?? now();
@@ -65,7 +69,7 @@ class PhaseGantt extends Component
'a_start_pct' => $aStartPct, 'a_start_pct' => $aStartPct,
'a_width_pct' => $aWidthPct ? min($aWidthPct, 100 - $aStartPct) : null, 'a_width_pct' => $aWidthPct ? min($aWidthPct, 100 - $aStartPct) : null,
'is_delayed' => $isDelayed, 'is_delayed' => $isDelayed,
'features_count' => $phase->layers->sum(fn($l) => $l->features->count()), 'features_count' => $phase->layers->sum(fn ($l) => $l->features->count()),
]; ];
})->toArray(); })->toArray();
} }
+10 -2
View File
@@ -2,7 +2,6 @@
namespace App\Livewire\Phases; namespace App\Livewire\Phases;
use App\Models\Phase;
use App\Models\Project; use App\Models\Project;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\On; use Livewire\Attributes\On;
@@ -14,17 +13,26 @@ class PhaseList extends Component
// Modal state // Modal state
public bool $showForm = false; public bool $showForm = false;
public $editingId = null; public $editingId = null;
// Form fields // Form fields
public string $name = ''; public string $name = '';
public string $description = ''; public string $description = '';
public string $color = '#3b82f6'; public string $color = '#3b82f6';
public int $order = 1; public int $order = 1;
public int $progressPercent = 0; public int $progressPercent = 0;
public string $plannedStart = ''; public string $plannedStart = '';
public string $plannedEnd = ''; public string $plannedEnd = '';
public string $actualStart = ''; public string $actualStart = '';
public string $actualEnd = ''; public string $actualEnd = '';
public function mount(Project $project) public function mount(Project $project)
@@ -77,7 +85,7 @@ class PhaseList extends Component
$this->actualEnd = $phase->actual_end?->format('Y-m-d') ?? ''; $this->actualEnd = $phase->actual_end?->format('Y-m-d') ?? '';
} else { } else {
$this->order = (int) $this->project->phases()->max('order') + 1; $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; $this->showForm = true;
+4 -2
View File
@@ -2,15 +2,17 @@
namespace App\Livewire\Phases; namespace App\Livewire\Phases;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\Phase; use App\Models\Phase;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')] #[Layout('layouts.app')]
class PhaseProgress extends Component class PhaseProgress extends Component
{ {
public Phase $phase; public Phase $phase;
public $progress; public $progress;
public $comment = ''; public $comment = '';
public function mount(Phase $phase) public function mount(Phase $phase)
+7 -5
View File
@@ -6,6 +6,7 @@ use App\Models\Phase;
use App\Models\Project; use App\Models\Project;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Livewire\Attributes\On; use Livewire\Attributes\On;
use Rappasoft\LaravelLivewireTables\DataTableComponent; use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column; use Rappasoft\LaravelLivewireTables\Views\Column;
@@ -54,16 +55,16 @@ class PhaseTable extends DataTableComponent
->format(function ($value, $row) { ->format(function ($value, $row) {
$html = '<span class="font-medium">'.e($value).'</span>'; $html = '<span class="font-medium">'.e($value).'</span>';
if ($row->description) { 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>'; $html .= '<div class="text-xs text-base-content/50 truncate max-w-xs">'.e(Str::limit($row->description, 60)).'</div>';
} }
return $html; return $html;
}) })
->html(), ->html(),
Column::make('Progreso', 'progress_percent') Column::make('Progreso', 'progress_percent')
->sortable() ->sortable()
->format(fn ($value) => ->format(fn ($value) => '<div class="flex items-center gap-2 min-w-[110px]">
'<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> <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> <span class="text-xs text-base-content/60 w-8 text-right">'.(int) $value.'%</span>
</div>') </div>')
@@ -76,13 +77,13 @@ class PhaseTable extends DataTableComponent
if (! $ps && ! $pe) { if (! $ps && ! $pe) {
return '<span class="text-base-content/30 text-xs">—</span>'; return '<span class="text-base-content/30 text-xs">—</span>';
} }
return '<span class="text-xs">'.($ps ?: '?').' → '.($pe ?: '?').'</span>'; return '<span class="text-xs">'.($ps ?: '?').' → '.($pe ?: '?').'</span>';
}) })
->html(), ->html(),
Column::make('Color', 'color') Column::make('Color', 'color')
->format(fn ($value) => ->format(fn ($value) => '<div class="w-6 h-6 rounded border border-base-300" style="background:'.e($value).'" title="'.e($value).'"></div>')
'<div class="w-6 h-6 rounded border border-base-300" style="background:'.e($value).'" title="'.e($value).'"></div>')
->html(), ->html(),
Column::make('Acciones') Column::make('Acciones')
@@ -104,6 +105,7 @@ class PhaseTable extends DataTableComponent
</button>'; </button>';
} }
$html .= '</div>'; $html .= '</div>';
return $html; return $html;
}) })
->html(), ->html(),
+6
View File
@@ -14,13 +14,18 @@ use Livewire\Component;
class FeatureManager extends Component class FeatureManager extends Component
{ {
public Project $project; public Project $project;
public $featureTypes = []; public $featureTypes = [];
// Edit modal // Edit modal
public bool $showForm = false; public bool $showForm = false;
public $editingId = null; public $editingId = null;
public string $name = ''; public string $name = '';
public $featureTypeId = ''; public $featureTypeId = '';
public bool $isActive = true; public bool $isActive = true;
public function mount(Project $project) public function mount(Project $project)
@@ -33,6 +38,7 @@ class FeatureManager extends Component
private function canManage(): bool private function canManage(): bool
{ {
$user = Auth::user(); $user = Auth::user();
return $user->can('manage all') return $user->can('manage all')
|| ($user->can('edit layers') && $this->project->users()->where('user_id', $user->id)->exists()); || ($user->can('edit layers') && $this->project->users()->where('user_id', $user->id)->exists());
} }
+11 -9
View File
@@ -49,7 +49,7 @@ class FeatureTable extends DataTableComponent
->sortable() ->sortable()
->searchable() ->searchable()
->secondaryHeaderFilter('name') ->secondaryHeaderFilter('name')
->format(fn ($value) => '<span class="font-medium">' . e($value) . '</span>') ->format(fn ($value) => '<span class="font-medium">'.e($value).'</span>')
->html(), ->html(),
Column::make('Capa') Column::make('Capa')
@@ -64,20 +64,22 @@ class FeatureTable extends DataTableComponent
->sortable() ->sortable()
->format(function ($value) { ->format(function ($value) {
$cls = $value >= 100 ? 'badge-success' : ($value > 0 ? 'badge-warning' : 'badge-ghost'); $cls = $value >= 100 ? 'badge-success' : ($value > 0 ? 'badge-warning' : 'badge-ghost');
return '<span class="badge badge-sm ' . $cls . '">' . (int) $value . '%</span>';
return '<span class="badge badge-sm '.$cls.'">'.(int) $value.'%</span>';
}) })
->html(), ->html(),
Column::make('Acciones') Column::make('Acciones')
->label(function ($row) { ->label(function ($row) {
$id = $row->id; $id = $row->id;
return '<div class="flex justify-end">' return '<div class="flex justify-end">'
. '<button wire:click="$dispatch(\'map-select-feature\', { featureId: ' . $id . ' })"' .'<button wire:click="$dispatch(\'map-select-feature\', { featureId: '.$id.' })"'
. 'class="btn btn-xs btn-primary gap-1" title="' . e(__('Editar elemento')) . '">' .'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>' .'<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')) .e(__('Abrir'))
. '</button>' .'</button>'
. '</div>'; .'</div>';
}) })
->html(), ->html(),
]; ];
@@ -93,7 +95,7 @@ class FeatureTable extends DataTableComponent
return [ return [
TextFilter::make('Elemento', 'name') TextFilter::make('Elemento', 'name')
->config(['placeholder' => 'Buscar elemento…']) ->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') SelectFilter::make('Capa', 'layer')
->options(['' => 'Todas'] + $layers) ->options(['' => 'Todas'] + $layers)
+5 -1
View File
@@ -13,9 +13,13 @@ class FeatureTypeManager extends Component
public $types = []; public $types = [];
public bool $showForm = false; public bool $showForm = false;
public $editingId = null; public $editingId = null;
public string $name = ''; public string $name = '';
public string $description = ''; public string $description = '';
public string $color = '#6b7280'; public string $color = '#6b7280';
public function mount() public function mount()
@@ -32,7 +36,7 @@ class FeatureTypeManager extends Component
protected function rules(): array protected function rules(): array
{ {
return [ 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', 'description' => 'nullable|string|max:255',
'color' => 'required|string|max:7', 'color' => 'required|string|max:7',
]; ];
+27 -29
View File
@@ -59,7 +59,7 @@ class InspectionTable extends DataTableComponent
Column::make('Elemento') Column::make('Elemento')
->secondaryHeaderFilter('elemento') ->secondaryHeaderFilter('elemento')
->label(fn ($row) => $row->feature?->name ->label(fn ($row) => $row->feature?->name
? '<span class="font-medium">' . e($row->feature->name) . '</span>' ? '<span class="font-medium">'.e($row->feature->name).'</span>'
: '<span class="text-base-content/30 text-xs">—</span>') : '<span class="text-base-content/30 text-xs">—</span>')
->html(), ->html(),
@@ -70,7 +70,7 @@ class InspectionTable extends DataTableComponent
->sortable() ->sortable()
->secondaryHeaderFilter('resultado') ->secondaryHeaderFilter('resultado')
->format(fn ($value) => $value ->format(fn ($value) => $value
? '<span class="badge badge-sm badge-outline">' . e($value) . '</span>' ? '<span class="badge badge-sm badge-outline">'.e($value).'</span>'
: '<span class="text-base-content/30 text-xs">—</span>') : '<span class="text-base-content/30 text-xs">—</span>')
->html(), ->html(),
@@ -83,26 +83,25 @@ class InspectionTable extends DataTableComponent
->html(), ->html(),
Column::make('Acciones') Column::make('Acciones')
->label(fn ($row) => ->label(fn ($row) => '<div class="flex justify-end gap-1">'
'<div class="flex justify-end gap-1">' .'<button wire:click="$dispatch(\'map-view-inspection\', '.$row->id.')"'
. '<button wire:click="$dispatch(\'map-view-inspection\', ' . $row->id . ')"' .'class="btn btn-xs btn-ghost" title="Ver inspección">'
. '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>'
. '<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>'
. '</button>' .'@can("edit inspections")'
. '@can("edit inspections")' .'<button wire:click="$dispatch(\'edit-inspection\', '.$row->id.')"'
. '<button wire:click="$dispatch(\'edit-inspection\', ' . $row->id . ')"' .'class="btn btn-xs btn-ghost" title="Editar inspección">'
. '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>'
. '<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>'
. '</button>' .'@endcan'
. '@endcan' .'@can("delete inspections")'
. '@can("delete inspections")' .'<button wire:click="$dispatch(\'delete-inspection\', '.$row->id.')"'
. '<button wire:click="$dispatch(\'delete-inspection\', ' . $row->id . ')"' .'class="btn btn-xs btn-ghost btn-error" title="Eliminar inspección"'
. 'class="btn btn-xs btn-ghost btn-error" title="Eliminar inspección"' .'onclick="return confirm(\'¿Eliminar esta inspección? No se puede deshacer.\');">'
. '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>'
. '<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>'
. '</button>' .'@endcan'
. '@endcan' .'</div>')
. '</div>')
->html(), ->html(),
]; ];
} }
@@ -116,15 +115,14 @@ class InspectionTable extends DataTableComponent
return '<span class="text-base-content/30 text-xs">—</span>'; return '<span class="text-base-content/30 text-xs">—</span>';
} }
$thumbnails = $images->take(3)->map(fn ($m) => $thumbnails = $images->take(3)->map(fn ($m) => '<a href="'.$m->url.'" target="_blank" class="inline-block mr-1">'
'<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" />'
. '<img src="' . $m->url . '" class="w-8 h-8 object-cover rounded border border-base-300" alt="' . e($m->name) . '" loading="lazy" />' .'</a>'
. '</a>'
)->implode(''); )->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 public function filters(): array
@@ -143,7 +141,7 @@ class InspectionTable extends DataTableComponent
TextFilter::make('Elemento', 'elemento') TextFilter::make('Elemento', 'elemento')
->config(['placeholder' => 'Buscar 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') SelectFilter::make('Resultado', 'resultado')
->options(['' => 'Todos'] + $results) ->options(['' => 'Todos'] + $results)
+4 -1
View File
@@ -11,8 +11,11 @@ use Livewire\Component;
class ProjectCompanies extends Component class ProjectCompanies extends Component
{ {
public Project $project; public Project $project;
public $allCompanies = []; public $allCompanies = [];
public $selectedCompanyId = ''; public $selectedCompanyId = '';
public $selectedRole = 'other'; public $selectedRole = 'other';
public function mount(Project $project) public function mount(Project $project)
@@ -41,7 +44,7 @@ class ProjectCompanies extends Component
$this->validate([ $this->validate([
'selectedCompanyId' => 'required|exists:companies,id', '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, [ $this->project->companies()->attach($this->selectedCompanyId, [
@@ -3,6 +3,7 @@
namespace App\Livewire\Projects; namespace App\Livewire\Projects;
use App\Models\Company; use App\Models\Company;
use App\Models\Project;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\On; use Livewire\Attributes\On;
@@ -62,6 +63,7 @@ class ProjectCompaniesTable extends DataTableComponent
$html .= '<div class="text-xs text-base-content/50">'.e($row->tax_id).'</div>'; $html .= '<div class="text-xs text-base-content/50">'.e($row->tax_id).'</div>';
} }
$html .= '</div></div>'; $html .= '</div></div>';
return $html; return $html;
}) })
->html(), ->html(),
@@ -76,6 +78,7 @@ class ProjectCompaniesTable extends DataTableComponent
foreach (self::ROLES as $val => $label) { foreach (self::ROLES as $val => $label) {
$opts .= '<option value="'.$val.'"'.($current === $val ? ' selected' : '').'>'.$label.'</option>'; $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>'; return '<select wire:change="changeRole('.$row->id.', $event.target.value)" class="select select-bordered select-xs">'.$opts.'</select>';
}) })
->html(), ->html(),
@@ -85,6 +88,7 @@ class ProjectCompaniesTable extends DataTableComponent
if (! Auth::user()->can('assign companies')) { if (! Auth::user()->can('assign companies')) {
return ''; return '';
} }
return '<div class="flex justify-end"> return '<div class="flex justify-end">
<button wire:click="removeCompany('.$row->id.')" wire:confirm="¿Quitar a '.e($row->name).' del proyecto?" <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"> class="btn btn-xs btn-error btn-outline" title="Quitar del proyecto">
@@ -111,7 +115,7 @@ class ProjectCompaniesTable extends DataTableComponent
if (! array_key_exists($role, self::ROLES)) { if (! array_key_exists($role, self::ROLES)) {
return; return;
} }
\App\Models\Project::findOrFail($this->projectId) Project::findOrFail($this->projectId)
->companies()->updateExistingPivot($companyId, ['role_in_project' => $role]); ->companies()->updateExistingPivot($companyId, ['role_in_project' => $role]);
$this->dispatch('project-companies-changed'); $this->dispatch('project-companies-changed');
$this->dispatch('notify', 'Rol actualizado.'); $this->dispatch('notify', 'Rol actualizado.');
@@ -120,7 +124,7 @@ class ProjectCompaniesTable extends DataTableComponent
public function removeCompany($companyId): void public function removeCompany($companyId): void
{ {
abort_unless(Auth::user()->can('assign companies'), 403); 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('project-companies-changed');
$this->dispatch('notify', 'Empresa eliminada del proyecto.'); $this->dispatch('notify', 'Empresa eliminada del proyecto.');
} }
+20 -12
View File
@@ -2,14 +2,14 @@
namespace App\Livewire\Projects; 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\Feature;
use App\Models\Inspection; use App\Models\Inspection;
use App\Models\Issue; use App\Models\Issue;
use App\Models\Phase;
use App\Models\Project;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')] #[Layout('layouts.app')]
class ProjectDashboard extends Component class ProjectDashboard extends Component
@@ -18,10 +18,15 @@ class ProjectDashboard extends Component
// Computed stats (cached as properties after mount) // Computed stats (cached as properties after mount)
public array $stats = []; public array $stats = [];
public $phases; public $phases;
public $recentInspections; public $recentInspections;
public $recentIssues; public $recentIssues;
public $teamMembers; public $teamMembers;
public $companies; public $companies;
public function mount(Project $project): void public function mount(Project $project): void
@@ -34,8 +39,12 @@ class ProjectDashboard extends Component
private function checkAccess(): void private function checkAccess(): void
{ {
$user = Auth::user(); $user = Auth::user();
if ($user->can('manage all')) return; if ($user->can('manage all')) {
if (!$this->project->users()->where('user_id', $user->id)->exists()) abort(403); return;
}
if (! $this->project->users()->where('user_id', $user->id)->exists()) {
abort(403);
}
} }
private function loadData(): void private function loadData(): void
@@ -44,14 +53,14 @@ class ProjectDashboard extends Component
$this->phases = Phase::where('project_id', $pid) $this->phases = Phase::where('project_id', $pid)
->withCount('layers') ->withCount('layers')
->with(['layers' => fn($q) => $q->withCount('features')]) ->with(['layers' => fn ($q) => $q->withCount('features')])
->orderBy('order') ->orderBy('order')
->get(); ->get();
$totalFeatures = Feature::whereHas('layer.phase', fn($q) => $q->where('project_id', $pid))->count(); $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)) $completedFeatures = Feature::whereHas('layer.phase', fn ($q) => $q->where('project_id', $pid))
->where('status', 'completed')->count(); ->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(); ->where('status', 'verified')->count();
$openIssues = Issue::where('project_id', $pid)->where('status', 'open')->count(); $openIssues = Issue::where('project_id', $pid)->where('status', 'open')->count();
@@ -64,8 +73,7 @@ class ProjectDashboard extends Component
$globalProgress = $this->phases->avg('progress_percent') ?? 0; $globalProgress = $this->phases->avg('progress_percent') ?? 0;
$delayedPhases = $this->phases->filter(fn($p) => $delayedPhases = $this->phases->filter(fn ($p) => $p->planned_end && $p->planned_end < now() && $p->progress_percent < 100
$p->planned_end && $p->planned_end < now() && $p->progress_percent < 100
)->count(); )->count();
$this->stats = [ $this->stats = [
+12 -10
View File
@@ -4,6 +4,7 @@ namespace App\Livewire\Projects;
use App\Models\Feature; use App\Models\Feature;
use App\Models\FeatureType; use App\Models\FeatureType;
use App\Models\Layer;
use App\Models\Project; use App\Models\Project;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@@ -37,6 +38,7 @@ class ProjectFeaturesTable extends DataTableComponent
private function canManage(): bool private function canManage(): bool
{ {
$user = Auth::user(); $user = Auth::user();
return $user->can('manage all') || return $user->can('manage all') ||
($user->can('edit layers') && ($user->can('edit layers') &&
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());
@@ -57,7 +59,7 @@ class ProjectFeaturesTable extends DataTableComponent
Column::make('Elemento', 'name') Column::make('Elemento', 'name')
->sortable()->searchable() ->sortable()->searchable()
->secondaryHeaderFilter('name') ->secondaryHeaderFilter('name')
->format(fn ($value) => '<span class="font-medium">' . e($value) . '</span>') ->format(fn ($value) => '<span class="font-medium">'.e($value).'</span>')
->html(), ->html(),
Column::make('Capa') Column::make('Capa')
@@ -70,7 +72,7 @@ class ProjectFeaturesTable extends DataTableComponent
Column::make('Tipo') Column::make('Tipo')
->secondaryHeaderFilter('type') ->secondaryHeaderFilter('type')
->label(fn ($row) => $row->featureType ->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="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>') : '<span class="text-base-content/30 text-xs">—</span>')
->html(), ->html(),
@@ -78,19 +80,19 @@ class ProjectFeaturesTable extends DataTableComponent
->sortable() ->sortable()
->label(function ($row) { ->label(function ($row) {
if ($row->is_active) { 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-success badge-sm" title="Desactivar">Activo</button>';
} }
return '<button wire:click="toggleActive(' . $row->id . ')" class="badge badge-ghost badge-sm" title="Activar">Inactivo</button>';
return '<button wire:click="toggleActive('.$row->id.')" class="badge badge-ghost badge-sm" title="Activar">Inactivo</button>';
}) })
->html(), ->html(),
Column::make('Acciones') Column::make('Acciones')
->label(fn ($row) => ->label(fn ($row) => '<div class="flex justify-end gap-1">
'<div class="flex justify-end gap-1"> <button wire:click="$dispatch(\'feature-edit\', { id: '.$row->id.' })" class="btn btn-xs btn-ghost" title="Editar">
<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> <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>
<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"> 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> <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> </button>
@@ -105,10 +107,10 @@ class ProjectFeaturesTable extends DataTableComponent
return [ return [
TextFilter::make('Elemento', 'name') 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') 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)), ->filter(fn (Builder $q, string $v) => $q->where('features.layer_id', $v)),
SelectFilter::make('Tipo', 'type') SelectFilter::make('Tipo', 'type')
+15 -5
View File
@@ -2,12 +2,12 @@
namespace App\Livewire\Projects; namespace App\Livewire\Projects;
use Livewire\Component;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use App\Models\Project; use App\Models\Project;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
#[Layout('layouts.app')] #[Layout('layouts.app')]
class ProjectForm extends Component class ProjectForm extends Component
@@ -16,17 +16,23 @@ class ProjectForm extends Component
// Identification // Identification
public string $name = ''; public string $name = '';
public string $reference = ''; public string $reference = '';
public string $status = 'planning'; public string $status = 'planning';
// Location // Location
public string $address = ''; public string $address = '';
public string $country = ''; public string $country = '';
public string $lat = ''; public string $lat = '';
public string $lng = ''; public string $lng = '';
// Planning // Planning
public string $startDate = ''; public string $startDate = '';
public string $endDateEstimated = ''; public string $endDateEstimated = '';
public function mount(?Project $project = null): void public function mount(?Project $project = null): void
@@ -56,8 +62,12 @@ class ProjectForm extends Component
{ {
$this->lat = $lat; $this->lat = $lat;
$this->lng = $lng; $this->lng = $lng;
if ($address) $this->address = $address; if ($address) {
if ($country) $this->country = strtolower($country); $this->address = $address;
}
if ($country) {
$this->country = strtolower($country);
}
} }
protected function rules(): array protected function rules(): array
+6 -4
View File
@@ -2,11 +2,11 @@
namespace App\Livewire\Projects; namespace App\Livewire\Projects;
use Livewire\Component;
use Livewire\WithPagination;
use Livewire\Attributes\Layout;
use App\Models\Project; use App\Models\Project;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithPagination;
#[Layout('layouts.app')] #[Layout('layouts.app')]
class ProjectList extends Component class ProjectList extends Component
@@ -14,6 +14,7 @@ class ProjectList extends Component
use WithPagination; use WithPagination;
public $search = ''; public $search = '';
public $statusFilter = ''; public $statusFilter = '';
public function deleteProject($id) public function deleteProject($id)
@@ -29,12 +30,13 @@ class ProjectList extends Component
{ {
$query = Project::accessibleBy(Auth::user()); $query = Project::accessibleBy(Auth::user());
if ($this->search) { if ($this->search) {
$query->where('name', 'like', '%' . $this->search . '%'); $query->where('name', 'like', '%'.$this->search.'%');
} }
if ($this->statusFilter) { if ($this->statusFilter) {
$query->where('status', $this->statusFilter); $query->where('status', $this->statusFilter);
} }
$projects = $query->with('phases')->latest()->paginate(10); $projects = $query->with('phases')->latest()->paginate(10);
return view('livewire.projects.project-list', ['projects' => $projects]); return view('livewire.projects.project-list', ['projects' => $projects]);
} }
} }
+182 -66
View File
@@ -2,63 +2,86 @@
namespace App\Livewire\Projects; 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\Feature;
use App\Models\Inspection; use App\Models\Inspection;
use App\Models\InspectionTemplate; use App\Models\InspectionTemplate;
use App\Models\Issue; use App\Models\Issue;
use App\Models\Layer;
use App\Models\Media; 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 class ProjectMap extends Component
{ {
use WithFileUploads; use WithFileUploads;
public Project $project; public Project $project;
public $phases; public $phases;
public $activeLayers = []; // Now stores Layer IDs (not Phase IDs) public $activeLayers = []; // Now stores Layer IDs (not Phase IDs)
public $showLayerModal = false; public $showLayerModal = false;
// Editor properties // Editor properties
public $selectedFeature = null; public $selectedFeature = null;
public $selectedPhaseId = null; public $selectedPhaseId = null;
public $editProgress = 0; public $editProgress = 0;
public $editComment = ''; public $editComment = '';
public $editResponsible = ''; public $editResponsible = '';
public $editPhotos = []; public $editPhotos = [];
public $formFullscreen = false; public $formFullscreen = false;
// Tab management // Tab management
public $activeTab = 'edit'; public $activeTab = 'edit';
public $allFeatures; public $allFeatures;
public $allInspections; public $allInspections;
// Templates e inspecciones // Templates e inspecciones
public $templates = []; public $templates = [];
public $selectedTemplateId = null; public $selectedTemplateId = null;
public $inspectionFormData = []; public $inspectionFormData = [];
public $inspectionHistory = []; public $inspectionHistory = [];
// Imágenes en mapa // Imágenes en mapa
public $showFeatureImages = false; public $showFeatureImages = false;
public $featureImageMarkers = []; public $featureImageMarkers = [];
// Filters // Filters
public $filterStatus = ''; public $filterStatus = '';
public $filterResponsible = ''; public $filterResponsible = '';
public $filterProgressMin = 0; public $filterProgressMin = 0;
public $filterProgressMax = 100; public $filterProgressMax = 100;
public $showFilters = false; public $showFilters = false;
// Inspection workflow // Inspection workflow
public $inspectionResult = ''; public $inspectionResult = '';
public $inspectionNotes = ''; public $inspectionNotes = '';
public $inspectionPhotos = []; public $inspectionPhotos = [];
// Issues // Issues
@@ -69,10 +92,15 @@ class ProjectMap extends Component
// Inspection editor (para editar inspecciones existentes) // Inspection editor (para editar inspecciones existentes)
public $editingInspection = null; public $editingInspection = null;
public $editInspectionFormData = []; public $editInspectionFormData = [];
public $editInspectionResult = ''; public $editInspectionResult = '';
public $editInspectionNotes = ''; public $editInspectionNotes = '';
public $editInspectionPhotos = []; public $editInspectionPhotos = [];
public $editInspectionPhotosToDelete = []; public $editInspectionPhotosToDelete = [];
public function mount(Project $project) public function mount(Project $project)
@@ -81,20 +109,20 @@ class ProjectMap extends Component
$this->authorizeProjectAccess(); $this->authorizeProjectAccess();
$this->phases = $project->phases()->with([ $this->phases = $project->phases()->with([
'layers' => fn($q) => $q->withCount('features'), 'layers' => fn ($q) => $q->withCount('features'),
'layers.features', 'layers.features',
'layers.features.images', 'layers.features.images',
])->get(); ])->get();
// Initialize activeLayers with ALL layer IDs (not phase IDs) // Initialize activeLayers with ALL layer IDs (not phase IDs)
$this->activeLayers = $this->phases $this->activeLayers = $this->phases
->flatMap(fn($p) => $p->layers->pluck('id')) ->flatMap(fn ($p) => $p->layers->pluck('id'))
->map(fn($id) => (int) $id) ->map(fn ($id) => (int) $id)
->toArray(); ->toArray();
$this->loadTemplates(); $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); $q->where('project_id', $project->id);
})->with(['layer.phase', 'template'])->get(); })->with(['layer.phase', 'template'])->get();
@@ -111,8 +139,12 @@ class ProjectMap extends Component
private function authorizeProjectAccess(): void private function authorizeProjectAccess(): void
{ {
$user = Auth::user(); $user = Auth::user();
if ($user->can('manage all')) return; if ($user->can('manage all')) {
if (!$this->project->users()->where('user_id', $user->id)->exists()) abort(403); return;
}
if (! $this->project->users()->where('user_id', $user->id)->exists()) {
abort(403);
}
} }
public function loadTemplates() public function loadTemplates()
@@ -139,9 +171,11 @@ class ProjectMap extends Component
public function togglePhase($phaseId) public function togglePhase($phaseId)
{ {
$phase = $this->phases->find($phaseId); $phase = $this->phases->find($phaseId);
if (!$phase) return; if (! $phase) {
$layerIds = $phase->layers->pluck('id')->map(fn($id) => (int) $id)->toArray(); return;
$allActive = !empty($layerIds) && collect($layerIds)->every(fn($id) => in_array($id, $this->activeLayers)); }
$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) { if ($allActive) {
$this->activeLayers = array_values(array_diff($this->activeLayers, $layerIds)); $this->activeLayers = array_values(array_diff($this->activeLayers, $layerIds));
} else { } else {
@@ -150,22 +184,51 @@ class ProjectMap extends Component
$this->dispatch('layersUpdated', $this->activeLayers); $this->dispatch('layersUpdated', $this->activeLayers);
} }
public function openLayerModal() { $this->showLayerModal = true; } public function openLayerModal()
public function closeLayerModal() { $this->showLayerModal = false; } {
$this->showLayerModal = true;
}
public function closeLayerModal()
{
$this->showLayerModal = false;
}
// ─── Filters ──────────────────────────────────────────────────────────────── // ─── Filters ────────────────────────────────────────────────────────────────
public function updatedFilterStatus() { $this->applyFilters(); } public function updatedFilterStatus()
public function updatedFilterResponsible() { $this->applyFilters(); } {
public function updatedFilterProgressMin() { $this->applyFilters(); } $this->applyFilters();
public function updatedFilterProgressMax() { $this->applyFilters(); } }
public function updatedFilterResponsible()
{
$this->applyFilters();
}
public function updatedFilterProgressMin()
{
$this->applyFilters();
}
public function updatedFilterProgressMax()
{
$this->applyFilters();
}
public function applyFilters() public function applyFilters()
{ {
$filtered = $this->allFeatures->filter(function($f) { $filtered = $this->allFeatures->filter(function ($f) {
if ($this->filterStatus && $f->status !== $this->filterStatus) return false; if ($this->filterStatus && $f->status !== $this->filterStatus) {
if ($this->filterResponsible && !str_contains(strtolower($f->responsible ?? ''), strtolower($this->filterResponsible))) return false; return false;
if ($f->progress < $this->filterProgressMin || $f->progress > $this->filterProgressMax) 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; return true;
}); });
$this->dispatch('filtersChanged', $filtered->pluck('id')->values()->toArray()); $this->dispatch('filtersChanged', $filtered->pluck('id')->values()->toArray());
@@ -184,16 +247,24 @@ class ProjectMap extends Component
public function editFeatureStatus($status) public function editFeatureStatus($status)
{ {
if (!$this->selectedFeature) return; if (! $this->selectedFeature) {
return;
}
$feature = Feature::with('layer.phase')->findOrFail($this->selectedFeature->id); $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; $feature->status = $status;
if ($status === 'completed') $feature->progress = 100; if ($status === 'completed') {
if ($status === 'planned') $feature->progress = 0; $feature->progress = 100;
}
if ($status === 'planned') {
$feature->progress = 0;
}
$feature->save(); $feature->save();
$this->selectedFeature = $feature; $this->selectedFeature = $feature;
$this->editProgress = $feature->progress; $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('featureStatusChanged', $feature->id, $feature->status, $feature->status_color);
$this->dispatch('notify', 'Estado actualizado'); $this->dispatch('notify', 'Estado actualizado');
} }
@@ -202,11 +273,14 @@ class ProjectMap extends Component
{ {
$feature = Feature::with('layer.phase')->findOrFail($featureId); $feature = Feature::with('layer.phase')->findOrFail($featureId);
$user = Auth::user(); $user = Auth::user();
if (!$user->can('update progress')) { if (! $user->can('update progress')) {
$this->dispatch('notify', 'Sin permisos'); $this->dispatch('notify', 'Sin permisos');
return; 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->progress = min(100, max(0, $newProgress));
$feature->save(); $feature->save();
$phase = $feature->layer->phase; $phase = $feature->layer->phase;
@@ -228,17 +302,25 @@ class ProjectMap extends Component
#[On('map-select-feature')] #[On('map-select-feature')]
public function selectFeature($featureId) 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 } // Handle both formats: direct ID or { featureId: X }
if (is_array($featureId) && isset($featureId['featureId'])) { if (is_array($featureId) && isset($featureId['featureId'])) {
$featureId = $featureId['featureId']; $featureId = $featureId['featureId'];
\Log::info('[ProjectMap] Extracted featureId from array', ['featureId' => $featureId]);
} }
$this->selectedFeature = null; $this->selectedFeature = null;
$feature = Feature::with(['template', 'layer.phase'])->find($featureId); $feature = Feature::with(['template', 'layer.phase'])->find($featureId);
if (!$feature) return; if (! $feature) {
if ($feature->layer->phase->project_id !== $this->project->id) abort(403); \Log::warning('[ProjectMap] Feature not found', ['featureId' => $featureId]);
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->selectedFeature = $feature;
$this->selectedPhaseId = $feature->layer->phase_id; $this->selectedPhaseId = $feature->layer->phase_id;
@@ -256,8 +338,9 @@ class ProjectMap extends Component
public function loadInspectionHistory() public function loadInspectionHistory()
{ {
if (!$this->selectedFeature) { if (! $this->selectedFeature) {
$this->inspectionHistory = []; $this->inspectionHistory = [];
return; return;
} }
$this->inspectionHistory = Inspection::where('feature_id', $this->selectedFeature->id) $this->inspectionHistory = Inspection::where('feature_id', $this->selectedFeature->id)
@@ -284,19 +367,23 @@ class ProjectMap extends Component
public function saveInspection() public function saveInspection()
{ {
if (!$this->selectedFeature || !$this->selectedTemplateId) { if (! $this->selectedFeature || ! $this->selectedTemplateId) {
$this->dispatch('notify', 'Selecciona un elemento y un template.'); $this->dispatch('notify', 'Selecciona un elemento y un template.');
return; return;
} }
// Verificar permiso // Verificar permiso
if (!auth()->user()->can('create inspections')) { if (! auth()->user()->can('create inspections')) {
$this->dispatch('notify', 'Sin permisos para crear inspecciones.'); $this->dispatch('notify', 'Sin permisos para crear inspecciones.');
return; return;
} }
$feature = Feature::with('layer.phase')->find($this->selectedFeature->id); $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([ $this->validate([
'selectedTemplateId' => 'required|exists:inspection_templates,id', 'selectedTemplateId' => 'required|exists:inspection_templates,id',
@@ -307,6 +394,7 @@ class ProjectMap extends Component
foreach ($template->fields as $field) { foreach ($template->fields as $field) {
if (($field['required'] ?? false) && empty($this->inspectionFormData[$field['name']])) { if (($field['required'] ?? false) && empty($this->inspectionFormData[$field['name']])) {
$this->dispatch('notify', "El campo {$field['label']} es obligatorio."); $this->dispatch('notify', "El campo {$field['label']} es obligatorio.");
return; return;
} }
} }
@@ -337,7 +425,7 @@ class ProjectMap extends Component
'file_size' => $photo->getSize(), 'file_size' => $photo->getSize(),
'category' => str_starts_with($mime, 'image/') ? 'image' : 'document', 'category' => str_starts_with($mime, 'image/') ? 'image' : 'document',
'uploaded_by' => auth()->id(), 'uploaded_by' => auth()->id(),
'uuid' => (string) \Illuminate\Support\Str::uuid(), 'uuid' => (string) Str::uuid(),
]); ]);
} }
@@ -346,7 +434,7 @@ class ProjectMap extends Component
'project_id' => $this->project->id, 'project_id' => $this->project->id,
'feature_id' => $this->selectedFeature->id, 'feature_id' => $this->selectedFeature->id,
'inspection_id' => $inspection->id, 'inspection_id' => $inspection->id,
'title' => 'Fallo en inspección: ' . ($template->name ?? 'Sin nombre'), 'title' => 'Fallo en inspección: '.($template->name ?? 'Sin nombre'),
'description' => $this->inspectionNotes, 'description' => $this->inspectionNotes,
'priority' => 'high', 'priority' => 'high',
'status' => 'open', 'status' => 'open',
@@ -357,7 +445,7 @@ class ProjectMap extends Component
$this->dispatch('notify', 'Inspección fallida — Issue creado automáticamente'); $this->dispatch('notify', 'Inspección fallida — Issue creado automáticamente');
} else { } else {
if (isset($this->inspectionFormData['progress'])) { 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'); $this->dispatch('notify', 'Inspección guardada correctamente');
} }
@@ -367,7 +455,7 @@ class ProjectMap extends Component
->where('user_id', '!=', auth()->id()) ->where('user_id', '!=', auth()->id())
->get(); ->get();
foreach ($usersToNotify as $user) { foreach ($usersToNotify as $user) {
$user->notify(new \App\Notifications\InspectionCompletedNotification($inspection)); $user->notify(new InspectionCompletedNotification($inspection));
} }
// Reload global list // Reload global list
@@ -382,10 +470,14 @@ class ProjectMap extends Component
public function assignTemplateToFeature($templateId) public function assignTemplateToFeature($templateId)
{ {
if (!$this->selectedFeature) return; if (! $this->selectedFeature) {
return;
}
$template = InspectionTemplate::where('id', $templateId) $template = InspectionTemplate::where('id', $templateId)
->where('project_id', $this->project->id)->first(); ->where('project_id', $this->project->id)->first();
if (!$template) abort(403); if (! $template) {
abort(403);
}
$feature = Feature::findOrFail($this->selectedFeature->id); $feature = Feature::findOrFail($this->selectedFeature->id);
$feature->template_id = $templateId; $feature->template_id = $templateId;
$feature->save(); $feature->save();
@@ -397,10 +489,14 @@ class ProjectMap extends Component
public function saveFeatureProgress() public function saveFeatureProgress()
{ {
if (!$this->selectedFeature) return; if (! $this->selectedFeature) {
return;
}
$feature = Feature::with('layer.phase')->findOrFail($this->selectedFeature->id); $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) {
$feature->progress = min(100, max(0, (int)$this->editProgress)); abort(403);
}
$feature->progress = min(100, max(0, (int) $this->editProgress));
$feature->responsible = $this->editResponsible; $feature->responsible = $this->editResponsible;
$feature->save(); $feature->save();
$this->selectedFeature = $feature; $this->selectedFeature = $feature;
@@ -424,7 +520,9 @@ class ProjectMap extends Component
$ins = Inspection::where('project_id', $this->project->id) $ins = Inspection::where('project_id', $this->project->id)
->with(['feature.layer.phase', 'template', 'user', 'media']) ->with(['feature.layer.phase', 'template', 'user', 'media'])
->find($id); ->find($id);
if (!$ins) return; if (! $ins) {
return;
}
$this->viewingInspection = [ $this->viewingInspection = [
'id' => $ins->id, 'id' => $ins->id,
'feature_name' => $ins->feature?->name ?? '—', 'feature_name' => $ins->feature?->name ?? '—',
@@ -455,7 +553,9 @@ class ProjectMap extends Component
$ins = Inspection::where('project_id', $this->project->id) $ins = Inspection::where('project_id', $this->project->id)
->with(['feature.layer.phase', 'template', 'user', 'media']) ->with(['feature.layer.phase', 'template', 'user', 'media'])
->find($id); ->find($id);
if (!$ins) return; if (! $ins) {
return;
}
$this->editingInspection = $ins; $this->editingInspection = $ins;
$this->editInspectionFormData = $ins->data ?? []; $this->editInspectionFormData = $ins->data ?? [];
@@ -480,11 +580,13 @@ class ProjectMap extends Component
public function deleteEditPhoto($mediaIndex) public function deleteEditPhoto($mediaIndex)
{ {
if (!$this->editingInspection) return; if (! $this->editingInspection) {
return;
}
$media = $this->editingInspection->media; $media = $this->editingInspection->media;
if (isset($media[$mediaIndex])) { if (isset($media[$mediaIndex])) {
$m = $media[$mediaIndex]; $m = $media[$mediaIndex];
if (!in_array($m->id, $this->editInspectionPhotosToDelete)) { if (! in_array($m->id, $this->editInspectionPhotosToDelete)) {
$this->editInspectionPhotosToDelete[] = $m->id; $this->editInspectionPhotosToDelete[] = $m->id;
} }
} }
@@ -492,11 +594,14 @@ class ProjectMap extends Component
public function saveEditInspection() public function saveEditInspection()
{ {
if (!$this->editingInspection) return; if (! $this->editingInspection) {
return;
}
// Verificar permiso // Verificar permiso
if (!auth()->user()->can('edit inspections')) { if (! auth()->user()->can('edit inspections')) {
$this->dispatch('notify', 'Sin permisos para editar inspecciones.'); $this->dispatch('notify', 'Sin permisos para editar inspecciones.');
return; return;
} }
@@ -508,18 +613,21 @@ class ProjectMap extends Component
]); ]);
$ins = $this->editingInspection; $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); $template = InspectionTemplate::find($this->selectedTemplateId);
foreach ($template->fields as $field) { foreach ($template->fields as $field) {
if (($field['required'] ?? false) && empty($this->editInspectionFormData[$field['name']])) { if (($field['required'] ?? false) && empty($this->editInspectionFormData[$field['name']])) {
$this->dispatch('notify', "El campo {$field['label']} es obligatorio."); $this->dispatch('notify', "El campo {$field['label']} es obligatorio.");
return; return;
} }
} }
// Eliminar fotos marcadas // Eliminar fotos marcadas
if (!empty($this->editInspectionPhotosToDelete)) { if (! empty($this->editInspectionPhotosToDelete)) {
$mediaToDelete = Media::whereIn('id', $this->editInspectionPhotosToDelete) $mediaToDelete = Media::whereIn('id', $this->editInspectionPhotosToDelete)
->where('mediable_type', Inspection::class) ->where('mediable_type', Inspection::class)
->where('mediable_id', $ins->id) ->where('mediable_id', $ins->id)
@@ -549,7 +657,7 @@ class ProjectMap extends Component
'file_size' => $photo->getSize(), 'file_size' => $photo->getSize(),
'category' => str_starts_with($mime, 'image/') ? 'image' : 'document', 'category' => str_starts_with($mime, 'image/') ? 'image' : 'document',
'uploaded_by' => auth()->id(), 'uploaded_by' => auth()->id(),
'uuid' => (string) \Illuminate\Support\Str::uuid(), 'uuid' => (string) Str::uuid(),
]); ]);
} }
@@ -571,18 +679,20 @@ class ProjectMap extends Component
{ {
\Log::info('deleteInspection: START', ['id' => $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.'); $this->dispatch('notify', 'Sin permisos para eliminar inspecciones.');
\Log::info('deleteInspection: permission denied'); \Log::info('deleteInspection: permission denied');
return; return;
} }
$ins = Inspection::where('project_id', $this->project->id) $ins = Inspection::where('project_id', $this->project->id)
->with(['feature', 'media']) ->with(['feature', 'media'])
->find($id); ->find($id);
if (!$ins) { if (! $ins) {
\Log::info('deleteInspection: inspection not found', ['id' => $id]); \Log::info('deleteInspection: inspection not found', ['id' => $id]);
$this->dispatch('notify', 'Inspección no encontrada'); $this->dispatch('notify', 'Inspección no encontrada');
return; return;
} }
@@ -612,7 +722,7 @@ class ProjectMap extends Component
->where('user_id', '!=', auth()->id()) ->where('user_id', '!=', auth()->id())
->get(); ->get();
foreach ($usersToNotify as $user) { foreach ($usersToNotify as $user) {
$user->notify(new \App\Notifications\InspectionDeletedNotification($ins)); $user->notify(new InspectionDeletedNotification($ins));
} }
} }
@@ -620,14 +730,18 @@ class ProjectMap extends Component
public function toggleFeatureImages() public function toggleFeatureImages()
{ {
$this->showFeatureImages = !$this->showFeatureImages; $this->showFeatureImages = ! $this->showFeatureImages;
$this->loadFeatureImageMarkers(); $this->loadFeatureImageMarkers();
$this->dispatch('featureImagesToggled', $this->showFeatureImages, $this->featureImageMarkers); $this->dispatch('featureImagesToggled', $this->showFeatureImages, $this->featureImageMarkers);
} }
public function loadFeatureImageMarkers() public function loadFeatureImageMarkers()
{ {
if (!$this->showFeatureImages) { $this->featureImageMarkers = []; return; } if (! $this->showFeatureImages) {
$this->featureImageMarkers = [];
return;
}
$markers = []; $markers = [];
foreach ($this->phases as $phase) { foreach ($this->phases as $phase) {
foreach ($phase->layers as $layer) { foreach ($phase->layers as $layer) {
@@ -662,8 +776,10 @@ class ProjectMap extends Component
public function toggleFullscreen() public function toggleFullscreen()
{ {
$this->formFullscreen = !$this->formFullscreen; $this->formFullscreen = ! $this->formFullscreen;
if (!$this->formFullscreen) $this->dispatch('mapResize'); if (! $this->formFullscreen) {
$this->dispatch('mapResize');
}
} }
public function setActiveTab($tab) public function setActiveTab($tab)
+7 -3
View File
@@ -2,11 +2,11 @@
namespace App\Livewire\Projects; namespace App\Livewire\Projects;
use Rappasoft\LaravelLivewireTables\DataTableComponent; use App\Models\Project;
use Rappasoft\LaravelLivewireTables\Views\Column;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use App\Models\Project; use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
class ProjectTable extends DataTableComponent class ProjectTable extends DataTableComponent
{ {
@@ -34,6 +34,7 @@ class ProjectTable extends DataTableComponent
->searchable() ->searchable()
->format(function ($value, $row) { ->format(function ($value, $row) {
$url = route('projects.dashboard', $row->id); $url = route('projects.dashboard', $row->id);
return $value return $value
? '<a href="'.$url.'" class="font-mono text-xs text-primary hover:underline" wire:navigate>'.e($value).'</a>' ? '<a href="'.$url.'" class="font-mono text-xs text-primary hover:underline" wire:navigate>'.e($value).'</a>'
: '<span class="text-gray-300">—</span>'; : '<span class="text-gray-300">—</span>';
@@ -62,6 +63,7 @@ class ProjectTable extends DataTableComponent
'completed' => ['badge-success', 'Completado'], 'completed' => ['badge-success', 'Completado'],
]; ];
[$cls, $label] = $map[$value] ?? ['badge-ghost', ucfirst($value)]; [$cls, $label] = $map[$value] ?? ['badge-ghost', ucfirst($value)];
return '<span class="badge '.$cls.'">'.$label.'</span>'; return '<span class="badge '.$cls.'">'.$label.'</span>';
}) })
->html(), ->html(),
@@ -70,6 +72,7 @@ class ProjectTable extends DataTableComponent
->label(function ($row) { ->label(function ($row) {
$avg = $row->phases->avg('progress_percent') ?? 0; $avg = $row->phases->avg('progress_percent') ?? 0;
$pct = round($avg); $pct = round($avg);
return ' return '
<div class="flex items-center gap-2 min-w-[100px]"> <div class="flex items-center gap-2 min-w-[100px]">
<div class="flex-1 bg-gray-200 rounded-full h-2"> <div class="flex-1 bg-gray-200 rounded-full h-2">
@@ -109,6 +112,7 @@ class ProjectTable extends DataTableComponent
</a>'; </a>';
} }
$html .= '</div>'; $html .= '</div>';
return $html; return $html;
}) })
->html(), ->html(),
@@ -12,7 +12,9 @@ use Livewire\Component;
class ProjectTemplatesPicker extends Component class ProjectTemplatesPicker extends Component
{ {
public Project $project; public Project $project;
public array $assignedIds = []; public array $assignedIds = [];
public string $search = ''; public string $search = '';
public function mount(Project $project) public function mount(Project $project)
@@ -41,7 +43,7 @@ class ProjectTemplatesPicker extends Component
public function render() public function render()
{ {
$templates = InspectionTemplate::query() $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(); ->orderBy('name')->get();
return view('livewire.projects.project-templates-picker', [ return view('livewire.projects.project-templates-picker', [
+4 -1
View File
@@ -11,8 +11,11 @@ use Livewire\Component;
class ProjectUsers extends Component class ProjectUsers extends Component
{ {
public Project $project; public Project $project;
public $allUsers = []; public $allUsers = [];
public $selectedUserId = ''; public $selectedUserId = '';
public $selectedRole = 'viewer'; public $selectedRole = 'viewer';
public function mount(Project $project) public function mount(Project $project)
@@ -41,7 +44,7 @@ class ProjectUsers extends Component
$this->validate([ $this->validate([
'selectedUserId' => 'required|exists:users,id', '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, [ $this->project->users()->attach($this->selectedUserId, [
+6 -2
View File
@@ -2,6 +2,7 @@
namespace App\Livewire\Projects; namespace App\Livewire\Projects;
use App\Models\Project;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@@ -53,6 +54,7 @@ class ProjectUsersTable extends DataTableComponent
->searchable() ->searchable()
->format(function ($value, $row) { ->format(function ($value, $row) {
$initial = strtoupper(mb_substr($value ?? '?', 0, 1)); $initial = strtoupper(mb_substr($value ?? '?', 0, 1));
return '<div class="flex items-center gap-2"> 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="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> <span class="font-medium">'.e($value).'</span>
@@ -74,6 +76,7 @@ class ProjectUsersTable extends DataTableComponent
foreach (self::ROLES as $val => $label) { foreach (self::ROLES as $val => $label) {
$opts .= '<option value="'.$val.'"'.($current === $val ? ' selected' : '').'>'.$label.'</option>'; $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>'; return '<select wire:change="changeRole('.$row->id.', $event.target.value)" class="select select-bordered select-xs">'.$opts.'</select>';
}) })
->html(), ->html(),
@@ -83,6 +86,7 @@ class ProjectUsersTable extends DataTableComponent
if (! Auth::user()->can('assign users')) { if (! Auth::user()->can('assign users')) {
return ''; return '';
} }
return '<div class="flex justify-end"> return '<div class="flex justify-end">
<button wire:click="removeUser('.$row->id.')" wire:confirm="¿Quitar a '.e($row->name).' del proyecto?" <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"> class="btn btn-xs btn-error btn-outline" title="Quitar del proyecto">
@@ -109,7 +113,7 @@ class ProjectUsersTable extends DataTableComponent
if (! array_key_exists($role, self::ROLES)) { if (! array_key_exists($role, self::ROLES)) {
return; return;
} }
\App\Models\Project::findOrFail($this->projectId) Project::findOrFail($this->projectId)
->users()->updateExistingPivot($userId, ['role_in_project' => $role]); ->users()->updateExistingPivot($userId, ['role_in_project' => $role]);
$this->dispatch('project-users-changed'); $this->dispatch('project-users-changed');
$this->dispatch('notify', 'Rol actualizado.'); $this->dispatch('notify', 'Rol actualizado.');
@@ -118,7 +122,7 @@ class ProjectUsersTable extends DataTableComponent
public function removeUser($userId): void public function removeUser($userId): void
{ {
abort_unless(Auth::user()->can('assign users'), 403); 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('project-users-changed');
$this->dispatch('notify', 'Usuario eliminado del proyecto.'); $this->dispatch('notify', 'Usuario eliminado del proyecto.');
} }
+2 -1
View File
@@ -21,6 +21,7 @@ class ReportBuilder extends Component
]; ];
public bool $showPreview = false; public bool $showPreview = false;
public ?array $previewData = null; public ?array $previewData = null;
public function mount(Project $project) public function mount(Project $project)
@@ -32,7 +33,7 @@ class ReportBuilder extends Component
public function authorizeAccess(): void public function authorizeAccess(): void
{ {
$user = auth()->guard()->user(); $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); abort(403);
} }
} }
+15 -14
View File
@@ -2,17 +2,18 @@
namespace App\Livewire\Reports; 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\Inspection;
use App\Models\Phase;
use App\Models\Project;
use Carbon\Carbon; use Carbon\Carbon;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')] #[Layout('layouts.app')]
class ReportsDashboard extends Component class ReportsDashboard extends Component
{ {
public $dateRange = 'month'; // week, month, quarter, year public $dateRange = 'month'; // week, month, quarter, year
public $chartData = []; public $chartData = [];
public function mount() public function mount()
@@ -23,7 +24,7 @@ class ReportsDashboard extends Component
public function loadChartData() public function loadChartData()
{ {
// Project progress over time (last 6 months) // 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'); $query->select('project_id', 'progress_percent', 'updated_at');
}])->get(); }])->get();
@@ -49,7 +50,7 @@ class ReportsDashboard extends Component
} }
$projectProgress[] = [ $projectProgress[] = [
'name' => $project->name, 'name' => $project->name,
'data' => $progressData 'data' => $progressData,
]; ];
} }
@@ -58,9 +59,9 @@ class ReportsDashboard extends Component
->whereDate('created_at', '>=', Carbon::now()->subMonths(6)) ->whereDate('created_at', '>=', Carbon::now()->subMonths(6))
->get(); ->get();
$inspectionTypes = $inspections->groupBy(function($inspection) { $inspectionTypes = $inspections->groupBy(function ($inspection) {
return $inspection->template ? $inspection->template->name : 'Sin plantilla'; return $inspection->template ? $inspection->template->name : 'Sin plantilla';
})->map(function($group) { })->map(function ($group) {
return $group->count(); return $group->count();
}); });
@@ -73,10 +74,10 @@ class ReportsDashboard extends Component
// Average phase progress by project // Average phase progress by project
$projectPhaseProgress = Project::with(['phases']) $projectPhaseProgress = Project::with(['phases'])
->get() ->get()
->map(function($project) { ->map(function ($project) {
return [ return [
'name' => $project->name, 'name' => $project->name,
'progress' => $project->phases->avg('progress_percent') ?? 0 'progress' => $project->phases->avg('progress_percent') ?? 0,
]; ];
}); });
@@ -85,15 +86,15 @@ class ReportsDashboard extends Component
'projectProgress' => $projectProgress, 'projectProgress' => $projectProgress,
'inspectionTypes' => [ 'inspectionTypes' => [
'labels' => $inspectionTypes->keys()->toArray(), 'labels' => $inspectionTypes->keys()->toArray(),
'data' => $inspectionTypes->values()->toArray() 'data' => $inspectionTypes->values()->toArray(),
], ],
'projectsByStatus' => [ 'projectsByStatus' => [
'labels' => array_map(function($status) { 'labels' => array_map(function ($status) {
return ucfirst(str_replace('_', ' ', $status)); return ucfirst(str_replace('_', ' ', $status));
}, array_keys($projectsByStatus)), }, array_keys($projectsByStatus)),
'data' => array_values($projectsByStatus) 'data' => array_values($projectsByStatus),
], ],
'projectPhaseProgress' => $projectPhaseProgress 'projectPhaseProgress' => $projectPhaseProgress,
]; ];
} }
+7 -6
View File
@@ -2,14 +2,15 @@
namespace App\Livewire\Users; namespace App\Livewire\Users;
use Livewire\Component;
use App\Models\User; use App\Models\User;
use Spatie\Permission\Models\Role;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Component;
use Spatie\Permission\Models\Role;
class AdminUsers extends Component class AdminUsers extends Component
{ {
public string $search = ''; public string $search = '';
public $roles; public $roles;
public function mount(): void public function mount(): void
@@ -21,10 +22,9 @@ class AdminUsers extends Component
public function getUsersProperty() public function getUsersProperty()
{ {
return User::with('roles') return User::with('roles')
->when($this->search, fn($q) => ->when($this->search, fn ($q) => $q->where(fn ($q2) => $q2
$q->where(fn($q2) => $q2 ->where('name', 'like', '%'.$this->search.'%')
->where('name', 'like', '%' . $this->search . '%') ->orWhere('email', 'like', '%'.$this->search.'%')))
->orWhere('email', 'like', '%' . $this->search . '%')))
->orderBy('name') ->orderBy('name')
->get(); ->get();
} }
@@ -33,6 +33,7 @@ class AdminUsers extends Component
{ {
if ($userId === Auth::id()) { if ($userId === Auth::id()) {
$this->dispatch('notify', 'No puedes eliminarte a ti mismo.'); $this->dispatch('notify', 'No puedes eliminarte a ti mismo.');
return; return;
} }
User::findOrFail($userId)->delete(); User::findOrFail($userId)->delete();
+22 -10
View File
@@ -2,14 +2,14 @@
namespace App\Livewire\Users; namespace App\Livewire\Users;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\User;
use App\Models\Company; use App\Models\Company;
use Spatie\Permission\Models\Role; use App\Models\User;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Spatie\Permission\Models\Role;
#[Layout('layouts.app')] #[Layout('layouts.app')]
class UserForm extends Component class UserForm extends Component
@@ -18,19 +18,27 @@ class UserForm extends Component
// Información personal // Información personal
public string $title = ''; public string $title = '';
public string $lastName = ''; public string $lastName = '';
public string $firstName = ''; public string $firstName = '';
// Validación // Validación
public string $userStatus = 'active'; public string $userStatus = 'active';
public string $validFrom = ''; public string $validFrom = '';
public string $validUntil = ''; public string $validUntil = '';
public string $formPassword = ''; public string $formPassword = '';
// Contacto // Contacto
public ?int $companyId = null; public ?int $companyId = null;
public string $address = ''; public string $address = '';
public string $phone = ''; public string $phone = '';
public string $email = ''; public string $email = '';
// Permisos // Permisos
@@ -44,6 +52,7 @@ class UserForm extends Component
// Catálogos // Catálogos
public $roles; public $roles;
public $companies; public $companies;
/** Idiomas disponibles (código => nombre + archivo de bandera). */ /** Idiomas disponibles (código => nombre + archivo de bandera). */
@@ -95,10 +104,10 @@ class UserForm extends Component
'phone' => 'nullable|string|max:30', 'phone' => 'nullable|string|max:30',
'email' => "required|email|max:255|unique:users,email,{$id}", 'email' => "required|email|max:255|unique:users,email,{$id}",
'formRole' => 'required|exists:roles,name', 'formRole' => 'required|exists:roles,name',
'locale' => 'required|in:' . implode(',', array_keys($this->languages)), 'locale' => 'required|in:'.implode(',', array_keys($this->languages)),
]; ];
if (!$this->user) { if (! $this->user) {
$rules['formPassword'] = ['required', Password::min(8)->letters()->mixedCase()->numbers()]; $rules['formPassword'] = ['required', Password::min(8)->letters()->mixedCase()->numbers()];
} elseif ($this->formPassword !== '') { } elseif ($this->formPassword !== '') {
$rules['formPassword'] = [Password::min(8)->letters()->mixedCase()->numbers()]; $rules['formPassword'] = [Password::min(8)->letters()->mixedCase()->numbers()];
@@ -114,14 +123,16 @@ class UserForm extends Component
'validFrom' => 'fecha de inicio', 'validFrom' => 'fecha de inicio',
'validUntil' => 'fecha de fin', 'validUntil' => 'fecha de fin',
'companyId' => 'empresa', 'companyId' => 'empresa',
'formPassword'=> 'contraseña', 'formPassword' => 'contraseña',
'formRole' => 'rol', 'formRole' => 'rol',
'locale' => 'idioma', 'locale' => 'idioma',
]; ];
public function copyCompanyAddress(): void public function copyCompanyAddress(): void
{ {
if (!$this->companyId) return; if (! $this->companyId) {
return;
}
$company = Company::find($this->companyId); $company = Company::find($this->companyId);
if ($company?->address) { if ($company?->address) {
$this->address = $company->address; $this->address = $company->address;
@@ -135,10 +146,11 @@ class UserForm extends Component
if ($this->user && $this->user->id === Auth::id() if ($this->user && $this->user->id === Auth::id()
&& $this->user->hasRole('Admin') && $this->formRole !== 'Admin') { && $this->user->hasRole('Admin') && $this->formRole !== 'Admin') {
$this->addError('formRole', 'No puedes quitarte el rol Admin a ti mismo.'); $this->addError('formRole', 'No puedes quitarte el rol Admin a ti mismo.');
return; return;
} }
$fullName = trim($this->firstName . ' ' . $this->lastName); $fullName = trim($this->firstName.' '.$this->lastName);
$data = [ $data = [
'name' => $fullName, 'name' => $fullName,
@@ -147,7 +159,7 @@ class UserForm extends Component
'last_name' => $this->lastName, 'last_name' => $this->lastName,
'status' => $this->userStatus, 'status' => $this->userStatus,
'valid_from' => $this->validFrom ?: null, 'valid_from' => $this->validFrom ?: null,
'valid_until'=> $this->validUntil ?: null, 'valid_until' => $this->validUntil ?: null,
'company_id' => $this->companyId, 'company_id' => $this->companyId,
'address' => $this->address ?: null, 'address' => $this->address ?: null,
'phone' => $this->phone ?: null, 'phone' => $this->phone ?: null,
+14 -12
View File
@@ -2,13 +2,13 @@
namespace App\Livewire\Users; 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\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column; use Rappasoft\LaravelLivewireTables\Views\Column;
use Rappasoft\LaravelLivewireTables\Views\Filters\SelectFilter; use Rappasoft\LaravelLivewireTables\Views\Filters\SelectFilter;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Spatie\Permission\Models\Role; use Spatie\Permission\Models\Role;
use App\Models\User;
class UserTable extends DataTableComponent class UserTable extends DataTableComponent
{ {
@@ -53,13 +53,13 @@ class UserTable extends DataTableComponent
$html .= '<p class="font-semibold text-sm leading-tight">'.e($value).'</p>'; $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 .= '<p class="text-xs text-gray-500">'.e($row->email).'</p>';
$html .= '</div></div>'; $html .= '</div></div>';
return $html; return $html;
}) })
->html(), ->html(),
Column::make('Empresa') Column::make('Empresa')
->label(fn ($row) => ->label(fn ($row) => $row->company
$row->company
? '<span class="text-sm">'.e($row->company->name).'</span>' ? '<span class="text-sm">'.e($row->company->name).'</span>'
: '<span class="text-gray-300 text-sm">—</span>' : '<span class="text-gray-300 text-sm">—</span>'
) )
@@ -70,8 +70,8 @@ class UserTable extends DataTableComponent
if ($row->roles->isEmpty()) { if ($row->roles->isEmpty()) {
return '<span class="badge badge-sm badge-ghost">Sin rol</span>'; 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>' return $row->roles->map(fn ($role) => '<span class="badge badge-sm '.($role->name === 'Admin' ? 'badge-error' : 'badge-primary').'">'.e($role->name).'</span>'
)->implode(' '); )->implode(' ');
}) })
->html(), ->html(),
@@ -85,14 +85,14 @@ class UserTable extends DataTableComponent
'suspended' => ['badge-error', 'Suspendido'], 'suspended' => ['badge-error', 'Suspendido'],
]; ];
[$cls, $label] = $map[$value ?? 'active'] ?? ['badge-ghost', ucfirst($value ?? '')]; [$cls, $label] = $map[$value ?? 'active'] ?? ['badge-ghost', ucfirst($value ?? '')];
return '<span class="badge badge-sm '.$cls.'">'.$label.'</span>'; return '<span class="badge badge-sm '.$cls.'">'.$label.'</span>';
}) })
->html(), ->html(),
Column::make('Verificado', 'email_verified_at') Column::make('Verificado', 'email_verified_at')
->sortable() ->sortable()
->format(fn ($value) => ->format(fn ($value) => $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-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>' : '<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>'
) )
@@ -120,6 +120,7 @@ class UserTable extends DataTableComponent
</button>'; </button>';
} }
$html .= '</div>'; $html .= '</div>';
return $html; return $html;
}) })
->html(), ->html(),
@@ -133,8 +134,7 @@ class UserTable extends DataTableComponent
return [ return [
SelectFilter::make('Rol') SelectFilter::make('Rol')
->options($roleOptions) ->options($roleOptions)
->filter(fn (Builder $query, string $value) => ->filter(fn (Builder $query, string $value) => $query->whereHas('roles', fn ($q) => $q->where('name', $value))
$query->whereHas('roles', fn ($q) => $q->where('name', $value))
), ),
SelectFilter::make('Estado', 'status') SelectFilter::make('Estado', 'status')
@@ -150,7 +150,9 @@ class UserTable extends DataTableComponent
public function deleteUser(int $id): void public function deleteUser(int $id): void
{ {
if ($id === Auth::id()) return; if ($id === Auth::id()) {
return;
}
User::findOrFail($id)->delete(); User::findOrFail($id)->delete();
} }
} }
+10 -4
View File
@@ -2,13 +2,13 @@
namespace App\Livewire\Users; 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\Inspection;
use App\Models\Issue; use App\Models\Issue;
use App\Models\Project;
use App\Models\User;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Permission;
use Spatie\Permission\PermissionRegistrar; use Spatie\Permission\PermissionRegistrar;
@@ -16,19 +16,24 @@ use Spatie\Permission\PermissionRegistrar;
class UserView extends Component class UserView extends Component
{ {
public User $user; public User $user;
public string $activeTab = 'ficha'; public string $activeTab = 'ficha';
// Projects tab // Projects tab
public ?int $addProjectId = null; public ?int $addProjectId = null;
public string $addProjectRole = ''; public string $addProjectRole = '';
public $availableProjects; public $availableProjects;
// Notes tab // Notes tab
public string $notes = ''; public string $notes = '';
public bool $editingNotes = false; public bool $editingNotes = false;
// Recent activity (loaded once) // Recent activity (loaded once)
public $recentInspections; public $recentInspections;
public $recentIssues; public $recentIssues;
public function mount(User $user): void public function mount(User $user): void
@@ -146,6 +151,7 @@ class UserView extends Component
->groupBy(fn ($perm) => $perm->group ?: 'General') ->groupBy(fn ($perm) => $perm->group ?: 'General')
->sortBy(function ($perms, $section) use ($order) { ->sortBy(function ($perms, $section) use ($order) {
$i = array_search($section, $order, true); $i = array_search($section, $order, true);
return $i === false ? 999 : $i; return $i === false ? 999 : $i;
}); });
-1
View File
@@ -2,7 +2,6 @@
namespace App\Models; namespace App\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
+6 -4
View File
@@ -2,14 +2,15 @@
namespace App\Models; namespace App\Models;
use App\Traits\LogsActivity;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use App\Traits\LogsActivity;
class Feature extends Model class Feature extends Model
{ {
use SoftDeletes, LogsActivity; use LogsActivity, SoftDeletes;
const STATUSES = ['planned', 'started', 'in_progress', 'completed', 'verified']; const STATUSES = ['planned', 'started', 'in_progress', 'completed', 'verified'];
@@ -80,7 +81,7 @@ class Feature extends Model
public function getStatusColorAttribute(): string public function getStatusColorAttribute(): string
{ {
return match($this->status) { return match ($this->status) {
'planned' => '#6b7280', 'planned' => '#6b7280',
'started' => '#3b82f6', 'started' => '#3b82f6',
'in_progress' => '#f59e0b', 'in_progress' => '#f59e0b',
@@ -113,7 +114,7 @@ class Feature extends Model
return $this->planned_start->diffInDays($start, false); 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(); $date = $date ?? now();
@@ -152,6 +153,7 @@ class Feature extends Model
if ($spi === null) { if ($spi === null) {
return null; return null;
} }
return $spi >= 0.95; return $spi >= 0.95;
} }
} }
+17 -5
View File
@@ -2,13 +2,13 @@
namespace App\Models; namespace App\Models;
use App\Traits\LogsActivity;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use App\Traits\LogsActivity;
class Inspection extends Model class Inspection extends Model
{ {
use SoftDeletes, LogsActivity; use LogsActivity, SoftDeletes;
protected static function booted(): void protected static function booted(): void
{ {
@@ -19,6 +19,7 @@ class Inspection extends Model
} }
const STATUSES = ['pending', 'in_progress', 'completed', 'approved', 'rejected']; const STATUSES = ['pending', 'in_progress', 'completed', 'approved', 'rejected'];
const RESULTS = ['pass', 'fail', 'conditional']; const RESULTS = ['pass', 'fail', 'conditional'];
protected $fillable = [ protected $fillable = [
@@ -72,7 +73,18 @@ class Inspection extends Model
return $this->hasMany(Issue::class); return $this->hasMany(Issue::class);
} }
public function scopePending($q) { return $q->where('status', 'pending'); } public function scopePending($q)
public function scopeCompleted($q) { return $q->where('status', 'completed'); } {
public function scopeRejected($q) { return $q->where('status', 'rejected'); } return $q->where('status', 'pending');
}
public function scopeCompleted($q)
{
return $q->where('status', 'completed');
}
public function scopeRejected($q)
{
return $q->where('status', 'rejected');
}
} }
+56 -15
View File
@@ -2,16 +2,18 @@
namespace App\Models; namespace App\Models;
use App\Traits\LogsActivity;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use App\Traits\LogsActivity;
class Issue extends Model 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 PRIORITIES = ['low', 'medium', 'high', 'critical'];
const TYPES = ['defect', 'safety', 'quality', 'documentation', 'other']; const TYPES = ['defect', 'safety', 'quality', 'documentation', 'other'];
protected $fillable = [ protected $fillable = [
@@ -23,17 +25,55 @@ class Issue extends Model
protected $casts = ['resolved_at' => 'datetime']; protected $casts = ['resolved_at' => 'datetime'];
public function project() { return $this->belongsTo(Project::class); } public function project()
public function feature() { return $this->belongsTo(Feature::class); } {
public function inspection() { return $this->belongsTo(Inspection::class); } return $this->belongsTo(Project::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 feature()
public function scopeCritical($q) { return $q->where('priority', 'critical'); } {
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. */ /** Resolution progress derived from the checklist: done tasks / total. */
public function getProgressAttribute(): int public function getProgressAttribute(): int
@@ -42,6 +82,7 @@ class Issue extends Model
if ($total === 0) { if ($total === 0) {
return in_array($this->status, ['resolved', 'closed'], true) ? 100 : 0; return in_array($this->status, ['resolved', 'closed'], true) ? 100 : 0;
} }
return (int) round($this->tasks->where('is_done', true)->count() / $total * 100); return (int) round($this->tasks->where('is_done', true)->count() / $total * 100);
} }
@@ -53,7 +94,7 @@ class Issue extends Model
public function getPriorityColorAttribute(): string public function getPriorityColorAttribute(): string
{ {
return match($this->priority) { return match ($this->priority) {
'low' => '#6b7280', 'low' => '#6b7280',
'medium' => '#f59e0b', 'medium' => '#f59e0b',
'high' => '#ef4444', 'high' => '#ef4444',
@@ -64,7 +105,7 @@ class Issue extends Model
public function getStatusColorAttribute(): string public function getStatusColorAttribute(): string
{ {
return match($this->status) { return match ($this->status) {
'open' => '#ef4444', 'open' => '#ef4444',
'in_review' => '#f59e0b', 'in_review' => '#f59e0b',
'resolved' => '#10b981', 'resolved' => '#10b981',
@@ -92,7 +133,7 @@ class Issue extends Model
public function getTypeColorAttribute(): string public function getTypeColorAttribute(): string
{ {
return match($this->type) { return match ($this->type) {
'defect' => '#ef4444', 'defect' => '#ef4444',
'safety' => '#f97316', 'safety' => '#f97316',
'quality' => '#0ea5e9', 'quality' => '#0ea5e9',
+14 -3
View File
@@ -14,7 +14,18 @@ class IssueComment extends Model
'uuid', 'client_updated_at', 'uuid', 'client_updated_at',
]; ];
public function issue() { return $this->belongsTo(Issue::class); } public function issue()
public function user() { return $this->belongsTo(User::class); } {
public function media() { return $this->morphMany(Media::class, 'mediable'); } return $this->belongsTo(Issue::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
public function media()
{
return $this->morphMany(Media::class, 'mediable');
}
} }
+19 -4
View File
@@ -30,10 +30,25 @@ class IssueTask extends Model
->whereDate('due_date', '<', now()->toDateString()); ->whereDate('due_date', '<', now()->toDateString());
} }
public function issue() { return $this->belongsTo(Issue::class); } public function issue()
public function assignee() { return $this->belongsTo(User::class, 'assigned_to'); } {
public function completer() { return $this->belongsTo(User::class, 'done_by'); } return $this->belongsTo(Issue::class);
public function media() { return $this->morphMany(Media::class, 'mediable'); } }
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. */ /** Overdue = has a due date in the past and not yet done. */
public function getIsOverdueAttribute(): bool public function getIsOverdueAttribute(): bool
+2 -2
View File
@@ -5,13 +5,12 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
class Layer extends Model class Layer extends Model
{ {
use SoftDeletes; use SoftDeletes;
protected $fillable = [ 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 = [ protected $casts = [
@@ -32,6 +31,7 @@ class Layer extends Model
{ {
return $this->belongsTo(User::class, 'uploaded_by'); return $this->belongsTo(User::class, 'uploaded_by');
} }
public function features() public function features()
{ {
return $this->hasMany(Feature::class); return $this->hasMany(Feature::class);
+11 -4
View File
@@ -48,10 +48,17 @@ class Media extends Model
public function getFormattedSizeAttribute() public function getFormattedSizeAttribute()
{ {
$bytes = $this->file_size; $bytes = $this->file_size;
if ($bytes >= 1073741824) return round($bytes / 1073741824, 2) . ' GB'; if ($bytes >= 1073741824) {
if ($bytes >= 1048576) return round($bytes / 1048576, 1) . ' MB'; return round($bytes / 1073741824, 2).' GB';
if ($bytes >= 1024) return round($bytes / 1024) . ' KB'; }
return $bytes . ' B'; if ($bytes >= 1048576) {
return round($bytes / 1048576, 1).' MB';
}
if ($bytes >= 1024) {
return round($bytes / 1024).' KB';
}
return $bytes.' B';
} }
// Scopes // Scopes
+3 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
@@ -99,7 +100,7 @@ class Phase extends Model
/** /**
* Planned progress at a given date (linear interpolation) * 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(); $date = $date ?? now();
@@ -144,6 +145,7 @@ class Phase extends Model
if ($spi === null) { if ($spi === null) {
return null; return null;
} }
return $spi >= 0.95; return $spi >= 0.95;
} }
} }
+1
View File
@@ -52,6 +52,7 @@ class ProgressSnapshot extends Model
if ($id) { if ($id) {
$query->where('trackable_id', $id); $query->where('trackable_id', $id);
} }
return $query; return $query;
} }
+1 -1
View File
@@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Model;
class ProgressUpdate extends Model class ProgressUpdate extends Model
{ {
protected $fillable = [ 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 = [ protected $casts = [
@@ -2,9 +2,9 @@
namespace App\Notifications; namespace App\Notifications;
use App\Models\Feature;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification; use Illuminate\Notifications\Notification;
use App\Models\Feature;
class FeatureCompletedNotification extends Notification class FeatureCompletedNotification extends Notification
{ {
@@ -2,10 +2,9 @@
namespace App\Notifications; namespace App\Notifications;
use App\Models\Inspection;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification; use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
use App\Models\Inspection;
class InspectionCompletedNotification extends Notification class InspectionCompletedNotification extends Notification
{ {
@@ -27,7 +26,7 @@ class InspectionCompletedNotification extends Notification
'feature_name' => $this->inspection->feature?->name ?? '—', 'feature_name' => $this->inspection->feature?->name ?? '—',
'template_name' => $this->inspection->template?->name ?? '—', 'template_name' => $this->inspection->template?->name ?? '—',
'result' => $this->inspection->result, 'result' => $this->inspection->result,
'message' => "Inspección completada en '{$this->inspection->feature?->name}': " . ($this->inspection->result ?? 'sin resultado'), 'message' => "Inspección completada en '{$this->inspection->feature?->name}': ".($this->inspection->result ?? 'sin resultado'),
]; ];
} }
} }
@@ -2,9 +2,9 @@
namespace App\Notifications; namespace App\Notifications;
use App\Models\Inspection;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification; use Illuminate\Notifications\Notification;
use App\Models\Inspection;
class InspectionDeletedNotification extends Notification class InspectionDeletedNotification extends Notification
{ {
@@ -2,9 +2,9 @@
namespace App\Notifications; namespace App\Notifications;
use App\Models\Inspection;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification; use Illuminate\Notifications\Notification;
use App\Models\Inspection;
class InspectionUpdatedNotification extends Notification class InspectionUpdatedNotification extends Notification
{ {
@@ -27,7 +27,7 @@ class IssueCommentedNotification extends Notification
'issue_id' => $this->comment->issue_id, 'issue_id' => $this->comment->issue_id,
'project_id' => $issue?->project_id, 'project_id' => $issue?->project_id,
'author' => $this->comment->user?->name, 'author' => $this->comment->user?->name,
'message' => "{$this->comment->user?->name} comentó en '{$issue?->title}': " . Str::limit($this->comment->body, 60), 'message' => "{$this->comment->user?->name} comentó en '{$issue?->title}': ".Str::limit($this->comment->body, 60),
]; ];
} }
} }
@@ -2,9 +2,9 @@
namespace App\Notifications; namespace App\Notifications;
use App\Models\Issue;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification; use Illuminate\Notifications\Notification;
use App\Models\Issue;
class IssueReportedNotification extends Notification class IssueReportedNotification extends Notification
{ {
+26 -23
View File
@@ -8,16 +8,15 @@ use App\Models\Inspection;
use App\Models\Issue; use App\Models\Issue;
use App\Models\Media; use App\Models\Media;
use App\Models\Phase; use App\Models\Phase;
use App\Models\Project;
use App\Models\ProgressSnapshot; use App\Models\ProgressSnapshot;
use App\Models\Project;
use App\Models\Task; use App\Models\Task;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
class ReportGenerator class ReportGenerator
{ {
protected Project $project; protected Project $project;
protected ReportFilters $filters; protected ReportFilters $filters;
public function __construct(Project $project, ReportFilters $filters) public function __construct(Project $project, ReportFilters $filters)
@@ -82,7 +81,7 @@ class ReportGenerator
->with(['layers.features']) ->with(['layers.features'])
->get(); ->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(); $completedFeatures = $allFeatures->where('status', 'completed')->count();
$inspectionsQuery = Inspection::where('project_id', $this->project->id); $inspectionsQuery = Inspection::where('project_id', $this->project->id);
@@ -136,7 +135,7 @@ class ReportGenerator
->get(); ->get();
return $phases->map(function ($phase) { return $phases->map(function ($phase) {
$phaseFeatures = $phase->layers->flatMap(fn($l) => $l->features); $phaseFeatures = $phase->layers->flatMap(fn ($l) => $l->features);
return [ return [
'id' => $phase->id, 'id' => $phase->id,
@@ -158,7 +157,7 @@ class ReportGenerator
'is_on_track' => $phase->is_on_track, 'is_on_track' => $phase->is_on_track,
'features_count' => $phaseFeatures->count(), 'features_count' => $phaseFeatures->count(),
'completed_features' => $phaseFeatures->where('status', 'completed')->count(), 'completed_features' => $phaseFeatures->where('status', 'completed')->count(),
'layers' => $phase->layers->map(fn($l) => [ 'layers' => $phase->layers->map(fn ($l) => [
'id' => $l->id, 'id' => $l->id,
'name' => $l->name, 'name' => $l->name,
'features_count' => $l->features->count(), 'features_count' => $l->features->count(),
@@ -169,7 +168,7 @@ class ReportGenerator
protected function buildFeaturesData(): array 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']); ->with(['layer.phase', 'template', 'inspections', 'issues']);
// Apply date filter on feature created_at if date range provided // Apply date filter on feature created_at if date range provided
@@ -227,7 +226,7 @@ class ReportGenerator
'date' => $inspection->created_at->format('d/m/Y H:i'), 'date' => $inspection->created_at->format('d/m/Y H:i'),
'status' => $inspection->status, 'status' => $inspection->status,
'result' => $inspection->result, 'result' => $inspection->result,
'result_label' => match($inspection->result) { 'result_label' => match ($inspection->result) {
'pass' => 'Aprobada', 'pass' => 'Aprobada',
'fail' => 'Fallida', 'fail' => 'Fallida',
'conditional' => 'Condicional', 'conditional' => 'Condicional',
@@ -256,7 +255,7 @@ class ReportGenerator
'feature' => $issue->feature?->name ?? '—', 'feature' => $issue->feature?->name ?? '—',
'phase' => $issue->feature?->layer?->phase?->name ?? '—', 'phase' => $issue->feature?->layer?->phase?->name ?? '—',
'priority' => $issue->priority, 'priority' => $issue->priority,
'priority_label' => match($issue->priority) { 'priority_label' => match ($issue->priority) {
'low' => 'Baja', 'low' => 'Baja',
'medium' => 'Media', 'medium' => 'Media',
'high' => 'Alta', 'high' => 'Alta',
@@ -264,7 +263,7 @@ class ReportGenerator
default => ucfirst($issue->priority ?? ''), default => ucfirst($issue->priority ?? ''),
}, },
'status' => $issue->status, 'status' => $issue->status,
'status_label' => match($issue->status) { 'status_label' => match ($issue->status) {
'open' => 'Abierta', 'open' => 'Abierta',
'in_review' => 'En revisión', 'in_review' => 'En revisión',
'closed' => 'Cerrada', 'closed' => 'Cerrada',
@@ -308,7 +307,7 @@ class ReportGenerator
'actual_hours' => $task->actual_hours, 'actual_hours' => $task->actual_hours,
'progress' => $task->progress, 'progress' => $task->progress,
'is_overdue' => $task->is_overdue, 'is_overdue' => $task->is_overdue,
'subtasks' => $task->subtasks->map(fn($st) => [ 'subtasks' => $task->subtasks->map(fn ($st) => [
'id' => $st->id, 'id' => $st->id,
'title' => $st->title, 'title' => $st->title,
'status' => $st->status, 'status' => $st->status,
@@ -341,7 +340,9 @@ class ReportGenerator
// Filter by project in PHP (polymorphic complexity) // Filter by project in PHP (polymorphic complexity)
$filtered = $media->filter(function ($m) { $filtered = $media->filter(function ($m) {
$mediable = $m->mediable; $mediable = $m->mediable;
if (!$mediable) return false; if (! $mediable) {
return false;
}
if ($mediable instanceof Phase) { if ($mediable instanceof Phase) {
return $mediable->project_id === $this->project->id; return $mediable->project_id === $this->project->id;
@@ -358,6 +359,7 @@ class ReportGenerator
if ($mediable instanceof Task) { if ($mediable instanceof Task) {
return $mediable->project_id === $this->project->id; return $mediable->project_id === $this->project->id;
} }
return false; return false;
}); });
@@ -398,9 +400,9 @@ class ReportGenerator
'spi' => $phase->spi, 'spi' => $phase->spi,
'is_on_track' => $phase->is_on_track, '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') ->whereNotNull('planned_end')
->with(['layer.phase']) ->with(['layer.phase'])
->get() ->get()
@@ -428,12 +430,12 @@ class ReportGenerator
'phases' => $phaseDeviations, 'phases' => $phaseDeviations,
'features' => $featureDeviations, 'features' => $featureDeviations,
'summary' => [ 'summary' => [
'phases_delayed' => count(array_filter($phaseDeviations, fn($p) => ($p['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_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)), '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_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_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)), '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 // Get snapshots for this project's phases and features
$phaseIds = $this->project->phases->pluck('id')->toArray(); $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(); ->pluck('id')->toArray();
$taskIds = Task::where('project_id', $this->project->id)->pluck('id')->toArray(); $taskIds = Task::where('project_id', $this->project->id)->pluck('id')->toArray();
@@ -523,7 +525,7 @@ class ReportGenerator
protected function getFeatureStatusLabel(string $status): string protected function getFeatureStatusLabel(string $status): string
{ {
return match($status) { return match ($status) {
'planned' => 'Planificado', 'planned' => 'Planificado',
'started' => 'Iniciado', 'started' => 'Iniciado',
'in_progress' => 'En progreso', 'in_progress' => 'En progreso',
@@ -541,6 +543,7 @@ class ReportGenerator
$bytes /= 1024; $bytes /= 1024;
$i++; $i++;
} }
return round($bytes, 1) . ' ' . $units[$i];
return round($bytes, 1).' '.$units[$i];
} }
} }
+77 -31
View File
@@ -22,7 +22,9 @@ class SpatialFileConverter
default => null, default => null,
}; };
if (!$geojson) return null; if (! $geojson) {
return null;
}
return self::postProcess($geojson); return self::postProcess($geojson);
} }
@@ -37,15 +39,19 @@ class SpatialFileConverter
foreach ($geojson['features'] ?? [] as $feature) { foreach ($geojson['features'] ?? [] as $feature) {
if (!isset($feature['geometry'])) continue; if (! isset($feature['geometry'])) {
continue;
}
$geometry = self::cleanGeometry($feature['geometry']); $geometry = self::cleanGeometry($feature['geometry']);
if (!$geometry) continue; if (! $geometry) {
continue;
}
$features[] = [ $features[] = [
'type' => 'Feature', 'type' => 'Feature',
'geometry' => $geometry, 'geometry' => $geometry,
'properties' => self::normalizeProperties($feature['properties'] ?? []) 'properties' => self::normalizeProperties($feature['properties'] ?? []),
]; ];
} }
@@ -53,7 +59,7 @@ class SpatialFileConverter
'type' => 'FeatureCollection', 'type' => 'FeatureCollection',
'features' => $features, 'features' => $features,
'bbox' => self::calculateBBox($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 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') { if ($geom['type'] === 'Polygon') {
$geom['coordinates'] = array_map(function ($ring) { $geom['coordinates'] = array_map(function ($ring) {
if ($ring[0] !== end($ring)) { if ($ring[0] !== end($ring)) {
$ring[] = $ring[0]; $ring[] = $ring[0];
} }
return $ring; return $ring;
}, $geom['coordinates']); }, $geom['coordinates']);
} }
@@ -101,7 +110,9 @@ class SpatialFileConverter
$coords = array_merge($coords, self::flattenCoords($f['geometry']['coordinates'])); $coords = array_merge($coords, self::flattenCoords($f['geometry']['coordinates']));
} }
if (empty($coords)) return null; if (empty($coords)) {
return null;
}
$lons = array_column($coords, 0); $lons = array_column($coords, 0);
$lats = array_column($coords, 1); $lats = array_column($coords, 1);
@@ -110,7 +121,7 @@ class SpatialFileConverter
min($lons), min($lons),
min($lats), min($lats),
max($lons), max($lons),
max($lats) max($lats),
]; ];
} }
@@ -126,7 +137,9 @@ class SpatialFileConverter
$coords = array_merge($coords, self::flattenCoords($f['geometry']['coordinates'])); $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); $x = array_sum(array_column($coords, 0)) / count($coords);
$y = array_sum(array_column($coords, 1)) / count($coords); $y = array_sum(array_column($coords, 1)) / count($coords);
@@ -139,10 +152,13 @@ class SpatialFileConverter
$result = []; $result = [];
$iterator = function ($c) use (&$result, &$iterator) { $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])) { if (isset($c[0]) && isset($c[1]) && is_numeric($c[0])) {
$result[] = [$c[0], $c[1]]; $result[] = [$c[0], $c[1]];
return; return;
} }
@@ -163,6 +179,7 @@ class SpatialFileConverter
private static function parseGeoJson($path): ?array private static function parseGeoJson($path): ?array
{ {
$data = json_decode(file_get_contents($path), true); $data = json_decode(file_get_contents($path), true);
return json_last_error() === JSON_ERROR_NONE ? $data : null; return json_last_error() === JSON_ERROR_NONE ? $data : null;
} }
@@ -175,18 +192,24 @@ class SpatialFileConverter
libxml_use_internal_errors(true); libxml_use_internal_errors(true);
$xml = simplexml_load_file($path); $xml = simplexml_load_file($path);
if (!$xml) return null; if (! $xml) {
return null;
}
// Namespace-agnostic: usamos local-name() en el XPath para aceptar KMLs // 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). // con cualquier xmlns (opengis 2.2, earth.google 2.1/2.0, o sin xmlns).
$placemarks = $xml->xpath('//*[local-name()="Placemark"]'); $placemarks = $xml->xpath('//*[local-name()="Placemark"]');
if ($placemarks === false) $placemarks = []; if ($placemarks === false) {
$placemarks = [];
}
$features = []; $features = [];
foreach ($placemarks as $pm) { foreach ($placemarks as $pm) {
$geom = self::parseKmlGeometry($pm); $geom = self::parseKmlGeometry($pm);
if (!$geom) continue; if (! $geom) {
continue;
}
$features[] = [ $features[] = [
'type' => 'Feature', 'type' => 'Feature',
@@ -204,12 +227,15 @@ class SpatialFileConverter
/** Descomprime un KMZ y parsea el .kml interno. */ /** Descomprime un KMZ y parsea el .kml interno. */
private static function kmzToGeoJson(string $path): ?array private static function kmzToGeoJson(string $path): ?array
{ {
$zip = new \ZipArchive(); $zip = new \ZipArchive;
if ($zip->open($path) !== true) return null; 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)) { if (! @mkdir($tmp, 0777, true) && ! is_dir($tmp)) {
$zip->close(); $zip->close();
return null; return null;
} }
$zip->extractTo($tmp); $zip->extractTo($tmp);
@@ -228,15 +254,20 @@ class SpatialFileConverter
// Limpieza // Limpieza
self::rrmdir($tmp); self::rrmdir($tmp);
return $result; return $result;
} }
private static function rrmdir(string $dir): void private static function rrmdir(string $dir): void
{ {
if (! is_dir($dir)) return; if (! is_dir($dir)) {
return;
}
foreach (scandir($dir) ?: [] as $item) { foreach (scandir($dir) ?: [] as $item) {
if ($item === '.' || $item === '..') continue; if ($item === '.' || $item === '..') {
$p = $dir . DIRECTORY_SEPARATOR . $item; continue;
}
$p = $dir.DIRECTORY_SEPARATOR.$item;
is_dir($p) ? self::rrmdir($p) : @unlink($p); is_dir($p) ? self::rrmdir($p) : @unlink($p);
} }
@rmdir($dir); @rmdir($dir);
@@ -245,38 +276,45 @@ class SpatialFileConverter
/** Devuelve el primer hijo cuyo local-name coincida (sin depender del prefijo). */ /** Devuelve el primer hijo cuyo local-name coincida (sin depender del prefijo). */
private static function kmlChild(\SimpleXMLElement $node, string $name): \SimpleXMLElement|string 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] : ''; return $matches ? $matches[0] : '';
} }
private static function parseKmlGeometry($pm): ?array private static function parseKmlGeometry($pm): ?array
{ {
// Todos los accesos van por xpath local-name para tolerar cualquier xmlns. // Todos los accesos van por xpath local-name para tolerar cualquier xmlns.
$find = fn (string $tag) => $pm->xpath('./*[local-name()="' . $tag . '"]'); $find = fn (string $tag) => $pm->xpath('./*[local-name()="'.$tag.'"]');
$findDeep = fn (\SimpleXMLElement $n, string $tag) => $n->xpath('.//*[local-name()="' . $tag . '"]'); $findDeep = fn (\SimpleXMLElement $n, string $tag) => $n->xpath('.//*[local-name()="'.$tag.'"]');
if ($multi = $find('MultiGeometry')) { if ($multi = $find('MultiGeometry')) {
$geoms = []; $geoms = [];
foreach ($multi[0]->children() as $g) { foreach ($multi[0]->children() as $g) {
$parsed = self::parseKmlGeometry($g); $parsed = self::parseKmlGeometry($g);
if ($parsed) $geoms[] = $parsed; if ($parsed) {
$geoms[] = $parsed;
} }
}
return ['type' => 'GeometryCollection', 'geometries' => $geoms]; return ['type' => 'GeometryCollection', 'geometries' => $geoms];
} }
if ($point = $find('Point')) { if ($point = $find('Point')) {
$coords = self::parseKmlCoords((string) ($findDeep($point[0], 'coordinates')[0] ?? '')); $coords = self::parseKmlCoords((string) ($findDeep($point[0], 'coordinates')[0] ?? ''));
return $coords ? ['type' => 'Point', 'coordinates' => $coords[0]] : null; return $coords ? ['type' => 'Point', 'coordinates' => $coords[0]] : null;
} }
if ($line = $find('LineString')) { if ($line = $find('LineString')) {
$coords = self::parseKmlCoords((string) ($findDeep($line[0], 'coordinates')[0] ?? '')); $coords = self::parseKmlCoords((string) ($findDeep($line[0], 'coordinates')[0] ?? ''));
return $coords ? ['type' => 'LineString', 'coordinates' => $coords] : null; return $coords ? ['type' => 'LineString', 'coordinates' => $coords] : null;
} }
if ($poly = $find('Polygon')) { if ($poly = $find('Polygon')) {
$outer = $findDeep($poly[0], 'coordinates')[0] ?? ''; $outer = $findDeep($poly[0], 'coordinates')[0] ?? '';
$coords = self::parseKmlCoords((string) $outer); $coords = self::parseKmlCoords((string) $outer);
return $coords ? ['type' => 'Polygon', 'coordinates' => [$coords]] : null; return $coords ? ['type' => 'Polygon', 'coordinates' => [$coords]] : null;
} }
@@ -289,9 +327,10 @@ class SpatialFileConverter
foreach (preg_split('/\s+/', trim($text)) as $pair) { foreach (preg_split('/\s+/', trim($text)) as $pair) {
$p = explode(',', $pair); $p = explode(',', $pair);
if (count($p) >= 2) { if (count($p) >= 2) {
$coords[] = [(float)$p[0], (float)$p[1]]; $coords[] = [(float) $p[0], (float) $p[1]];
} }
} }
return $coords; return $coords;
} }
@@ -307,16 +346,20 @@ class SpatialFileConverter
$features = []; $features = [];
while ($record = $reader->fetchRecord()) { while ($record = $reader->fetchRecord()) {
if ($record->isDeleted()) continue; if ($record->isDeleted()) {
continue;
}
$geom = json_decode($record->getGeometry()->toGeoJSON(), true); $geom = json_decode($record->getGeometry()->toGeoJSON(), true);
if (!$geom) continue; if (! $geom) {
continue;
}
$features[] = [ $features[] = [
'type' => 'Feature', 'type' => 'Feature',
'geometry' => $geom, 'geometry' => $geom,
'properties' => $record->getDataArray() 'properties' => $record->getDataArray(),
]; ];
} }
@@ -324,6 +367,7 @@ class SpatialFileConverter
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error($e->getMessage()); Log::error($e->getMessage());
return null; return null;
} }
} }
@@ -334,11 +378,13 @@ class SpatialFileConverter
private static function handleZip($zipPath): ?array 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); mkdir($dir);
$zip->extractTo($dir); $zip->extractTo($dir);
@@ -347,7 +393,7 @@ class SpatialFileConverter
$result = null; $result = null;
foreach (scandir($dir) as $file) { foreach (scandir($dir) as $file) {
$full = $dir . '/' . $file; $full = $dir.'/'.$file;
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
if ($ext === 'shp') { if ($ext === 'shp') {
-1
View File
@@ -2,7 +2,6 @@
namespace App\Traits; namespace App\Traits;
use Illuminate\Support\Facades\Auth;
use App\Models\ActivityLog; use App\Models\ActivityLog;
trait LogsActivity trait LogsActivity
+20 -13
View File
@@ -1,8 +1,16 @@
<?php <?php
use App\Http\Middleware\ForceHttpsScheme;
use App\Http\Middleware\SetLocale;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; 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__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
@@ -12,32 +20,31 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->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 // 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 // (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 // URLs firmadas —p. ej. /livewire/upload-file— se firman con un scheme y
// se validan con otro → 401). Debe ir ANTES de TrustProxies. // 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: // 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 // sin esto, tras HTTPS el proxy Laravel genera URLs con http:// y las cookies
// "secure" no viajan → 419 en /livewire/update. // "secure" no viajan → 419 en /livewire/update.
$middleware->trustProxies(at: '*', headers: $middleware->trustProxies(at: '*', headers: Request::HEADER_X_FORWARDED_FOR |
Illuminate\Http\Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST |
Illuminate\Http\Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PORT |
Illuminate\Http\Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO |
Illuminate\Http\Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_AWS_ELB,
Illuminate\Http\Request::HEADER_X_FORWARDED_AWS_ELB,
); );
// Spatie permission + Sanctum ability middleware aliases // Spatie permission + Sanctum ability middleware aliases
$middleware->alias([ $middleware->alias([
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class, 'role' => RoleMiddleware::class,
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class, 'permission' => PermissionMiddleware::class,
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class, 'role_or_permission' => RoleOrPermissionMiddleware::class,
'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class, 'abilities' => CheckAbilities::class,
'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class, 'ability' => CheckForAnyAbility::class,
]); ]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
+5 -2
View File
@@ -1,6 +1,9 @@
<?php <?php
use App\Providers\AppServiceProvider;
use App\Providers\VoltServiceProvider;
return [ return [
App\Providers\AppServiceProvider::class, AppServiceProvider::class,
App\Providers\VoltServiceProvider::class, VoltServiceProvider::class,
]; ];
@@ -32,7 +32,9 @@ return new class extends Migration
foreach ($rows as $r) { foreach ($rows as $r) {
// Evitar duplicados; si el proyecto ya no existe lo ignoramos // Evitar duplicados; si el proyecto ya no existe lo ignoramos
$projectExists = DB::table('projects')->where('id', $r->project_id)->exists(); $projectExists = DB::table('projects')->where('id', $r->project_id)->exists();
if (! $projectExists) continue; if (! $projectExists) {
continue;
}
DB::table('inspection_template_project')->updateOrInsert( DB::table('inspection_template_project')->updateOrInsert(
['inspection_template_id' => $r->id, 'project_id' => $r->project_id], ['inspection_template_id' => $r->id, 'project_id' => $r->project_id],
@@ -46,9 +48,15 @@ return new class extends Migration
Schema::table('inspection_templates', function (Blueprint $table) { Schema::table('inspection_templates', function (Blueprint $table) {
// 1) FK primero: si no la dropeamos, MySQL rechaza soltar el índice // 1) FK primero: si no la dropeamos, MySQL rechaza soltar el índice
// que la sustenta ("needed in a foreign key constraint"). // que la sustenta ("needed in a foreign key constraint").
try { $table->dropForeign(['phase_id']); } catch (\Throwable $e) { /* sqlite sin FKs / ya inexistente */ } try {
$table->dropForeign(['phase_id']);
} catch (Throwable $e) { /* sqlite sin FKs / ya inexistente */
}
// 2) Índice después (nombre por convención de Laravel). // 2) Índice después (nombre por convención de Laravel).
try { $table->dropIndex('inspection_templates_phase_id_index'); } catch (\Throwable $e) { /* ya no existe */ } try {
$table->dropIndex('inspection_templates_phase_id_index');
} catch (Throwable $e) { /* ya no existe */
}
// 3) Columna al final. // 3) Columna al final.
$table->dropColumn('phase_id'); $table->dropColumn('phase_id');
}); });
@@ -24,7 +24,7 @@ return new class extends Migration
$table->dropColumn([ $table->dropColumn([
'planned_start', 'planned_end', 'planned_start', 'planned_end',
'actual_start', 'actual_end', 'actual_start', 'actual_end',
'baseline_start', 'baseline_end' 'baseline_start', 'baseline_end',
]); ]);
}); });
} }
-1
View File
@@ -22,7 +22,6 @@ class DatabaseSeeder extends Seeder
'email' => 'test@example.com', 'email' => 'test@example.com',
]);*/ ]);*/
$this->call([ $this->call([
RolesAndPermissionsSeeder::class, RolesAndPermissionsSeeder::class,
PermissionCatalogSeeder::class, PermissionCatalogSeeder::class,
+15 -14
View File
@@ -2,13 +2,14 @@
namespace Database\Seeders; namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\Project;
use App\Models\Phase;
use App\Models\Layer;
use App\Models\Feature; use App\Models\Feature;
use App\Models\Inspection;
use App\Models\InspectionTemplate; use App\Models\InspectionTemplate;
use App\Models\Layer;
use App\Models\Phase;
use App\Models\Project;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Seeder;
class ProjectExampleSeeder extends Seeder class ProjectExampleSeeder extends Seeder
{ {
@@ -16,7 +17,7 @@ class ProjectExampleSeeder extends Seeder
{ {
// Obtener usuario admin // Obtener usuario admin
$admin = User::where('email', 'admin@email.com')->first(); $admin = User::where('email', 'admin@email.com')->first();
if (!$admin) { if (! $admin) {
$admin = User::first(); $admin = User::first();
} }
@@ -56,7 +57,7 @@ class ProjectExampleSeeder extends Seeder
['name' => 'progress', 'label' => 'Progreso de vertido', 'type' => 'percentage', 'required' => true], ['name' => 'progress', 'label' => 'Progreso de vertido', 'type' => 'percentage', 'required' => true],
['name' => 'calidad', 'label' => 'Calidad del hormigón', 'type' => 'select', 'options' => 'Excelente,Bueno,Regular,Deficiente', 'required' => true], ['name' => 'calidad', 'label' => 'Calidad del hormigón', 'type' => 'select', 'options' => 'Excelente,Bueno,Regular,Deficiente', 'required' => true],
['name' => 'observaciones', 'label' => 'Observaciones', 'type' => 'textarea', 'required' => false], ['name' => 'observaciones', 'label' => 'Observaciones', 'type' => 'textarea', 'required' => false],
] ],
]); ]);
$templateAcero = InspectionTemplate::create([ $templateAcero = InspectionTemplate::create([
@@ -65,7 +66,7 @@ class ProjectExampleSeeder extends Seeder
'fields' => [ 'fields' => [
['name' => 'progress', 'label' => '% de acero colocado', 'type' => 'percentage', 'required' => true], ['name' => 'progress', 'label' => '% de acero colocado', 'type' => 'percentage', 'required' => true],
['name' => 'diametros', 'label' => 'Diámetros verificados', 'type' => 'text', 'required' => false], ['name' => 'diametros', 'label' => 'Diámetros verificados', 'type' => 'text', 'required' => false],
] ],
]); ]);
// Crear capa (sin geojson_data) // Crear capa (sin geojson_data)
@@ -88,8 +89,8 @@ class ProjectExampleSeeder extends Seeder
[-3.702, 40.415], [-3.702, 40.415],
[-3.702, 40.418], [-3.702, 40.418],
[-3.705, 40.418], [-3.705, 40.418],
[-3.705, 40.415] [-3.705, 40.415],
]] ]],
], ],
'properties' => ['description' => 'Zapata esquina noroeste'], 'properties' => ['description' => 'Zapata esquina noroeste'],
'template_id' => $templateHormigon->id, 'template_id' => $templateHormigon->id,
@@ -107,8 +108,8 @@ class ProjectExampleSeeder extends Seeder
[-3.697, 40.415], [-3.697, 40.415],
[-3.697, 40.418], [-3.697, 40.418],
[-3.700, 40.418], [-3.700, 40.418],
[-3.700, 40.415] [-3.700, 40.415],
]] ]],
], ],
'properties' => ['description' => 'Zapata lado este'], 'properties' => ['description' => 'Zapata lado este'],
'template_id' => $templateAcero->id, 'template_id' => $templateAcero->id,
@@ -121,7 +122,7 @@ class ProjectExampleSeeder extends Seeder
'name' => 'Punto de control topográfico', 'name' => 'Punto de control topográfico',
'geometry' => [ 'geometry' => [
'type' => 'Point', 'type' => 'Point',
'coordinates' => [-3.703, 40.4165] 'coordinates' => [-3.703, 40.4165],
], ],
'properties' => ['tipo' => 'estación total'], 'properties' => ['tipo' => 'estación total'],
'template_id' => null, 'template_id' => null,
@@ -130,7 +131,7 @@ class ProjectExampleSeeder extends Seeder
]); ]);
// (Opcional) Crear una inspección de ejemplo // (Opcional) Crear una inspección de ejemplo
\App\Models\Inspection::create([ Inspection::create([
'project_id' => $project->id, 'project_id' => $project->id,
'layer_id' => $layer->id, 'layer_id' => $layer->id,
'feature_id' => $feature1->id, 'feature_id' => $feature1->id,
@@ -139,7 +140,7 @@ class ProjectExampleSeeder extends Seeder
'data' => [ 'data' => [
'progress' => 45, 'progress' => 45,
'calidad' => 'Bueno', 'calidad' => 'Bueno',
'observaciones' => 'Vertido completado en un 45%, sin fisuras aparentes.' 'observaciones' => 'Vertido completado en un 45%, sin fisuras aparentes.',
], ],
]); ]);
} }
+1 -1
View File
@@ -17,6 +17,6 @@ return [
'sent' => 'Nous vous avons envoyé par e-mail votre lien de réinitialisation de mot de passe.', 'sent' => 'Nous vous avons envoyé par e-mail votre lien de réinitialisation de mot de passe.',
'throttled' => 'Veuillez patienter avant de réessayer.', 'throttled' => 'Veuillez patienter avant de réessayer.',
'token' => 'Ce jeton de réinitialisation de mot de passe est invalide.', 'token' => 'Ce jeton de réinitialisation de mot de passe est invalide.',
'user' => "Nous ne trouvons aucun utilisateur avec cette adresse e-mail.", 'user' => 'Nous ne trouvons aucun utilisateur avec cette adresse e-mail.',
]; ];
+1 -1
View File
@@ -17,6 +17,6 @@ return [
'sent' => 'Мы отправили ссылку для сброса пароля на вашу электронную почту.', 'sent' => 'Мы отправили ссылку для сброса пароля на вашу электронную почту.',
'throttled' => 'Пожалуйста, подождите перед повторной попыткой.', 'throttled' => 'Пожалуйста, подождите перед повторной попыткой.',
'token' => 'Этот токен сброса пароля недействителен.', 'token' => 'Этот токен сброса пароля недействителен.',
'user' => "Мы не можем найти пользователя с таким адресом электронной почты.", 'user' => 'Мы не можем найти пользователя с таким адресом электронной почты.',
]; ];
+2 -2
View File
@@ -1,4 +1,5 @@
<?php <?php
$paises = [ $paises = [
'AF' => 'Afganistán', 'AF' => 'Afganistán',
'AX' => 'Islas Åland', 'AX' => 'Islas Åland',
@@ -249,6 +250,5 @@ $paises = [
'BQ' => 'Bonaire, San Eustaquio y Saba', 'BQ' => 'Bonaire, San Eustaquio y Saba',
'CW' => 'Curazao', 'CW' => 'Curazao',
'MF' => 'San Martín (parte francesa)', 'MF' => 'San Martín (parte francesa)',
'SX' => 'San Martín (parte neerlandesa)' 'SX' => 'San Martín (parte neerlandesa)',
]; ];
?>
+4
View File
@@ -44,7 +44,11 @@
<!-- Page Content --> <!-- Page Content -->
<main> <main>
@isset($slot)
{{ $slot }} {{ $slot }}
@else
@yield('content')
@endisset
</main> </main>
</div> </div>
@@ -741,8 +741,13 @@
// Define selectFeature here so Livewire is guaranteed to be available // Define selectFeature here so Livewire is guaranteed to be available
window.selectFeature = function(featureId) { window.selectFeature = function(featureId) {
console.log('selectFeature called with:', featureId); console.log('[ProjectMap] selectFeature called with:', featureId, typeof featureId);
if (typeof Livewire !== 'undefined' && Livewire.emit) {
Livewire.emit('map-select-feature', { featureId: featureId }); Livewire.emit('map-select-feature', { featureId: featureId });
console.log('[ProjectMap] Livewire.emit executed');
} else {
console.error('[ProjectMap] Livewire not available or emit not a function');
}
}; };
Livewire.on('layersUpdated', (activeIds) => { Livewire.on('layersUpdated', (activeIds) => {
@@ -1,6 +1,3 @@
@extends('layouts.app')
@section('content')
<div class="container mx-auto px-4 py-8"> <div class="container mx-auto px-4 py-8">
{{-- Header --}} {{-- Header --}}
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-8"> <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-8">
@@ -176,4 +173,3 @@
@endif @endif
</div> </div>
</div> </div>
@endsection
+8 -6
View File
@@ -3,17 +3,19 @@
namespace Tests\Feature\Api; namespace Tests\Feature\Api;
use App\Models\Feature; use App\Models\Feature;
use App\Models\FeatureType;
use App\Models\Inspection; use App\Models\Inspection;
use App\Models\InspectionTemplate; use App\Models\InspectionTemplate;
use App\Models\Issue; use App\Models\Issue;
use App\Models\IssueComment; use App\Models\IssueComment;
use App\Models\IssueTask; use App\Models\IssueTask;
use App\Models\Layer; use App\Models\Layer;
use App\Models\Media;
use App\Models\Phase; use App\Models\Phase;
use App\Models\Project;
use App\Models\ProgressUpdate; use App\Models\ProgressUpdate;
use App\Models\User; use App\Models\Project;
use App\Models\SyncLog; use App\Models\SyncLog;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@@ -239,7 +241,7 @@ class MobileApiTest extends TestCase
$new = $this->makePhase($project, 'Fase nueva'); $new = $this->makePhase($project, 'Fase nueva');
Sanctum::actingAs($user, ['mobile-sync']); Sanctum::actingAs($user, ['mobile-sync']);
$res = $this->getJson("/api/v1/projects/{$project->id}/bundle?since=" . urlencode($since->toIso8601String()))->assertOk(); $res = $this->getJson("/api/v1/projects/{$project->id}/bundle?since=".urlencode($since->toIso8601String()))->assertOk();
$ids = collect($res->json('phases'))->pluck('id'); $ids = collect($res->json('phases'))->pluck('id');
$this->assertTrue($ids->contains($new->id)); $this->assertTrue($ids->contains($new->id));
@@ -262,7 +264,7 @@ class MobileApiTest extends TestCase
$feature->delete(); // soft delete $feature->delete(); // soft delete
Sanctum::actingAs($user, ['mobile-sync']); Sanctum::actingAs($user, ['mobile-sync']);
$res = $this->getJson("/api/v1/projects/{$project->id}/bundle?since=" . urlencode($since->toIso8601String()))->assertOk(); $res = $this->getJson("/api/v1/projects/{$project->id}/bundle?since=".urlencode($since->toIso8601String()))->assertOk();
$this->assertContains($feature->id, $res->json('deleted.features')); $this->assertContains($feature->id, $res->json('deleted.features'));
@@ -362,7 +364,7 @@ class MobileApiTest extends TestCase
$user->givePermissionTo('update progress'); $user->givePermissionTo('update progress');
$project = $this->makeProject($user); $project = $this->makeProject($user);
$feature = $this->makeFeature($this->makeLayer($this->makePhase($project))); $feature = $this->makeFeature($this->makeLayer($this->makePhase($project)));
$type = \App\Models\FeatureType::create(['name' => 'Pilar', 'color' => '#ffffff']); $type = FeatureType::create(['name' => 'Pilar', 'color' => '#ffffff']);
Sanctum::actingAs($user, ['mobile-sync']); Sanctum::actingAs($user, ['mobile-sync']);
$this->postJson('/api/v1/sync', ['operations' => [[ $this->postJson('/api/v1/sync', ['operations' => [[
@@ -431,7 +433,7 @@ class MobileApiTest extends TestCase
'file' => UploadedFile::fake()->image('foto.jpg'), 'file' => UploadedFile::fake()->image('foto.jpg'),
])->assertOk()->assertJsonPath('status', 'duplicate'); ])->assertOk()->assertJsonPath('status', 'duplicate');
$this->assertEquals(1, \App\Models\Media::where('uuid', $uuid)->count()); $this->assertEquals(1, Media::where('uuid', $uuid)->count());
} }
// ── Issue tasks / comments (enriquecimiento incidencias) ───────────────────── // ── Issue tasks / comments (enriquecimiento incidencias) ─────────────────────
+2
View File
@@ -21,7 +21,9 @@ class FeatureManagementTest extends TestCase
use RefreshDatabase; use RefreshDatabase;
private User $user; private User $user;
private Project $project; private Project $project;
private Feature $feature; private Feature $feature;
protected function setUp(): void protected function setUp(): void
+4 -3
View File
@@ -24,11 +24,12 @@ class GlobalTemplatesTest extends TestCase
private function project(User $owner, string $ref = 'P'): Project private function project(User $owner, string $ref = 'P'): Project
{ {
$p = Project::create([ $p = Project::create([
'reference' => $ref, 'name' => 'P-' . $ref, 'address' => 'x', 'lat' => 40, 'lng' => -3, 'reference' => $ref, 'name' => 'P-'.$ref, 'address' => 'x', 'lat' => 40, 'lng' => -3,
'start_date' => now()->toDateString(), 'end_date_estimated' => now()->addMonth()->toDateString(), 'start_date' => now()->toDateString(), 'end_date_estimated' => now()->addMonth()->toDateString(),
'status' => 'in_progress', 'created_by' => $owner->id, 'status' => 'in_progress', 'created_by' => $owner->id,
]); ]);
$p->users()->attach($owner->id, ['role_in_project' => 'supervisor']); $p->users()->attach($owner->id, ['role_in_project' => 'supervisor']);
return $p; return $p;
} }
@@ -124,8 +125,8 @@ class GlobalTemplatesTest extends TestCase
$admin->givePermissionTo('manage templates'); $admin->givePermissionTo('manage templates');
$csv = "group,name,label,question,type,required,options,min,max,step,help\n" $csv = "group,name,label,question,type,required,options,min,max,step,help\n"
. "Dimensiones,altura,Altura (m),¿Cumple la cota?,decimal,1,,0,100,0.1,Medir con flexómetro\n" ."Dimensiones,altura,Altura (m),¿Cumple la cota?,decimal,1,,0,100,0.1,Medir con flexómetro\n"
. "Acabados,ok,¿Acabado?,,boolean,0,,,,,\n"; ."Acabados,ok,¿Acabado?,,boolean,0,,,,,\n";
$file = UploadedFile::fake()->createWithContent('plantilla.csv', $csv); $file = UploadedFile::fake()->createWithContent('plantilla.csv', $csv);
$cmp = Livewire::actingAs($admin) $cmp = Livewire::actingAs($admin)
+9 -7
View File
@@ -11,6 +11,8 @@ use App\Models\Media;
use App\Models\Phase; use App\Models\Phase;
use App\Models\Project; use App\Models\Project;
use App\Models\User; use App\Models\User;
use Database\Seeders\PermissionCatalogSeeder;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -25,8 +27,8 @@ class InspectionFormTest extends TestCase
{ {
parent::setUp(); parent::setUp();
Storage::fake('public'); Storage::fake('public');
$this->seed(\Database\Seeders\RolesAndPermissionsSeeder::class); $this->seed(RolesAndPermissionsSeeder::class);
$this->seed(\Database\Seeders\PermissionCatalogSeeder::class); $this->seed(PermissionCatalogSeeder::class);
} }
private function createTestData(): array private function createTestData(): array
@@ -100,7 +102,7 @@ class InspectionFormTest extends TestCase
'mediable_type' => Inspection::class, 'mediable_type' => Inspection::class,
'mediable_id' => $inspection->id, 'mediable_id' => $inspection->id,
'name' => 'original.jpg', 'name' => 'original.jpg',
'file_path' => 'uploads/inspections/' . $inspection->id . '/original.jpg', 'file_path' => 'uploads/inspections/'.$inspection->id.'/original.jpg',
'file_type' => 'image/jpeg', 'file_type' => 'image/jpeg',
'file_extension' => 'jpg', 'file_extension' => 'jpg',
'file_size' => 1000, 'file_size' => 1000,
@@ -150,7 +152,7 @@ class InspectionFormTest extends TestCase
'mediable_type' => Inspection::class, 'mediable_type' => Inspection::class,
'mediable_id' => $inspection->id, 'mediable_id' => $inspection->id,
'name' => 'to_delete.jpg', 'name' => 'to_delete.jpg',
'file_path' => 'uploads/inspections/' . $inspection->id . '/to_delete.jpg', 'file_path' => 'uploads/inspections/'.$inspection->id.'/to_delete.jpg',
'file_type' => 'image/jpeg', 'file_type' => 'image/jpeg',
'file_extension' => 'jpg', 'file_extension' => 'jpg',
'file_size' => 1000, 'file_size' => 1000,
@@ -161,7 +163,7 @@ class InspectionFormTest extends TestCase
'mediable_type' => Inspection::class, 'mediable_type' => Inspection::class,
'mediable_id' => $inspection->id, 'mediable_id' => $inspection->id,
'name' => 'keep.jpg', 'name' => 'keep.jpg',
'file_path' => 'uploads/inspections/' . $inspection->id . '/keep.jpg', 'file_path' => 'uploads/inspections/'.$inspection->id.'/keep.jpg',
'file_type' => 'image/jpeg', 'file_type' => 'image/jpeg',
'file_extension' => 'jpg', 'file_extension' => 'jpg',
'file_size' => 1000, 'file_size' => 1000,
@@ -253,7 +255,7 @@ class InspectionFormTest extends TestCase
'mediable_type' => Inspection::class, 'mediable_type' => Inspection::class,
'mediable_id' => $inspection->id, 'mediable_id' => $inspection->id,
'name' => 'test.jpg', 'name' => 'test.jpg',
'file_path' => 'uploads/inspections/' . $inspection->id . '/test.jpg', 'file_path' => 'uploads/inspections/'.$inspection->id.'/test.jpg',
'file_type' => 'image/jpeg', 'file_type' => 'image/jpeg',
'file_extension' => 'jpg', 'file_extension' => 'jpg',
'file_size' => 1000, 'file_size' => 1000,
@@ -288,7 +290,7 @@ class InspectionFormTest extends TestCase
'mediable_type' => Inspection::class, 'mediable_type' => Inspection::class,
'mediable_id' => $inspection2->id, 'mediable_id' => $inspection2->id,
'name' => 'test2.jpg', 'name' => 'test2.jpg',
'file_path' => 'uploads/inspections/' . $inspection2->id . '/test2.jpg', 'file_path' => 'uploads/inspections/'.$inspection2->id.'/test2.jpg',
'file_type' => 'image/jpeg', 'file_type' => 'image/jpeg',
'file_extension' => 'jpg', 'file_extension' => 'jpg',
'file_size' => 1000, 'file_size' => 1000,
+5 -3
View File
@@ -8,7 +8,6 @@ use App\Livewire\Issues\IssueForm;
use App\Models\Feature; use App\Models\Feature;
use App\Models\Issue; use App\Models\Issue;
use App\Models\IssueChecklistTemplate; use App\Models\IssueChecklistTemplate;
use App\Models\IssueTask;
use App\Models\Layer; use App\Models\Layer;
use App\Models\Phase; use App\Models\Phase;
use App\Models\Project; use App\Models\Project;
@@ -18,8 +17,8 @@ use App\Notifications\IssueStatusChangedNotification;
use App\Notifications\IssueTaskAssignedNotification; use App\Notifications\IssueTaskAssignedNotification;
use App\Notifications\IssueTaskOverdueNotification; use App\Notifications\IssueTaskOverdueNotification;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Str;
use Livewire\Livewire; use Livewire\Livewire;
use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Permission;
use Tests\TestCase; use Tests\TestCase;
@@ -29,7 +28,9 @@ class IssuesEnhancementsTest extends TestCase
use RefreshDatabase; use RefreshDatabase;
private User $user; private User $user;
private User $assignee; private User $assignee;
private Project $project; private Project $project;
protected function setUp(): void protected function setUp(): void
@@ -79,6 +80,7 @@ class IssuesEnhancementsTest extends TestCase
'project_id' => $this->project->id, 'phase_id' => $phase->id, 'project_id' => $this->project->id, 'phase_id' => $phase->id,
'name' => 'L', 'color' => '#111', 'uploaded_by' => $this->user->id, 'name' => 'L', 'color' => '#111', 'uploaded_by' => $this->user->id,
]); ]);
return Feature::create([ return Feature::create([
'layer_id' => $layer->id, 'name' => 'Muro norte', 'layer_id' => $layer->id, 'name' => 'Muro norte',
'geometry' => ['type' => 'Point', 'coordinates' => [-3.0, 40.0]], 'geometry' => ['type' => 'Point', 'coordinates' => [-3.0, 40.0]],
@@ -172,7 +174,7 @@ class IssuesEnhancementsTest extends TestCase
'assigned_to' => $this->assignee->id, 'assigned_to' => $this->assignee->id,
'due_date' => now()->subDays(2)->toDateString(), 'due_date' => now()->subDays(2)->toDateString(),
'is_done' => false, 'is_done' => false,
'uuid' => (string) \Illuminate\Support\Str::uuid(), 'uuid' => (string) Str::uuid(),
]); ]);
$this->artisan('issues:notify-overdue')->assertSuccessful(); $this->artisan('issues:notify-overdue')->assertSuccessful();

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