chore: cleanup dead code + format with Pint

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

Tests: 101 passing (319 assertions)
API routes: unchanged (8 routes intact)
This commit is contained in:
Javier Braña
2026-08-28 13:04:28 +02:00
parent 2dccd59385
commit 90630379fb
139 changed files with 2888 additions and 2510 deletions
@@ -1,44 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Services\SpatialFileConverter;
use App\Models\Phase;
class ConvertSpatialFile extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'convert:spatial {file} {phase_id}';
protected $description = 'Convert a spatial file (DWG, SHP, KML, GeoJSON) to GeoJSON and attach to phase';
/**
* Execute the console command.
*/
public function handle()
{
$filePath = $this->argument('file');
$phaseId = $this->argument('phase_id');
$phase = Phase::findOrFail($phaseId);
$file = new \Illuminate\Http\UploadedFile($filePath, basename($filePath));
$geojson = SpatialFileConverter::convertToGeoJson($file, $file->getClientOriginalName());
if ($geojson) {
$layer = $phase->layers()->create([
'project_id' => $phase->project_id,
'name' => 'Converted: ' . basename($filePath),
'geojson_data' => $geojson,
'uploaded_by' => 1, // admin
'original_file' => $filePath
]);
$this->info("GeoJSON saved to layer ID {$layer->id}");
} else {
$this->error("Conversion failed for file type.");
}
}
}
@@ -1,45 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Layer;
use App\Models\Feature;
use Illuminate\Console\Command;
class MigrateGeojsonToFeatures extends Command
{
protected $signature = 'migrate:geojson-to-features';
protected $description = 'Migrate features from layer.geojson_data to features table';
public function handle()
{
$layers = Layer::whereNotNull('geojson_data')->get();
$totalFeatures = 0;
foreach ($layers as $layer) {
$geojson = $layer->geojson_data;
if (!isset($geojson['features'])) continue;
foreach ($geojson['features'] as $featureData) {
$geometry = $featureData['geometry'];
$props = $featureData['properties'] ?? [];
Feature::create([
'layer_id' => $layer->id,
'name' => $props['name'] ?? null,
'geometry' => $geometry,
'properties' => $props,
'template_id' => $props['template_id'] ?? null,
'progress' => $props['progress'] ?? 0,
'responsible' => $props['responsible'] ?? null,
]);
$totalFeatures++;
}
// Opcional: Marcar la capa como migrada (podrías agregar columna 'migrated_at')
$this->info("Layer {$layer->id} ({$layer->name}) migrated.");
}
$this->info("Total features migrated: {$totalFeatures}");
}
}
+8 -8
View File
@@ -3,7 +3,6 @@
namespace App\DTO; 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',
]; ];
} }
} }
+73 -68
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 = [];
@@ -129,7 +131,7 @@ abstract class BaseSheet implements FromArray, WithHeadings, WithStyles, WithCol
// Auto-filter // Auto-filter
$sheet->setAutoFilter($sheet->calculateWorksheetDimension()); $sheet->setAutoFilter($sheet->calculateWorksheetDimension());
// Freeze header row // Freeze header row
$sheet->freezePane('A2'); $sheet->freezePane('A2');
} }
@@ -147,13 +149,14 @@ 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)
{ {
$this->project = $project; $this->project = $project;
$this->filters = $filters; $this->filters = $filters;
$rows = [ $rows = [
['Informe de Proyecto', $project->name], ['Informe de Proyecto', $project->name],
['Referencia', $project->reference ?? '—'], ['Referencia', $project->reference ?? '—'],
@@ -194,7 +197,7 @@ class SummarySheet extends BaseSheet
['Retrasadas', $summary['phases_delayed'] ?? 0], ['Retrasadas', $summary['phases_delayed'] ?? 0],
['Sin datos', $summary['phases_no_data'] ?? 0], ['Sin datos', $summary['phases_no_data'] ?? 0],
]; ];
parent::__construct($rows); parent::__construct($rows);
} }
@@ -218,10 +221,10 @@ class PhasesSheet extends BaseSheet
public function headings(): array public function headings(): array
{ {
return [ return [
'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',
]; ];
} }
@@ -252,9 +255,9 @@ class PhasesSheet extends BaseSheet
public function columnWidths(): array public function columnWidths(): array
{ {
return ['A' => 8, 'B' => 25, 'C' => 8, 'D' => 10, 'E' => 12, 'F' => 12, return ['A' => 8, 'B' => 25, 'C' => 8, 'D' => 10, 'E' => 12, 'F' => 12,
'G' => 12, 'H' => 12, 'I' => 12, 'J' => 14, 'K' => 14, 'L' => 14, 'G' => 12, 'H' => 12, 'I' => 12, 'J' => 14, 'K' => 14, 'L' => 14,
'M' => 8, 'N' => 10, 'O' => 10, 'P' => 12, 'Q' => 10]; 'M' => 8, 'N' => 10, 'O' => 10, 'P' => 12, 'Q' => 10];
} }
} }
@@ -270,7 +273,7 @@ class FeaturesSheet extends BaseSheet
'ID', 'Elemento', 'Fase', 'Capa', 'Estado', 'Progreso (%)', 'Progreso Plan (%)', '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',
]; ];
} }
@@ -305,10 +308,10 @@ class FeaturesSheet extends BaseSheet
public function columnWidths(): array public function columnWidths(): array
{ {
return ['A' => 8, 'B' => 25, 'C' => 20, 'D' => 20, 'E' => 15, 'F' => 12, return ['A' => 8, 'B' => 25, 'C' => 20, 'D' => 20, 'E' => 15, 'F' => 12,
'G' => 14, 'H' => 12, 'I' => 12, 'J' => 12, 'K' => 12, 'L' => 14, 'G' => 14, 'H' => 12, 'I' => 12, 'J' => 12, 'K' => 12, 'L' => 14,
'M' => 14, 'N' => 8, 'O' => 10, 'P' => 20, 'Q' => 20, 'R' => 14, 'M' => 14, 'N' => 8, 'O' => 10, 'P' => 20, 'Q' => 20, 'R' => 14,
'S' => 12, 'T' => 12, 'U' => 12]; 'S' => 12, 'T' => 12, 'U' => 12];
} }
} }
@@ -321,8 +324,8 @@ class InspectionsSheet extends BaseSheet
public function headings(): array public function headings(): array
{ {
return [ return [
'ID', 'Elemento', 'Fase', 'Template', 'Inspector', 'Fecha', 'ID', 'Elemento', 'Fase', 'Template', 'Inspector', 'Fecha',
'Estado', 'Resultado', 'Notas', 'Fotos' 'Estado', 'Resultado', 'Notas', 'Fotos',
]; ];
} }
@@ -346,8 +349,8 @@ class InspectionsSheet extends BaseSheet
public function columnWidths(): array public function columnWidths(): array
{ {
return ['A' => 8, 'B' => 25, 'C' => 20, 'D' => 25, 'E' => 20, 'F' => 18, return ['A' => 8, 'B' => 25, 'C' => 20, 'D' => 25, 'E' => 20, 'F' => 18,
'G' => 12, 'H' => 12, 'I' => 40, 'J' => 8]; 'G' => 12, 'H' => 12, 'I' => 40, 'J' => 8];
} }
} }
@@ -360,9 +363,9 @@ class IssuesSheet extends BaseSheet
public function headings(): array public function headings(): array
{ {
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',
]; ];
} }
@@ -389,8 +392,8 @@ class IssuesSheet extends BaseSheet
public function columnWidths(): array public function columnWidths(): array
{ {
return ['A' => 8, 'B' => 35, 'C' => 20, 'D' => 20, 'E' => 12, 'F' => 15, return ['A' => 8, 'B' => 35, 'C' => 20, 'D' => 20, 'E' => 12, 'F' => 15,
'G' => 20, 'H' => 20, 'I' => 12, 'J' => 12, 'K' => 12, 'L' => 12, 'M' => 14]; 'G' => 20, 'H' => 20, 'I' => 12, 'J' => 12, 'K' => 12, 'L' => 12, 'M' => 14];
} }
} }
@@ -404,15 +407,16 @@ 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'],
@@ -435,9 +439,9 @@ class TasksSheet extends BaseSheet
public function columnWidths(): array public function columnWidths(): array
{ {
return ['A' => 8, 'B' => 30, 'C' => 20, 'D' => 15, 'E' => 12, 'F' => 20, return ['A' => 8, 'B' => 30, 'C' => 20, 'D' => 15, 'E' => 12, 'F' => 20,
'G' => 20, 'H' => 12, 'I' => 12, 'J' => 12, 'K' => 10, 'L' => 10, 'G' => 20, 'H' => 12, 'I' => 12, 'J' => 12, 'K' => 10, 'L' => 10,
'M' => 12, 'N' => 10, 'O' => 40]; 'M' => 12, 'N' => 10, 'O' => 40];
} }
} }
@@ -453,14 +457,14 @@ class DeviationsSheet extends BaseSheet
{ {
$this->deviations = $deviations; $this->deviations = $deviations;
$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 (%)',
'Desvío Prog.', 'SPI', 'En Plazo']; 'Desvío Prog.', 'SPI', 'En Plazo'];
foreach ($deviations['phases'] as $phase) { foreach ($deviations['phases'] as $phase) {
$rows[] = [ $rows[] = [
$phase['id'], $phase['name'], $phase['planned_start'], $phase['planned_end'], $phase['id'], $phase['name'], $phase['planned_start'], $phase['planned_end'],
@@ -475,12 +479,12 @@ 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 (%)',
'Desvío Prog.', 'SPI', 'En Plazo', 'Responsable']; 'Desvío Prog.', 'SPI', 'En Plazo', 'Responsable'];
foreach ($deviations['features'] as $feature) { foreach ($deviations['features'] as $feature) {
$rows[] = [ $rows[] = [
$feature['id'], $feature['name'], $feature['phase'], $feature['id'], $feature['name'], $feature['phase'],
@@ -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];
@@ -522,9 +526,9 @@ class DeviationsSheet extends BaseSheet
public function columnWidths(): array public function columnWidths(): array
{ {
return ['A' => 25, 'B' => 25, 'C' => 20, 'D' => 12, 'E' => 12, 'F' => 12, return ['A' => 25, 'B' => 25, 'C' => 20, 'D' => 12, 'E' => 12, 'F' => 12,
'G' => 12, 'H' => 14, 'I' => 14, 'J' => 14, 'K' => 14, 'L' => 10, 'G' => 12, 'H' => 14, 'I' => 14, 'J' => 14, 'K' => 14, 'L' => 10,
'M' => 10, 'N' => 20]; 'M' => 10, 'N' => 20];
} }
} }
@@ -558,8 +562,8 @@ class MediaSheet extends BaseSheet
public function columnWidths(): array public function columnWidths(): array
{ {
return ['A' => 8, 'B' => 30, 'C' => 12, 'D' => 15, 'E' => 30, 'F' => 12, return ['A' => 8, 'B' => 30, 'C' => 12, 'D' => 15, 'E' => 30, 'F' => 12,
'G' => 20, 'H' => 18, 'I' => 50]; 'G' => 20, 'H' => 18, 'I' => 50];
} }
} }
@@ -574,17 +578,17 @@ class ProgressCurveSheet extends BaseSheet
public function __construct(array $curveData) public function __construct(array $curveData)
{ {
$this->curveData = $curveData; $this->curveData = $curveData;
$rows = [['Fecha', 'Progreso Planificado (%)', 'Progreso Real (%)']]; $rows = [['Fecha', 'Progreso Planificado (%)', 'Progreso Real (%)']];
$labels = $curveData['labels'] ?? []; $labels = $curveData['labels'] ?? [];
$planned = $curveData['planned'] ?? []; $planned = $curveData['planned'] ?? [];
$actual = $curveData['actual'] ?? []; $actual = $curveData['actual'] ?? [];
for ($i = 0; $i < count($labels); $i++) { for ($i = 0; $i < count($labels); $i++) {
$rows[] = [$labels[$i], $planned[$i] ?? 0, $actual[$i] ?? 0]; $rows[] = [$labels[$i], $planned[$i] ?? 0, $actual[$i] ?? 0];
} }
$this->rows = $rows; $this->rows = $rows;
} }
@@ -611,13 +615,14 @@ 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)
{ {
$this->filters = $filters; $this->filters = $filters;
$this->project = $project; $this->project = $project;
$rows = [ $rows = [
['Parámetro', 'Valor'], ['Parámetro', 'Valor'],
['Proyecto', $project->name], ['Proyecto', $project->name],
@@ -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'],
@@ -634,7 +639,7 @@ class ParametersSheet extends BaseSheet
['Generado', now()->format('d/m/Y H:i')], ['Generado', now()->format('d/m/Y H:i')],
['Generado por', auth()->guard()->user()?->name ?? 'Sistema'], ['Generado por', auth()->guard()->user()?->name ?? 'Sistema'],
]; ];
parent::__construct($rows); parent::__construct($rows);
} }
@@ -652,4 +657,4 @@ class ParametersSheet extends BaseSheet
{ {
return ['A' => 30, 'B' => 50]; return ['A' => 30, 'B' => 50];
} }
} }
+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',
]; ];
} }
} }
@@ -17,8 +17,8 @@ class AuthController extends Controller
public function login(Request $request) public function login(Request $request)
{ {
$data = $request->validate([ $data = $request->validate([
'email' => ['required', 'email'], 'email' => ['required', 'email'],
'password' => ['required', 'string'], 'password' => ['required', 'string'],
'device_name' => ['required', 'string', 'max:255'], 'device_name' => ['required', 'string', 'max:255'],
'app_version' => ['nullable', 'string', 'max:50'], 'app_version' => ['nullable', 'string', 'max:50'],
]); ]);
@@ -39,15 +39,15 @@ class AuthController extends Controller
Device::updateOrCreate( Device::updateOrCreate(
['user_id' => $user->id, 'name' => $data['device_name']], ['user_id' => $user->id, 'name' => $data['device_name']],
[ [
'token_id' => $token->accessToken->id, 'token_id' => $token->accessToken->id,
'app_version' => $data['app_version'] ?? null, 'app_version' => $data['app_version'] ?? null,
'last_seen_at' => now(), 'last_seen_at' => now(),
] ]
); );
return response()->json([ return response()->json([
'token' => $token->plainTextToken, 'token' => $token->plainTextToken,
'user' => $this->userPayload($user), 'user' => $this->userPayload($user),
]); ]);
} }
@@ -70,10 +70,10 @@ class AuthController extends Controller
private function userPayload(User $user): array private function userPayload(User $user): array
{ {
return [ return [
'id' => $user->id, 'id' => $user->id,
'name' => $user->name, 'name' => $user->name,
'email' => $user->email, 'email' => $user->email,
'roles' => $user->getRoleNames(), 'roles' => $user->getRoleNames(),
'permissions' => $user->getAllPermissions()->pluck('name')->values(), 'permissions' => $user->getAllPermissions()->pluck('name')->values(),
]; ];
} }
+35 -34
View File
@@ -13,31 +13,31 @@ use App\Models\Phase;
use App\Models\Project; use App\Models\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
{ {
private array $map = [ private array $map = [
'feature' => Feature::class, 'feature' => Feature::class,
'issue' => Issue::class, 'issue' => Issue::class,
'issue_task' => IssueTask::class, 'issue_task' => IssueTask::class,
'issue_comment' => IssueComment::class, 'issue_comment' => IssueComment::class,
'project' => Project::class, 'project' => Project::class,
'phase' => Phase::class, 'phase' => Phase::class,
'layer' => Layer::class, 'layer' => Layer::class,
]; ];
/** Upload a file (multipart) and attach it to a parent record. Idempotent by uuid. */ /** Upload a file (multipart) and attach it to a parent record. Idempotent by uuid. */
public function upload(Request $request) public function upload(Request $request)
{ {
$data = $request->validate([ $data = $request->validate([
'uuid' => ['required', 'uuid'], 'uuid' => ['required', 'uuid'],
'parent_entity' => ['required', Rule::in(array_keys($this->map))], 'parent_entity' => ['required', Rule::in(array_keys($this->map))],
'parent_id' => ['required', 'integer'], 'parent_id' => ['required', 'integer'],
'file' => ['required', 'file', 'max:20480'], // 20 MB 'file' => ['required', 'file', 'max:20480'], // 20 MB
'category' => ['nullable', 'in:image,document,other'], 'category' => ['nullable', 'in:image,document,other'],
'description' => ['nullable', 'string'], 'description' => ['nullable', 'string'],
]); ]);
// Idempotency: same uuid already uploaded → return it. // Idempotency: same uuid already uploaded → return it.
@@ -59,15 +59,15 @@ class MediaController extends Controller
$mime = $file->getClientMimeType(); $mime = $file->getClientMimeType();
$media = $parent->media()->create([ $media = $parent->media()->create([
'uuid' => $data['uuid'], 'uuid' => $data['uuid'],
'name' => $file->getClientOriginalName(), 'name' => $file->getClientOriginalName(),
'file_path' => $path, 'file_path' => $path,
'file_type' => $mime, 'file_type' => $mime,
'file_extension' => $file->getClientOriginalExtension(), 'file_extension' => $file->getClientOriginalExtension(),
'file_size' => $file->getSize(), 'file_size' => $file->getSize(),
'category' => $data['category'] ?? (Str::startsWith($mime, 'image/') ? 'image' : 'document'), 'category' => $data['category'] ?? (Str::startsWith($mime, 'image/') ? 'image' : 'document'),
'description' => $data['description'] ?? null, 'description' => $data['description'] ?? null,
'uploaded_by' => $user->id, 'uploaded_by' => $user->id,
'client_updated_at' => $request->input('client_updated_at'), 'client_updated_at' => $request->input('client_updated_at'),
]); ]);
@@ -77,14 +77,14 @@ class MediaController extends Controller
private function projectOf(string $entity, $parent): ?Project private function projectOf(string $entity, $parent): ?Project
{ {
return match ($entity) { return match ($entity) {
'project' => $parent, 'project' => $parent,
'phase' => $parent->project, 'phase' => $parent->project,
'layer' => $parent->phase?->project, 'layer' => $parent->phase?->project,
'feature' => $parent->layer?->phase?->project, 'feature' => $parent->layer?->phase?->project,
'issue' => $parent->project, 'issue' => $parent->project,
'issue_task' => $parent->issue?->project, 'issue_task' => $parent->issue?->project,
'issue_comment' => $parent->issue?->project, 'issue_comment' => $parent->issue?->project,
default => null, default => null,
}; };
} }
@@ -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();
} }
@@ -100,12 +101,12 @@ class MediaController extends Controller
private function payload(Media $m): array private function payload(Media $m): array
{ {
return [ return [
'id' => $m->id, 'id' => $m->id,
'uuid' => $m->uuid, 'uuid' => $m->uuid,
'url' => $m->url, 'url' => $m->url,
'name' => $m->name, 'name' => $m->name,
'file_type' => $m->file_type, 'file_type' => $m->file_type,
'category' => $m->category, 'category' => $m->category,
'updated_at' => $m->updated_at?->toIso8601String(), 'updated_at' => $m->updated_at?->toIso8601String(),
]; ];
} }
@@ -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;
@@ -53,35 +54,35 @@ class ProjectApiController extends Controller
$templates = $changed(InspectionTemplate::whereHas('projects', fn ($q) => $q->where('projects.id', $project->id)))->get(); $templates = $changed(InspectionTemplate::whereHas('projects', fn ($q) => $q->where('projects.id', $project->id)))->get();
$allIssueIds = Issue::withTrashed()->where('project_id', $project->id)->pluck('id'); $allIssueIds = Issue::withTrashed()->where('project_id', $project->id)->pluck('id');
$issueTasks = $changed(IssueTask::whereIn('issue_id', $allIssueIds))->get(); $issueTasks = $changed(IssueTask::whereIn('issue_id', $allIssueIds))->get();
$issueComments = $changed(IssueComment::whereIn('issue_id', $allIssueIds))->get(); $issueComments = $changed(IssueComment::whereIn('issue_id', $allIssueIds))->get();
$featureIds = Feature::whereIn('layer_id', $allLayerIds)->pluck('id'); $featureIds = Feature::whereIn('layer_id', $allLayerIds)->pluck('id');
$issueIds = Issue::where('project_id', $project->id)->pluck('id'); $issueIds = Issue::where('project_id', $project->id)->pluck('id');
$taskIds = IssueTask::whereIn('issue_id', $allIssueIds)->pluck('id'); $taskIds = IssueTask::whereIn('issue_id', $allIssueIds)->pluck('id');
$commentIds = IssueComment::whereIn('issue_id', $allIssueIds)->pluck('id'); $commentIds = IssueComment::whereIn('issue_id', $allIssueIds)->pluck('id');
$media = $changed(Media::where(function ($q) use ($project, $featureIds, $issueIds, $taskIds, $commentIds) { $media = $changed(Media::where(function ($q) use ($project, $featureIds, $issueIds, $taskIds, $commentIds) {
$q->where(fn ($w) => $w->where('mediable_type', Project::class)->where('mediable_id', $project->id)) $q->where(fn ($w) => $w->where('mediable_type', Project::class)->where('mediable_id', $project->id))
->orWhere(fn ($w) => $w->where('mediable_type', Feature::class)->whereIn('mediable_id', $featureIds)) ->orWhere(fn ($w) => $w->where('mediable_type', Feature::class)->whereIn('mediable_id', $featureIds))
->orWhere(fn ($w) => $w->where('mediable_type', Issue::class)->whereIn('mediable_id', $issueIds)) ->orWhere(fn ($w) => $w->where('mediable_type', Issue::class)->whereIn('mediable_id', $issueIds))
->orWhere(fn ($w) => $w->where('mediable_type', IssueTask::class)->whereIn('mediable_id', $taskIds)) ->orWhere(fn ($w) => $w->where('mediable_type', IssueTask::class)->whereIn('mediable_id', $taskIds))
->orWhere(fn ($w) => $w->where('mediable_type', IssueComment::class)->whereIn('mediable_id', $commentIds)); ->orWhere(fn ($w) => $w->where('mediable_type', IssueComment::class)->whereIn('mediable_id', $commentIds));
}))->get(); }))->get();
return response()->json([ return response()->json([
'server_time' => now()->toIso8601String(), 'server_time' => now()->toIso8601String(),
'project' => $this->mapProject($project), 'project' => $this->mapProject($project),
'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(),
'issue_comments' => $issueComments->map(fn ($c) => $this->mapIssueComment($c))->values(), 'issue_comments' => $issueComments->map(fn ($c) => $this->mapIssueComment($c))->values(),
'templates' => $templates->map(fn ($t) => $this->mapTemplate($t))->values(), 'templates' => $templates->map(fn ($t) => $this->mapTemplate($t))->values(),
'media' => $media->map(fn ($m) => $this->mapMedia($m))->values(), 'media' => $media->map(fn ($m) => $this->mapMedia($m))->values(),
'deleted' => $since ? $this->tombstones($since, $project, $allPhaseIds, $allLayerIds, $allIssueIds) : (object) [], 'deleted' => $since ? $this->tombstones($since, $project, $allPhaseIds, $allLayerIds, $allIssueIds) : (object) [],
]); ]);
} }
@@ -118,12 +119,12 @@ class ProjectApiController extends Controller
private function tombstones(Carbon $since, Project $project, $allPhaseIds, $allLayerIds, $allIssueIds): array private function tombstones(Carbon $since, Project $project, $allPhaseIds, $allLayerIds, $allIssueIds): array
{ {
return [ return [
'phases' => Phase::onlyTrashed()->where('project_id', $project->id)->where('deleted_at', '>', $since)->pluck('id')->values(), 'phases' => Phase::onlyTrashed()->where('project_id', $project->id)->where('deleted_at', '>', $since)->pluck('id')->values(),
'layers' => Layer::onlyTrashed()->whereIn('phase_id', $allPhaseIds)->where('deleted_at', '>', $since)->pluck('id')->values(), 'layers' => Layer::onlyTrashed()->whereIn('phase_id', $allPhaseIds)->where('deleted_at', '>', $since)->pluck('id')->values(),
'features' => Feature::onlyTrashed()->whereIn('layer_id', $allLayerIds)->where('deleted_at', '>', $since)->pluck('id')->values(), 'features' => Feature::onlyTrashed()->whereIn('layer_id', $allLayerIds)->where('deleted_at', '>', $since)->pluck('id')->values(),
'inspections' => Inspection::onlyTrashed()->where('project_id', $project->id)->where('deleted_at', '>', $since)->pluck('id')->values(), 'inspections' => Inspection::onlyTrashed()->where('project_id', $project->id)->where('deleted_at', '>', $since)->pluck('id')->values(),
'issues' => Issue::onlyTrashed()->where('project_id', $project->id)->where('deleted_at', '>', $since)->pluck('id')->values(), 'issues' => Issue::onlyTrashed()->where('project_id', $project->id)->where('deleted_at', '>', $since)->pluck('id')->values(),
'issue_tasks' => IssueTask::onlyTrashed()->whereIn('issue_id', $allIssueIds)->where('deleted_at', '>', $since)->pluck('id')->values(), 'issue_tasks' => IssueTask::onlyTrashed()->whereIn('issue_id', $allIssueIds)->where('deleted_at', '>', $since)->pluck('id')->values(),
'issue_comments' => IssueComment::onlyTrashed()->whereIn('issue_id', $allIssueIds)->where('deleted_at', '>', $since)->pluck('id')->values(), 'issue_comments' => IssueComment::onlyTrashed()->whereIn('issue_id', $allIssueIds)->where('deleted_at', '>', $since)->pluck('id')->values(),
]; ];
} }
@@ -209,7 +210,7 @@ class ProjectApiController extends Controller
'id' => $t->id, 'project_id' => $t->project_id, 'phase_id' => $t->phase_id, '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(),
]; ];
} }
@@ -217,10 +218,10 @@ class ProjectApiController extends Controller
private function mapMedia(Media $m): array private function mapMedia(Media $m): array
{ {
$entity = [ $entity = [
Project::class => 'project', Project::class => 'project',
Feature::class => 'feature', Feature::class => 'feature',
Issue::class => 'issue', Issue::class => 'issue',
IssueTask::class => 'issue_task', IssueTask::class => 'issue_task',
IssueComment::class => 'issue_comment', IssueComment::class => 'issue_comment',
][$m->mediable_type] ?? class_basename($m->mediable_type); ][$m->mediable_type] ?? class_basename($m->mediable_type);
@@ -232,4 +233,3 @@ class ProjectApiController extends Controller
]; ];
} }
} }
+102 -100
View File
@@ -30,12 +30,12 @@ class SyncController extends Controller
public function sync(Request $request) public function sync(Request $request)
{ {
$data = $request->validate([ $data = $request->validate([
'operations' => ['required', 'array'], 'operations' => ['required', 'array'],
'operations.*.entity' => ['required', 'string'], 'operations.*.entity' => ['required', 'string'],
'operations.*.op' => ['required', 'string'], 'operations.*.op' => ['required', 'string'],
'operations.*.uuid' => ['required', 'uuid'], 'operations.*.uuid' => ['required', 'uuid'],
'operations.*.data' => ['required', 'array'], 'operations.*.data' => ['required', 'array'],
'operations.*.client_updated_at' => ['nullable', 'date'], 'operations.*.client_updated_at' => ['nullable', 'date'],
]); ]);
$user = $request->user(); $user = $request->user();
@@ -74,16 +74,16 @@ 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),
'issue.update' => $this->issueUpdate($user, $uuid, $op), 'issue.update' => $this->issueUpdate($user, $uuid, $op),
'issue_task.create' => $this->issueTaskCreate($user, $uuid, $op), 'issue_task.create' => $this->issueTaskCreate($user, $uuid, $op),
'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());
@@ -92,11 +92,11 @@ class SyncController extends Controller
// Record only terminal successes so conflicts/errors can be safely retried. // Record only terminal successes so conflicts/errors can be safely retried.
if ($result['status'] === 'applied') { if ($result['status'] === 'applied') {
SyncLog::create([ SyncLog::create([
'user_id' => $user->id, 'user_id' => $user->id,
'op_uuid' => $uuid, 'op_uuid' => $uuid,
'entity' => $op['entity'], 'entity' => $op['entity'],
'op' => $op['op'], 'op' => $op['op'],
'status' => 'applied', 'status' => 'applied',
'server_id' => $result['server_id'] ?? null, 'server_id' => $result['server_id'] ?? null,
]); ]);
} }
@@ -115,11 +115,11 @@ class SyncController extends Controller
$v = Validator::make($op['data'], [ $v = Validator::make($op['data'], [
'phase_id' => ['required', 'integer', 'exists:phases,id'], 'phase_id' => ['required', 'integer', 'exists:phases,id'],
'progress' => ['required', 'integer', 'min:0', 'max:100'], 'progress' => ['required', 'integer', 'min:0', 'max:100'],
'comment' => ['nullable', 'string'], 'comment' => ['nullable', 'string'],
'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();
@@ -129,12 +129,12 @@ class SyncController extends Controller
} }
$pu = ProgressUpdate::create([ $pu = ProgressUpdate::create([
'uuid' => $uuid, 'uuid' => $uuid,
'phase_id' => $phase->id, 'phase_id' => $phase->id,
'user_id' => $user->id, 'user_id' => $user->id,
'progress_percent' => $d['progress'], 'progress_percent' => $d['progress'],
'comment' => $d['comment'] ?? null, 'comment' => $d['comment'] ?? null,
'location' => $d['location'] ?? null, 'location' => $d['location'] ?? null,
'client_updated_at' => $op['client_updated_at'] ?? null, 'client_updated_at' => $op['client_updated_at'] ?? null,
]); ]);
@@ -153,15 +153,15 @@ class SyncController extends Controller
} }
$v = Validator::make($op['data'], [ $v = Validator::make($op['data'], [
'feature_id' => ['required', 'integer', 'exists:features,id'], 'feature_id' => ['required', 'integer', 'exists:features,id'],
'template_id' => ['nullable', 'integer', 'exists:inspection_templates,id'], 'template_id' => ['nullable', 'integer', 'exists:inspection_templates,id'],
'data' => ['nullable', 'array'], 'data' => ['nullable', 'array'],
'status' => ['nullable', 'string'], 'status' => ['nullable', 'string'],
'result' => ['nullable', 'string'], 'result' => ['nullable', 'string'],
'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();
@@ -172,16 +172,16 @@ class SyncController extends Controller
} }
$inspection = Inspection::create([ $inspection = Inspection::create([
'uuid' => $uuid, 'uuid' => $uuid,
'project_id' => $project->id, 'project_id' => $project->id,
'layer_id' => $feature->layer_id, 'layer_id' => $feature->layer_id,
'feature_id' => $feature->id, 'feature_id' => $feature->id,
'template_id' => $d['template_id'] ?? null, 'template_id' => $d['template_id'] ?? null,
'user_id' => $user->id, 'user_id' => $user->id,
'data' => $d['data'] ?? [], 'data' => $d['data'] ?? [],
'status' => $d['status'] ?? 'completed', 'status' => $d['status'] ?? 'completed',
'result' => $d['result'] ?? null, 'result' => $d['result'] ?? null,
'notes' => $d['notes'] ?? null, 'notes' => $d['notes'] ?? null,
'client_updated_at' => $op['client_updated_at'] ?? null, 'client_updated_at' => $op['client_updated_at'] ?? null,
]); ]);
@@ -197,16 +197,16 @@ class SyncController extends Controller
} }
$v = Validator::make($op['data'], [ $v = Validator::make($op['data'], [
'project_id' => ['required', 'integer', 'exists:projects,id'], 'project_id' => ['required', 'integer', 'exists:projects,id'],
'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();
@@ -216,15 +216,15 @@ class SyncController extends Controller
} }
$issue = Issue::create([ $issue = Issue::create([
'uuid' => $uuid, 'uuid' => $uuid,
'project_id' => $project->id, 'project_id' => $project->id,
'feature_id' => $d['feature_id'] ?? null, 'feature_id' => $d['feature_id'] ?? null,
'title' => $d['title'], 'title' => $d['title'],
'description' => $d['description'] ?? null, 'description' => $d['description'] ?? null,
'priority' => $d['priority'] ?? 'medium', 'priority' => $d['priority'] ?? 'medium',
'status' => $d['status'] ?? 'open', 'status' => $d['status'] ?? 'open',
'type' => $d['type'] ?? 'other', 'type' => $d['type'] ?? 'other',
'reported_by' => $user->id, 'reported_by' => $user->id,
'client_updated_at' => $op['client_updated_at'] ?? null, 'client_updated_at' => $op['client_updated_at'] ?? null,
]); ]);
@@ -234,17 +234,17 @@ class SyncController extends Controller
private function issueUpdate(User $user, string $uuid, array $op): array private function issueUpdate(User $user, string $uuid, array $op): array
{ {
$v = Validator::make($op['data'], [ $v = Validator::make($op['data'], [
'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();
@@ -276,14 +276,14 @@ class SyncController extends Controller
} }
$v = Validator::make($op['data'], [ $v = Validator::make($op['data'], [
'issue_id' => ['required', 'integer', 'exists:issues,id'], 'issue_id' => ['required', 'integer', 'exists:issues,id'],
'title' => ['required', 'string', 'max:255'], 'title' => ['required', 'string', 'max:255'],
'assigned_to' => ['nullable', 'integer', 'exists:users,id'], 'assigned_to' => ['nullable', 'integer', 'exists:users,id'],
'due_date' => ['nullable', 'date'], 'due_date' => ['nullable', 'date'],
'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();
@@ -294,15 +294,15 @@ class SyncController extends Controller
$done = $d['is_done'] ?? false; $done = $d['is_done'] ?? false;
$task = IssueTask::create([ $task = IssueTask::create([
'uuid' => $uuid, 'uuid' => $uuid,
'issue_id' => $issue->id, 'issue_id' => $issue->id,
'title' => $d['title'], 'title' => $d['title'],
'assigned_to' => $d['assigned_to'] ?? null, 'assigned_to' => $d['assigned_to'] ?? null,
'due_date' => $d['due_date'] ?? null, 'due_date' => $d['due_date'] ?? null,
'is_done' => $done, 'is_done' => $done,
'done_at' => $done ? now() : null, 'done_at' => $done ? now() : null,
'done_by' => $done ? $user->id : null, 'done_by' => $done ? $user->id : null,
'order' => ((int) $issue->tasks()->max('order')) + 1, 'order' => ((int) $issue->tasks()->max('order')) + 1,
'client_updated_at' => $op['client_updated_at'] ?? null, 'client_updated_at' => $op['client_updated_at'] ?? null,
]); ]);
@@ -312,14 +312,14 @@ class SyncController extends Controller
private function issueTaskUpdate(User $user, string $uuid, array $op): array private function issueTaskUpdate(User $user, string $uuid, array $op): array
{ {
$v = Validator::make($op['data'], [ $v = Validator::make($op['data'], [
'id' => ['required', 'integer', 'exists:issue_tasks,id'], 'id' => ['required', 'integer', 'exists:issue_tasks,id'],
'title' => ['nullable', 'string', 'max:255'], 'title' => ['nullable', 'string', 'max:255'],
'assigned_to' => ['nullable', 'integer', 'exists:users,id'], 'assigned_to' => ['nullable', 'integer', 'exists:users,id'],
'due_date' => ['nullable', 'date'], 'due_date' => ['nullable', 'date'],
'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();
@@ -354,10 +354,10 @@ class SyncController extends Controller
$v = Validator::make($op['data'], [ $v = Validator::make($op['data'], [
'issue_id' => ['required', 'integer', 'exists:issues,id'], 'issue_id' => ['required', 'integer', 'exists:issues,id'],
'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();
@@ -367,10 +367,10 @@ class SyncController extends Controller
} }
$comment = IssueComment::create([ $comment = IssueComment::create([
'uuid' => $uuid, 'uuid' => $uuid,
'issue_id' => $issue->id, 'issue_id' => $issue->id,
'user_id' => $user->id, 'user_id' => $user->id,
'body' => $d['body'], 'body' => $d['body'],
'client_updated_at' => $op['client_updated_at'] ?? null, 'client_updated_at' => $op['client_updated_at'] ?? null,
]); ]);
@@ -382,15 +382,15 @@ class SyncController extends Controller
private function featureUpdate(User $user, string $uuid, array $op): array private function featureUpdate(User $user, string $uuid, array $op): array
{ {
$v = Validator::make($op['data'], [ $v = Validator::make($op['data'], [
'id' => ['required', 'integer', 'exists:features,id'], 'id' => ['required', 'integer', 'exists:features,id'],
'status' => ['nullable', 'string'], 'status' => ['nullable', 'string'],
'progress' => ['nullable', 'integer', 'min:0', 'max:100'], 'progress' => ['nullable', 'integer', 'min:0', 'max:100'],
'responsible' => ['nullable', 'string'], 'responsible' => ['nullable', 'string'],
'is_active' => ['nullable', 'boolean'], 'is_active' => ['nullable', 'boolean'],
'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();
} }
@@ -440,11 +441,12 @@ class SyncController extends Controller
$clientAt = Carbon::parse($op['client_updated_at']); $clientAt = Carbon::parse($op['client_updated_at']);
if ($model->updated_at && $model->updated_at->gt($clientAt)) { if ($model->updated_at && $model->updated_at->gt($clientAt)) {
return [ return [
'uuid' => $uuid, 'uuid' => $uuid,
'status' => 'conflict', 'status' => 'conflict',
'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)
{
//
}
}
+2 -4
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);
@@ -52,4 +50,4 @@ class ProfileController extends Controller
return redirect('/'); return redirect('/');
} }
} }
+3 -2
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');
} }
@@ -94,4 +95,4 @@ class ProjectController extends Controller
// (lo validaremos dentro del componente Livewire) // (lo validaremos dentro del componente Livewire)
return view('projects.map', compact('project')); return view('projects.map', compact('project'));
} }
} }
@@ -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,11 +22,11 @@ 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),
]; ];
$pdf_data = compact('project', 'phases', 'stats'); $pdf_data = compact('project', 'phases', 'stats');
+7 -7
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,8 +65,8 @@ 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);
} }
@@ -76,13 +76,13 @@ class ReportController extends Controller
protected function authorizeProjectAccess(Project $project): void protected function authorizeProjectAccess(Project $project): void
{ {
$user = Auth::user(); $user = Auth::user();
if ($user->can('manage all')) { if ($user->can('manage all')) {
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
{ {
+3 -2
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);
} }
} }
+4 -4
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');
} }
@@ -52,4 +52,4 @@ class SetLocale
return $next($request); return $next($request);
} }
} }
+5 -5
View File
@@ -22,7 +22,7 @@ class CaptureDailyProgressSnapshot implements ShouldQueue
public function handle(): void public function handle(): void
{ {
$date = $this->snapshotDate ? Carbon::parse($this->snapshotDate) : Carbon::today(); $date = $this->snapshotDate ? Carbon::parse($this->snapshotDate) : Carbon::today();
Log::info('Starting daily progress snapshot capture', ['date' => $date->toDateString()]); Log::info('Starting daily progress snapshot capture', ['date' => $date->toDateString()]);
$captured = 0; $captured = 0;
@@ -45,7 +45,7 @@ class CaptureDailyProgressSnapshot implements ShouldQueue
private function capturePhaseSnapshots(Carbon $date): int private function capturePhaseSnapshots(Carbon $date): int
{ {
$count = 0; $count = 0;
Phase::with(['project'])->chunkById(100, function ($phases) use ($date, &$count) { Phase::with(['project'])->chunkById(100, function ($phases) use ($date, &$count) {
foreach ($phases as $phase) { foreach ($phases as $phase) {
// Skip if snapshot already exists for this date // Skip if snapshot already exists for this date
@@ -87,7 +87,7 @@ class CaptureDailyProgressSnapshot implements ShouldQueue
private function captureFeatureSnapshots(Carbon $date): int private function captureFeatureSnapshots(Carbon $date): int
{ {
$count = 0; $count = 0;
Feature::with(['layer.phase.project'])->chunkById(200, function ($features) use ($date, &$count) { Feature::with(['layer.phase.project'])->chunkById(200, function ($features) use ($date, &$count) {
foreach ($features as $feature) { foreach ($features as $feature) {
if (ProgressSnapshot::where('trackable_type', Feature::class) if (ProgressSnapshot::where('trackable_type', Feature::class)
@@ -131,7 +131,7 @@ class CaptureDailyProgressSnapshot implements ShouldQueue
private function captureTaskSnapshots(Carbon $date): int private function captureTaskSnapshots(Carbon $date): int
{ {
$count = 0; $count = 0;
Task::with(['project', 'phase'])->chunkById(200, function ($tasks) use ($date, &$count) { Task::with(['project', 'phase'])->chunkById(200, function ($tasks) use ($date, &$count) {
foreach ($tasks as $task) { foreach ($tasks as $task) {
if (ProgressSnapshot::where('trackable_type', Task::class) if (ProgressSnapshot::where('trackable_type', Task::class)
@@ -171,4 +171,4 @@ class CaptureDailyProgressSnapshot implements ShouldQueue
return $count; return $count;
} }
} }
+8 -6
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
@@ -24,8 +26,8 @@ class RoleForm extends Component
abort_unless(Auth::user()?->can('manage roles'), 403); abort_unless(Auth::user()?->can('manage roles'), 403);
if ($role && $role->exists) { if ($role && $role->exists) {
$this->role = $role; $this->role = $role;
$this->name = $role->name; $this->name = $role->name;
$this->description = $role->description ?? ''; $this->description = $role->description ?? '';
} }
} }
@@ -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']);
@@ -46,7 +48,7 @@ class RoleForm extends Component
$this->role->save(); $this->role->save();
} else { } else {
Role::create([ Role::create([
'name' => $this->name, 'name' => $this->name,
'description' => $this->description ?: null, 'description' => $this->description ?: null,
]); ]);
} }
+11 -6
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;
} }
@@ -103,7 +108,7 @@ class RolePermissionManager extends Component
public function render() public function render()
{ {
return view('livewire.roles.role-permission-manager', [ return view('livewire.roles.role-permission-manager', [
'roles' => Role::with('permissions')->orderBy('name')->get(), 'roles' => Role::with('permissions')->orderBy('name')->get(),
'permissions' => Permission::orderBy('name')->get(), 'permissions' => Permission::orderBy('name')->get(),
]); ]);
} }
+39 -37
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;
@@ -17,8 +17,8 @@ class RoleTable extends DataTableComponent
public function configure(): void public function configure(): void
{ {
$this->setPrimaryKey('id') $this->setPrimaryKey('id')
->setDefaultSort('name', 'asc') ->setDefaultSort('name', 'asc')
->setSortingPillsEnabled(false); ->setSortingPillsEnabled(false);
} }
public function builder(): Builder public function builder(): Builder
@@ -30,48 +30,48 @@ class RoleTable extends DataTableComponent
{ {
return [ return [
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(),
Column::make(__('Description'), 'description') Column::make(__('Description'), 'description')
->sortable() ->sortable()
->searchable() ->searchable()
->format(fn ($value) => $value ->format(fn ($value) => $value
? '<span class="text-sm text-gray-500">'.e($value).'</span>' ? '<span class="text-sm text-gray-500">'.e($value).'</span>'
: '<span class="text-gray-300">—</span>') : '<span class="text-gray-300">—</span>')
->html(), ->html(),
Column::make(__('Permissions')) Column::make(__('Permissions'))
->label(fn ($row) => '<span class="badge badge-outline badge-sm">'.(int) $row->permissions_count.'</span>') ->label(fn ($row) => '<span class="badge badge-outline badge-sm">'.(int) $row->permissions_count.'</span>')
->html(), ->html(),
Column::make(__('Users')) Column::make(__('Users'))
->label(fn ($row) => '<span class="badge badge-ghost badge-sm">'.(int) $row->users_count.'</span>') ->label(fn ($row) => '<span class="badge badge-ghost badge-sm">'.(int) $row->users_count.'</span>')
->html(), ->html(),
Column::make(__('Actions')) Column::make(__('Actions'))
->label(function ($row) { ->label(function ($row) {
$show = route('admin.roles.show', $row->id); $show = route('admin.roles.show', $row->id);
$edit = route('admin.roles.edit', $row->id); $edit = route('admin.roles.edit', $row->id);
$eye = '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>'; $eye = '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>';
$pencil = '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>'; $pencil = '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>';
$trash = '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>'; $trash = '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>';
$html = '<div class="flex items-center gap-1">'; $html = '<div class="flex items-center gap-1">';
$html .= '<a href="'.$show.'" class="btn btn-xs btn-ghost" title="Ver" wire:navigate>'.$eye.'</a>'; $html .= '<a href="'.$show.'" class="btn btn-xs btn-ghost" title="Ver" wire:navigate>'.$eye.'</a>';
$html .= '<a href="'.$edit.'" class="btn btn-xs btn-ghost text-info" title="Editar" wire:navigate>'.$pencil.'</a>'; $html .= '<a href="'.$edit.'" class="btn btn-xs btn-ghost text-info" title="Editar" wire:navigate>'.$pencil.'</a>';
if (! in_array($row->name, self::PROTECTED_ROLES, true)) { if (! in_array($row->name, self::PROTECTED_ROLES, true)) {
$html .= '<button wire:click="deleteRole('.$row->id.')" wire:confirm="¿Eliminar el rol \''.e($row->name).'\'?" class="btn btn-xs btn-ghost text-error" title="Eliminar">'.$trash.'</button>'; $html .= '<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();
+16 -9
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;
}); });
@@ -133,11 +140,11 @@ class RoleView extends Component
->orderBy('first_name')->orderBy('name')->get(); ->orderBy('first_name')->orderBy('name')->get();
return view('livewire.roles.role-view', [ return view('livewire.roles.role-view', [
'users' => $users, 'users' => $users,
'availableUsers' => $availableUsers, 'availableUsers' => $availableUsers,
'grouped' => $grouped, 'grouped' => $grouped,
'rolePerms' => $this->role->permissions->pluck('name')->toArray(), 'rolePerms' => $this->role->permissions->pluck('name')->toArray(),
'isProtected' => in_array($this->role->name, self::PROTECTED_ROLES, true), 'isProtected' => in_array($this->role->name, self::PROTECTED_ROLES, true),
]); ]);
} }
} }
+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();
+3 -3
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;
} }
@@ -51,4 +51,4 @@ class LanguageSwitcher extends Component
{ {
return view('livewire.common.language-switcher'); return view('livewire.common.language-switcher');
} }
} }
+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()
+47 -36
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
@@ -16,50 +16,61 @@ class CompanyForm extends Component
public ?Company $company = null; public ?Company $company = null;
// Form fields // Form fields
public string $name = ''; public string $name = '';
public string $apodo = '';
public string $tax_id = ''; public string $apodo = '';
public string $estado = 'activo';
public string $type = 'other'; public string $tax_id = '';
public string $estado = 'activo';
public string $type = 'other';
public string $address = ''; public string $address = '';
public string $phone = '';
public string $email = ''; public string $phone = '';
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
{ {
if ($company && $company->exists) { if ($company && $company->exists) {
$this->company = $company; $this->company = $company;
$this->name = $company->name; $this->name = $company->name;
$this->apodo = $company->apodo ?? ''; $this->apodo = $company->apodo ?? '';
$this->tax_id = $company->tax_id ?? ''; $this->tax_id = $company->tax_id ?? '';
$this->estado = $company->estado ?? 'activo'; $this->estado = $company->estado ?? 'activo';
$this->type = $company->type ?? 'other'; $this->type = $company->type ?? 'other';
$this->address = $company->address ?? ''; $this->address = $company->address ?? '';
$this->phone = $company->phone ?? ''; $this->phone = $company->phone ?? '';
$this->email = $company->email ?? ''; $this->email = $company->email ?? '';
$this->website = $company->website ?? ''; $this->website = $company->website ?? '';
$this->notes = $company->notes ?? ''; $this->notes = $company->notes ?? '';
} }
} }
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',
'tax_id' => "nullable|string|max:50|unique:companies,tax_id,{$id}", 'tax_id' => "nullable|string|max:50|unique:companies,tax_id,{$id}",
'estado' => 'required|in:activo,inactivo,suspendido', 'estado' => 'required|in:activo,inactivo,suspendido',
'type' => 'required|in:owner,constructor,subcontractor,consultant,supplier,other', 'type' => 'required|in:owner,constructor,subcontractor,consultant,supplier,other',
'address' => 'nullable|string', 'address' => 'nullable|string',
'phone' => 'nullable|string|max:30', 'phone' => 'nullable|string|max:30',
'email' => 'nullable|email|max:255', 'email' => 'nullable|email|max:255',
'website' => 'nullable|url|max:255', 'website' => 'nullable|url|max:255',
'notes' => 'nullable|string', 'notes' => 'nullable|string',
'logo' => 'nullable|image|max:2048', 'logo' => 'nullable|image|max:2048',
]; ];
} }
@@ -68,16 +79,16 @@ class CompanyForm extends Component
$this->validate(); $this->validate();
$data = [ $data = [
'name' => $this->name, 'name' => $this->name,
'apodo' => $this->apodo ?: null, 'apodo' => $this->apodo ?: null,
'tax_id' => $this->tax_id ?: null, 'tax_id' => $this->tax_id ?: null,
'estado' => $this->estado, 'estado' => $this->estado,
'type' => $this->type, 'type' => $this->type,
'address' => $this->address ?: null, 'address' => $this->address ?: null,
'phone' => $this->phone ?: null, 'phone' => $this->phone ?: null,
'email' => $this->email ?: null, 'email' => $this->email ?: null,
'website' => $this->website ?: null, 'website' => $this->website ?: null,
'notes' => $this->notes ?: null, 'notes' => $this->notes ?: null,
]; ];
if ($this->logo) { if ($this->logo) {
+15 -13
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, [
+100 -93
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
{ {
@@ -17,17 +16,17 @@ class CompanyTable extends DataTableComponent
public function configure(): void public function configure(): void
{ {
$this->setPrimaryKey('id') $this->setPrimaryKey('id')
->setDefaultSort('name', 'asc') ->setDefaultSort('name', 'asc')
->setSortingPillsEnabled(false) ->setSortingPillsEnabled(false)
->setAdditionalSelects([ ->setAdditionalSelects([
'companies.id as id', 'companies.id as id',
'companies.apodo as apodo', 'companies.apodo as apodo',
'companies.tax_id as tax_id', 'companies.tax_id as tax_id',
'companies.phone as phone', 'companies.phone as phone',
'companies.email as email', 'companies.email as email',
'companies.logo_path as logo_path', 'companies.logo_path as logo_path',
'companies.created_at as created_at', 'companies.created_at as created_at',
]); ]);
} }
public function builder(): Builder public function builder(): Builder
@@ -39,100 +38,108 @@ class CompanyTable extends DataTableComponent
{ {
return [ return [
Column::make('Empresa', 'name') Column::make('Empresa', 'name')
->sortable() ->sortable()
->searchable() ->searchable()
->format(function ($value, $row) { ->format(function ($value, $row) {
$logoHtml = ''; $logoHtml = '';
if ($row->logo_path && Storage::disk('public')->exists($row->logo_path)) { if ($row->logo_path && Storage::disk('public')->exists($row->logo_path)) {
$url = Storage::disk('public')->url($row->logo_path); $url = Storage::disk('public')->url($row->logo_path);
$logoHtml = '<img src="'.e($url).'" class="w-9 h-9 rounded object-contain border border-base-300 shrink-0" />'; $logoHtml = '<img src="'.e($url).'" class="w-9 h-9 rounded object-contain border border-base-300 shrink-0" />';
} else { } else {
$logoHtml = '<div class="w-9 h-9 rounded bg-base-200 flex items-center justify-center shrink-0 text-gray-400"> $logoHtml = '<div class="w-9 h-9 rounded bg-base-200 flex items-center justify-center shrink-0 text-gray-400">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-2 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg> <svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-2 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg>
</div>'; </div>';
} }
$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>';
$html .= '</div></div>'; }
return $html; if ($row->tax_id) {
}) $html .= '<p class="text-xs text-gray-400">NIF: '.e($row->tax_id).'</p>';
->html(), }
$html .= '</div></div>';
return $html;
})
->html(),
Column::make('Tipo', 'type') Column::make('Tipo', 'type')
->sortable() ->sortable()
->format(function ($value) { ->format(function ($value) {
$map = [ $map = [
'owner' => ['badge-success', 'Promotor'], 'owner' => ['badge-success', 'Promotor'],
'constructor' => ['badge-primary', 'Constructor'], 'constructor' => ['badge-primary', 'Constructor'],
'subcontractor' => ['badge-secondary', 'Subcontratista'], 'subcontractor' => ['badge-secondary', 'Subcontratista'],
'consultant' => ['badge-info', 'Consultor'], 'consultant' => ['badge-info', 'Consultor'],
'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(),
Column::make('Contacto', 'phone') Column::make('Contacto', 'phone')
->format(function ($value, $row) { ->format(function ($value, $row) {
$html = ''; $html = '';
if ($row->phone) { if ($row->phone) {
$html .= '<div class="flex items-center gap-1 text-sm"> $html .= '<div class="flex items-center gap-1 text-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 opacity-50 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 opacity-50 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"/></svg>
'.e($row->phone).'</div>'; '.e($row->phone).'</div>';
} }
if ($row->email) { if ($row->email) {
$html .= '<div class="flex items-center gap-1 text-xs text-gray-500 max-w-[180px] truncate"> $html .= '<div class="flex items-center gap-1 text-xs text-gray-500 max-w-[180px] truncate">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 opacity-50 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg> <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(),
Column::make('Estado', 'estado') Column::make('Estado', 'estado')
->sortable() ->sortable()
->format(function ($value) { ->format(function ($value) {
$map = [ $map = [
'activo' => ['badge-success', 'Activo'], 'activo' => ['badge-success', 'Activo'],
'inactivo' => ['badge-ghost', 'Inactivo'], 'inactivo' => ['badge-ghost', 'Inactivo'],
'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(),
Column::make('Acciones') Column::make('Acciones')
->label(function ($row) { ->label(function ($row) {
$ver = route('companies.show', $row->id); $ver = route('companies.show', $row->id);
$editar = route('companies.edit', $row->id); $editar = route('companies.edit', $row->id);
$name = addslashes($row->name); $name = addslashes($row->name);
$html = '<div class="flex items-center justify-end gap-1">'; $html = '<div class="flex items-center justify-end gap-1">';
$html .= '<a href="'.$ver.'" class="btn btn-xs btn-outline" title="Ver" wire:navigate> $html .= '<a href="'.$ver.'" class="btn btn-xs btn-outline" title="Ver" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</a>'; </a>';
$html .= '<a href="'.$editar.'" class="btn btn-xs btn-outline btn-info" title="Editar" wire:navigate> $html .= '<a href="'.$editar.'" class="btn btn-xs btn-outline btn-info" title="Editar" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</a>'; </a>';
$html .= '<button wire:click="deleteCompany('.$row->id.')" $html .= '<button wire:click="deleteCompany('.$row->id.')"
wire:confirm="¿Eliminar \''.$name.'\'? Esta acción no se puede deshacer." wire:confirm="¿Eliminar \''.$name.'\'? Esta acción no se puede deshacer."
class="btn btn-xs btn-outline btn-error" title="Eliminar"> class="btn btn-xs btn-outline btn-error" title="Eliminar">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg> <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(),
]; ];
} }
@@ -141,21 +148,21 @@ class CompanyTable extends DataTableComponent
return [ return [
SelectFilter::make('Tipo', 'type') SelectFilter::make('Tipo', 'type')
->options([ ->options([
'' => 'Tipo: todos', '' => 'Tipo: todos',
'owner' => 'Promotor', 'owner' => 'Promotor',
'constructor' => 'Constructor', 'constructor' => 'Constructor',
'subcontractor' => 'Subcontratista', 'subcontractor' => 'Subcontratista',
'consultant' => 'Consultor', 'consultant' => 'Consultor',
'supplier' => 'Proveedor', 'supplier' => 'Proveedor',
'other' => 'Otro', 'other' => 'Otro',
]) ])
->filter(fn (Builder $query, string $value) => $query->where('type', $value)), ->filter(fn (Builder $query, string $value) => $query->where('type', $value)),
SelectFilter::make('Estado', 'estado') SelectFilter::make('Estado', 'estado')
->options([ ->options([
'' => 'Estado: todos', '' => 'Estado: todos',
'activo' => 'Activo', 'activo' => 'Activo',
'inactivo' => 'Inactivo', 'inactivo' => 'Inactivo',
'suspendido' => 'Suspendido', 'suspendido' => 'Suspendido',
]) ])
->filter(fn (Builder $query, string $value) => $query->where('estado', $value)), ->filter(fn (Builder $query, string $value) => $query->where('estado', $value)),
+28 -20
View File
@@ -2,45 +2,53 @@
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 float $avgProgress = 0.0; public int $projectsCount = 0;
public int $openIssues = 0;
public float $avgProgress = 0.0;
public int $openIssues = 0;
public function mount(Company $company): void public function mount(Company $company): void
{ {
abort_unless(Auth::user()->can('view companies'), 403); abort_unless(Auth::user()->can('view companies'), 403);
$this->company = $company->load(['users.roles', 'projects.phases']); $this->company = $company->load(['users.roles', 'projects.phases']);
$this->notes = $company->notes ?? ''; $this->notes = $company->notes ?? '';
$this->loadAvailableProjects(); $this->loadAvailableProjects();
$this->loadAssignableUsers(); $this->loadAssignableUsers();
@@ -60,16 +68,16 @@ class CompanyView extends Component
{ {
$this->assignableUsers = User::where(function ($q) { $this->assignableUsers = User::where(function ($q) {
$q->where('company_id', '!=', $this->company->id) $q->where('company_id', '!=', $this->company->id)
->orWhereNull('company_id'); ->orWhereNull('company_id');
})->orderBy('name')->get(); })->orderBy('name')->get();
} }
private function computeStats(): void private function computeStats(): void
{ {
$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()
@@ -89,7 +97,7 @@ class CompanyView extends Component
public function assignProject(): void public function assignProject(): void
{ {
$this->validate([ $this->validate([
'addProjectId' => 'required|exists:projects,id', 'addProjectId' => 'required|exists:projects,id',
'addProjectRole' => 'required|string|max:150', 'addProjectRole' => 'required|string|max:150',
], [], ['addProjectId' => 'proyecto', 'addProjectRole' => 'rol en proyecto']); ], [], ['addProjectId' => 'proyecto', 'addProjectRole' => 'rol en proyecto']);
@@ -98,7 +106,7 @@ class CompanyView extends Component
]); ]);
$this->company->load('projects.phases'); $this->company->load('projects.phases');
$this->addProjectId = null; $this->addProjectId = null;
$this->addProjectRole = ''; $this->addProjectRole = '';
$this->loadAvailableProjects(); $this->loadAvailableProjects();
$this->computeStats(); $this->computeStats();
@@ -18,29 +18,35 @@ 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' => '',
'fields' => [], 'fields' => [],
]; ];
// ── Importar desde CSV/Excel ─────────────────────────────────────────── // ── Importar desde CSV/Excel ───────────────────────────────────────────
public $showImportFileModal = false; public $showImportFileModal = false;
public $importFile = null;
public $importPreviewFields = []; public $importFile = null;
public $importTemplateName = '';
public $importError = ''; public $importPreviewFields = [];
public $importTemplateName = '';
public $importError = '';
public $fieldTypes = [ public $fieldTypes = [
'text' => 'Texto corto', 'text' => 'Texto corto',
'textarea' => 'Texto largo', 'textarea' => 'Texto largo',
'integer' => 'Número entero', 'integer' => 'Número entero',
'decimal' => 'Número decimal', 'decimal' => 'Número decimal',
'percentage' => 'Porcentaje (0-100)', 'percentage' => 'Porcentaje (0-100)',
'boolean' => 'Sí/No (checkbox)', 'boolean' => 'Sí/No (checkbox)',
'date' => 'Fecha', 'date' => 'Fecha',
'select' => 'Lista desplegable', 'select' => 'Lista desplegable',
]; ];
public function mount() public function mount()
@@ -66,9 +72,9 @@ class GlobalTemplateManager extends Component
{ {
$template = InspectionTemplate::findOrFail($id); $template = InspectionTemplate::findOrFail($id);
$this->form = [ $this->form = [
'name' => $template->name, 'name' => $template->name,
'description' => $template->description ?? '', 'description' => $template->description ?? '',
'fields' => $template->fields ?? [], 'fields' => $template->fields ?? [],
]; ];
$this->editingTemplate = $id; $this->editingTemplate = $id;
$this->showForm = true; $this->showForm = true;
@@ -83,9 +89,9 @@ class GlobalTemplateManager extends Component
public function resetForm() public function resetForm()
{ {
$this->form = [ $this->form = [
'name' => '', 'name' => '',
'description' => '', 'description' => '',
'fields' => [], 'fields' => [],
]; ];
$this->editingTemplate = null; $this->editingTemplate = null;
} }
@@ -93,17 +99,17 @@ class GlobalTemplateManager extends Component
public function addField() public function addField()
{ {
$this->form['fields'][] = [ $this->form['fields'][] = [
'group' => '', 'group' => '',
'name' => '', 'name' => '',
'label' => '', 'label' => '',
'question' => '', 'question' => '',
'type' => 'text', 'type' => 'text',
'options' => '', 'options' => '',
'required' => false, 'required' => false,
'min' => null, 'min' => null,
'max' => null, 'max' => null,
'step' => null, 'step' => null,
'help' => '', 'help' => '',
]; ];
} }
@@ -116,15 +122,15 @@ class GlobalTemplateManager extends Component
public function saveTemplate() public function saveTemplate()
{ {
$this->validate([ $this->validate([
'form.name' => 'required|string|max:255', 'form.name' => 'required|string|max:255',
'form.fields' => 'array', 'form.fields' => 'array',
]); ]);
$data = [ $data = [
'name' => $this->form['name'], 'name' => $this->form['name'],
'description' => $this->form['description'], 'description' => $this->form['description'],
'project_id' => null, 'project_id' => null,
'fields' => array_values($this->form['fields']), 'fields' => array_values($this->form['fields']),
]; ];
if ($this->editingTemplate) { if ($this->editingTemplate) {
@@ -153,10 +159,10 @@ class GlobalTemplateManager extends Component
public function openImportFileModal() public function openImportFileModal()
{ {
$this->importFile = null; $this->importFile = null;
$this->importPreviewFields = []; $this->importPreviewFields = [];
$this->importTemplateName = ''; $this->importTemplateName = '';
$this->importError = ''; $this->importError = '';
$this->showImportFileModal = true; $this->showImportFileModal = true;
} }
@@ -164,31 +170,34 @@ 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()
{ {
$this->importError = ''; $this->importError = '';
$this->validate([ $this->validate([
'importFile' => 'required|file|mimes:csv,txt,xlsx,xls|max:5120', 'importFile' => 'required|file|mimes:csv,txt,xlsx,xls|max:5120',
'importTemplateName' => 'required|string|max:255', 'importTemplateName' => 'required|string|max:255',
]); ]);
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,19 +205,21 @@ 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,
'description' => 'Importado desde archivo', 'description' => 'Importado desde archivo',
'project_id' => null, 'project_id' => null,
'fields' => array_values($this->importPreviewFields), 'fields' => array_values($this->importPreviewFields),
]); ]);
$this->showImportFileModal = false; $this->showImportFileModal = false;
$this->importPreviewFields = []; $this->importPreviewFields = [];
$this->importTemplateName = ''; $this->importTemplateName = '';
$this->importFile = null; $this->importFile = null;
$this->loadTemplates(); $this->loadTemplates();
$this->dispatch('templates-changed'); $this->dispatch('templates-changed');
$this->dispatch('notify', 'Plantilla importada'); $this->dispatch('notify', 'Plantilla importada');
@@ -216,7 +227,7 @@ class GlobalTemplateManager extends Component
private function readFileRows(): array private function readFileRows(): array
{ {
$ext = strtolower($this->importFile->getClientOriginalExtension()); $ext = strtolower($this->importFile->getClientOriginalExtension());
$path = $this->importFile->getRealPath(); $path = $this->importFile->getRealPath();
// Fila no vacía = tiene al menos una celda con contenido (no filtramos por // Fila no vacía = tiene al menos una celda con contenido (no filtramos por
@@ -226,21 +237,27 @@ class GlobalTemplateManager extends Component
if ($ext === 'xlsx' || $ext === 'xls') { if ($ext === 'xlsx' || $ext === 'xls') {
$spreadsheet = IOFactory::load($path); $spreadsheet = IOFactory::load($path);
$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;
} }
@@ -250,24 +267,27 @@ class GlobalTemplateManager extends Component
// group, name, label, question, type, required, options, min, max, step, help // group, name, label, question, type, required, options, min, max, step, help
$fields = []; $fields = [];
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] ?? ''),
'name' => $this->slugify($rawName), 'name' => $this->slugify($rawName),
'label' => trim($row[2] ?? '') ?: $rawName, 'label' => trim($row[2] ?? '') ?: $rawName,
'question' => trim($row[3] ?? ''), 'question' => trim($row[3] ?? ''),
'type' => $this->normalizeType($row[4] ?? 'text'), 'type' => $this->normalizeType($row[4] ?? 'text'),
'required' => in_array(strtolower(trim($row[5] ?? '0')), ['1', 'si', 'sí', 'yes', 'true']), 'required' => in_array(strtolower(trim($row[5] ?? '0')), ['1', 'si', 'sí', 'yes', 'true']),
'options' => trim($row[6] ?? ''), 'options' => trim($row[6] ?? ''),
'min' => ($row[7] ?? '') !== '' ? $row[7] : null, 'min' => ($row[7] ?? '') !== '' ? $row[7] : null,
'max' => ($row[8] ?? '') !== '' ? $row[8] : null, 'max' => ($row[8] ?? '') !== '' ? $row[8] : null,
'step' => ($row[9] ?? '') !== '' ? $row[9] : null, 'step' => ($row[9] ?? '') !== '' ? $row[9] : null,
'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';
} }
@@ -18,13 +18,13 @@ class InspectionTemplatesTable extends DataTableComponent
public function configure(): void public function configure(): void
{ {
$this->setPrimaryKey('id') $this->setPrimaryKey('id')
->setDefaultSort('inspection_templates.name', 'asc') ->setDefaultSort('inspection_templates.name', 'asc')
->setSortingPillsEnabled(false) ->setSortingPillsEnabled(false)
->setSecondaryHeaderEnabled() ->setSecondaryHeaderEnabled()
->setAdditionalSelects([ ->setAdditionalSelects([
'inspection_templates.id as id', 'inspection_templates.id as id',
'inspection_templates.fields as fields', 'inspection_templates.fields as fields',
]); ]);
} }
/** Refrescar cuando el manager crea/edita/borra. */ /** Refrescar cuando el manager crea/edita/borra. */
@@ -46,47 +46,46 @@ class InspectionTemplatesTable extends DataTableComponent
{ {
return [ return [
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')
->secondaryHeaderFilter('usage') ->secondaryHeaderFilter('usage')
->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>
</div>') </div>')
->html(), ->html(),
]; ];
} }
@@ -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();
} }
@@ -74,14 +79,15 @@ class IssueChecklistManager extends Component
public function save(): void public function save(): void
{ {
$this->validate([ $this->validate([
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'items' => 'required|array', 'items' => 'required|array',
'items.*' => 'nullable|string|max:255', 'items.*' => 'nullable|string|max:255',
]); ]);
$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;
} }
+29 -23
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();
} }
@@ -97,17 +103,17 @@ class IssueDetail extends Component
{ {
abort_unless($this->canEdit(), 403); abort_unless($this->canEdit(), 403);
$this->validate([ $this->validate([
'newTaskTitle' => 'required|string|max:255', 'newTaskTitle' => 'required|string|max:255',
'newTaskAssignee' => 'nullable|exists:users,id', 'newTaskAssignee' => 'nullable|exists:users,id',
'newTaskDue' => 'nullable|date', 'newTaskDue' => 'nullable|date',
]); ]);
$task = $this->issue->tasks()->create([ $task = $this->issue->tasks()->create([
'title' => $this->newTaskTitle, 'title' => $this->newTaskTitle,
'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(),
]); ]);
} }
@@ -170,7 +176,7 @@ class IssueDetail extends Component
{ {
// Anyone who can view the issue (and is a member) may comment / report progress. // Anyone who can view the issue (and is a member) may comment / report progress.
$this->validate([ $this->validate([
'newComment' => 'nullable|string|max:5000', 'newComment' => 'nullable|string|max:5000',
'commentPhoto' => 'nullable|image|max:20480', // 20 MB 'commentPhoto' => 'nullable|image|max:20480', // 20 MB
]); ]);
@@ -182,8 +188,8 @@ 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();
@@ -230,14 +236,14 @@ class IssueDetail extends Component
$path = $file->store("uploads/issues/{$this->issue->id}/{$entity}", 'public'); $path = $file->store("uploads/issues/{$this->issue->id}/{$entity}", 'public');
$parent->media()->create([ $parent->media()->create([
'name' => $file->getClientOriginalName(), 'name' => $file->getClientOriginalName(),
'file_path' => $path, 'file_path' => $path,
'file_type' => $mime, 'file_type' => $mime,
'file_extension' => $file->getClientOriginalExtension(), 'file_extension' => $file->getClientOriginalExtension(),
'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(),
]); ]);
} }
@@ -256,8 +262,8 @@ class IssueDetail extends Component
{ {
abort_unless($this->canEdit(), 403); abort_unless($this->canEdit(), 403);
$this->issue->update([ $this->issue->update([
'status' => 'resolved', 'status' => 'resolved',
'resolved_at' => $this->issue->resolved_at ?? now(), 'resolved_at' => $this->issue->resolved_at ?? now(),
'resolution_notes' => $this->resolutionNotes ?: null, 'resolution_notes' => $this->resolutionNotes ?: null,
]); ]);
$this->notifyStakeholders(new IssueStatusChangedNotification($this->issue, 'resolved')); $this->notifyStakeholders(new IssueStatusChangedNotification($this->issue, 'resolved'));
@@ -269,7 +275,7 @@ class IssueDetail extends Component
{ {
abort_unless($this->canEdit(), 403); abort_unless($this->canEdit(), 403);
$this->issue->update([ $this->issue->update([
'status' => 'closed', 'status' => 'closed',
'resolved_at' => $this->issue->resolved_at ?? now(), 'resolved_at' => $this->issue->resolved_at ?? now(),
]); ]);
$this->notifyStakeholders(new IssueStatusChangedNotification($this->issue, 'closed')); $this->notifyStakeholders(new IssueStatusChangedNotification($this->issue, 'closed'));
+38 -27
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)
@@ -44,22 +54,22 @@ class IssueForm extends Component
$this->projectUsers = $project->users()->orderBy('name')->get(); $this->projectUsers = $project->users()->orderBy('name')->get();
if ($issue) { if ($issue) {
$this->issue = $issue; $this->issue = $issue;
$this->title = $issue->title; $this->title = $issue->title;
$this->description = $issue->description ?? ''; $this->description = $issue->description ?? '';
$this->status = $issue->status; $this->status = $issue->status;
$this->priority = $issue->priority; $this->priority = $issue->priority;
$this->type = $issue->type ?? 'defect'; $this->type = $issue->type ?? 'defect';
$this->assignedTo = $issue->assigned_to ?? ''; $this->assignedTo = $issue->assigned_to ?? '';
$this->resolutionNotes = $issue->resolution_notes ?? ''; $this->resolutionNotes = $issue->resolution_notes ?? '';
$this->featureId = $issue->feature_id; $this->featureId = $issue->feature_id;
$this->inspectionId = $issue->inspection_id; $this->inspectionId = $issue->inspection_id;
$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();
} }
@@ -75,12 +86,12 @@ class IssueForm extends Component
protected function rules(): array protected function rules(): array
{ {
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',
]; ];
} }
@@ -91,14 +102,14 @@ class IssueForm extends Component
$this->validate(); $this->validate();
$payload = [ $payload = [
'title' => $this->title, 'title' => $this->title,
'description' => $this->description, 'description' => $this->description,
'status' => $this->status, 'status' => $this->status,
'priority' => $this->priority, 'priority' => $this->priority,
'type' => $this->type, 'type' => $this->type,
'feature_id' => $this->featureId, 'feature_id' => $this->featureId,
'inspection_id' => $this->inspectionId, 'inspection_id' => $this->inspectionId,
'assigned_to' => $this->assignedTo ?: null, 'assigned_to' => $this->assignedTo ?: null,
'resolution_notes' => $this->resolutionNotes ?: null, 'resolution_notes' => $this->resolutionNotes ?: null,
]; ];
@@ -120,7 +131,7 @@ class IssueForm extends Component
} }
} else { } else {
$issue = Issue::create(array_merge($payload, [ $issue = Issue::create(array_merge($payload, [
'project_id' => $this->project->id, 'project_id' => $this->project->id,
'reported_by' => Auth::id(), 'reported_by' => Auth::id(),
])); ]));
+8 -7
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();
} }
@@ -44,11 +45,11 @@ class IssueManager extends Component
->pluck('c', 'status'); ->pluck('c', 'status');
return view('livewire.issues.issue-manager', [ return view('livewire.issues.issue-manager', [
'countOpen' => (int) ($counts['open'] ?? 0), 'countOpen' => (int) ($counts['open'] ?? 0),
'countInReview' => (int) ($counts['in_review'] ?? 0), 'countInReview' => (int) ($counts['in_review'] ?? 0),
'countResolved' => (int) ($counts['resolved'] ?? 0), 'countResolved' => (int) ($counts['resolved'] ?? 0),
'countClosed' => (int) ($counts['closed'] ?? 0), 'countClosed' => (int) ($counts['closed'] ?? 0),
'countTotal' => (int) $counts->sum(), 'countTotal' => (int) $counts->sum(),
]); ]);
} }
} }
+105 -94
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;
@@ -19,9 +20,9 @@ class IssueTable extends DataTableComponent
public function configure(): void public function configure(): void
{ {
$this->setPrimaryKey('id') $this->setPrimaryKey('id')
->setDefaultSort('created_at', 'desc') ->setDefaultSort('created_at', 'desc')
->setSortingPillsEnabled(false) ->setSortingPillsEnabled(false)
->setAdditionalSelects(['issues.id as id', 'issues.created_at as created_at']); ->setAdditionalSelects(['issues.id as id', 'issues.created_at as created_at']);
} }
public function builder(): Builder public function builder(): Builder
@@ -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
); );
@@ -53,119 +54,129 @@ class IssueTable extends DataTableComponent
{ {
return [ return [
Column::make('Prioridad', 'priority') Column::make('Prioridad', 'priority')
->sortable() ->sortable()
->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(),
Column::make('Título', 'title') Column::make('Título', 'title')
->sortable() ->sortable()
->searchable() ->searchable()
->format(function ($value, $row) { ->format(function ($value, $row) {
$url = route('projects.issues.show', [$this->projectId, $row->id]); $url = route('projects.issues.show', [$this->projectId, $row->id]);
$html = '<a href="'.$url.'" wire:navigate class="font-medium text-sm link link-hover">'.e($value).'</a>'; $html = '<a href="'.$url.'" wire:navigate class="font-medium text-sm link link-hover">'.e($value).'</a>';
if ($row->description) { if ($row->description) {
$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 ($meta) { if ($row->comments_count) {
$html .= '<div class="text-xs text-base-content/40 mt-0.5">'.implode(' · ', $meta).'</div>'; $meta[] = '💬 '.$row->comments_count;
} }
if ($row->tasks_count) { if ($row->media_count) {
$pct = (int) round($row->tasks_done_count / $row->tasks_count * 100); $meta[] = '📷 '.$row->media_count;
$html .= '<div class="flex items-center gap-2 mt-1 max-w-xs"> }
if ($meta) {
$html .= '<div class="text-xs text-base-content/40 mt-0.5">'.implode(' · ', $meta).'</div>';
}
if ($row->tasks_count) {
$pct = (int) round($row->tasks_done_count / $row->tasks_count * 100);
$html .= '<div class="flex items-center gap-2 mt-1 max-w-xs">
<progress class="progress progress-success w-24 h-1.5" value="'.$pct.'" max="100"></progress> <progress class="progress progress-success w-24 h-1.5" value="'.$pct.'" max="100"></progress>
<span class="text-xs text-base-content/50">'.$row->tasks_done_count.'/'.$row->tasks_count.' tareas</span> <span class="text-xs text-base-content/50">'.$row->tasks_done_count.'/'.$row->tasks_count.' tareas</span>
</div>'; </div>';
} }
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')
->label(fn ($row) => $row->feature ->label(fn ($row) => $row->feature
? '<span class="badge badge-outline badge-sm">'.e($row->feature->name).'</span>' ? '<span class="badge badge-outline badge-sm">'.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(),
Column::make('Estado', 'status') Column::make('Estado', 'status')
->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(),
Column::make('Asignado a') Column::make('Asignado a')
->label(fn ($row) => $row->assignee ->label(fn ($row) => $row->assignee
? '<span class="text-sm">'.e($row->assignee->name).'</span>' ? '<span class="text-sm">'.e($row->assignee->name).'</span>'
: '<span class="text-base-content/30 text-xs">Sin asignar</span>') : '<span class="text-base-content/30 text-xs">Sin asignar</span>')
->html(), ->html(),
Column::make('Fecha', 'created_at') Column::make('Fecha', 'created_at')
->sortable() ->sortable()
->format(function ($value, $row) { ->format(function ($value, $row) {
$html = $row->created_at->format('d/m/Y'); $html = $row->created_at->format('d/m/Y');
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(),
Column::make('Acciones') Column::make('Acciones')
->label(function ($row) { ->label(function ($row) {
$user = Auth::user(); $user = Auth::user();
$detail = route('projects.issues.show', [$this->projectId, $row->id]); $detail = route('projects.issues.show', [$this->projectId, $row->id]);
$edit = route('projects.issues.edit', [$this->projectId, $row->id]); $edit = route('projects.issues.edit', [$this->projectId, $row->id]);
$html = '<div class="flex items-center justify-end gap-1 flex-wrap">'; $html = '<div class="flex items-center justify-end gap-1 flex-wrap">';
$html .= '<a href="'.$detail.'" wire:navigate class="btn btn-xs btn-ghost" title="Abrir detalle"> $html .= '<a href="'.$detail.'" wire:navigate class="btn btn-xs btn-ghost" title="Abrir detalle">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg> <svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
</a>'; </a>';
if ($user->can('edit issues')) { if ($user->can('edit issues')) {
$html .= '<a href="'.$edit.'" wire:navigate class="btn btn-xs btn-ghost" title="Editar"> $html .= '<a href="'.$edit.'" wire:navigate class="btn btn-xs btn-ghost" title="Editar">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</a>'; </a>';
if (in_array($row->status, ['open', 'in_review'])) { if (in_array($row->status, ['open', 'in_review'])) {
$html .= '<button wire:click="resolve('.$row->id.')" class="btn btn-xs btn-success" title="Marcar como resuelto"> $html .= '<button wire:click="resolve('.$row->id.')" class="btn btn-xs btn-success" title="Marcar como resuelto">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg> <svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
</button>'; </button>';
} }
if ($row->status !== 'closed') { if ($row->status !== 'closed') {
$html .= '<button wire:click="close('.$row->id.')" class="btn btn-xs btn-neutral" title="Cerrar"> $html .= '<button wire:click="close('.$row->id.')" class="btn btn-xs btn-neutral" title="Cerrar">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg> <svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>'; </button>';
} }
} }
if ($user->can('delete issues')) { if ($user->can('delete issues')) {
$html .= '<button wire:click="deleteIssue('.$row->id.')" wire:confirm="¿Eliminar esta incidencia? Esta acción no se puede deshacer." $html .= '<button wire:click="deleteIssue('.$row->id.')" wire:confirm="¿Eliminar esta incidencia? Esta acción no se puede deshacer."
class="btn btn-xs btn-error btn-outline" title="Eliminar"> 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>';
} }
$html .= '</div>'; $html .= '</div>';
return $html;
}) return $html;
->html(), })
->html(),
]; ];
} }
@@ -174,26 +185,26 @@ class IssueTable extends DataTableComponent
return [ return [
SelectFilter::make('Estado', 'status') SelectFilter::make('Estado', 'status')
->options([ ->options([
'' => 'Estado: todos', '' => 'Estado: todos',
'open' => 'Abierto', 'open' => 'Abierto',
'in_review' => 'En revisión', 'in_review' => 'En revisión',
'resolved' => 'Resuelto', 'resolved' => 'Resuelto',
'closed' => 'Cerrado', 'closed' => 'Cerrado',
]) ])
->filter(fn (Builder $query, string $value) => $query->where('issues.status', $value)), ->filter(fn (Builder $query, string $value) => $query->where('issues.status', $value)),
SelectFilter::make('Prioridad', 'priority') SelectFilter::make('Prioridad', 'priority')
->options([ ->options([
'' => 'Prioridad: todas', '' => 'Prioridad: todas',
'critical' => 'Crítica', 'critical' => 'Crítica',
'high' => 'Alta', 'high' => 'Alta',
'medium' => 'Media', 'medium' => 'Media',
'low' => 'Baja', 'low' => 'Baja',
]) ])
->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)),
]; ];
} }
+122 -86
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,29 +22,39 @@ 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 $layerColor = '#3b82f6'; public $layerName = '';
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)
{ {
$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);
} }
@@ -71,28 +81,28 @@ class LayerManager extends Component
private function buildLayerPayload(Layer $layer): array private function buildLayerPayload(Layer $layer): array
{ {
$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,
'properties' => [ 'properties' => [
'name' => $f->name ?? 'Elemento', 'name' => $f->name ?? 'Elemento',
'progress' => $f->progress, 'progress' => $f->progress,
'status' => $f->status ?? 'planned', 'status' => $f->status ?? 'planned',
'responsible' => $f->responsible, 'responsible' => $f->responsible,
'template_id' => $f->template_id, 'template_id' => $f->template_id,
], ],
])->values()->toArray(); ])->values()->toArray();
return [ return [
'id' => $layer->id, 'id' => $layer->id,
'color' => $color, 'color' => $color,
'geojson' => [ 'geojson' => [
'type' => 'FeatureCollection', 'type' => 'FeatureCollection',
'features' => $features, 'features' => $features,
'style' => ['color' => $color], 'style' => ['color' => $color],
], ],
]; ];
} }
@@ -101,8 +111,8 @@ 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);
} }
@@ -139,9 +152,9 @@ class LayerManager extends Component
$this->dispatch('layerSelectedForEdit', [ $this->dispatch('layerSelectedForEdit', [
'layerId' => $layerId, 'layerId' => $layerId,
'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,32 +162,35 @@ 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;
} }
$this->validate([ $this->validate([
'uploadFile' => 'required|file|max:51200', 'uploadFile' => 'required|file|max:51200',
'layerName' => 'required|string|max:255', 'layerName' => 'required|string|max:255',
'layerColor' => 'nullable|string|size:7', 'layerColor' => 'nullable|string|size:7',
]); ]);
$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;
} }
$layerColor = $this->layerColor ?: '#3b82f6'; $layerColor = $this->layerColor ?: '#3b82f6';
$layerName = $this->layerName; $layerName = $this->layerName;
try { try {
DB::transaction(function () use ($geojson, $layerColor, $layerName, $user) { DB::transaction(function () use ($geojson, $layerColor, $layerName, $user) {
@@ -183,28 +199,30 @@ class LayerManager extends Component
); );
$layer = Layer::create([ $layer = Layer::create([
'project_id' => $this->project->id, 'project_id' => $this->project->id,
'phase_id' => $this->phase->id, 'phase_id' => $this->phase->id,
'name' => $layerName, 'name' => $layerName,
'color' => $layerColor, 'color' => $layerColor,
'original_file' => $path, 'original_file' => $path,
'uploaded_by' => $user->id, 'uploaded_by' => $user->id,
]); ]);
$idx = 0; $idx = 0;
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,
'name' => $name, 'name' => $name,
'geometry' => $fd['geometry'], 'geometry' => $fd['geometry'],
'properties' => $fd['properties'] ?? [], 'properties' => $fd['properties'] ?? [],
'template_id' => $fd['properties']['template_id'] ?? null, 'template_id' => $fd['properties']['template_id'] ?? null,
'progress' => $fd['properties']['progress'] ?? 0, 'progress' => $fd['properties']['progress'] ?? 0,
'status' => in_array($fd['properties']['status'] ?? '', Feature::STATUSES) 'status' => in_array($fd['properties']['status'] ?? '', Feature::STATUSES)
? $fd['properties']['status'] ? $fd['properties']['status']
: 'planned', : 'planned',
'responsible' => $fd['properties']['responsible'] ?? null, 'responsible' => $fd['properties']['responsible'] ?? null,
@@ -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,18 +248,19 @@ 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;
} }
$layer = Layer::create([ $layer = Layer::create([
'project_id' => $this->project->id, 'project_id' => $this->project->id,
'phase_id' => $this->phase->id, 'phase_id' => $this->phase->id,
'name' => $this->layerName ?: 'Nueva capa', 'name' => $this->layerName ?: 'Nueva capa',
'color' => $this->layerColor ?: '#3b82f6', 'color' => $this->layerColor ?: '#3b82f6',
'original_file' => null, 'original_file' => null,
'uploaded_by' => $user->id, 'uploaded_by' => $user->id,
]); ]);
$this->loadLayers(); $this->loadLayers();
@@ -255,18 +275,20 @@ 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;
} }
$layerId = $this->selectedLayer->id; $layerId = $this->selectedLayer->id;
$layerName = $this->selectedLayer->name; $layerName = $this->selectedLayer->name;
try { try {
@@ -278,16 +300,18 @@ 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,
'name' => $name, 'name' => $name,
'geometry' => $fd['geometry'], 'geometry' => $fd['geometry'],
'properties' => $fd['properties'] ?? [], 'properties' => $fd['properties'] ?? [],
'template_id' => $fd['properties']['template_id'] ?? null, 'template_id' => $fd['properties']['template_id'] ?? null,
'progress' => $fd['properties']['progress'] ?? 0, 'progress' => $fd['properties']['progress'] ?? 0,
'status' => in_array($fd['properties']['status'] ?? '', Feature::STATUSES) 'status' => in_array($fd['properties']['status'] ?? '', Feature::STATUSES)
? $fd['properties']['status'] ? $fd['properties']['status']
: 'planned', : 'planned',
'responsible' => $fd['properties']['responsible'] ?? null, 'responsible' => $fd['properties']['responsible'] ?? null,
@@ -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,25 +368,27 @@ 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 ?? [], [
'name' => $f->name, 'name' => $f->name,
'progress' => $f->progress, 'progress' => $f->progress,
'status' => $f->status, 'status' => $f->status,
'responsible' => $f->responsible, 'responsible' => $f->responsible,
'template_id' => $f->template_id, 'template_id' => $f->template_id,
]), ]),
])->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;
} }
+18 -16
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),
]); ]);
} }
} }
+37 -33
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;
@@ -25,47 +28,48 @@ class PhaseGantt extends Component
{ {
$phases = $this->project->phases()->with(['layers.features'])->orderBy('order')->get(); $phases = $this->project->phases()->with(['layers.features'])->orderBy('order')->get();
$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;
$actual_end = $phase->actual_end; $actual_end = $phase->actual_end;
$totalDays = max(1, $projectStart->diffInDays($projectEnd)); $totalDays = max(1, $projectStart->diffInDays($projectEnd));
$pStartOffset = max(0, $projectStart->diffInDays($planned_start)); $pStartOffset = max(0, $projectStart->diffInDays($planned_start));
$pDuration = max(1, $planned_start->diffInDays($planned_end)); $pDuration = max(1, $planned_start->diffInDays($planned_end));
$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();
$aDuration = max(1, $actual_start->diffInDays($aEnd)); $aDuration = max(1, $actual_start->diffInDays($aEnd));
$aStartPct = round(($aStart / $totalDays) * 100, 2); $aStartPct = round(($aStart / $totalDays) * 100, 2);
$aWidthPct = round(($aDuration / $totalDays) * 100, 2); $aWidthPct = round(($aDuration / $totalDays) * 100, 2);
} }
$isDelayed = $phase->planned_end && $phase->planned_end->isPast() && $phase->progress_percent < 100; $isDelayed = $phase->planned_end && $phase->planned_end->isPast() && $phase->progress_percent < 100;
return [ return [
'id' => $phase->id, 'id' => $phase->id,
'name' => $phase->name, 'name' => $phase->name,
'color' => $phase->color ?? '#3b82f6', 'color' => $phase->color ?? '#3b82f6',
'progress' => $phase->progress_percent, 'progress' => $phase->progress_percent,
'planned_start' => $planned_start->format('d/m/Y'), 'planned_start' => $planned_start->format('d/m/Y'),
'planned_end' => $planned_end->format('d/m/Y'), 'planned_end' => $planned_end->format('d/m/Y'),
'actual_start' => $actual_start?->format('d/m/Y'), 'actual_start' => $actual_start?->format('d/m/Y'),
'actual_end' => $actual_end?->format('d/m/Y'), 'actual_end' => $actual_end?->format('d/m/Y'),
'p_start_pct' => $pStartPct, 'p_start_pct' => $pStartPct,
'p_width_pct' => min($pWidthPct, 100 - $pStartPct), 'p_width_pct' => min($pWidthPct, 100 - $pStartPct),
'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();
} }
@@ -75,9 +79,9 @@ class PhaseGantt extends Component
$phase = $this->project->phases()->findOrFail($phaseId); $phase = $this->project->phases()->findOrFail($phaseId);
$phase->update([ $phase->update([
'planned_start' => $plannedStart ?: null, 'planned_start' => $plannedStart ?: null,
'planned_end' => $plannedEnd ?: null, 'planned_end' => $plannedEnd ?: null,
'actual_start' => $actualStart ?: null, 'actual_start' => $actualStart ?: null,
'actual_end' => $actualEnd ?: null, 'actual_end' => $actualEnd ?: null,
]); ]);
$this->loadGanttData(); $this->loadGanttData();
$this->dispatch('notify', 'Fechas actualizadas'); $this->dispatch('notify', 'Fechas actualizadas');
@@ -87,7 +91,7 @@ class PhaseGantt extends Component
{ {
return view('livewire.projects.phase-gantt', [ return view('livewire.projects.phase-gantt', [
'project' => $this->project, 'project' => $this->project,
'phases' => $this->project->phases()->orderBy('order')->get(), 'phases' => $this->project->phases()->orderBy('order')->get(),
]); ]);
} }
} }
+42 -34
View File
@@ -2,7 +2,6 @@
namespace App\Livewire\Phases; 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)
@@ -35,27 +43,27 @@ class PhaseList extends Component
protected function rules(): array protected function rules(): array
{ {
return [ return [
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'description' => 'nullable|string', 'description' => 'nullable|string',
'color' => 'required|string|max:7', 'color' => 'required|string|max:7',
'order' => 'required|integer|min:0', 'order' => 'required|integer|min:0',
'progressPercent' => 'required|integer|min:0|max:100', 'progressPercent' => 'required|integer|min:0|max:100',
'plannedStart' => 'nullable|date', 'plannedStart' => 'nullable|date',
'plannedEnd' => 'nullable|date|after_or_equal:plannedStart', 'plannedEnd' => 'nullable|date|after_or_equal:plannedStart',
'actualStart' => 'nullable|date', 'actualStart' => 'nullable|date',
'actualEnd' => 'nullable|date|after_or_equal:actualStart', 'actualEnd' => 'nullable|date|after_or_equal:actualStart',
]; ];
} }
protected $validationAttributes = [ protected $validationAttributes = [
'name' => 'nombre', 'name' => 'nombre',
'color' => 'color', 'color' => 'color',
'order' => 'orden', 'order' => 'orden',
'progressPercent' => 'progreso', 'progressPercent' => 'progreso',
'plannedStart' => 'inicio previsto', 'plannedStart' => 'inicio previsto',
'plannedEnd' => 'fin previsto', 'plannedEnd' => 'fin previsto',
'actualStart' => 'inicio real', 'actualStart' => 'inicio real',
'actualEnd' => 'fin real', 'actualEnd' => 'fin real',
]; ];
public function openForm($phaseId = null): void public function openForm($phaseId = null): void
@@ -65,19 +73,19 @@ class PhaseList extends Component
if ($phaseId) { if ($phaseId) {
$phase = $this->project->phases()->findOrFail($phaseId); $phase = $this->project->phases()->findOrFail($phaseId);
$this->editingId = $phase->id; $this->editingId = $phase->id;
$this->name = $phase->name; $this->name = $phase->name;
$this->description = $phase->description ?? ''; $this->description = $phase->description ?? '';
$this->color = $phase->color ?? '#3b82f6'; $this->color = $phase->color ?? '#3b82f6';
$this->order = (int) $phase->order; $this->order = (int) $phase->order;
$this->progressPercent = (int) $phase->progress_percent; $this->progressPercent = (int) $phase->progress_percent;
$this->plannedStart = $phase->planned_start?->format('Y-m-d') ?? ''; $this->plannedStart = $phase->planned_start?->format('Y-m-d') ?? '';
$this->plannedEnd = $phase->planned_end?->format('Y-m-d') ?? ''; $this->plannedEnd = $phase->planned_end?->format('Y-m-d') ?? '';
$this->actualStart = $phase->actual_start?->format('Y-m-d') ?? ''; $this->actualStart = $phase->actual_start?->format('Y-m-d') ?? '';
$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;
@@ -113,15 +121,15 @@ class PhaseList extends Component
$this->validate(); $this->validate();
$data = [ $data = [
'name' => $this->name, 'name' => $this->name,
'description' => $this->description ?: null, 'description' => $this->description ?: null,
'color' => $this->color, 'color' => $this->color,
'order' => $this->order, 'order' => $this->order,
'progress_percent' => $this->progressPercent, 'progress_percent' => $this->progressPercent,
'planned_start' => $this->plannedStart ?: null, 'planned_start' => $this->plannedStart ?: null,
'planned_end' => $this->plannedEnd ?: null, 'planned_end' => $this->plannedEnd ?: null,
'actual_start' => $this->actualStart ?: null, 'actual_start' => $this->actualStart ?: null,
'actual_end' => $this->actualEnd ?: null, 'actual_end' => $this->actualEnd ?: null,
]; ];
if ($this->editingId) { if ($this->editingId) {
+5 -3
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)
@@ -39,4 +41,4 @@ class PhaseProgress extends Component
{ {
return view('livewire.phases.phase-progress'); return view('livewire.phases.phase-progress');
} }
} }
+44 -42
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;
@@ -19,9 +20,9 @@ class PhaseTable extends DataTableComponent
public function configure(): void public function configure(): void
{ {
$this->setPrimaryKey('id') $this->setPrimaryKey('id')
->setDefaultSort('order', 'asc') ->setDefaultSort('order', 'asc')
->setSortingPillsEnabled(false) ->setSortingPillsEnabled(false)
->setAdditionalSelects(['phases.id as id', 'phases.order as order']); ->setAdditionalSelects(['phases.id as id', 'phases.order as order']);
} }
/** Re-render when the parent (PhaseList) creates/edits a phase. */ /** Re-render when the parent (PhaseList) creates/edits a phase. */
@@ -49,64 +50,65 @@ class PhaseTable extends DataTableComponent
Column::make('Orden', 'order')->sortable(), Column::make('Orden', 'order')->sortable(),
Column::make('Nombre', 'name') Column::make('Nombre', 'name')
->sortable() ->sortable()
->searchable() ->searchable()
->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>')
->html(), ->html(),
Column::make('Fechas') Column::make('Fechas')
->label(function ($row) { ->label(function ($row) {
$ps = $row->planned_start?->format('d/m/Y'); $ps = $row->planned_start?->format('d/m/Y');
$pe = $row->planned_end?->format('d/m/Y'); $pe = $row->planned_end?->format('d/m/Y');
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')
->label(function ($row) { ->label(function ($row) {
$user = Auth::user(); $user = Auth::user();
$progress = route('phases.progress', $row->id); $progress = route('phases.progress', $row->id);
$html = '<div class="flex items-center justify-end gap-1">'; $html = '<div class="flex items-center justify-end gap-1">';
$html .= '<a href="'.$progress.'" class="btn btn-xs btn-outline btn-info" title="Actualizar progreso" wire:navigate> $html .= '<a href="'.$progress.'" class="btn btn-xs btn-outline btn-info" title="Actualizar progreso" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg> <svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
</a>'; </a>';
if ($user->can('manage phases')) { if ($user->can('manage phases')) {
$html .= '<button wire:click="$dispatch(\'phase-edit\', { id: '.$row->id.' })" class="btn btn-xs btn-ghost" title="Editar"> $html .= '<button wire:click="$dispatch(\'phase-edit\', { id: '.$row->id.' })" class="btn btn-xs btn-ghost" title="Editar">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> <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>';
$html .= '<button wire:click="deletePhase('.$row->id.')" wire:confirm="¿Eliminar la fase \''.e($row->name).'\'? Esta acción no se puede deshacer." $html .= '<button wire:click="deletePhase('.$row->id.')" wire:confirm="¿Eliminar la fase \''.e($row->name).'\'? Esta acción no se puede deshacer."
class="btn btn-xs btn-error btn-outline" title="Eliminar"> 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>';
} }
$html .= '</div>'; $html .= '</div>';
return $html;
}) return $html;
->html(), })
->html(),
]; ];
} }
+12 -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());
} }
@@ -42,10 +48,10 @@ class FeatureManager extends Component
{ {
abort_unless($this->canManage(), 403); abort_unless($this->canManage(), 403);
$feature = $this->findFeature($id); $feature = $this->findFeature($id);
$this->editingId = $feature->id; $this->editingId = $feature->id;
$this->name = $feature->name ?? ''; $this->name = $feature->name ?? '';
$this->featureTypeId = $feature->feature_type_id ?? ''; $this->featureTypeId = $feature->feature_type_id ?? '';
$this->isActive = (bool) $feature->is_active; $this->isActive = (bool) $feature->is_active;
$this->resetErrorBag(); $this->resetErrorBag();
$this->showForm = true; $this->showForm = true;
} }
@@ -54,14 +60,14 @@ class FeatureManager extends Component
{ {
abort_unless($this->canManage(), 403); abort_unless($this->canManage(), 403);
$this->validate([ $this->validate([
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'featureTypeId' => 'nullable|exists:feature_types,id', 'featureTypeId' => 'nullable|exists:feature_types,id',
]); ]);
$this->findFeature($this->editingId)->update([ $this->findFeature($this->editingId)->update([
'name' => $this->name, 'name' => $this->name,
'feature_type_id' => $this->featureTypeId ?: null, 'feature_type_id' => $this->featureTypeId ?: null,
'is_active' => $this->isActive, 'is_active' => $this->isActive,
]); ]);
$this->showForm = false; $this->showForm = false;
+34 -32
View File
@@ -22,10 +22,10 @@ class FeatureTable extends DataTableComponent
public function configure(): void public function configure(): void
{ {
$this->setPrimaryKey('id') $this->setPrimaryKey('id')
->setDefaultSort('name', 'asc') ->setDefaultSort('name', 'asc')
->setSortingPillsEnabled(false) ->setSortingPillsEnabled(false)
->setSecondaryHeaderEnabled() ->setSecondaryHeaderEnabled()
->setAdditionalSelects(['features.id as id', 'features.layer_id as layer_id']); ->setAdditionalSelects(['features.id as id', 'features.layer_id as layer_id']);
} }
public function builder(): Builder public function builder(): Builder
@@ -46,40 +46,42 @@ class FeatureTable extends DataTableComponent
{ {
return [ return [
Column::make('Elemento', 'name') Column::make('Elemento', 'name')
->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')
->secondaryHeaderFilter('layer') ->secondaryHeaderFilter('layer')
->label(fn ($row) => e($row->layer?->name ?? '—')), ->label(fn ($row) => e($row->layer?->name ?? '—')),
Column::make('Fase') Column::make('Fase')
->secondaryHeaderFilter('phase') ->secondaryHeaderFilter('phase')
->label(fn ($row) => e($row->layer?->phase?->name ?? '—')), ->label(fn ($row) => e($row->layer?->phase?->name ?? '—')),
Column::make('Progreso', 'progress') Column::make('Progreso', 'progress')
->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">'
. '<button wire:click="$dispatch(\'map-select-feature\', { featureId: ' . $id . ' })"' return '<div class="flex justify-end">'
. 'class="btn btn-xs btn-primary gap-1" title="' . e(__('Editar elemento')) . '">' .'<button wire:click="$dispatch(\'map-select-feature\', { featureId: '.$id.' })"'
. '<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>' .'class="btn btn-xs btn-primary gap-1" title="'.e(__('Editar elemento')).'">'
. e(__('Abrir')) .'<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>' .e(__('Abrir'))
. '</div>'; .'</button>'
}) .'</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)
@@ -104,4 +106,4 @@ class FeatureTable extends DataTableComponent
->filter(fn (Builder $query, string $value) => $query->whereHas('layer', fn ($l) => $l->where('phase_id', $value))), ->filter(fn (Builder $query, string $value) => $query->whereHas('layer', fn ($l) => $l->where('phase_id', $value))),
]; ];
} }
} }
+9 -5
View File
@@ -13,9 +13,13 @@ class FeatureTypeManager extends Component
public $types = []; public $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,9 +36,9 @@ 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',
]; ];
} }
@@ -49,10 +53,10 @@ class FeatureTypeManager extends Component
public function edit($id): void public function edit($id): void
{ {
$t = FeatureType::findOrFail($id); $t = FeatureType::findOrFail($id);
$this->editingId = $t->id; $this->editingId = $t->id;
$this->name = $t->name; $this->name = $t->name;
$this->description = $t->description ?? ''; $this->description = $t->description ?? '';
$this->color = $t->color ?? '#6b7280'; $this->color = $t->color ?? '#6b7280';
$this->resetErrorBag(); $this->resetErrorBag();
$this->showForm = true; $this->showForm = true;
} }
+57 -59
View File
@@ -22,16 +22,16 @@ class InspectionTable extends DataTableComponent
public function configure(): void public function configure(): void
{ {
$this->setPrimaryKey('id') $this->setPrimaryKey('id')
->setDefaultSort('inspections.created_at', 'desc') ->setDefaultSort('inspections.created_at', 'desc')
->setSortingPillsEnabled(false) ->setSortingPillsEnabled(false)
->setSecondaryHeaderEnabled() ->setSecondaryHeaderEnabled()
->setAdditionalSelects([ ->setAdditionalSelects([
'inspections.id as id', 'inspections.id as id',
'inspections.created_at as created_at', 'inspections.created_at as created_at',
'inspections.feature_id as feature_id', 'inspections.feature_id as feature_id',
'inspections.template_id as template_id', 'inspections.template_id as template_id',
'inspections.user_id as user_id', 'inspections.user_id as user_id',
]); ]);
} }
public function builder(): Builder public function builder(): Builder
@@ -52,58 +52,57 @@ class InspectionTable extends DataTableComponent
{ {
return [ return [
Column::make('Fecha', 'created_at') Column::make('Fecha', 'created_at')
->sortable() ->sortable()
->secondaryHeaderFilter('fecha') ->secondaryHeaderFilter('fecha')
->format(fn ($value, $row) => $row->created_at?->format('d/m/Y') ?? '—'), ->format(fn ($value, $row) => $row->created_at?->format('d/m/Y') ?? '—'),
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(),
Column::make('Plantilla') Column::make('Plantilla')
->label(fn ($row) => e($row->template?->name ?? '—')), ->label(fn ($row) => e($row->template?->name ?? '—')),
Column::make('Resultado', 'result') Column::make('Resultado', 'result')
->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(),
Column::make('Usuario') Column::make('Usuario')
->secondaryHeaderFilter('usuario') ->secondaryHeaderFilter('usuario')
->label(fn ($row) => e($row->user?->name ?? '—')), ->label(fn ($row) => e($row->user?->name ?? '—')),
Column::make('Fotos') Column::make('Fotos')
->label(fn ($row) => $this->renderPhotosColumn($row)) ->label(fn ($row) => $this->renderPhotosColumn($row))
->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
@@ -134,7 +132,7 @@ class InspectionTable extends DataTableComponent
->pluck('result', 'result')->toArray(); ->pluck('result', 'result')->toArray();
$users = User::whereIn('id', Inspection::where('project_id', $this->projectId) $users = User::whereIn('id', Inspection::where('project_id', $this->projectId)
->whereNotNull('user_id')->distinct()->pluck('user_id')) ->whereNotNull('user_id')->distinct()->pluck('user_id'))
->orderBy('name')->pluck('name', 'id')->toArray(); ->orderBy('name')->pluck('name', 'id')->toArray();
return [ return [
@@ -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)
@@ -154,4 +152,4 @@ class InspectionTable extends DataTableComponent
->filter(fn (Builder $query, string $value) => $query->where('inspections.user_id', $value)), ->filter(fn (Builder $query, string $value) => $query->where('inspections.user_id', $value)),
]; ];
} }
} }
+4 -1
View File
@@ -11,8 +11,11 @@ use Livewire\Component;
class ProjectCompanies extends Component 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, [
+45 -41
View File
@@ -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;
@@ -18,20 +19,20 @@ class ProjectCompaniesTable extends DataTableComponent
/** role_in_project => label */ /** role_in_project => label */
public const ROLES = [ public const ROLES = [
'owner' => 'Promotor', 'owner' => 'Promotor',
'constructor' => 'Constructor', 'constructor' => 'Constructor',
'subcontractor' => 'Subcontratista', 'subcontractor' => 'Subcontratista',
'consultant' => 'Consultor', 'consultant' => 'Consultor',
'supplier' => 'Proveedor', 'supplier' => 'Proveedor',
'other' => 'Otro', 'other' => 'Otro',
]; ];
public function configure(): void public function configure(): void
{ {
$this->setPrimaryKey('id') $this->setPrimaryKey('id')
->setDefaultSort('companies.name', 'asc') ->setDefaultSort('companies.name', 'asc')
->setSortingPillsEnabled(false) ->setSortingPillsEnabled(false)
->setAdditionalSelects(['companies.id as id', 'company_project.role_in_project as role_in_project']); ->setAdditionalSelects(['companies.id as id', 'company_project.role_in_project as role_in_project']);
} }
#[On('project-companies-changed')] #[On('project-companies-changed')]
@@ -51,48 +52,51 @@ class ProjectCompaniesTable extends DataTableComponent
{ {
return [ return [
Column::make('Empresa', 'name') Column::make('Empresa', 'name')
->sortable() ->sortable()
->searchable() ->searchable()
->format(function ($value, $row) { ->format(function ($value, $row) {
$initial = strtoupper(mb_substr($value ?? '?', 0, 1)); $initial = strtoupper(mb_substr($value ?? '?', 0, 1));
$html = '<div class="flex items-center gap-2"> $html = '<div class="flex items-center gap-2">
<span class="w-7 h-7 rounded-full bg-primary text-primary-content flex items-center justify-center text-xs font-bold shrink-0">'.$initial.'</span> <span class="w-7 h-7 rounded-full bg-primary text-primary-content flex items-center justify-center text-xs font-bold shrink-0">'.$initial.'</span>
<div><span class="font-medium">'.e($value).'</span>'; <div><span class="font-medium">'.e($value).'</span>';
if ($row->tax_id) { if ($row->tax_id) {
$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(),
Column::make('Rol', 'role_in_project') Column::make('Rol', 'role_in_project')
->label(function ($row) { ->label(function ($row) {
$current = $row->role_in_project; $current = $row->role_in_project;
if (! Auth::user()->can('assign companies')) { if (! Auth::user()->can('assign companies')) {
return '<span class="badge badge-sm">'.(self::ROLES[$current] ?? ucfirst((string) $current)).'</span>'; return '<span class="badge badge-sm">'.(self::ROLES[$current] ?? ucfirst((string) $current)).'</span>';
} }
$opts = ''; $opts = '';
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(),
Column::make('Acciones') Column::make('Acciones')
->label(function ($row) { ->label(function ($row) {
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">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg> <svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button> </button>
</div>'; </div>';
}) })
->html(), ->html(),
]; ];
} }
@@ -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.');
} }
+38 -30
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
@@ -17,11 +17,16 @@ class ProjectDashboard extends Component
public Project $project; public Project $project;
// 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,43 +53,42 @@ 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();
$closedIssues = Issue::where('project_id', $pid)->where('status', 'closed')->count(); $closedIssues = Issue::where('project_id', $pid)->where('status', 'closed')->count();
$criticalIssues = Issue::where('project_id', $pid)->where('status', 'open')->where('priority', 'critical')->count(); $criticalIssues = Issue::where('project_id', $pid)->where('status', 'open')->where('priority', 'critical')->count();
$totalInspections = Inspection::where('project_id', $pid)->count(); $totalInspections = Inspection::where('project_id', $pid)->count();
$passedInspections = Inspection::where('project_id', $pid)->where('result', 'pass')->count(); $passedInspections = Inspection::where('project_id', $pid)->where('result', 'pass')->count();
$failedInspections = Inspection::where('project_id', $pid)->where('result', 'fail')->count(); $failedInspections = Inspection::where('project_id', $pid)->where('result', 'fail')->count();
$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 = [
'global_progress' => round($globalProgress), 'global_progress' => round($globalProgress),
'total_phases' => $this->phases->count(), 'total_phases' => $this->phases->count(),
'delayed_phases' => $delayedPhases, 'delayed_phases' => $delayedPhases,
'total_features' => $totalFeatures, 'total_features' => $totalFeatures,
'completed_features' => $completedFeatures, 'completed_features' => $completedFeatures,
'verified_features' => $verifiedFeatures, 'verified_features' => $verifiedFeatures,
'open_issues' => $openIssues, 'open_issues' => $openIssues,
'closed_issues' => $closedIssues, 'closed_issues' => $closedIssues,
'critical_issues' => $criticalIssues, 'critical_issues' => $criticalIssues,
'total_inspections' => $totalInspections, 'total_inspections' => $totalInspections,
'passed_inspections' => $passedInspections, 'passed_inspections' => $passedInspections,
'failed_inspections' => $failedInspections, 'failed_inspections' => $failedInspections,
]; ];
$this->recentInspections = Inspection::where('project_id', $pid) $this->recentInspections = Inspection::where('project_id', $pid)
+33 -31
View File
@@ -4,6 +4,7 @@ namespace App\Livewire\Projects;
use App\Models\Feature; use App\Models\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;
@@ -22,10 +23,10 @@ class ProjectFeaturesTable extends DataTableComponent
public function configure(): void public function configure(): void
{ {
$this->setPrimaryKey('id') $this->setPrimaryKey('id')
->setDefaultSort('name', 'asc') ->setDefaultSort('name', 'asc')
->setSortingPillsEnabled(false) ->setSortingPillsEnabled(false)
->setSecondaryHeaderEnabled() ->setSecondaryHeaderEnabled()
->setAdditionalSelects(['features.id as id', 'features.layer_id as layer_id', 'features.feature_type_id as feature_type_id']); ->setAdditionalSelects(['features.id as id', 'features.layer_id as layer_id', 'features.feature_type_id as feature_type_id']);
} }
#[On('features-changed')] #[On('features-changed')]
@@ -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());
@@ -55,47 +57,47 @@ class ProjectFeaturesTable extends DataTableComponent
{ {
return [ return [
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')
->secondaryHeaderFilter('layer') ->secondaryHeaderFilter('layer')
->label(fn ($row) => e($row->layer?->name ?? '—')), ->label(fn ($row) => e($row->layer?->name ?? '—')),
Column::make('Fase') Column::make('Fase')
->label(fn ($row) => e($row->layer?->phase?->name ?? '—')), ->label(fn ($row) => e($row->layer?->phase?->name ?? '—')),
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(),
Column::make('Activo', 'is_active') Column::make('Activo', 'is_active')
->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>
</div>') </div>')
->html(), ->html(),
]; ];
} }
@@ -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')
+54 -44
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
@@ -15,33 +15,39 @@ class ProjectForm extends Component
public ?Project $project = null; public ?Project $project = null;
// 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 $lng = ''; public string $lat = '';
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
{ {
if ($project && $project->exists) { if ($project && $project->exists) {
Gate::authorize('edit projects', $project); Gate::authorize('edit projects', $project);
$this->project = $project; $this->project = $project;
$this->name = $project->name; $this->name = $project->name;
$this->reference = $project->reference ?? ''; $this->reference = $project->reference ?? '';
$this->status = $project->status; $this->status = $project->status;
$this->address = $project->address; $this->address = $project->address;
$this->country = $project->country ?? ''; $this->country = $project->country ?? '';
$this->lat = (string) ($project->lat ?? ''); $this->lat = (string) ($project->lat ?? '');
$this->lng = (string) ($project->lng ?? ''); $this->lng = (string) ($project->lng ?? '');
$this->startDate = $project->start_date->format('Y-m-d'); $this->startDate = $project->start_date->format('Y-m-d');
$this->endDateEstimated = $project->end_date_estimated?->format('Y-m-d') ?? ''; $this->endDateEstimated = $project->end_date_estimated?->format('Y-m-d') ?? '';
} else { } else {
Gate::authorize('create projects'); Gate::authorize('create projects');
@@ -56,34 +62,38 @@ 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
{ {
return [ return [
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'reference' => 'nullable|string|max:100', 'reference' => 'nullable|string|max:100',
'status' => 'required|in:planning,in_progress,paused,completed', 'status' => 'required|in:planning,in_progress,paused,completed',
'address' => 'required|string', 'address' => 'required|string',
'country' => 'nullable|string|size:2', 'country' => 'nullable|string|size:2',
'lat' => 'nullable|numeric|between:-90,90', 'lat' => 'nullable|numeric|between:-90,90',
'lng' => 'nullable|numeric|between:-180,180', 'lng' => 'nullable|numeric|between:-180,180',
'startDate' => 'required|date', 'startDate' => 'required|date',
'endDateEstimated' => 'nullable|date|after_or_equal:startDate', 'endDateEstimated' => 'nullable|date|after_or_equal:startDate',
]; ];
} }
protected $validationAttributes = [ protected $validationAttributes = [
'name' => 'nombre', 'name' => 'nombre',
'reference' => 'referencia', 'reference' => 'referencia',
'status' => 'estado', 'status' => 'estado',
'address' => 'dirección', 'address' => 'dirección',
'country' => 'país', 'country' => 'país',
'lat' => 'latitud', 'lat' => 'latitud',
'lng' => 'longitud', 'lng' => 'longitud',
'startDate' => 'fecha de inicio', 'startDate' => 'fecha de inicio',
'endDateEstimated' => 'fecha de fin estimada', 'endDateEstimated' => 'fecha de fin estimada',
]; ];
@@ -92,14 +102,14 @@ class ProjectForm extends Component
$this->validate(); $this->validate();
$data = [ $data = [
'name' => $this->name, 'name' => $this->name,
'reference' => $this->reference ?: null, 'reference' => $this->reference ?: null,
'status' => $this->status, 'status' => $this->status,
'address' => $this->address, 'address' => $this->address,
'country' => $this->country ?: null, 'country' => $this->country ?: null,
'lat' => $this->lat ?: null, 'lat' => $this->lat ?: null,
'lng' => $this->lng ?: null, 'lng' => $this->lng ?: null,
'start_date' => $this->startDate, 'start_date' => $this->startDate,
'end_date_estimated' => $this->endDateEstimated ?: null, 'end_date_estimated' => $this->endDateEstimated ?: null,
]; ];
+7 -5
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]);
} }
} }
+252 -136
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,20 +273,23 @@ 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;
$phase->progress_percent = $phase->features()->avg('progress') ?: 0; $phase->progress_percent = $phase->features()->avg('progress') ?: 0;
$phase->save(); $phase->save();
$phase->progressUpdates()->create([ $phase->progressUpdates()->create([
'user_id' => $user->id, 'user_id' => $user->id,
'progress_percent' => $phase->progress_percent, 'progress_percent' => $phase->progress_percent,
'comment' => $comment, 'comment' => $comment,
]); ]);
$this->dispatch('progressUpdated', $featureId, $feature->progress); $this->dispatch('progressUpdated', $featureId, $feature->progress);
$this->dispatch('notify', 'Progreso actualizado'); $this->dispatch('notify', 'Progreso actualizado');
@@ -228,25 +302,33 @@ 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]);
$this->selectedFeature = $feature; return;
$this->selectedPhaseId = $feature->layer->phase_id; }
$this->editProgress = $feature->progress; if ($feature->layer->phase->project_id !== $this->project->id) {
$this->editResponsible = $feature->responsible ?? ''; \Log::warning('[ProjectMap] Feature not in project', ['featureId' => $featureId, 'projectId' => $this->project->id]);
$this->editPhotos = $feature->properties['photos'] ?? []; abort(403);
}
$this->selectedFeature = $feature;
$this->selectedPhaseId = $feature->layer->phase_id;
$this->editProgress = $feature->progress;
$this->editResponsible = $feature->responsible ?? '';
$this->editPhotos = $feature->properties['photos'] ?? [];
$this->selectedTemplateId = $feature->template_id; $this->selectedTemplateId = $feature->template_id;
$this->activeTab = 'edit'; $this->activeTab = 'edit';
$this->loadInspectionHistory(); $this->loadInspectionHistory();
$this->resetInspectionForm(); $this->resetInspectionForm();
@@ -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)
@@ -269,9 +352,9 @@ class ProjectMap extends Component
public function resetInspectionForm() public function resetInspectionForm()
{ {
$this->inspectionFormData = []; $this->inspectionFormData = [];
$this->inspectionResult = ''; $this->inspectionResult = '';
$this->inspectionNotes = ''; $this->inspectionNotes = '';
$this->inspectionPhotos = []; $this->inspectionPhotos = [];
if ($this->selectedTemplateId) { if ($this->selectedTemplateId) {
$template = InspectionTemplate::find($this->selectedTemplateId); $template = InspectionTemplate::find($this->selectedTemplateId);
if ($template) { if ($template) {
@@ -284,45 +367,50 @@ 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',
'inspectionPhotos.*' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:10240', 'inspectionPhotos.*' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:10240',
]); ]);
$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->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;
} }
} }
$inspection = Inspection::create([ $inspection = Inspection::create([
'project_id' => $this->project->id, 'project_id' => $this->project->id,
'layer_id' => $this->selectedFeature->layer_id, 'layer_id' => $this->selectedFeature->layer_id,
'feature_id' => $this->selectedFeature->id, 'feature_id' => $this->selectedFeature->id,
'template_id' => $this->selectedTemplateId, 'template_id' => $this->selectedTemplateId,
'user_id' => auth()->id(), 'user_id' => auth()->id(),
'inspector_user_id' => auth()->id(), 'inspector_user_id' => auth()->id(),
'status' => 'completed', 'status' => 'completed',
'completed_at' => now(), 'completed_at' => now(),
'result' => $this->inspectionResult ?: null, 'result' => $this->inspectionResult ?: null,
'notes' => $this->inspectionNotes ?: null, 'notes' => $this->inspectionNotes ?: null,
'data' => $this->inspectionFormData, 'data' => $this->inspectionFormData,
]); ]);
// Fotos adjuntas a la inspección // Fotos adjuntas a la inspección
@@ -330,34 +418,34 @@ class ProjectMap extends Component
$mime = $photo->getMimeType(); $mime = $photo->getMimeType();
$path = $photo->store("uploads/inspections/{$inspection->id}", 'public'); $path = $photo->store("uploads/inspections/{$inspection->id}", 'public');
$inspection->media()->create([ $inspection->media()->create([
'name' => $photo->getClientOriginalName(), 'name' => $photo->getClientOriginalName(),
'file_path' => $path, 'file_path' => $path,
'file_type' => $mime, 'file_type' => $mime,
'file_extension' => $photo->getClientOriginalExtension(), 'file_extension' => $photo->getClientOriginalExtension(),
'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(),
]); ]);
} }
if ($this->inspectionResult === 'fail') { if ($this->inspectionResult === 'fail') {
Issue::create([ Issue::create([
'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',
'reported_by' => auth()->id(), 'reported_by' => auth()->id(),
]); ]);
$this->openIssuesCount = Issue::where('project_id', $this->project->id) $this->openIssuesCount = Issue::where('project_id', $this->project->id)
->where('status', 'open')->count(); ->where('status', 'open')->count();
$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,14 +470,18 @@ 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();
$this->selectedFeature = $feature; $this->selectedFeature = $feature;
$this->selectedTemplateId = $templateId; $this->selectedTemplateId = $templateId;
$this->resetInspectionForm(); $this->resetInspectionForm();
$this->dispatch('notify', 'Template asignado al elemento'); $this->dispatch('notify', 'Template asignado al elemento');
@@ -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,21 +520,23 @@ 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 ?? '—',
'layer_name' => $ins->feature?->layer?->name ?? '—', 'layer_name' => $ins->feature?->layer?->name ?? '—',
'phase_name' => $ins->feature?->layer?->phase?->name ?? '—', 'phase_name' => $ins->feature?->layer?->phase?->name ?? '—',
'template_name' => $ins->template?->name ?? '—', 'template_name' => $ins->template?->name ?? '—',
'user_name' => $ins->user?->name ?? '—', 'user_name' => $ins->user?->name ?? '—',
'date' => $ins->created_at->format('d/m/Y H:i'), 'date' => $ins->created_at->format('d/m/Y H:i'),
'status' => $ins->status, 'status' => $ins->status,
'result' => $ins->result, 'result' => $ins->result,
'notes' => $ins->notes, 'notes' => $ins->notes,
'data' => $ins->data ?? [], 'data' => $ins->data ?? [],
'fields' => $ins->template?->fields ?? [], 'fields' => $ins->template?->fields ?? [],
'photos' => $ins->media->map(fn ($m) => ['url' => $m->url, 'name' => $m->name, 'id' => $m->id])->values()->all(), 'photos' => $ins->media->map(fn ($m) => ['url' => $m->url, 'name' => $m->name, 'id' => $m->id])->values()->all(),
]; ];
} }
@@ -455,7 +553,9 @@ class ProjectMap extends Component
$ins = Inspection::where('project_id', $this->project->id) $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,34 +594,40 @@ 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;
} }
$this->validate([ $this->validate([
'selectedTemplateId' => 'required|exists:inspection_templates,id', 'selectedTemplateId' => 'required|exists:inspection_templates,id',
'editInspectionPhotos.*' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:10240', 'editInspectionPhotos.*' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:10240',
'editInspectionPhotosToDelete' => 'array', 'editInspectionPhotosToDelete' => 'array',
'editInspectionPhotosToDelete.*' => 'exists:media,id', 'editInspectionPhotosToDelete.*' => 'exists:media,id',
]); ]);
$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)
@@ -532,9 +640,9 @@ class ProjectMap extends Component
// Actualizar datos // Actualizar datos
$ins->update([ $ins->update([
'template_id' => $this->selectedTemplateId, 'template_id' => $this->selectedTemplateId,
'result' => $this->editInspectionResult ?: null, 'result' => $this->editInspectionResult ?: null,
'notes' => $this->editInspectionNotes ?: null, 'notes' => $this->editInspectionNotes ?: null,
'data' => $this->editInspectionFormData, 'data' => $this->editInspectionFormData,
]); ]);
// Añadir nuevas fotos // Añadir nuevas fotos
@@ -542,14 +650,14 @@ class ProjectMap extends Component
$mime = $photo->getMimeType(); $mime = $photo->getMimeType();
$path = $photo->store("uploads/inspections/{$ins->id}", 'public'); $path = $photo->store("uploads/inspections/{$ins->id}", 'public');
$ins->media()->create([ $ins->media()->create([
'name' => $photo->getClientOriginalName(), 'name' => $photo->getClientOriginalName(),
'file_path' => $path, 'file_path' => $path,
'file_type' => $mime, 'file_type' => $mime,
'file_extension' => $photo->getClientOriginalExtension(), 'file_extension' => $photo->getClientOriginalExtension(),
'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(),
]); ]);
} }
@@ -570,19 +678,21 @@ class ProjectMap extends Component
public function deleteInspection($id) public function deleteInspection($id)
{ {
\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,21 +730,25 @@ 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) {
foreach ($layer->features as $feature) { foreach ($layer->features as $feature) {
$image = $feature->images->first(); $image = $feature->images->first();
if ($image) { if ($image) {
$geo = $feature->geometry; $geo = $feature->geometry;
$coords = null; $coords = null;
if ($geo && isset($geo['coordinates'])) { if ($geo && isset($geo['coordinates'])) {
if ($geo['type'] === 'Point') { if ($geo['type'] === 'Point') {
@@ -646,10 +760,10 @@ class ProjectMap extends Component
if ($coords && $coords['lat'] && $coords['lng']) { if ($coords && $coords['lat'] && $coords['lng']) {
$markers[] = [ $markers[] = [
'feature_id' => $feature->id, 'feature_id' => $feature->id,
'name' => $feature->name, 'name' => $feature->name,
'lat' => $coords['lat'], 'lat' => $coords['lat'],
'lng' => $coords['lng'], 'lng' => $coords['lng'],
'image_url' => $image->url, 'image_url' => $image->url,
'image_name' => $image->name, 'image_name' => $image->name,
]; ];
} }
@@ -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)
@@ -675,7 +791,7 @@ class ProjectMap extends Component
{ {
return view('livewire.projects.project-map', [ return view('livewire.projects.project-map', [
'project' => $this->project, 'project' => $this->project,
'phases' => $this->phases, 'phases' => $this->phases,
]); ]);
} }
} }
+64 -60
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
{ {
@@ -15,9 +15,9 @@ class ProjectTable extends DataTableComponent
public function configure(): void public function configure(): void
{ {
$this->setPrimaryKey('id') $this->setPrimaryKey('id')
->setDefaultSort('created_at', 'desc') ->setDefaultSort('created_at', 'desc')
->setSortingPillsEnabled(false) ->setSortingPillsEnabled(false)
->setAdditionalSelects(['projects.id as id', 'projects.created_at as created_at']); ->setAdditionalSelects(['projects.id as id', 'projects.created_at as created_at']);
} }
public function builder(): Builder public function builder(): Builder
@@ -30,88 +30,92 @@ class ProjectTable extends DataTableComponent
{ {
return [ return [
Column::make('Referencia', 'reference') Column::make('Referencia', 'reference')
->sortable() ->sortable()
->searchable() ->searchable()
->format(function ($value, $row) { ->format(function ($value, $row) {
$url = route('projects.dashboard', $row->id); $url = route('projects.dashboard', $row->id);
return $value
? '<a href="'.$url.'" class="font-mono text-xs text-primary hover:underline" wire:navigate>'.e($value).'</a>' return $value
: '<span class="text-gray-300">—</span>'; ? '<a href="'.$url.'" class="font-mono text-xs text-primary hover:underline" wire:navigate>'.e($value).'</a>'
}) : '<span class="text-gray-300">—</span>';
->html(), })
->html(),
Column::make(__('Name'), 'name') Column::make(__('Name'), 'name')
->sortable() ->sortable()
->searchable(), ->searchable(),
Column::make(__('Address'), 'address') Column::make(__('Address'), 'address')
->sortable() ->sortable()
->searchable() ->searchable()
->format(fn ($value) => $value ->format(fn ($value) => $value
? '<span class="truncate block max-w-xs" title="'.e($value).'">'.e($value).'</span>' ? '<span class="truncate block max-w-xs" title="'.e($value).'">'.e($value).'</span>'
: '<span class="text-gray-400">—</span>') : '<span class="text-gray-400">—</span>')
->html(), ->html(),
Column::make(__('Status'), 'status') Column::make(__('Status'), 'status')
->sortable() ->sortable()
->format(function ($value) { ->format(function ($value) {
$map = [ $map = [
'planning' => ['badge-ghost', 'Planificación'], 'planning' => ['badge-ghost', 'Planificación'],
'in_progress' => ['badge-primary', 'En progreso'], 'in_progress' => ['badge-primary', 'En progreso'],
'paused' => ['badge-warning', 'Pausado'], 'paused' => ['badge-warning', 'Pausado'],
'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(),
Column::make(__('Progress')) Column::make(__('Progress'))
->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">
<div class="bg-primary h-2 rounded-full" style="width:'.$pct.'%"></div> <div class="bg-primary h-2 rounded-full" style="width:'.$pct.'%"></div>
</div> </div>
<span class="text-xs text-gray-500 w-8 text-right">'.$pct.'%</span> <span class="text-xs text-gray-500 w-8 text-right">'.$pct.'%</span>
</div>'; </div>';
}) })
->html(), ->html(),
Column::make(__('Start Date'), 'start_date') Column::make(__('Start Date'), 'start_date')
->sortable() ->sortable()
->format(fn ($value) => $value ? $value->format('d/m/Y') : '—'), ->format(fn ($value) => $value ? $value->format('d/m/Y') : '—'),
Column::make(__('Est. End'), 'end_date_estimated') Column::make(__('Est. End'), 'end_date_estimated')
->sortable() ->sortable()
->format(fn ($value) => $value ? $value->format('d/m/Y') : '—'), ->format(fn ($value) => $value ? $value->format('d/m/Y') : '—'),
Column::make(__('Actions')) Column::make(__('Actions'))
->label(function ($row) { ->label(function ($row) {
$dashboard = route('projects.dashboard', $row->id); $dashboard = route('projects.dashboard', $row->id);
$map = route('projects.map', $row->id); $map = route('projects.map', $row->id);
$edit = route('projects.edit', $row->id); $edit = route('projects.edit', $row->id);
$canEdit = Auth::user()->can('edit projects'); $canEdit = Auth::user()->can('edit projects');
$html = '<div class="flex items-center gap-1">'; $html = '<div class="flex items-center gap-1">';
$html .= '<a href="'.$dashboard.'" class="btn btn-xs btn-outline" title="Dashboard" wire:navigate> $html .= '<a href="'.$dashboard.'" class="btn btn-xs btn-outline" title="Dashboard" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg>
</a>'; </a>';
$html .= '<a href="'.$map.'" class="btn btn-xs btn-outline" title="Mapa" wire:navigate> $html .= '<a href="'.$map.'" class="btn btn-xs btn-outline" title="Mapa" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg> <svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>
</a>'; </a>';
if ($canEdit) { if ($canEdit) {
$html .= '<a href="'.$edit.'" class="btn btn-xs btn-warning" title="Editar" wire:navigate> $html .= '<a href="'.$edit.'" class="btn btn-xs btn-warning" title="Editar" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</a>'; </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, [
+39 -35
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;
@@ -20,16 +21,16 @@ class ProjectUsersTable extends DataTableComponent
public const ROLES = [ public const ROLES = [
'supervisor' => 'Supervisor', 'supervisor' => 'Supervisor',
'consultant' => 'Consultor', 'consultant' => 'Consultor',
'client' => 'Cliente', 'client' => 'Cliente',
'viewer' => 'Observador', 'viewer' => 'Observador',
]; ];
public function configure(): void public function configure(): void
{ {
$this->setPrimaryKey('id') $this->setPrimaryKey('id')
->setDefaultSort('users.name', 'asc') ->setDefaultSort('users.name', 'asc')
->setSortingPillsEnabled(false) ->setSortingPillsEnabled(false)
->setAdditionalSelects(['users.id as id', 'project_user.role_in_project as role_in_project']); ->setAdditionalSelects(['users.id as id', 'project_user.role_in_project as role_in_project']);
} }
#[On('project-users-changed')] #[On('project-users-changed')]
@@ -49,48 +50,51 @@ class ProjectUsersTable extends DataTableComponent
{ {
return [ return [
Column::make('Nombre', 'name') Column::make('Nombre', 'name')
->sortable() ->sortable()
->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>
</div>'; </div>';
}) })
->html(), ->html(),
Column::make('Email', 'email') Column::make('Email', 'email')
->sortable() ->sortable()
->searchable(), ->searchable(),
Column::make('Rol', 'role_in_project') Column::make('Rol', 'role_in_project')
->label(function ($row) { ->label(function ($row) {
$current = $row->role_in_project; $current = $row->role_in_project;
if (! Auth::user()->can('assign users')) { if (! Auth::user()->can('assign users')) {
return '<span class="badge badge-sm">'.(self::ROLES[$current] ?? ucfirst((string) $current)).'</span>'; return '<span class="badge badge-sm">'.(self::ROLES[$current] ?? ucfirst((string) $current)).'</span>';
} }
$opts = ''; $opts = '';
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(),
Column::make('Acciones') Column::make('Acciones')
->label(function ($row) { ->label(function ($row) {
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">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg> <svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button> </button>
</div>'; </div>';
}) })
->html(), ->html(),
]; ];
} }
@@ -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.');
} }
+5 -4
View File
@@ -10,7 +10,7 @@ use Livewire\Component;
class ReportBuilder extends Component class ReportBuilder extends Component
{ {
public Project $project; public Project $project;
public array $filters = [ public array $filters = [
'date_from' => null, 'date_from' => null,
'date_to' => null, 'date_to' => null,
@@ -19,8 +19,9 @@ class ReportBuilder extends Component
'include_charts' => false, 'include_charts' => false,
'format' => 'html', 'format' => 'html',
]; ];
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);
} }
} }
@@ -122,4 +123,4 @@ class ReportBuilder extends Component
'availableEntities' => (new ReportFilters)->getAvailableEntities(), 'availableEntities' => (new ReportFilters)->getAvailableEntities(),
]); ]);
} }
} }
+25 -24
View File
@@ -2,31 +2,32 @@
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()
{ {
$this->loadChartData(); $this->loadChartData();
} }
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();
// Simulate monthly progress data (since we don't have historical stored) // Simulate monthly progress data (since we don't have historical stored)
// In a real app, we'd have a progress_history table or similar // In a real app, we'd have a progress_history table or similar
$months = []; $months = [];
@@ -35,7 +36,7 @@ class ReportsDashboard extends Component
$month = $current->copy()->subMonths($i); $month = $current->copy()->subMonths($i);
$months[] = $month->format('M Y'); $months[] = $month->format('M Y');
} }
$projectProgress = []; $projectProgress = [];
foreach ($projects as $project) { foreach ($projects as $project) {
$progressData = []; $progressData = [];
@@ -49,54 +50,54 @@ class ReportsDashboard extends Component
} }
$projectProgress[] = [ $projectProgress[] = [
'name' => $project->name, 'name' => $project->name,
'data' => $progressData 'data' => $progressData,
]; ];
} }
// Inspections by type (last 6 months) // Inspections by type (last 6 months)
$inspections = Inspection::with(['template', 'feature']) $inspections = Inspection::with(['template', 'feature'])
->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();
}); });
// Projects by status // Projects by status
$projectsByStatus = Project::selectRaw('status, count(*) as count') $projectsByStatus = Project::selectRaw('status, count(*) as count')
->groupBy('status') ->groupBy('status')
->pluck('count', 'status') ->pluck('count', 'status')
->toArray(); ->toArray();
// 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,
]; ];
}); });
$this->chartData = [ $this->chartData = [
'months' => $months, 'months' => $months,
'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,
]; ];
} }
public function render() public function render()
{ {
return view('livewire.reports.reports-dashboard'); return view('livewire.reports.reports-dashboard');
+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();
+72 -60
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
@@ -17,21 +17,29 @@ class UserForm extends Component
public ?User $user = null; public ?User $user = null;
// 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 $phone = ''; public string $address = '';
public string $email = '';
public string $phone = '';
public string $email = '';
// Permisos // Permisos
public string $formRole = ''; public string $formRole = '';
@@ -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). */
@@ -58,47 +67,47 @@ class UserForm extends Component
{ {
abort_unless(Auth::user()->can('create users') || Auth::user()->can('edit users'), 403); abort_unless(Auth::user()->can('create users') || Auth::user()->can('edit users'), 403);
$this->roles = Role::orderBy('name')->get(); $this->roles = Role::orderBy('name')->get();
$this->companies = Company::where('estado', 'activo')->orderBy('name')->get(); $this->companies = Company::where('estado', 'activo')->orderBy('name')->get();
$this->formRole = $this->roles->first()?->name ?? ''; $this->formRole = $this->roles->first()?->name ?? '';
if ($user && $user->exists) { if ($user && $user->exists) {
$this->user = $user; $this->user = $user;
$this->title = $user->title ?? ''; $this->title = $user->title ?? '';
$this->lastName = $user->last_name ?? ''; $this->lastName = $user->last_name ?? '';
$this->firstName = $user->first_name ?? ''; $this->firstName = $user->first_name ?? '';
$this->userStatus = $user->status ?? 'active'; $this->userStatus = $user->status ?? 'active';
$this->validFrom = $user->valid_from?->format('Y-m-d') ?? ''; $this->validFrom = $user->valid_from?->format('Y-m-d') ?? '';
$this->validUntil = $user->valid_until?->format('Y-m-d') ?? ''; $this->validUntil = $user->valid_until?->format('Y-m-d') ?? '';
$this->companyId = $user->company_id; $this->companyId = $user->company_id;
$this->address = $user->address ?? ''; $this->address = $user->address ?? '';
$this->phone = $user->phone ?? ''; $this->phone = $user->phone ?? '';
$this->email = $user->email; $this->email = $user->email;
$this->notes = $user->notes ?? ''; $this->notes = $user->notes ?? '';
$this->formRole = $user->roles->first()?->name ?? $this->formRole; $this->formRole = $user->roles->first()?->name ?? $this->formRole;
$this->locale = $user->locale ?? $this->locale; $this->locale = $user->locale ?? $this->locale;
} }
} }
protected function rules(): array protected function rules(): array
{ {
$id = $this->user?->id ?? 'NULL'; $id = $this->user?->id ?? 'NULL';
$rules = [ $rules = [
'lastName' => 'required|string|max:100', 'lastName' => 'required|string|max:100',
'firstName' => 'required|string|max:100', 'firstName' => 'required|string|max:100',
'title' => 'nullable|string|max:20', 'title' => 'nullable|string|max:20',
'userStatus' => 'required|in:active,inactive,suspended', 'userStatus' => 'required|in:active,inactive,suspended',
'validFrom' => 'nullable|date', 'validFrom' => 'nullable|date',
'validUntil' => 'nullable|date|after_or_equal:validFrom', 'validUntil' => 'nullable|date|after_or_equal:validFrom',
'companyId' => 'required|exists:companies,id', 'companyId' => 'required|exists:companies,id',
'address' => 'nullable|string', 'address' => 'nullable|string',
'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()];
@@ -108,20 +117,22 @@ class UserForm extends Component
} }
protected $validationAttributes = [ protected $validationAttributes = [
'lastName' => 'apellidos', 'lastName' => 'apellidos',
'firstName' => 'nombre', 'firstName' => 'nombre',
'userStatus' => 'estado', 'userStatus' => 'estado',
'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,25 +146,26 @@ 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,
'title' => $this->title ?: null, 'title' => $this->title ?: null,
'first_name' => $this->firstName, 'first_name' => $this->firstName,
'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,
'email' => $this->email, 'email' => $this->email,
'notes' => $this->notes ?: null, 'notes' => $this->notes ?: null,
'locale' => $this->locale, 'locale' => $this->locale,
]; ];
if ($this->formPassword !== '') { if ($this->formPassword !== '') {
+84 -82
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
{ {
@@ -17,17 +17,17 @@ class UserTable extends DataTableComponent
public function configure(): void public function configure(): void
{ {
$this->setPrimaryKey('id') $this->setPrimaryKey('id')
->setDefaultSort('name', 'asc') ->setDefaultSort('name', 'asc')
->setSortingPillsEnabled(false) ->setSortingPillsEnabled(false)
->setAdditionalSelects([ ->setAdditionalSelects([
'users.id as id', 'users.id as id',
'users.email as email', 'users.email as email',
'users.email_verified_at as email_verified_at', 'users.email_verified_at as email_verified_at',
'users.status as status', 'users.status as status',
'users.phone as phone', 'users.phone as phone',
'users.company_id as company_id', 'users.company_id as company_id',
'users.created_at as created_at', 'users.created_at as created_at',
]); ]);
} }
public function builder(): Builder public function builder(): Builder
@@ -39,90 +39,91 @@ class UserTable extends DataTableComponent
{ {
return [ return [
Column::make('Usuario', 'name') Column::make('Usuario', 'name')
->sortable() ->sortable()
->searchable() ->searchable()
->format(function ($value, $row) { ->format(function ($value, $row) {
$initial = strtoupper(mb_substr($value, 0, 1)); $initial = strtoupper(mb_substr($value, 0, 1));
$html = '<div class="flex items-center gap-3">'; $html = '<div class="flex items-center gap-3">';
$html .= '<div class="avatar placeholder shrink-0"> $html .= '<div class="avatar placeholder shrink-0">
<div class="bg-neutral text-neutral-content rounded-full w-8"> <div class="bg-neutral text-neutral-content rounded-full w-8">
<span class="text-xs font-semibold">'.$initial.'</span> <span class="text-xs font-semibold">'.$initial.'</span>
</div> </div>
</div>'; </div>';
$html .= '<div>'; $html .= '<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>';
$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>' )
) ->html(),
->html(),
Column::make('Rol') Column::make('Rol')
->label(function ($row) { ->label(function ($row) {
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(),
Column::make('Estado', 'status') Column::make('Estado', 'status')
->sortable() ->sortable()
->format(function ($value) { ->format(function ($value) {
$map = [ $map = [
'active' => ['badge-success', 'Activo'], 'active' => ['badge-success', 'Activo'],
'inactive' => ['badge-ghost', 'Inactivo'], 'inactive' => ['badge-ghost', 'Inactivo'],
'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>' )
) ->html(),
->html(),
Column::make('Acciones') Column::make('Acciones')
->label(function ($row) { ->label(function ($row) {
$ver = route('admin.users.show', $row->id); $ver = route('admin.users.show', $row->id);
$editar = route('admin.users.edit', $row->id); $editar = route('admin.users.edit', $row->id);
$name = addslashes($row->name); $name = addslashes($row->name);
$isSelf = $row->id === Auth::id(); $isSelf = $row->id === Auth::id();
$html = '<div class="flex items-center justify-end gap-1">'; $html = '<div class="flex items-center justify-end gap-1">';
$html .= '<a href="'.$ver.'" class="btn btn-xs btn-outline" title="Ver" wire:navigate> $html .= '<a href="'.$ver.'" class="btn btn-xs btn-outline" title="Ver" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</a>'; </a>';
$html .= '<a href="'.$editar.'" class="btn btn-xs btn-outline btn-info" title="Editar" wire:navigate> $html .= '<a href="'.$editar.'" class="btn btn-xs btn-outline btn-info" title="Editar" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</a>'; </a>';
if (! $isSelf) { if (! $isSelf) {
$html .= '<button wire:click="deleteUser('.$row->id.')" $html .= '<button wire:click="deleteUser('.$row->id.')"
wire:confirm="¿Eliminar a \''.$name.'\'? Se perderán todos sus datos." wire:confirm="¿Eliminar a \''.$name.'\'? Se perderán todos sus datos."
class="btn btn-xs btn-outline btn-error" title="Eliminar"> class="btn btn-xs btn-outline btn-error" title="Eliminar">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg> <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(),
]; ];
} }
@@ -133,16 +134,15 @@ 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')
->options([ ->options([
'' => 'Estado: todos', '' => 'Estado: todos',
'active' => 'Activo', 'active' => 'Activo',
'inactive' => 'Inactivo', 'inactive' => 'Inactivo',
'suspended' => 'Suspendido', 'suspended' => 'Suspendido',
]) ])
->filter(fn (Builder $query, string $value) => $query->where('status', $value)), ->filter(fn (Builder $query, string $value) => $query->where('status', $value)),
]; ];
@@ -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();
} }
} }
+19 -13
View File
@@ -2,40 +2,45 @@
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;
#[Layout('layouts.app')] #[Layout('layouts.app')]
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
{ {
abort_unless(Auth::user()->can('view users'), 403); abort_unless(Auth::user()->can('view users'), 403);
$this->user = $user->load(['roles', 'company', 'projects.phases']); $this->user = $user->load(['roles', 'company', 'projects.phases']);
$this->notes = $user->notes ?? ''; $this->notes = $user->notes ?? '';
$this->loadAvailableProjects(); $this->loadAvailableProjects();
@@ -72,7 +77,7 @@ class UserView extends Component
public function assignProject(): void public function assignProject(): void
{ {
$this->validate([ $this->validate([
'addProjectId' => 'required|exists:projects,id', 'addProjectId' => 'required|exists:projects,id',
'addProjectRole' => 'nullable|string|max:100', 'addProjectRole' => 'nullable|string|max:100',
], [], ['addProjectId' => 'proyecto', 'addProjectRole' => 'rol en proyecto']); ], [], ['addProjectId' => 'proyecto', 'addProjectRole' => 'rol en proyecto']);
@@ -81,7 +86,7 @@ class UserView extends Component
]); ]);
$this->user->load('projects.phases'); $this->user->load('projects.phases');
$this->addProjectId = null; $this->addProjectId = null;
$this->addProjectRole = ''; $this->addProjectRole = '';
$this->loadAvailableProjects(); $this->loadAvailableProjects();
$this->dispatch('notify', 'Proyecto asignado.'); $this->dispatch('notify', 'Proyecto asignado.');
@@ -146,13 +151,14 @@ 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;
}); });
return view('livewire.users.user-view', [ return view('livewire.users.user-view', [
'grouped' => $grouped, 'grouped' => $grouped,
'directPerms' => $this->user->getDirectPermissions()->pluck('name')->toArray(), 'directPerms' => $this->user->getDirectPermissions()->pluck('name')->toArray(),
'rolePerms' => $this->user->getPermissionsViaRoles()->pluck('name')->toArray(), 'rolePerms' => $this->user->getPermissionsViaRoles()->pluck('name')->toArray(),
]); ]);
} }
} }
+5 -5
View File
@@ -12,18 +12,18 @@ class ActivityLog extends Model
protected $fillable = ['action', 'model_type', 'model_id', 'user_id', 'changes', 'created_at']; protected $fillable = ['action', 'model_type', 'model_id', 'user_id', 'changes', 'created_at'];
protected $casts = [ protected $casts = [
'changes' => 'array', 'changes' => 'array',
'created_at' => 'datetime', 'created_at' => 'datetime',
]; ];
public static function record(string $action, Model $model, array $changes = []): void public static function record(string $action, Model $model, array $changes = []): void
{ {
static::create([ static::create([
'action' => $action, 'action' => $action,
'model_type' => class_basename($model), 'model_type' => class_basename($model),
'model_id' => $model->getKey(), 'model_id' => $model->getKey(),
'user_id' => Auth::id(), 'user_id' => Auth::id(),
'changes' => empty($changes) ? null : $changes, 'changes' => empty($changes) ? null : $changes,
'created_at' => now(), 'created_at' => now(),
]); ]);
} }
+2 -3
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;
@@ -35,7 +34,7 @@ class Company extends Model
public function projects() public function projects()
{ {
return $this->belongsToMany(Project::class, 'company_project') return $this->belongsToMany(Project::class, 'company_project')
->withPivot('role_in_project') ->withPivot('role_in_project')
->withTimestamps(); ->withTimestamps();
} }
} }
+28 -26
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'];
@@ -22,15 +23,15 @@ class Feature extends Model
]; ];
protected $casts = [ protected $casts = [
'geometry' => 'array', 'geometry' => 'array',
'properties' => 'array', 'properties' => 'array',
'is_active' => 'boolean', 'is_active' => 'boolean',
'planned_start' => 'date', 'planned_start' => 'date',
'planned_end' => 'date', 'planned_end' => 'date',
'actual_start' => 'date', 'actual_start' => 'date',
'actual_end' => 'date', 'actual_end' => 'date',
'baseline_start' => 'date', 'baseline_start' => 'date',
'baseline_end' => 'date', 'baseline_end' => 'date',
]; ];
public function featureType() public function featureType()
@@ -80,13 +81,13 @@ 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',
'completed' => '#10b981', 'completed' => '#10b981',
'verified' => '#8b5cf6', 'verified' => '#8b5cf6',
default => '#6b7280', default => '#6b7280',
}; };
} }
@@ -99,7 +100,7 @@ class Feature extends Model
return null; return null;
} }
$end = $this->actual_end ?? now()->toDateString(); $end = $this->actual_end ?? now()->toDateString();
return $this->planned_end->diffInDays($end, false); return $this->planned_end->diffInDays($end, false);
} }
@@ -109,28 +110,28 @@ class Feature extends Model
return null; return null;
} }
$start = $this->actual_start ?? now()->toDateString(); $start = $this->actual_start ?? now()->toDateString();
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();
if (! $this->planned_start || ! $this->planned_end) { if (! $this->planned_start || ! $this->planned_end) {
return 0; return 0;
} }
if ($date <= $this->planned_start) { if ($date <= $this->planned_start) {
return 0; return 0;
} }
if ($date >= $this->planned_end) { if ($date >= $this->planned_end) {
return 100; return 100;
} }
$totalDays = $this->planned_start->diffInDays($this->planned_end); $totalDays = $this->planned_start->diffInDays($this->planned_end);
$elapsedDays = $this->planned_start->diffInDays($date); $elapsedDays = $this->planned_start->diffInDays($date);
return round(($elapsedDays / $totalDays) * 100, 2); return round(($elapsedDays / $totalDays) * 100, 2);
} }
@@ -138,11 +139,11 @@ class Feature extends Model
{ {
$pv = $this->planned_progress_at; $pv = $this->planned_progress_at;
$ev = $this->progress; $ev = $this->progress;
if ($pv <= 0) { if ($pv <= 0) {
return null; return null;
} }
return round($ev / $pv, 2); return round($ev / $pv, 2);
} }
@@ -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;
} }
} }
+19 -7
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,7 +19,8 @@ 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 = [
'project_id', 'layer_id', 'feature_id', 'template_id', 'user_id', 'project_id', 'layer_id', 'feature_id', 'template_id', 'user_id',
@@ -28,7 +29,7 @@ class Inspection extends Model
]; ];
protected $casts = [ protected $casts = [
'data' => 'array', 'data' => 'array',
'completed_at' => 'datetime', 'completed_at' => 'datetime',
]; ];
@@ -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');
}
} }
+74 -33
View File
@@ -2,17 +2,19 @@
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 = [
'project_id', 'feature_id', 'inspection_id', 'project_id', 'feature_id', 'inspection_id',
@@ -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,23 +94,23 @@ 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',
'critical' => '#7c3aed', 'critical' => '#7c3aed',
default => '#6b7280', default => '#6b7280',
}; };
} }
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',
'closed' => '#6b7280', 'closed' => '#6b7280',
default => '#6b7280', default => '#6b7280',
}; };
} }
@@ -77,11 +118,11 @@ class Issue extends Model
public static function typeLabels(): array public static function typeLabels(): array
{ {
return [ return [
'defect' => 'Defecto', 'defect' => 'Defecto',
'safety' => 'Seguridad', 'safety' => 'Seguridad',
'quality' => 'Calidad', 'quality' => 'Calidad',
'documentation' => 'Documentación', 'documentation' => 'Documentación',
'other' => 'Otro', 'other' => 'Otro',
]; ];
} }
@@ -92,12 +133,12 @@ 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',
'documentation' => '#8b5cf6', 'documentation' => '#8b5cf6',
default => '#6b7280', default => '#6b7280',
}; };
} }
} }
+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');
}
} }
+24 -9
View File
@@ -16,9 +16,9 @@ class IssueTask extends Model
]; ];
protected $casts = [ protected $casts = [
'is_done' => 'boolean', 'is_done' => 'boolean',
'done_at' => 'datetime', 'done_at' => 'datetime',
'due_date' => 'date', 'due_date' => 'date',
'overdue_notified_at' => 'datetime', 'overdue_notified_at' => 'datetime',
]; ];
@@ -26,14 +26,29 @@ class IssueTask extends Model
public function scopeOverdue($q) public function scopeOverdue($q)
{ {
return $q->where('is_done', false) return $q->where('is_done', false)
->whereNotNull('due_date') ->whereNotNull('due_date')
->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
+3 -3
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);
@@ -46,4 +46,4 @@ class Layer extends Model
{ {
return $this->morphMany(Media::class, 'mediable'); return $this->morphMany(Media::class, 'mediable');
} }
} }
+12 -5
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
@@ -72,4 +79,4 @@ class Media extends Model
Storage::delete($media->file_path); Storage::delete($media->file_path);
}); });
} }
} }
+1 -1
View File
@@ -12,4 +12,4 @@ class PendingSync extends Model
'payload' => 'array', 'payload' => 'array',
'synced_at' => 'datetime', 'synced_at' => 'datetime',
]; ];
} }
+11 -9
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;
@@ -79,7 +80,7 @@ class Phase extends Model
return null; return null;
} }
$end = $this->actual_end ?? now()->toDateString(); $end = $this->actual_end ?? now()->toDateString();
return $this->planned_end->diffInDays($end, false); return $this->planned_end->diffInDays($end, false);
} }
@@ -92,31 +93,31 @@ class Phase extends Model
return null; return null;
} }
$start = $this->actual_start ?? now()->toDateString(); $start = $this->actual_start ?? now()->toDateString();
return $this->planned_start->diffInDays($start, false); return $this->planned_start->diffInDays($start, false);
} }
/** /**
* 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();
if (! $this->planned_start || ! $this->planned_end) { if (! $this->planned_start || ! $this->planned_end) {
return 0; return 0;
} }
if ($date <= $this->planned_start) { if ($date <= $this->planned_start) {
return 0; return 0;
} }
if ($date >= $this->planned_end) { if ($date >= $this->planned_end) {
return 100; return 100;
} }
$totalDays = $this->planned_start->diffInDays($this->planned_end); $totalDays = $this->planned_start->diffInDays($this->planned_end);
$elapsedDays = $this->planned_start->diffInDays($date); $elapsedDays = $this->planned_start->diffInDays($date);
return round(($elapsedDays / $totalDays) * 100, 2); return round(($elapsedDays / $totalDays) * 100, 2);
} }
@@ -127,11 +128,11 @@ class Phase extends Model
{ {
$pv = $this->planned_progress_at; // Planned Value $pv = $this->planned_progress_at; // Planned Value
$ev = $this->progress_percent; // Earned Value (current progress) $ev = $this->progress_percent; // Earned Value (current progress)
if ($pv <= 0) { if ($pv <= 0) {
return null; return null;
} }
return round($ev / $pv, 2); return round($ev / $pv, 2);
} }
@@ -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;
} }
} }
+2 -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;
} }
@@ -76,4 +77,4 @@ class ProgressSnapshot extends Model
->get(['snapshot_date', 'progress', 'method']) ->get(['snapshot_date', 'progress', 'method'])
->toArray(); ->toArray();
} }
} }
+2 -2
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 = [
@@ -24,4 +24,4 @@ class ProgressUpdate extends Model
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
} }
@@ -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
{ {
@@ -20,12 +20,12 @@ class FeatureCompletedNotification extends Notification
public function toArray($notifiable): array public function toArray($notifiable): array
{ {
return [ return [
'type' => 'feature_completed', 'type' => 'feature_completed',
'feature_id' => $this->feature->id, 'feature_id' => $this->feature->id,
'project_id' => $this->feature->layer?->phase?->project_id, 'project_id' => $this->feature->layer?->phase?->project_id,
'feature_name' => $this->feature->name, 'feature_name' => $this->feature->name,
'progress' => 100, 'progress' => 100,
'message' => "Elemento '{$this->feature->name}' marcado como completado", 'message' => "Elemento '{$this->feature->name}' marcado como completado",
]; ];
} }
} }
@@ -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
{ {
@@ -21,13 +20,13 @@ class InspectionCompletedNotification extends Notification
public function toArray($notifiable): array public function toArray($notifiable): array
{ {
return [ return [
'type' => 'inspection_completed', 'type' => 'inspection_completed',
'inspection_id' => $this->inspection->id, 'inspection_id' => $this->inspection->id,
'project_id' => $this->inspection->project_id, 'project_id' => $this->inspection->project_id,
'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
{ {
@@ -20,13 +20,13 @@ class InspectionDeletedNotification extends Notification
public function toArray($notifiable): array public function toArray($notifiable): array
{ {
return [ return [
'type' => 'inspection_deleted', 'type' => 'inspection_deleted',
'inspection_id' => $this->inspection->id, 'inspection_id' => $this->inspection->id,
'project_id' => $this->inspection->project_id, 'project_id' => $this->inspection->project_id,
'feature_name' => $this->inspection->feature?->name ?? '—', 'feature_name' => $this->inspection->feature?->name ?? '—',
'template_name' => $this->inspection->template?->name ?? '—', 'template_name' => $this->inspection->template?->name ?? '—',
'deleted_by' => $this->deletedBy, 'deleted_by' => $this->deletedBy,
'message' => "Inspección eliminada en '{$this->inspection->feature?->name}' por {$this->deletedBy}", 'message' => "Inspección eliminada en '{$this->inspection->feature?->name}' por {$this->deletedBy}",
]; ];
} }
} }
@@ -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
{ {
@@ -20,14 +20,14 @@ class InspectionUpdatedNotification extends Notification
public function toArray($notifiable): array public function toArray($notifiable): array
{ {
return [ return [
'type' => 'inspection_updated', 'type' => 'inspection_updated',
'inspection_id' => $this->inspection->id, 'inspection_id' => $this->inspection->id,
'project_id' => $this->inspection->project_id, 'project_id' => $this->inspection->project_id,
'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,
'changes' => $this->changes, 'changes' => $this->changes,
'message' => "Inspección actualizada en '{$this->inspection->feature?->name}'", 'message' => "Inspección actualizada en '{$this->inspection->feature?->name}'",
]; ];
} }
} }
@@ -20,11 +20,11 @@ class IssueAssignedNotification extends Notification
public function toArray($notifiable): array public function toArray($notifiable): array
{ {
return [ return [
'type' => 'issue_assigned', 'type' => 'issue_assigned',
'issue_id' => $this->issue->id, 'issue_id' => $this->issue->id,
'project_id' => $this->issue->project_id, 'project_id' => $this->issue->project_id,
'priority' => $this->issue->priority, 'priority' => $this->issue->priority,
'message' => "Se te ha asignado la incidencia '{$this->issue->title}'", 'message' => "Se te ha asignado la incidencia '{$this->issue->title}'",
]; ];
} }
} }
@@ -23,11 +23,11 @@ class IssueCommentedNotification extends Notification
$issue = $this->comment->issue; $issue = $this->comment->issue;
return [ return [
'type' => 'issue_commented', 'type' => 'issue_commented',
'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
{ {
@@ -20,12 +20,12 @@ class IssueReportedNotification extends Notification
public function toArray($notifiable): array public function toArray($notifiable): array
{ {
return [ return [
'type' => 'issue_reported', 'type' => 'issue_reported',
'issue_id' => $this->issue->id, 'issue_id' => $this->issue->id,
'project_id' => $this->issue->project_id, 'project_id' => $this->issue->project_id,
'feature_name' => $this->issue->feature?->name ?? '—', 'feature_name' => $this->issue->feature?->name ?? '—',
'priority' => $this->issue->priority, 'priority' => $this->issue->priority,
'message' => "Nuevo issue '{$this->issue->title}' (prioridad: {$this->issue->priority})", 'message' => "Nuevo issue '{$this->issue->title}' (prioridad: {$this->issue->priority})",
]; ];
} }
} }
@@ -20,18 +20,18 @@ class IssueStatusChangedNotification extends Notification
public function toArray($notifiable): array public function toArray($notifiable): array
{ {
$label = [ $label = [
'open' => 'reabierta', 'open' => 'reabierta',
'in_review' => 'enviada a revisión', 'in_review' => 'enviada a revisión',
'resolved' => 'resuelta', 'resolved' => 'resuelta',
'closed' => 'cerrada', 'closed' => 'cerrada',
][$this->status] ?? $this->status; ][$this->status] ?? $this->status;
return [ return [
'type' => 'issue_status_changed', 'type' => 'issue_status_changed',
'issue_id' => $this->issue->id, 'issue_id' => $this->issue->id,
'project_id' => $this->issue->project_id, 'project_id' => $this->issue->project_id,
'status' => $this->status, 'status' => $this->status,
'message' => "La incidencia '{$this->issue->title}' ha sido {$label}", 'message' => "La incidencia '{$this->issue->title}' ha sido {$label}",
]; ];
} }
} }
@@ -20,12 +20,12 @@ class IssueTaskAssignedNotification extends Notification
public function toArray($notifiable): array public function toArray($notifiable): array
{ {
return [ return [
'type' => 'issue_task_assigned', 'type' => 'issue_task_assigned',
'issue_id' => $this->task->issue_id, 'issue_id' => $this->task->issue_id,
'task_id' => $this->task->id, 'task_id' => $this->task->id,
'project_id' => $this->task->issue?->project_id, 'project_id' => $this->task->issue?->project_id,
'due_date' => $this->task->due_date?->toDateString(), 'due_date' => $this->task->due_date?->toDateString(),
'message' => "Se te ha asignado la tarea '{$this->task->title}'", 'message' => "Se te ha asignado la tarea '{$this->task->title}'",
]; ];
} }
} }
@@ -20,12 +20,12 @@ class IssueTaskOverdueNotification extends Notification
public function toArray($notifiable): array public function toArray($notifiable): array
{ {
return [ return [
'type' => 'issue_task_overdue', 'type' => 'issue_task_overdue',
'issue_id' => $this->task->issue_id, 'issue_id' => $this->task->issue_id,
'task_id' => $this->task->id, 'task_id' => $this->task->id,
'project_id' => $this->task->issue?->project_id, 'project_id' => $this->task->issue?->project_id,
'due_date' => $this->task->due_date?->toDateString(), 'due_date' => $this->task->due_date?->toDateString(),
'message' => "Tarea vencida: '{$this->task->title}' (venció el {$this->task->due_date?->format('d/m/Y')})", 'message' => "Tarea vencida: '{$this->task->title}' (venció el {$this->task->due_date?->format('d/m/Y')})",
]; ];
} }
} }
+31 -28
View File
@@ -8,16 +8,15 @@ use App\Models\Inspection;
use App\Models\Issue; use App\Models\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,8 +135,8 @@ 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,
'name' => $phase->name, 'name' => $phase->name,
@@ -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
@@ -179,7 +178,7 @@ class ReportGenerator
return $features->map(function ($feature) { return $features->map(function ($feature) {
$lastInspection = $feature->inspections->sortByDesc('created_at')->first(); $lastInspection = $feature->inspections->sortByDesc('created_at')->first();
return [ return [
'id' => $feature->id, 'id' => $feature->id,
'name' => $feature->name, 'name' => $feature->name,
@@ -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,8 +340,10 @@ 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();
@@ -475,7 +477,7 @@ class ReportGenerator
foreach ($grouped as $date => $snaps) { foreach ($grouped as $date => $snaps) {
$dates[] = Carbon::parse($date)->format('d/m/Y'); $dates[] = Carbon::parse($date)->format('d/m/Y');
$phaseSnaps = $snaps->where('trackable_type', Phase::class); $phaseSnaps = $snaps->where('trackable_type', Phase::class);
$featureSnaps = $snaps->where('trackable_type', Feature::class); $featureSnaps = $snaps->where('trackable_type', Feature::class);
$taskSnaps = $snaps->where('trackable_type', Task::class); $taskSnaps = $snaps->where('trackable_type', Task::class);
@@ -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];
} }
} }
+83 -37
View File
@@ -15,14 +15,16 @@ class SpatialFileConverter
$geojson = match ($ext) { $geojson = match ($ext) {
'geojson' => self::parseGeoJson($path), 'geojson' => self::parseGeoJson($path),
'kml' => self::kmlToGeoJson($path), 'kml' => self::kmlToGeoJson($path),
'kmz' => self::kmzToGeoJson($path), 'kmz' => self::kmzToGeoJson($path),
'shp' => self::shapefileToGeoJson($path), 'shp' => self::shapefileToGeoJson($path),
'zip' => self::handleZip($path), 'zip' => self::handleZip($path),
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') {
@@ -373,4 +419,4 @@ class SpatialFileConverter
} }
rmdir($dir); rmdir($dir);
} }
} }
-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,
]; ];
+6 -6
View File
@@ -32,17 +32,17 @@ return [
| Esto permite subir .kml/.kmz/.shp/.dwg/.zip grandes. | Esto permite subir .kml/.kmz/.shp/.dwg/.zip grandes.
*/ */
'temporary_file_upload' => [ 'temporary_file_upload' => [
'disk' => null, 'disk' => null,
'rules' => ['required', 'file', 'max:51200'], // 50 MB 'rules' => ['required', 'file', 'max:51200'], // 50 MB
'directory' => null, 'directory' => null,
'middleware' => null, 'middleware' => null,
'preview_mimes' => [ 'preview_mimes' => [
'png', 'gif', 'bmp', 'svg', 'wav', 'mp4', 'png', 'gif', 'bmp', 'svg', 'wav', 'mp4',
'mov', 'avi', 'wmv', 'mp3', 'm4a', 'mov', 'avi', 'wmv', 'mp3', 'm4a',
'jpg', 'jpeg', 'mpga', 'webp', 'wma', 'jpg', 'jpeg', 'mpga', 'webp', 'wma',
], ],
'max_upload_time' => 5, 'max_upload_time' => 5,
'cleanup' => true, 'cleanup' => true,
], ],
/* /*
@@ -64,7 +64,7 @@ return [
| Navegación SPA (wire:navigate). | Navegación SPA (wire:navigate).
*/ */
'navigate' => [ 'navigate' => [
'show_progress_bar' => true, 'show_progress_bar' => true,
'progress_bar_color' => '#2299dd', 'progress_bar_color' => '#2299dd',
], ],
@@ -30,4 +30,4 @@ return new class extends Migration
{ {
Schema::dropIfExists('projects'); Schema::dropIfExists('projects');
} }
}; };
@@ -29,4 +29,4 @@ return new class extends Migration
{ {
Schema::dropIfExists('phases'); Schema::dropIfExists('phases');
} }
}; };
@@ -25,4 +25,4 @@ return new class extends Migration
{ {
Schema::dropIfExists('layers'); Schema::dropIfExists('layers');
} }
}; };
@@ -25,4 +25,4 @@ return new class extends Migration
{ {
Schema::dropIfExists('progress_updates'); Schema::dropIfExists('progress_updates');
} }
}; };
@@ -22,4 +22,4 @@ return new class extends Migration
{ {
Schema::dropIfExists('pending_syncs'); Schema::dropIfExists('pending_syncs');
} }
}; };
@@ -21,4 +21,4 @@ return new class extends Migration
{ {
Schema::dropIfExists('project_user'); Schema::dropIfExists('project_user');
} }
}; };

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