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}");
}
}
+1 -1
View File
@@ -3,7 +3,6 @@
namespace App\DTO;
use Carbon\Carbon;
use Illuminate\Support\Collection;
class ReportFilters
{
@@ -63,6 +62,7 @@ class ReportFilters
if ($this->dateTo) {
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',
];
}
}
+16 -11
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)
@@ -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,7 +408,7 @@ 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',
];
}
@@ -413,6 +416,7 @@ class TasksSheet extends BaseSheet
{
return array_map(function ($task) {
$subtasksStr = implode('; ', array_map(fn ($st) => "{$st['title']} ({$st['status']})", $task['subtasks']));
return [
$task['id'],
$task['title'],
@@ -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)
+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(),
@@ -232,4 +233,3 @@ class ProjectApiController extends Controller
];
}
}
@@ -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)
{
//
}
}
@@ -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
{
+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
@@ -22,8 +24,8 @@ class ProjectReportController extends Controller
$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_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),
];
+2 -2
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,
]);
}
@@ -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);
}
}
+4 -2
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
+8 -3
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
@@ -37,6 +39,7 @@ class RolePermissionManager extends Component
// 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."'.");
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;
}
@@ -92,6 +96,7 @@ class RolePermissionManager extends Component
if ($permission->name === self::CORE_PERMISSION) {
$this->dispatch('notify', "El permiso '".self::CORE_PERMISSION."' está protegido y no se puede borrar.");
return;
}
+6 -4
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,8 +32,7 @@ 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>'
->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();
+11 -4
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
@@ -39,6 +42,7 @@ class RoleView extends Component
&& $permissionName === self::CORE_PERMISSION
&& $this->role->hasPermissionTo($permissionName)) {
$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;
});
+13 -13
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()
@@ -51,7 +51,7 @@ class ClientProjects extends Component
$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) {
@@ -79,7 +79,7 @@ class ClientProjects extends Component
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'),
],
];
}
@@ -119,7 +119,7 @@ class ClientProjects extends Component
'description' => $order->description,
'status' => $order->status,
'requested_at' => $order->requested_at->format('d/m/Y'),
'amount' => $order->amount
'amount' => $order->amount,
];
})
->toArray();
+1 -1
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
{
+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',
+4 -2
View File
@@ -2,16 +2,18 @@
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()
+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(),
+11 -3
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
@@ -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 = [
@@ -168,6 +174,7 @@ class GlobalTemplateManager extends Component
."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);
}
@@ -183,12 +190,14 @@ class GlobalTemplateManager extends Component
$rows = $this->readFileRows();
} catch (\Throwable $e) {
$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';
}
@@ -60,8 +60,7 @@ class InspectionTemplatesTable extends DataTableComponent
->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,13 +68,13 @@ 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>';
})
->html(),
Column::make('Acciones')
->label(fn ($row) =>
'<div class="flex justify-end gap-1">
->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>
@@ -104,8 +103,12 @@ class InspectionTemplatesTable extends DataTableComponent
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(),
]);
}
+12 -1
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();
}
+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)),
];
}
+52 -16
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,7 +49,9 @@ 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()) {
@@ -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,7 +139,9 @@ 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)) {
$this->visibleLayers[] = $layerId;
@@ -151,6 +164,7 @@ class LayerManager extends Component
$user = Auth::user();
if (! $user->can('upload layers')) {
$this->dispatch('notify', 'Sin permisos para subir capas');
return;
}
@@ -164,12 +178,14 @@ class LayerManager extends Component
$allowed = ['geojson', 'json', 'kmz', 'kml', 'shp', 'dwg', 'zip'];
if (! in_array($ext, $allowed)) {
$this->dispatch('notify', 'Extensión no permitida. Válidas: '.implode(', ', $allowed));
return;
}
$geojson = SpatialFileConverter::convertToGeoJson($this->uploadFile);
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,
@@ -215,6 +233,7 @@ class LayerManager extends Component
});
} catch (\Throwable $e) {
$this->dispatch('notify', 'Error al importar: '.$e->getMessage());
return;
}
@@ -231,6 +250,7 @@ class LayerManager extends Component
$user = Auth::user();
if (! $user->can('upload layers')) {
$this->dispatch('notify', 'Sin permisos para crear capas');
return;
}
@@ -257,12 +277,14 @@ class LayerManager extends Component
{
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'])) {
$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,
@@ -296,6 +320,7 @@ class LayerManager extends Component
});
} catch (\Throwable $e) {
$this->dispatch('notify', 'Error al guardar: '.$e->getMessage());
return;
}
@@ -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,7 +368,9 @@ class LayerManager extends Component
->where('id', $layerId)
->where('phase_id', $this->phase->id)
->first();
if (!$layer) return;
if (! $layer) {
return;
}
$fc = [
'type' => 'FeatureCollection',
@@ -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;
}
+11 -9
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 = [
@@ -67,6 +65,7 @@ class MediaManager extends Component
$user = Auth::user();
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;
}
@@ -132,6 +132,7 @@ class MediaManager extends Component
$user = Auth::user();
if (! $user->can('delete media') && $media->uploaded_by !== $user->id) {
session()->flash('error', 'No puedes borrar archivos de otro usuario.');
return;
}
@@ -146,6 +147,7 @@ class MediaManager extends Component
if (! $media->is_image) {
// Si no es imagen, abrir en nueva pestaña
$this->dispatch('openUrl', $media->url);
return;
}
$this->viewingMedia = $media;
+7 -3
View File
@@ -1,14 +1,17 @@
<?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)
@@ -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();
+9 -1
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)
+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());
}
+2
View File
@@ -64,6 +64,7 @@ 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>';
})
->html(),
@@ -71,6 +72,7 @@ class FeatureTable extends DataTableComponent
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')).'">'
@@ -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()
+2 -4
View File
@@ -83,8 +83,7 @@ class InspectionTable extends DataTableComponent
->html(),
Column::make('Acciones')
->label(fn ($row) =>
'<div class="flex justify-end gap-1">'
->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>'
@@ -116,8 +115,7 @@ 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">'
$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('');
@@ -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)
@@ -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.');
}
+16 -8
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
@@ -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 = [
@@ -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());
@@ -80,13 +82,13 @@ class ProjectFeaturesTable extends DataTableComponent
if ($row->is_active) {
return '<button wire:click="toggleActive('.$row->id.')" class="badge badge-success badge-sm" title="Desactivar">Activo</button>';
}
return '<button wire:click="toggleActive('.$row->id.')" class="badge badge-ghost badge-sm" title="Activar">Inactivo</button>';
})
->html(),
Column::make('Acciones')
->label(fn ($row) =>
'<div class="flex justify-end gap-1">
->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>
@@ -108,7 +110,7 @@ class ProjectFeaturesTable extends DataTableComponent
->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
+5 -3
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)
@@ -35,6 +36,7 @@ class ProjectList extends Component
$query->where('status', $this->statusFilter);
}
$projects = $query->with('phases')->latest()->paginate(10);
return view('livewire.projects.project-list', ['projects' => $projects]);
}
}
+160 -44
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)
@@ -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,7 +171,9 @@ class ProjectMap extends Component
public function togglePhase($phaseId)
{
$phase = $this->phases->find($phaseId);
if (!$phase) return;
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) {
@@ -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;
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,12 +247,20 @@ 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;
@@ -204,9 +275,12 @@ class ProjectMap extends Component
$user = Auth::user();
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;
@@ -258,6 +340,7 @@ class ProjectMap extends Component
{
if (! $this->selectedFeature) {
$this->inspectionHistory = [];
return;
}
$this->inspectionHistory = Inspection::where('feature_id', $this->selectedFeature->id)
@@ -286,17 +369,21 @@ class ProjectMap extends Component
{
if (! $this->selectedFeature || ! $this->selectedTemplateId) {
$this->dispatch('notify', 'Selecciona un elemento y un template.');
return;
}
// Verificar permiso
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(),
]);
}
@@ -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,9 +489,13 @@ 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);
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();
@@ -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,7 +580,9 @@ 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];
@@ -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')) {
$this->dispatch('notify', 'Sin permisos para editar inspecciones.');
return;
}
@@ -508,12 +613,15 @@ 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;
}
}
@@ -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(),
]);
}
@@ -574,6 +682,7 @@ class ProjectMap extends Component
if (! auth()->user()->can('delete inspections')) {
$this->dispatch('notify', 'Sin permisos para eliminar inspecciones.');
\Log::info('deleteInspection: permission denied');
return;
}
@@ -583,6 +692,7 @@ class ProjectMap extends Component
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));
}
}
@@ -627,7 +737,11 @@ class ProjectMap extends Component
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) {
@@ -663,7 +777,9 @@ class ProjectMap extends Component
public function toggleFullscreen()
{
$this->formFullscreen = ! $this->formFullscreen;
if (!$this->formFullscreen) $this->dispatch('mapResize');
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)
+3
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)
+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.');
}
+1
View File
@@ -21,6 +21,7 @@ class ReportBuilder extends Component
];
public bool $showPreview = false;
public ?array $previewData = null;
public function mount(Project $project)
+10 -9
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()
@@ -49,7 +50,7 @@ class ReportsDashboard extends Component
}
$projectProgress[] = [
'name' => $project->name,
'data' => $progressData
'data' => $progressData,
];
}
@@ -76,7 +77,7 @@ class ReportsDashboard extends Component
->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) {
return ucfirst(str_replace('_', ' ', $status));
}, array_keys($projectsByStatus)),
'data' => array_values($projectsByStatus)
'data' => array_values($projectsByStatus),
],
'projectPhaseProgress' => $projectPhaseProgress
'projectPhaseProgress' => $projectPhaseProgress,
];
}
+5 -4
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,8 +22,7 @@ class AdminUsers extends Component
public function getUsersProperty()
{
return User::with('roles')
->when($this->search, fn($q) =>
$q->where(fn($q2) => $q2
->when($this->search, fn ($q) => $q->where(fn ($q2) => $q2
->where('name', 'like', '%'.$this->search.'%')
->orWhere('email', 'like', '%'.$this->search.'%')))
->orderBy('name')
@@ -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();
+17 -5
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). */
@@ -121,7 +130,9 @@ class UserForm extends Component
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,6 +146,7 @@ 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;
}
+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;
+5 -3
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'];
@@ -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');
}
}
+53 -12
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);
}
+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);
+10 -3
View File
@@ -48,9 +48,16 @@ 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';
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';
}
+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
{
@@ -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
{
@@ -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
{
+7 -4
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)
@@ -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;
});
@@ -541,6 +543,7 @@ class ReportGenerator
$bytes /= 1024;
$i++;
}
return round($bytes, 1).' '.$units[$i];
}
}
+69 -23
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();
if (! @mkdir($tmp, 0777, true) && ! is_dir($tmp)) {
$zip->close();
return null;
}
$zip->extractTo($tmp);
@@ -228,14 +254,19 @@ 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;
if ($item === '.' || $item === '..') {
continue;
}
$p = $dir.DIRECTORY_SEPARATOR.$item;
is_dir($p) ? self::rrmdir($p) : @unlink($p);
}
@@ -246,6 +277,7 @@ class SpatialFileConverter
private static function kmlChild(\SimpleXMLElement $node, string $name): \SimpleXMLElement|string
{
$matches = $node->xpath('./*[local-name()="'.$name.'"]');
return $matches ? $matches[0] : '';
}
@@ -259,24 +291,30 @@ class SpatialFileConverter
$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;
}
@@ -292,6 +330,7 @@ class SpatialFileConverter
$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,9 +378,11 @@ 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();
mkdir($dir);
-1
View File
@@ -2,7 +2,6 @@
namespace App\Traits;
use Illuminate\Support\Facades\Auth;
use App\Models\ActivityLog;
trait LogsActivity
+20 -13
View File
@@ -1,8 +1,16 @@
<?php
use App\Http\Middleware\ForceHttpsScheme;
use App\Http\Middleware\SetLocale;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
use Spatie\Permission\Middleware\PermissionMiddleware;
use Spatie\Permission\Middleware\RoleMiddleware;
use Spatie\Permission\Middleware\RoleOrPermissionMiddleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
@@ -12,32 +20,31 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->appendToGroup('web', \App\Http\Middleware\SetLocale::class);
$middleware->appendToGroup('web', SetLocale::class);
// Si APP_URL es https, forzar el scheme percibido en TODAS las peticiones
// (algunos proxies no reenvían X-Forwarded-Proto en todas las rutas, y las
// URLs firmadas —p. ej. /livewire/upload-file— se firman con un scheme y
// se validan con otro → 401). Debe ir ANTES de TrustProxies.
$middleware->prepend(\App\Http\Middleware\ForceHttpsScheme::class);
$middleware->prepend(ForceHttpsScheme::class);
// Confiar en el proxy inverso (OpenResty/Nginx) para leer X-Forwarded-Proto:
// sin esto, tras HTTPS el proxy Laravel genera URLs con http:// y las cookies
// "secure" no viajan → 419 en /livewire/update.
$middleware->trustProxies(at: '*', headers:
Illuminate\Http\Request::HEADER_X_FORWARDED_FOR |
Illuminate\Http\Request::HEADER_X_FORWARDED_HOST |
Illuminate\Http\Request::HEADER_X_FORWARDED_PORT |
Illuminate\Http\Request::HEADER_X_FORWARDED_PROTO |
Illuminate\Http\Request::HEADER_X_FORWARDED_AWS_ELB,
$middleware->trustProxies(at: '*', headers: Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO |
Request::HEADER_X_FORWARDED_AWS_ELB,
);
// Spatie permission + Sanctum ability middleware aliases
$middleware->alias([
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class,
'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class,
'role' => RoleMiddleware::class,
'permission' => PermissionMiddleware::class,
'role_or_permission' => RoleOrPermissionMiddleware::class,
'abilities' => CheckAbilities::class,
'ability' => CheckForAnyAbility::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
+5 -2
View File
@@ -1,6 +1,9 @@
<?php
use App\Providers\AppServiceProvider;
use App\Providers\VoltServiceProvider;
return [
App\Providers\AppServiceProvider::class,
App\Providers\VoltServiceProvider::class,
AppServiceProvider::class,
VoltServiceProvider::class,
];
@@ -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,
+14 -13
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
{
@@ -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
+6 -4
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;
@@ -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
+1
View File
@@ -29,6 +29,7 @@ class GlobalTemplatesTest extends TestCase
'status' => 'in_progress', 'created_by' => $owner->id,
]);
$p->users()->attach($owner->id, ['role_in_project' => 'supervisor']);
return $p;
}
+4 -2
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
+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();
+2
View File
@@ -19,7 +19,9 @@ class MapTablesTest extends TestCase
use RefreshDatabase;
private User $user;
private Project $project;
private Feature $feature;
protected function setUp(): void
+1 -1
View File
@@ -4,7 +4,6 @@ namespace Tests\Feature;
use App\Livewire\Phases\PhaseList;
use App\Livewire\Phases\PhaseTable;
use App\Models\Phase;
use App\Models\Project;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -17,6 +16,7 @@ class PhaseManagementTest extends TestCase
use RefreshDatabase;
private User $user;
private Project $project;
protected function setUp(): void

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