From f8a68312a3f46c11d6c5f51c6bd4113f581e58d0 Mon Sep 17 00:00:00 2001 From: javier Date: Thu, 25 Jun 2026 17:26:12 +0200 Subject: [PATCH] =?UTF-8?q?refactor(templates):=20plantillas=20globales=20?= =?UTF-8?q?+=20asignaci=C3=B3n=20por=20proyecto=20(pivot)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migración: tabla pivot inspection_template_project; drop de phase_id (asociación a fase descartada); project_id se mantiene en inspection_templates por compat. Migra automáticamente las plantillas existentes (project_id → pivot). - Modelos: Project::inspectionTemplates() ↔ InspectionTemplate::projects() (BTM); retirada la relación phase() de InspectionTemplate. - GlobalTemplateManager (nuevo, ruta /inspection-templates, permiso manage templates): catálogo único global, CRUD + import CSV; sin asociación a fase ni a proyecto. - ProjectTemplatesPicker (nuevo, ruta projects.templates, permiso edit projects): lista global con checkbox para asignar/desasignar plantillas al proyecto. - ProjectMap: el selector de plantillas del mapa lee SOLO las asignadas al proyecto vía pivot. API móvil (bundle + /templates) adaptado al pivot. - Eliminado el viejo TemplateManager por-proyecto, su vista wrapper y la tabla ImportTemplatesTable (ya no aplica: todas son globales). - Acceso "Plantillas" añadido al menú principal (gate manage templates). Tests: GlobalTemplatesTest (4). Suite 89 passing. Co-Authored-By: Claude Opus 4.7 --- .../Api/V1/ProjectApiController.php | 6 +- ...eManager.php => GlobalTemplateManager.php} | 182 ++++-------------- .../Projects/ImportTemplatesTable.php | 121 ------------ app/Livewire/Projects/ProjectMap.php | 5 +- .../Projects/ProjectTemplatesPicker.php | 51 +++++ app/Models/InspectionTemplate.php | 10 +- app/Models/Project.php | 6 + ...inspection_templates_global_with_pivot.php | 65 +++++++ ....php => global-template-manager.blade.php} | 164 +++------------- .../livewire/layout/navigation.blade.php | 8 + .../project-templates-picker.blade.php | 64 ++++++ resources/views/projects/templates.blade.php | 23 --- routes/web.php | 10 +- tests/Feature/Api/MobileApiTest.php | 8 +- tests/Feature/GlobalTemplatesTest.php | 101 ++++++++++ tests/Feature/TemplateCreationTest.php | 110 ----------- tests/Feature/TemplateImportTest.php | 91 --------- 17 files changed, 386 insertions(+), 639 deletions(-) rename app/Livewire/Inspections/{TemplateManager.php => GlobalTemplateManager.php} (52%) delete mode 100644 app/Livewire/Projects/ImportTemplatesTable.php create mode 100644 app/Livewire/Projects/ProjectTemplatesPicker.php create mode 100644 database/migrations/2026_06_25_120000_make_inspection_templates_global_with_pivot.php rename resources/views/livewire/inspections/{template-manager.blade.php => global-template-manager.blade.php} (57%) create mode 100644 resources/views/livewire/projects/project-templates-picker.blade.php delete mode 100644 resources/views/projects/templates.blade.php create mode 100644 tests/Feature/GlobalTemplatesTest.php delete mode 100644 tests/Feature/TemplateCreationTest.php delete mode 100644 tests/Feature/TemplateImportTest.php diff --git a/app/Http/Controllers/Api/V1/ProjectApiController.php b/app/Http/Controllers/Api/V1/ProjectApiController.php index 1d54621..113757b 100644 --- a/app/Http/Controllers/Api/V1/ProjectApiController.php +++ b/app/Http/Controllers/Api/V1/ProjectApiController.php @@ -49,7 +49,8 @@ class ProjectApiController extends Controller $features = $changed(Feature::whereIn('layer_id', $allLayerIds))->get(); $inspections = $changed(Inspection::where('project_id', $project->id))->get(); $issues = $changed(Issue::where('project_id', $project->id))->get(); - $templates = $changed(InspectionTemplate::where('project_id', $project->id))->get(); + // Plantillas asignadas al proyecto (catálogo global vía pivot) + $templates = $changed(InspectionTemplate::whereHas('projects', fn ($q) => $q->where('projects.id', $project->id)))->get(); $allIssueIds = Issue::withTrashed()->where('project_id', $project->id)->pluck('id'); $issueTasks = $changed(IssueTask::whereIn('issue_id', $allIssueIds))->get(); @@ -92,7 +93,8 @@ class ProjectApiController extends Controller $projectIds = Project::accessibleBy($request->user())->pluck('id'); - $query = InspectionTemplate::whereIn('project_id', $projectIds); + // Plantillas asignadas a cualquier proyecto al que el usuario tenga acceso + $query = InspectionTemplate::whereHas('projects', fn ($q) => $q->whereIn('projects.id', $projectIds)); if ($since) { $query->where('updated_at', '>', $since); } diff --git a/app/Livewire/Inspections/TemplateManager.php b/app/Livewire/Inspections/GlobalTemplateManager.php similarity index 52% rename from app/Livewire/Inspections/TemplateManager.php rename to app/Livewire/Inspections/GlobalTemplateManager.php index ce5f194..ff9e761 100644 --- a/app/Livewire/Inspections/TemplateManager.php +++ b/app/Livewire/Inspections/GlobalTemplateManager.php @@ -2,31 +2,25 @@ namespace App\Livewire\Inspections; -use Livewire\Component; -use Livewire\Attributes\On; -use Livewire\WithFileUploads; use App\Models\InspectionTemplate; -use App\Models\Project; -use App\Models\Phase; use Illuminate\Support\Facades\Auth; +use Livewire\Attributes\Layout; +use Livewire\Component; +use Livewire\WithFileUploads; use PhpOffice\PhpSpreadsheet\IOFactory; -class TemplateManager extends Component +#[Layout('layouts.app')] +class GlobalTemplateManager extends Component { use WithFileUploads; - public $project; public $templates; - public $phases; - // ── Formulario principal ─────────────────────────────────────────────── public $editingTemplate = null; public $showForm = false; public $form = [ 'name' => '', 'description' => '', - 'phase_id' => null, - 'is_global' => false, 'fields' => [], ]; @@ -37,9 +31,6 @@ class TemplateManager extends Component public $importTemplateName = ''; public $importError = ''; - // ── Importar desde otro proyecto (tabla Rappasoft) ───────────────────── - public $showImportProjectModal = false; - public $fieldTypes = [ 'text' => 'Texto corto', 'textarea' => 'Texto largo', @@ -51,31 +42,18 @@ class TemplateManager extends Component 'select' => 'Lista desplegable', ]; - public function mount(Project $project) + public function mount() { - $this->project = $project; - $this->loadPhases(); + abort_unless(Auth::user()->can('manage templates'), 403); $this->loadTemplates(); } - public function loadPhases() - { - $this->phases = $this->project->phases()->orderBy('name')->get(); - } - public function loadTemplates() { - // Plantillas del proyecto + globales (project_id null) - $this->templates = InspectionTemplate::where(function ($q) { - $q->where('project_id', $this->project->id)->orWhereNull('project_id'); - }) - ->with('phase') - ->orderBy('name') - ->get(); + // Catálogo global: todas las plantillas. + $this->templates = InspectionTemplate::orderBy('name')->get(); } - // ── Formulario manual ───────────────────────────────────────────────── - public function newTemplate() { $this->resetForm(); @@ -88,8 +66,6 @@ class TemplateManager extends Component $this->form = [ 'name' => $template->name, 'description' => $template->description ?? '', - 'phase_id' => $template->phase_id, - 'is_global' => is_null($template->project_id), 'fields' => $template->fields ?? [], ]; $this->editingTemplate = $id; @@ -107,8 +83,6 @@ class TemplateManager extends Component $this->form = [ 'name' => '', 'description' => '', - 'phase_id' => null, - 'is_global' => false, 'fields' => [], ]; $this->editingTemplate = null; @@ -140,25 +114,23 @@ class TemplateManager extends Component public function saveTemplate() { $this->validate([ - 'form.name' => 'required|string|max:255', - 'form.phase_id' => 'nullable|exists:phases,id', - 'form.fields' => 'array', + 'form.name' => 'required|string|max:255', + 'form.fields' => 'array', ]); $data = [ 'name' => $this->form['name'], 'description' => $this->form['description'], - 'project_id' => ($this->form['is_global'] ?? false) ? null : $this->project->id, - 'phase_id' => ($this->form['is_global'] ?? false) ? null : ($this->form['phase_id'] ?: null), + 'project_id' => null, 'fields' => array_values($this->form['fields']), ]; if ($this->editingTemplate) { InspectionTemplate::findOrFail($this->editingTemplate)->update($data); - $this->dispatch('notify', 'Template actualizado correctamente'); + $this->dispatch('notify', 'Plantilla actualizada'); } else { InspectionTemplate::create($data); - $this->dispatch('notify', 'Template creado correctamente'); + $this->dispatch('notify', 'Plantilla creada'); } $this->cancelForm(); @@ -169,86 +141,35 @@ class TemplateManager extends Component { InspectionTemplate::findOrFail($id)->delete(); $this->loadTemplates(); - $this->dispatch('notify', 'Template eliminado'); + $this->dispatch('notify', 'Plantilla eliminada'); } - // ── Exportar template a CSV ──────────────────────────────────────────── - - public function exportTemplate($id) - { - $template = InspectionTemplate::findOrFail($id); - $rows = []; - $rows[] = ['name', 'label', 'type', 'required', 'options', 'min', 'max', 'step']; - - foreach ($template->fields as $field) { - $rows[] = [ - $field['name'] ?? '', - $field['label'] ?? '', - $field['type'] ?? 'text', - ($field['required'] ?? false) ? '1' : '0', - $field['options'] ?? '', - $field['min'] ?? '', - $field['max'] ?? '', - $field['step'] ?? '', - ]; - } - - $filename = preg_replace('/[^a-z0-9_\-]/i', '_', $template->name) . '.csv'; - - return response()->streamDownload(function () use ($rows) { - $out = fopen('php://output', 'w'); - // BOM para Excel con UTF-8 - fwrite($out, "\xEF\xBB\xBF"); - foreach ($rows as $row) { - fputcsv($out, $row); - } - fclose($out); - }, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']); - } - - public function downloadExampleCsv() - { - $rows = [ - ['name', 'label', 'type', 'required', 'options', 'min', 'max', 'step'], - ['altura_viga', 'Altura de viga (mm)', 'decimal', '1', '', '0', '2000', '1'], - ['estado_armado', 'Estado del armado', 'select', '1', 'Conforme,No conforme,Obs.', '', '', ''], - ['fotos_tomadas', '¿Fotos tomadas?', 'boolean', '1', '', '', '', ''], - ['observaciones', 'Observaciones generales','textarea', '0', '', '', '', ''], - ['fecha_visita', 'Fecha de visita', 'date', '0', '', '', '', ''], - ['avance_pct', 'Avance medido (%)', 'percentage', '0', '', '0', '100', '1'], - ]; - - return response()->streamDownload(function () use ($rows) { - $out = fopen('php://output', 'w'); - fwrite($out, "\xEF\xBB\xBF"); - foreach ($rows as $row) { - fputcsv($out, $row); - } - fclose($out); - }, 'ejemplo-template.csv', ['Content-Type' => 'text/csv; charset=UTF-8']); - } - - // ── Importar desde CSV / Excel ───────────────────────────────────────── + // ── Importar desde CSV/Excel ─────────────────────────────────────────── public function openImportFileModal() { - $this->importFile = null; + $this->importFile = null; $this->importPreviewFields = []; $this->importTemplateName = ''; $this->importError = ''; $this->showImportFileModal = true; } + public function downloadExampleCsv() + { + $headers = ['Content-Type' => 'text/csv']; + $csv = "name,label,type,required,options,min,max,step\n" + . "altura,Altura (m),decimal,1,,0,100,0.1\n" + . "ok,¿OK?,boolean,1,,,,\n"; + return response()->streamDownload(fn () => print($csv), 'plantilla_ejemplo.csv', $headers); + } + public function parseImportFile() { $this->importError = ''; $this->validate([ 'importFile' => 'required|file|mimes:csv,txt,xlsx,xls|max:5120', 'importTemplateName' => 'required|string|max:255', - ], [ - 'importFile.required' => 'Selecciona un archivo.', - 'importFile.mimes' => 'Solo se aceptan archivos CSV o Excel (xlsx/xls).', - 'importTemplateName.required' => 'Escribe un nombre para el template.', ]); try { @@ -259,14 +180,11 @@ class TemplateManager extends Component } $fields = $this->parseRows($rows); - if (empty($fields)) { - $this->importError = 'No se encontraron filas de datos válidas. Revisa que el archivo tenga el formato correcto.'; + $this->importError = 'No se encontraron filas válidas.'; return; } - $this->importPreviewFields = $fields; - $this->dispatch('notify', count($fields) . ' campos detectados. Revisa la vista previa.'); } public function confirmImportFile() @@ -276,17 +194,16 @@ class TemplateManager extends Component InspectionTemplate::create([ 'name' => $this->importTemplateName, 'description' => 'Importado desde archivo', - 'project_id' => $this->project->id, - 'phase_id' => null, + 'project_id' => null, 'fields' => array_values($this->importPreviewFields), ]); - $this->showImportFileModal = false; - $this->importPreviewFields = []; - $this->importTemplateName = ''; - $this->importFile = null; + $this->showImportFileModal = false; + $this->importPreviewFields = []; + $this->importTemplateName = ''; + $this->importFile = null; $this->loadTemplates(); - $this->dispatch('notify', 'Template importado correctamente desde archivo'); + $this->dispatch('notify', 'Plantilla importada'); } private function readFileRows(): array @@ -298,18 +215,15 @@ class TemplateManager extends Component $spreadsheet = IOFactory::load($path); $sheet = $spreadsheet->getActiveSheet(); $rows = $sheet->toArray(null, true, true, false); - array_shift($rows); // quitar cabecera - return array_filter($rows, fn($r) => !empty($r[0])); + array_shift($rows); + return array_filter($rows, fn ($r) => !empty($r[0])); } - // CSV / TXT $rows = []; $handle = fopen($path, 'r'); - // Detectar y descartar BOM UTF-8 $bom = fread($handle, 3); if ($bom !== "\xEF\xBB\xBF") rewind($handle); - - fgetcsv($handle); // cabecera + fgetcsv($handle); while (($row = fgetcsv($handle)) !== false) { if (!empty($row[0])) $rows[] = $row; } @@ -331,9 +245,9 @@ class TemplateManager extends Component 'type' => $this->normalizeType($row[2] ?? 'text'), 'required' => in_array(strtolower(trim($row[3] ?? '0')), ['1', 'si', 'sí', 'yes', 'true']), 'options' => trim($row[4] ?? ''), - 'min' => $row[5] !== '' && $row[5] !== null ? $row[5] : null, - 'max' => $row[6] !== '' && $row[6] !== null ? $row[6] : null, - 'step' => $row[7] !== '' && $row[7] !== null ? $row[7] : null, + 'min' => ($row[5] ?? '') !== '' ? $row[5] : null, + 'max' => ($row[6] ?? '') !== '' ? $row[6] : null, + 'step' => ($row[7] ?? '') !== '' ? $row[7] : null, ]; } return $fields; @@ -362,24 +276,8 @@ class TemplateManager extends Component return $map[strtolower(trim($type))] ?? 'text'; } - // ── Importar desde otro proyecto ────────────────────────────────────── - - public function openImportProjectModal() - { - $this->showImportProjectModal = true; - } - - /** La tabla Rappasoft embebida avisa cuando ha importado plantillas. */ - #[On('templates-imported')] - public function onTemplatesImported(int $count = 0): void - { - $this->showImportProjectModal = false; - $this->loadTemplates(); - $this->dispatch('notify', "$count plantilla(s) importada(s) desde otro proyecto"); - } - public function render() { - return view('livewire.inspections.template-manager'); + return view('livewire.inspections.global-template-manager'); } } diff --git a/app/Livewire/Projects/ImportTemplatesTable.php b/app/Livewire/Projects/ImportTemplatesTable.php deleted file mode 100644 index 0b65f14..0000000 --- a/app/Livewire/Projects/ImportTemplatesTable.php +++ /dev/null @@ -1,121 +0,0 @@ -setPrimaryKey('id') - ->setDefaultSort('inspection_templates.name', 'asc') - ->setSortingPillsEnabled(false) - ->setBulkActionsEnabled(true) - ->setAdditionalSelects([ - 'inspection_templates.id as id', - 'inspection_templates.project_id as project_id', - 'inspection_templates.phase_id as phase_id', - ]); - } - - /** Sólo plantillas de proyectos accesibles, distintos del proyecto destino. */ - private function accessibleProjectIds() - { - return Project::accessibleBy(Auth::user())->pluck('id'); - } - - public function builder(): Builder - { - return InspectionTemplate::query() - ->whereIn('inspection_templates.project_id', $this->accessibleProjectIds()) - ->where('inspection_templates.project_id', '!=', $this->projectId) - ->with(['project', 'phase']); - } - - public function columns(): array - { - return [ - Column::make('Plantilla', 'name') - ->sortable()->searchable() - ->secondaryHeaderFilter('name') - ->format(fn ($value) => '' . e($value) . '') - ->html(), - - Column::make('Proyecto') - ->secondaryHeaderFilter('project') - ->label(fn ($row) => e($row->project?->name ?? '—')), - - Column::make('Fase') - ->label(fn ($row) => e($row->phase?->name ?? '—')), - - Column::make('Campos') - ->label(fn ($row) => '' . count($row->fields ?? []) . '') - ->html(), - ]; - } - - public function filters(): array - { - $projects = Project::whereIn('id', InspectionTemplate::query() - ->whereIn('project_id', $this->accessibleProjectIds()) - ->where('project_id', '!=', $this->projectId) - ->distinct()->pluck('project_id')) - ->orderBy('name')->pluck('name', 'id')->toArray(); - - return [ - TextFilter::make('Plantilla', 'name') - ->config(['placeholder' => 'Buscar nombre…']) - ->filter(fn (Builder $q, string $v) => $q->where('inspection_templates.name', 'like', '%' . $v . '%')), - - SelectFilter::make('Proyecto', 'project') - ->options(['' => 'Todos'] + $projects) - ->filter(fn (Builder $q, string $v) => $q->where('inspection_templates.project_id', $v)), - ]; - } - - public function bulkActions(): array - { - return ['importSelected' => 'Importar seleccionadas']; - } - - public function importSelected() - { - $allowed = $this->accessibleProjectIds(); - $imported = 0; - - foreach (InspectionTemplate::whereIn('id', $this->getSelected())->get() as $source) { - if (! $allowed->contains($source->project_id) || $source->project_id === $this->projectId) { - continue; - } - - $name = $source->name; - if (InspectionTemplate::where('project_id', $this->projectId)->where('name', $name)->exists()) { - $name .= ' (copia)'; - } - - InspectionTemplate::create([ - 'name' => $name, - 'description' => $source->description, - 'project_id' => $this->projectId, - 'phase_id' => null, - 'fields' => $source->fields, - ]); - $imported++; - } - - $this->clearSelected(); - $this->dispatch('templates-imported', count: $imported); - } -} diff --git a/app/Livewire/Projects/ProjectMap.php b/app/Livewire/Projects/ProjectMap.php index ef843a5..207b6e4 100644 --- a/app/Livewire/Projects/ProjectMap.php +++ b/app/Livewire/Projects/ProjectMap.php @@ -107,7 +107,10 @@ class ProjectMap extends Component public function loadTemplates() { - $this->templates = InspectionTemplate::where('project_id', $this->project->id)->get(); + // Las plantillas son globales; cada proyecto elige cuáles usa (pivot). + $this->templates = $this->project->inspectionTemplates() + ->orderBy('inspection_templates.name') + ->get(); } // ─── Layer / Phase visibility ──────────────────────────────────────────────── diff --git a/app/Livewire/Projects/ProjectTemplatesPicker.php b/app/Livewire/Projects/ProjectTemplatesPicker.php new file mode 100644 index 0000000..4fe05fa --- /dev/null +++ b/app/Livewire/Projects/ProjectTemplatesPicker.php @@ -0,0 +1,51 @@ +project = $project; + abort_unless(Auth::user()->can('edit projects'), 403); + $this->assignedIds = $project->inspectionTemplates()->pluck('inspection_templates.id')->map(fn ($v) => (int) $v)->all(); + } + + public function toggle(int $templateId): void + { + abort_unless(Auth::user()->can('edit projects'), 403); + InspectionTemplate::findOrFail($templateId); // valida + + if (in_array($templateId, $this->assignedIds, true)) { + $this->project->inspectionTemplates()->detach($templateId); + $this->assignedIds = array_values(array_diff($this->assignedIds, [$templateId])); + $this->dispatch('notify', 'Plantilla desasignada del proyecto'); + } else { + $this->project->inspectionTemplates()->syncWithoutDetaching([$templateId]); + $this->assignedIds[] = $templateId; + $this->dispatch('notify', 'Plantilla asignada al proyecto'); + } + } + + public function render() + { + $templates = InspectionTemplate::query() + ->when($this->search !== '', fn ($q) => $q->where('name', 'like', '%' . $this->search . '%')) + ->orderBy('name')->get(); + + return view('livewire.projects.project-templates-picker', [ + 'templates' => $templates, + ]); + } +} diff --git a/app/Models/InspectionTemplate.php b/app/Models/InspectionTemplate.php index 99ba645..64cc511 100644 --- a/app/Models/InspectionTemplate.php +++ b/app/Models/InspectionTemplate.php @@ -6,22 +6,24 @@ use Illuminate\Database\Eloquent\Model; class InspectionTemplate extends Model { - protected $fillable = ['name', 'description', 'project_id', 'phase_id', 'fields']; + protected $fillable = ['name', 'description', 'project_id', 'fields']; protected $casts = ['fields' => 'array']; + /** Proyecto que la creó (informativo; ya no determina visibilidad). */ public function project() { return $this->belongsTo(Project::class); } - public function phase() + /** Proyectos a los que está asignada esta plantilla (pivot). */ + public function projects() { - return $this->belongsTo(Phase::class); + return $this->belongsToMany(Project::class, 'inspection_template_project')->withTimestamps(); } public function inspections() { return $this->hasMany(Inspection::class); } -} \ No newline at end of file +} diff --git a/app/Models/Project.php b/app/Models/Project.php index 2937095..8fcbb71 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -58,6 +58,12 @@ class Project extends Model return $this->morphMany(Media::class, 'mediable'); } + /** Plantillas de inspección asignadas a este proyecto (catálogo global ↔ pivot). */ + public function inspectionTemplates() + { + return $this->belongsToMany(InspectionTemplate::class, 'inspection_template_project')->withTimestamps(); + } + public function images() { return $this->morphMany(Media::class, 'mediable')->where('category', 'image'); diff --git a/database/migrations/2026_06_25_120000_make_inspection_templates_global_with_pivot.php b/database/migrations/2026_06_25_120000_make_inspection_templates_global_with_pivot.php new file mode 100644 index 0000000..43f82bb --- /dev/null +++ b/database/migrations/2026_06_25_120000_make_inspection_templates_global_with_pivot.php @@ -0,0 +1,65 @@ +id(); + $table->foreignId('inspection_template_id')->constrained()->cascadeOnDelete(); + $table->foreignId('project_id')->constrained()->cascadeOnDelete(); + $table->timestamps(); + $table->unique(['inspection_template_id', 'project_id'], 'itp_template_project_unique'); + }); + + // Migrar la asignación existente (project_id en inspection_templates → pivot) + $rows = DB::table('inspection_templates')->whereNotNull('project_id')->get(['id', 'project_id']); + 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; + + DB::table('inspection_template_project')->updateOrInsert( + ['inspection_template_id' => $r->id, 'project_id' => $r->project_id], + ['created_at' => now(), 'updated_at' => now()], + ); + } + + // Drop columna phase_id (la asociación a fase deja de existir) + if (Schema::hasColumn('inspection_templates', 'phase_id')) { + Schema::table('inspection_templates', function (Blueprint $table) { + // Algunos motores (SQLite) requieren dropear el índice antes de la columna. + try { $table->dropIndex('inspection_templates_phase_id_index'); } catch (\Throwable $e) { /* ya no existe */ } + try { $table->dropForeign(['phase_id']); } catch (\Throwable $e) { /* sqlite sin FKs */ } + $table->dropColumn('phase_id'); + }); + } + + // project_id se mantiene en inspection_templates (puede usarse como + // "proyecto creador/origen" pero ya no determina visibilidad). + } + + public function down(): void + { + if (! Schema::hasColumn('inspection_templates', 'phase_id')) { + Schema::table('inspection_templates', function (Blueprint $table) { + $table->foreignId('phase_id')->nullable()->constrained('phases')->onDelete('set null'); + $table->index('phase_id'); + }); + } + + Schema::dropIfExists('inspection_template_project'); + } +}; diff --git a/resources/views/livewire/inspections/template-manager.blade.php b/resources/views/livewire/inspections/global-template-manager.blade.php similarity index 57% rename from resources/views/livewire/inspections/template-manager.blade.php rename to resources/views/livewire/inspections/global-template-manager.blade.php index 7085938..30477d0 100644 --- a/resources/views/livewire/inspections/template-manager.blade.php +++ b/resources/views/livewire/inspections/global-template-manager.blade.php @@ -1,12 +1,11 @@ -
+
-

📋 {{ __('Inspection templates') }}

+
+

📋 {{ __('Inspection templates') }}

+

{{ __('Global catalogue, reusable across all projects.') }}

+
-
- @if(session()->has('message')) -
{{ session('message') }}
- @endif - - {{-- Formulario de creación/edición con diseño de dos columnas --}} @if($showForm)
- {{-- Nombre del template --}} - - + + - - {{-- Descripción --}} - - - - - {{-- Fase asociada (opcional) --}} - - - - - - {{-- Plantilla global (disponible en todos los proyectos) --}} - - - + +
- {{ __('Template name') }} - - - {{ __('Template name') }}
- {{ __('Description') }} - - -
- {{ __('Associated phase (optional)') }} - - -
{{ __('Global template') }} - - {{ __('Description') }}
- {{-- Campos dinámicos --}}

{{ __('Form fields') }}

@foreach($form['fields'] as $index => $field)
- {{-- Fila: grupo / sección --}}
{{ __('Group / section') }}
- {{-- Fila: nombre interno --}}
{{ __('Internal name') }}
- {{-- Fila: etiqueta --}}
{{ __('Visible label') }}
- {{-- Fila: pregunta / revisar (texto corto) --}}
{{ __('Question / check') }}
-
+
- {{-- Fila: tipo --}}
{{ __('Field type') }}
@@ -114,7 +61,6 @@
- {{-- Fila: requerido y botón eliminar --}}
{{ __('Required') }}
@@ -122,8 +68,6 @@
- - {{-- Campos adicionales según tipo --}} @if(in_array($field['type'], ['integer', 'decimal', 'percentage']))
{{ __('Min') }} / {{ __('Max') }} / {{ __('Step') }}
@@ -140,7 +84,6 @@
@endif - {{-- Fila: comentarios / ayuda (texto largo) --}}
{{ __('Comments / help') }}
@@ -157,93 +100,68 @@ @endif - {{-- Tabla de templates existentes --}} + {{-- Tabla --}}
- + @forelse($templates as $template) - + - - + + @empty - - - + @endforelse
{{ __('Name') }} {{ __('Description') }}{{ __('Phase') }} {{ __('Fields') }}{{ __('Used in projects') }} {{ __('Actions') }}
- {{ $template->name }} - @if(is_null($template->project_id)) - {{ __('Global') }} - @endif - {{ $template->name }} {{ $template->description ?? '-' }}{{ $template->phase ? $template->phase->name : __('Global project') }}{{ count($template->fields) }}{{ count($template->fields ?? []) }}{{ $template->projects()->count() }} - +
{{ __('No templates yet (table)') }}
{{ __('No templates yet (table)') }}
- {{-- ════════════════════════════════════════════════════════════ - MODAL: Importar desde CSV/Excel - ════════════════════════════════════════════════════════════ --}} + {{-- MODAL: Importar desde CSV/Excel --}} @if($showImportFileModal)

{{ __('Import from CSV/Excel') }}

- +

{{ __('Columns: name, label, type, required, options, min, max, step') }}.

-
- + @error('importTemplateName'){{ $message }}@enderror
-
-
{{ __('Uploading…') }}
@error('importFile'){{ $message }}@enderror
- - @if($importError) -
{{ $importError }}
- @endif - + @if($importError)
{{ $importError }}
@endif
- +
- - {{-- Vista previa de campos detectados --}} @if(!empty($importPreviewFields))

{{ count($importPreviewFields) }} {{ __('fields detected') }}:

@@ -251,14 +169,9 @@ - @foreach($importPreviewFields as $f) - - - - - - - @endforeach + @foreach($importPreviewFields as $f) + + @endforeach
{{ __('Label') }}{{ __('Name') }}{{ __('Type') }}{{ __('Required') }}
{{ $f['label'] }}{{ $f['name'] }}{{ $f['type'] }}{{ !empty($f['required']) ? '✓' : '' }}
{{ $f['label'] }}{{ $f['name'] }}{{ $f['type'] }}{{ !empty($f['required']) ? '✓' : '' }}
@@ -267,32 +180,7 @@
- -
-
-
- @endif - - {{-- ════════════════════════════════════════════════════════════ - MODAL: Importar desde otro proyecto - ════════════════════════════════════════════════════════════ --}} - @if($showImportProjectModal) -
-
-
-
-
-

{{ __('Import from another project') }}

-

{{ __('Mark the templates and use "Import selected".') }}

-
- -
-
- +
diff --git a/resources/views/livewire/layout/navigation.blade.php b/resources/views/livewire/layout/navigation.blade.php index d9b101d..7ab16cb 100644 --- a/resources/views/livewire/layout/navigation.blade.php +++ b/resources/views/livewire/layout/navigation.blade.php @@ -56,6 +56,14 @@ new class extends Component
@endcan + + @can('manage templates') + + @endcan
diff --git a/resources/views/livewire/projects/project-templates-picker.blade.php b/resources/views/livewire/projects/project-templates-picker.blade.php new file mode 100644 index 0000000..b93ef30 --- /dev/null +++ b/resources/views/livewire/projects/project-templates-picker.blade.php @@ -0,0 +1,64 @@ +
+ + Volver + + +
+
+

Plantillas del proyecto

+

{{ $project->name }} · marca las plantillas globales que quieres usar al inspeccionar este proyecto

+
+ @can('manage templates') + + Gestionar catálogo + + @endcan +
+ +
+
+
+ +
+ + @if($templates->isEmpty()) +

+ No hay plantillas en el catálogo. + @can('manage templates') + Crear una. + @endcan +

+ @else +
+ + + + + + + + + + + @foreach($templates as $tpl) + @php $assigned = in_array($tpl->id, $assignedIds, true); @endphp + + + + + + + @endforeach + +
AsignadaPlantillaDescripciónCampos
+ + {{ $tpl->name }}{{ $tpl->description ?? '—' }}{{ count($tpl->fields ?? []) }}
+
+

{{ count($assignedIds) }} plantilla(s) asignadas a este proyecto.

+ @endif +
+
+
diff --git a/resources/views/projects/templates.blade.php b/resources/views/projects/templates.blade.php deleted file mode 100644 index 20a639b..0000000 --- a/resources/views/projects/templates.blade.php +++ /dev/null @@ -1,23 +0,0 @@ - - -
- - - -
-

{{ __('Inspection templates') }}

-

{{ $project->name }}

-
-
-
- -
-
-

- {{ __('Create generic templates that can be used in any phase of the project') }} -

- - -
-
-
diff --git a/routes/web.php b/routes/web.php index f22d7c7..0e295cf 100644 --- a/routes/web.php +++ b/routes/web.php @@ -76,9 +76,13 @@ Route::get('/reports/dashboard', ReportsDashboard::class)->name('reports.dashboa Route::get('/phases/{phase}/progress', PhaseProgress::class)->name('phases.progress'); Route::get('/projects-list', ProjectList::class)->name('projects.list'); // Ruta para templates - Route::get('/projects/{project}/templates', function ($project) { - return view('projects.templates', ['project' => \App\Models\Project::findOrFail($project)]); - })->name('projects.templates')->middleware('can:edit projects'); + // Plantillas globales (catálogo único) + Route::get('/inspection-templates', \App\Livewire\Inspections\GlobalTemplateManager::class) + ->middleware('can:manage templates')->name('inspection-templates'); + + // Plantillas asignadas a un proyecto (picker sobre el catálogo global) + Route::get('/projects/{project}/templates', \App\Livewire\Projects\ProjectTemplatesPicker::class) + ->middleware('can:edit projects')->name('projects.templates'); // Rutas para el LayerManager: Route::get('/projects/{project}/phases/{phase}/layers/manage', \App\Livewire\Layers\LayerManager::class)->name('layers.manage'); diff --git a/tests/Feature/Api/MobileApiTest.php b/tests/Feature/Api/MobileApiTest.php index 4b30619..399ed9e 100644 --- a/tests/Feature/Api/MobileApiTest.php +++ b/tests/Feature/Api/MobileApiTest.php @@ -273,11 +273,11 @@ class MobileApiTest extends TestCase { $user = User::factory()->create(); $project = $this->makeProject($user); - InspectionTemplate::create([ - 'project_id' => $project->id, - 'name' => 'Plantilla A', - 'fields' => [['name' => 'ok', 'label' => 'OK', 'type' => 'boolean']], + $tpl = InspectionTemplate::create([ + 'name' => 'Plantilla A', + 'fields' => [['name' => 'ok', 'label' => 'OK', 'type' => 'boolean']], ]); + $project->inspectionTemplates()->attach($tpl->id); Sanctum::actingAs($user, ['mobile-sync']); $res = $this->getJson('/api/v1/templates')->assertOk(); diff --git a/tests/Feature/GlobalTemplatesTest.php b/tests/Feature/GlobalTemplatesTest.php new file mode 100644 index 0000000..2625fcf --- /dev/null +++ b/tests/Feature/GlobalTemplatesTest.php @@ -0,0 +1,101 @@ + $ref, 'name' => 'P-' . $ref, 'address' => 'x', 'lat' => 40, 'lng' => -3, + 'start_date' => now()->toDateString(), 'end_date_estimated' => now()->addMonth()->toDateString(), + 'status' => 'in_progress', 'created_by' => $owner->id, + ]); + $p->users()->attach($owner->id, ['role_in_project' => 'supervisor']); + return $p; + } + + public function test_global_manager_requires_manage_templates(): void + { + Permission::findOrCreate('manage templates'); + $user = User::factory()->create(); // sin permiso + Livewire::actingAs($user) + ->test(GlobalTemplateManager::class) + ->assertForbidden(); + } + + public function test_global_manager_creates_template_without_project(): void + { + Permission::findOrCreate('manage templates'); + $admin = User::factory()->create(); + $admin->givePermissionTo('manage templates'); + + Livewire::actingAs($admin) + ->test(GlobalTemplateManager::class) + ->call('newTemplate') + ->set('form.name', 'Inspección obra') + ->call('saveTemplate') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('inspection_templates', [ + 'name' => 'Inspección obra', 'project_id' => null, + ]); + } + + public function test_picker_toggles_project_assignment(): void + { + Permission::findOrCreate('edit projects'); + $user = User::factory()->create(); + $user->givePermissionTo('edit projects'); + $project = $this->project($user); + $tpl = InspectionTemplate::create(['name' => 'Recep', 'fields' => []]); + + Livewire::actingAs($user) + ->test(ProjectTemplatesPicker::class, ['project' => $project]) + ->call('toggle', $tpl->id); + + $this->assertTrue($project->fresh()->inspectionTemplates()->where('inspection_templates.id', $tpl->id)->exists()); + + // Toggle de nuevo → desasigna + Livewire::actingAs($user) + ->test(ProjectTemplatesPicker::class, ['project' => $project]) + ->call('toggle', $tpl->id); + + $this->assertFalse($project->fresh()->inspectionTemplates()->where('inspection_templates.id', $tpl->id)->exists()); + } + + public function test_map_template_selector_only_shows_assigned_templates(): void + { + $user = User::factory()->create(); + $project = $this->project($user, 'M'); + + $assigned = InspectionTemplate::create(['name' => 'Asignada', 'fields' => []]); + $unassigned = InspectionTemplate::create(['name' => 'Otra', 'fields' => []]); + $project->inspectionTemplates()->attach($assigned->id); + + // Necesita una phase/layer al menos + Phase::create(['project_id' => $project->id, 'name' => 'F', 'order' => 1, 'color' => '#000', 'progress_percent' => 0]); + + $cmp = Livewire::actingAs($user) + ->test(ProjectMap::class, ['project' => $project]); + + $names = collect($cmp->get('templates'))->pluck('name')->all(); + $this->assertContains('Asignada', $names); + $this->assertNotContains('Otra', $names); + } +} diff --git a/tests/Feature/TemplateCreationTest.php b/tests/Feature/TemplateCreationTest.php deleted file mode 100644 index accd6e6..0000000 --- a/tests/Feature/TemplateCreationTest.php +++ /dev/null @@ -1,110 +0,0 @@ -create(); - $user->givePermissionTo('edit projects'); - $project = Project::create([ - 'reference' => 'TPL-2', 'name' => 'Proyecto TPL', 'address' => 'x', - 'lat' => 40.0, 'lng' => -3.0, 'start_date' => now()->toDateString(), - 'end_date_estimated' => now()->addMonth()->toDateString(), - 'status' => 'in_progress', 'created_by' => $user->id, - ]); - return [$user, $project]; - } - - public function test_templates_page_embeds_the_manager_component(): void - { - [$user, $project] = $this->setup_(); - - $this->actingAs($user) - ->get(route('projects.templates', $project)) - ->assertOk() - ->assertSeeLivewire(TemplateManager::class); - } - - public function test_new_template_button_opens_form_and_saves(): void - { - [$user, $project] = $this->setup_(); - - Livewire::actingAs($user) - ->test(TemplateManager::class, ['project' => $project]) - ->assertSet('showForm', false) - ->call('newTemplate') - ->assertSet('showForm', true) - ->set('form.name', 'Recepción de hormigón') - ->call('saveTemplate') - ->assertHasNoErrors(); - - $this->assertDatabaseHas('inspection_templates', [ - 'project_id' => $project->id, - 'name' => 'Recepción de hormigón', - ]); - } - - public function test_field_keeps_group_question_and_help(): void - { - [$user, $project] = $this->setup_(); - - Livewire::actingAs($user) - ->test(TemplateManager::class, ['project' => $project]) - ->call('newTemplate') - ->set('form.name', 'Con grupos') - ->call('addField') - ->set('form.fields.0.group', 'Geometría') - ->set('form.fields.0.name', 'altura') - ->set('form.fields.0.label', 'Altura (m)') - ->set('form.fields.0.question', '¿Cumple la cota?') - ->set('form.fields.0.help', 'Medir con láser') - ->call('saveTemplate') - ->assertHasNoErrors(); - - $tpl = \App\Models\InspectionTemplate::where('name', 'Con grupos')->first(); - $this->assertSame('Geometría', $tpl->fields[0]['group']); - $this->assertSame('¿Cumple la cota?', $tpl->fields[0]['question']); - $this->assertSame('Medir con láser', $tpl->fields[0]['help']); - } - - public function test_global_template_has_no_project_and_shows_in_other_projects(): void - { - [$user, $project] = $this->setup_(); - - Livewire::actingAs($user) - ->test(TemplateManager::class, ['project' => $project]) - ->call('newTemplate') - ->set('form.name', 'Plantilla global') - ->set('form.is_global', true) - ->call('saveTemplate') - ->assertHasNoErrors(); - - $this->assertDatabaseHas('inspection_templates', [ - 'name' => 'Plantilla global', 'project_id' => null, - ]); - - // Otro proyecto del mismo usuario ve la plantilla global en su listado - $other = \App\Models\Project::create([ - 'reference' => 'OTH', 'name' => 'Otro', 'address' => 'x', 'lat' => 40, 'lng' => -3, - 'start_date' => now()->toDateString(), 'end_date_estimated' => now()->addMonth()->toDateString(), - 'status' => 'in_progress', 'created_by' => $user->id, - ]); - - Livewire::actingAs($user) - ->test(TemplateManager::class, ['project' => $other]) - ->assertSee('Plantilla global'); - } -} diff --git a/tests/Feature/TemplateImportTest.php b/tests/Feature/TemplateImportTest.php deleted file mode 100644 index 3c620d8..0000000 --- a/tests/Feature/TemplateImportTest.php +++ /dev/null @@ -1,91 +0,0 @@ - $ref, 'name' => 'Proyecto ' . $ref, 'address' => 'x', - 'lat' => 40.0, 'lng' => -3.0, 'start_date' => now()->toDateString(), - 'end_date_estimated' => now()->addMonth()->toDateString(), - 'status' => 'in_progress', 'created_by' => $member->id, - ]); - $p->users()->attach($member->id, ['role_in_project' => 'supervisor']); - return $p; - } - - public function test_import_templates_from_another_project(): void - { - $user = User::factory()->create(); - $source = $this->project($user, 'SRC'); - $target = $this->project($user, 'DST'); - - $tpl = InspectionTemplate::create([ - 'project_id' => $source->id, 'name' => 'Recepción acero', - 'fields' => [['name' => 'ok', 'label' => 'OK', 'type' => 'boolean']], - ]); - - // Tabla Rappasoft: lista plantillas de otros proyectos, selección con checkbox. - Livewire::actingAs($user) - ->test(ImportTemplatesTable::class, ['projectId' => $target->id]) - ->assertSee('Recepción acero') - ->set('selected', [(string) $tpl->id]) - ->call('importSelected') - ->assertDispatched('templates-imported'); - - $this->assertDatabaseHas('inspection_templates', [ - 'project_id' => $target->id, 'name' => 'Recepción acero', - ]); - } - - public function test_import_table_excludes_target_project_templates(): void - { - $user = User::factory()->create(); - $target = $this->project($user, 'DST2'); - InspectionTemplate::create(['project_id' => $target->id, 'name' => 'Propia del destino', 'fields' => []]); - - Livewire::actingAs($user) - ->test(ImportTemplatesTable::class, ['projectId' => $target->id]) - ->assertDontSee('Propia del destino'); - } - - public function test_import_template_from_csv(): void - { - $user = User::factory()->create(); - $project = $this->project($user, 'CSV'); - - $csv = "name,label,type,required,options,min,max,step\n" - . "resistencia,Resistencia,integer,1,,,,\n" - . "acabado,Acabado,select,0,bueno;regular;malo,,,\n"; - $file = UploadedFile::fake()->createWithContent('campos.csv', $csv); - - Livewire::actingAs($user) - ->test(TemplateManager::class, ['project' => $project]) - ->call('openImportFileModal') - ->set('importTemplateName', 'Plantilla CSV') - ->set('importFile', $file) - ->call('parseImportFile') - ->assertHasNoErrors() - ->call('confirmImportFile'); - - $this->assertDatabaseHas('inspection_templates', [ - 'project_id' => $project->id, 'name' => 'Plantilla CSV', - ]); - $tpl = InspectionTemplate::where('name', 'Plantilla CSV')->first(); - $this->assertCount(2, $tpl->fields); - } -}