chore: cleanup dead code + format with Pint

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

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

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