refactor(templates): plantillas globales + asignación por proyecto (pivot)
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
+40
-142
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Projects;
|
||||
|
||||
use App\Models\InspectionTemplate;
|
||||
use App\Models\Project;
|
||||
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 Rappasoft\LaravelLivewireTables\Views\Filters\TextFilter;
|
||||
|
||||
class ImportTemplatesTable extends DataTableComponent
|
||||
{
|
||||
protected $model = InspectionTemplate::class;
|
||||
|
||||
public int $projectId;
|
||||
|
||||
public function configure(): void
|
||||
{
|
||||
$this->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) => '<span class="font-medium">' . e($value) . '</span>')
|
||||
->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) => '<span class="badge badge-ghost badge-sm">' . count($row->fields ?? []) . '</span>')
|
||||
->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);
|
||||
}
|
||||
}
|
||||
@@ -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 ────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Projects;
|
||||
|
||||
use App\Models\InspectionTemplate;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
class ProjectTemplatesPicker extends Component
|
||||
{
|
||||
public Project $project;
|
||||
public array $assignedIds = [];
|
||||
public string $search = '';
|
||||
|
||||
public function mount(Project $project)
|
||||
{
|
||||
$this->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Templates pasan a ser GLOBALES. Se introduce una tabla pivot many-to-many
|
||||
* para "asignar" plantillas globales a proyectos. Drop de la columna phase_id
|
||||
* (asociación a fase descartada). Se mantiene project_id en la tabla por
|
||||
* compatibilidad — pero ya no es la fuente de pertenencia.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Pivot template ↔ project
|
||||
Schema::create('inspection_template_project', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
+26
-138
@@ -1,12 +1,11 @@
|
||||
<div>
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<div class="bg-base-100 p-4 rounded shadow">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold">📋 {{ __('Inspection templates') }}</h2>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold">📋 {{ __('Inspection templates') }}</h1>
|
||||
<p class="text-sm text-base-content/60">{{ __('Global catalogue, reusable across all projects.') }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button wire:click="openImportProjectModal" class="btn btn-outline btn-sm gap-1" title="{{ __('Import from another project') }}">
|
||||
<x-heroicon-o-arrow-down-on-square-stack class="w-4 h-4" />
|
||||
{{ __('Import from project') }}
|
||||
</button>
|
||||
<button wire:click="openImportFileModal" class="btn btn-outline btn-sm gap-1" title="{{ __('Import from CSV/Excel') }}">
|
||||
<x-heroicon-o-document-arrow-up class="w-4 h-4" />
|
||||
{{ __('Import CSV/Excel') }}
|
||||
@@ -17,93 +16,41 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(session()->has('message'))
|
||||
<div class="alert alert-success mb-4">{{ session('message') }}</div>
|
||||
@endif
|
||||
|
||||
{{-- Formulario de creación/edición con diseño de dos columnas --}}
|
||||
@if($showForm)
|
||||
<form wire:submit.prevent="saveTemplate" class="border p-4 rounded mb-6 bg-base-200">
|
||||
<table class="w-full mb-8">
|
||||
<tbody>
|
||||
{{-- Nombre del template --}}
|
||||
<tr>
|
||||
<td class="w-1/4 py-3 pr-4 align-top">
|
||||
{{ __('Template name') }}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<input type="text" wire:model="form.name"
|
||||
class="input w-full"
|
||||
required>
|
||||
</td>
|
||||
<td class="w-1/4 py-3 pr-4 align-top">{{ __('Template name') }}</td>
|
||||
<td class="py-3"><input type="text" wire:model="form.name" class="input w-full" required></td>
|
||||
</tr>
|
||||
|
||||
{{-- Descripción --}}
|
||||
<tr>
|
||||
<td class="w-1/4 py-3 pr-4 align-top">
|
||||
{{ __('Description') }}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<textarea wire:model="form.description" class="textarea textarea-bordered w-full" rows="2"></textarea>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{{-- Fase asociada (opcional) --}}
|
||||
<tr>
|
||||
<td class="w-1/4 py-3 pr-4 align-top">
|
||||
{{ __('Associated phase (optional)') }}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<select wire:model="form.phase_id" class="select select-bordered w-full" @disabled($form['is_global'] ?? false)>
|
||||
<option value="">{{ __('Global project') }}</option>
|
||||
@foreach($phases as $phase)
|
||||
<option value="{{ $phase->id }}" {{ old('form.phase_id') == $phase->id ? 'selected' : '' }}>
|
||||
{{ $phase->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{{-- Plantilla global (disponible en todos los proyectos) --}}
|
||||
<tr>
|
||||
<td class="w-1/4 py-3 pr-4 align-top">{{ __('Global template') }}</td>
|
||||
<td class="py-3">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" wire:model.live="form.is_global" class="toggle toggle-primary toggle-sm" />
|
||||
<span class="text-sm text-base-content/70">{{ __('Available in all projects') }}</span>
|
||||
</label>
|
||||
</td>
|
||||
<td class="w-1/4 py-3 pr-4 align-top">{{ __('Description') }}</td>
|
||||
<td class="py-3"><textarea wire:model="form.description" class="textarea textarea-bordered w-full" rows="2"></textarea></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{{-- Campos dinámicos --}}
|
||||
<div class="border-t pt-4 mt-2">
|
||||
<h3 class="font-bold mb-3">{{ __('Form fields') }}</h3>
|
||||
@foreach($form['fields'] as $index => $field)
|
||||
<div class="border p-3 rounded mb-3 bg-base-100">
|
||||
{{-- Fila: grupo / sección --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
|
||||
<div class="font-medium">{{ __('Group / section') }}</div>
|
||||
<div><input type="text" wire:model="form.fields.{{ $index }}.group" placeholder="ej: Geometría" class="input input-sm w-full"></div>
|
||||
</div>
|
||||
{{-- Fila: nombre interno --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
|
||||
<div class="font-medium">{{ __('Internal name') }}</div>
|
||||
<div><input type="text" wire:model="form.fields.{{ $index }}.name" placeholder="ej: altura_medida" class="input input-sm w-full"></div>
|
||||
</div>
|
||||
{{-- Fila: etiqueta --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
|
||||
<div class="font-medium">{{ __('Visible label') }}</div>
|
||||
<div><input type="text" wire:model="form.fields.{{ $index }}.label" placeholder="ej: Altura medida (m)" class="input input-sm w-full"></div>
|
||||
</div>
|
||||
{{-- Fila: pregunta / revisar (texto corto) --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
|
||||
<div class="font-medium">{{ __('Question / check') }}</div>
|
||||
<div><input type="text" wire:model="form.fields.{{ $index }}.question" placeholder="ej: ¿Cumple la cota prevista?" class="input input-sm w-full"></div>
|
||||
<div><input type="text" wire:model="form.fields.{{ $index }}.question" placeholder="ej: ¿Cumple la cota?" class="input input-sm w-full"></div>
|
||||
</div>
|
||||
{{-- Fila: tipo --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
|
||||
<div class="font-medium">{{ __('Field type') }}</div>
|
||||
<div>
|
||||
@@ -114,7 +61,6 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{{-- Fila: requerido y botón eliminar --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
|
||||
<div class="font-medium">{{ __('Required') }}</div>
|
||||
<div class="flex justify-between items-center">
|
||||
@@ -122,8 +68,6 @@
|
||||
<button type="button" wire:click="removeField({{ $index }})" class="btn btn-xs btn-error">{{ __('Remove field') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Campos adicionales según tipo --}}
|
||||
@if(in_array($field['type'], ['integer', 'decimal', 'percentage']))
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
|
||||
<div class="font-medium">{{ __('Min') }} / {{ __('Max') }} / {{ __('Step') }}</div>
|
||||
@@ -140,7 +84,6 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Fila: comentarios / ayuda (texto largo) --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
|
||||
<div class="font-medium">{{ __('Comments / help') }}</div>
|
||||
<div><textarea wire:model="form.fields.{{ $index }}.help" rows="2" placeholder="{{ __('Notes or instructions for this field') }}" class="textarea textarea-bordered textarea-sm w-full"></textarea></div>
|
||||
@@ -157,93 +100,68 @@
|
||||
</form>
|
||||
@endif
|
||||
|
||||
{{-- Tabla de templates existentes --}}
|
||||
{{-- Tabla --}}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ __('Name') }}</th>
|
||||
<th>{{ __('Description') }}</th>
|
||||
<th>{{ __('Phase') }}</th>
|
||||
<th>{{ __('Fields') }}</th>
|
||||
<th>{{ __('Used in projects') }}</th>
|
||||
<th>{{ __('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($templates as $template)
|
||||
<tr>
|
||||
<td>
|
||||
{{ $template->name }}
|
||||
@if(is_null($template->project_id))
|
||||
<span class="badge badge-info badge-sm ml-1">{{ __('Global') }}</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="font-medium">{{ $template->name }}</td>
|
||||
<td>{{ $template->description ?? '-' }}</td>
|
||||
<td>{{ $template->phase ? $template->phase->name : __('Global project') }}</td>
|
||||
<td>{{ count($template->fields) }}</td>
|
||||
<td>{{ count($template->fields ?? []) }}</td>
|
||||
<td><span class="badge badge-ghost badge-sm">{{ $template->projects()->count() }}</span></td>
|
||||
<td>
|
||||
<button wire:click="editTemplate({{ $template->id }})" class="btn btn-xs btn-warning">
|
||||
{{ __('Edit') }}
|
||||
</button>
|
||||
<button wire:click="editTemplate({{ $template->id }})" class="btn btn-xs btn-warning">{{ __('Edit') }}</button>
|
||||
<button wire:click="deleteTemplate({{ $template->id }})"
|
||||
wire:confirm="{{ __('Delete template confirmation') }}"
|
||||
class="btn btn-xs btn-error">{{ __('Delete') }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="5" class="text-center">{{ __('No templates yet (table)') }}</td>
|
||||
</tr>
|
||||
<tr><td colspan="5" class="text-center text-base-content/40 py-6">{{ __('No templates yet (table)') }}</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ════════════════════════════════════════════════════════════
|
||||
MODAL: Importar desde CSV/Excel
|
||||
════════════════════════════════════════════════════════════ --}}
|
||||
{{-- MODAL: Importar desde CSV/Excel --}}
|
||||
@if($showImportFileModal)
|
||||
<div class="fixed inset-0 z-40 bg-black/50" wire:click="$set('showImportFileModal', false)"></div>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-base-100 rounded-box shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between p-4 border-b border-base-300">
|
||||
<h3 class="font-bold">{{ __('Import from CSV/Excel') }}</h3>
|
||||
<button wire:click="$set('showImportFileModal', false)" class="btn btn-sm btn-ghost btn-circle">
|
||||
<x-heroicon-o-x-mark class="w-4 h-4" />
|
||||
</button>
|
||||
<button wire:click="$set('showImportFileModal', false)" class="btn btn-sm btn-ghost btn-circle"><x-heroicon-o-x-mark class="w-4 h-4" /></button>
|
||||
</div>
|
||||
<div class="p-4 space-y-3">
|
||||
<p class="text-xs text-base-content/60">
|
||||
{{ __('Columns: name, label, type, required, options, min, max, step') }}.
|
||||
<button type="button" wire:click="downloadExampleCsv" class="link link-primary">{{ __('Download example CSV') }}</button>
|
||||
</p>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-medium">{{ __('Template name') }} <span class="text-error">*</span></span></label>
|
||||
<input type="text" wire:model="importTemplateName" class="input input-bordered w-full"
|
||||
placeholder="{{ __('e.g. Concrete reception') }}" />
|
||||
<input type="text" wire:model="importTemplateName" class="input input-bordered w-full" />
|
||||
@error('importTemplateName')<span class="text-xs text-error">{{ $message }}</span>@enderror
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-medium">{{ __('File (CSV / Excel)') }} <span class="text-error">*</span></span></label>
|
||||
<input type="file" wire:model="importFile" accept=".csv,.txt,.xlsx,.xls" class="file-input file-input-bordered w-full" />
|
||||
<div wire:loading wire:target="importFile" class="text-xs text-base-content/50 mt-1">{{ __('Uploading…') }}</div>
|
||||
@error('importFile')<span class="text-xs text-error">{{ $message }}</span>@enderror
|
||||
</div>
|
||||
|
||||
@if($importError)
|
||||
<div class="alert alert-error text-sm py-2">{{ $importError }}</div>
|
||||
@endif
|
||||
|
||||
@if($importError)<div class="alert alert-error text-sm py-2">{{ $importError }}</div>@endif
|
||||
<div class="flex justify-end">
|
||||
<button wire:click="parseImportFile" class="btn btn-sm btn-secondary gap-1" wire:loading.attr="disabled" wire:target="parseImportFile,importFile">
|
||||
<x-heroicon-o-eye class="w-4 h-4" /> {{ __('Preview') }}
|
||||
</button>
|
||||
<button wire:click="parseImportFile" class="btn btn-sm btn-secondary gap-1"><x-heroicon-o-eye class="w-4 h-4" /> {{ __('Preview') }}</button>
|
||||
</div>
|
||||
|
||||
{{-- Vista previa de campos detectados --}}
|
||||
@if(!empty($importPreviewFields))
|
||||
<div class="border border-base-300 rounded-box p-2">
|
||||
<p class="text-sm font-medium mb-2">{{ count($importPreviewFields) }} {{ __('fields detected') }}:</p>
|
||||
@@ -251,14 +169,9 @@
|
||||
<table class="table table-xs">
|
||||
<thead><tr><th>{{ __('Label') }}</th><th>{{ __('Name') }}</th><th>{{ __('Type') }}</th><th>{{ __('Required') }}</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach($importPreviewFields as $f)
|
||||
<tr>
|
||||
<td>{{ $f['label'] }}</td>
|
||||
<td class="font-mono text-xs">{{ $f['name'] }}</td>
|
||||
<td><span class="badge badge-ghost badge-sm">{{ $f['type'] }}</span></td>
|
||||
<td>{{ !empty($f['required']) ? '✓' : '' }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
@foreach($importPreviewFields as $f)
|
||||
<tr><td>{{ $f['label'] }}</td><td class="font-mono text-xs">{{ $f['name'] }}</td><td><span class="badge badge-ghost badge-sm">{{ $f['type'] }}</span></td><td>{{ !empty($f['required']) ? '✓' : '' }}</td></tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -267,32 +180,7 @@
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 p-4 border-t border-base-300">
|
||||
<button wire:click="$set('showImportFileModal', false)" class="btn btn-ghost btn-sm">{{ __('Cancel') }}</button>
|
||||
<button wire:click="confirmImportFile" class="btn btn-primary btn-sm" @disabled(empty($importPreviewFields))>
|
||||
{{ __('Create template') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- ════════════════════════════════════════════════════════════
|
||||
MODAL: Importar desde otro proyecto
|
||||
════════════════════════════════════════════════════════════ --}}
|
||||
@if($showImportProjectModal)
|
||||
<div class="fixed inset-0 z-40 bg-black/50" wire:click="$set('showImportProjectModal', false)"></div>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-base-100 rounded-box shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between p-4 border-b border-base-300">
|
||||
<div>
|
||||
<h3 class="font-bold">{{ __('Import from another project') }}</h3>
|
||||
<p class="text-xs text-base-content/60">{{ __('Mark the templates and use "Import selected".') }}</p>
|
||||
</div>
|
||||
<button wire:click="$set('showImportProjectModal', false)" class="btn btn-sm btn-ghost btn-circle">
|
||||
<x-heroicon-o-x-mark class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<livewire:projects.import-templates-table :project-id="$project->id" :key="'import-tpl-'.$project->id" />
|
||||
<button wire:click="confirmImportFile" class="btn btn-primary btn-sm" @disabled(empty($importPreviewFields))>{{ __('Create template') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,6 +56,14 @@ new class extends Component
|
||||
</x-nav-link>
|
||||
</div>
|
||||
@endcan
|
||||
|
||||
@can('manage templates')
|
||||
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
|
||||
<x-nav-link :href="route('inspection-templates')" :active="request()->routeIs('inspection-templates')" wire:navigate>
|
||||
{{ __('Templates') }}
|
||||
</x-nav-link>
|
||||
</div>
|
||||
@endcan
|
||||
</div>
|
||||
|
||||
<!-- Language Switcher -->
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<a href="{{ route('projects.dashboard', $project) }}" wire:navigate class="btn btn-ghost btn-sm gap-1 mb-3">
|
||||
<x-heroicon-o-arrow-left class="w-4 h-4" /> Volver
|
||||
</a>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 mb-5">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold">Plantillas del proyecto</h1>
|
||||
<p class="text-sm text-base-content/60">{{ $project->name }} · marca las plantillas globales que quieres usar al inspeccionar este proyecto</p>
|
||||
</div>
|
||||
@can('manage templates')
|
||||
<a href="{{ route('inspection-templates') }}" wire:navigate class="btn btn-outline btn-sm gap-1">
|
||||
<x-heroicon-o-cog-6-tooth class="w-4 h-4" /> Gestionar catálogo
|
||||
</a>
|
||||
@endcan
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 border border-base-300">
|
||||
<div class="card-body p-4">
|
||||
<div class="form-control mb-3">
|
||||
<input type="text" wire:model.live.debounce.300ms="search" class="input input-bordered input-sm" placeholder="Buscar plantilla por nombre…" />
|
||||
</div>
|
||||
|
||||
@if($templates->isEmpty())
|
||||
<p class="text-sm text-base-content/40 py-6 text-center">
|
||||
No hay plantillas en el catálogo.
|
||||
@can('manage templates')
|
||||
<a href="{{ route('inspection-templates') }}" wire:navigate class="link link-primary">Crear una</a>.
|
||||
@endcan
|
||||
</p>
|
||||
@else
|
||||
<div class="overflow-x-auto rounded-lg border border-base-300">
|
||||
<table class="table table-sm">
|
||||
<thead class="bg-base-200">
|
||||
<tr>
|
||||
<th class="w-10">Asignada</th>
|
||||
<th>Plantilla</th>
|
||||
<th>Descripción</th>
|
||||
<th class="text-center">Campos</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($templates as $tpl)
|
||||
@php $assigned = in_array($tpl->id, $assignedIds, true); @endphp
|
||||
<tr wire:key="tpl-{{ $tpl->id }}" class="hover">
|
||||
<td>
|
||||
<input type="checkbox"
|
||||
wire:click="toggle({{ $tpl->id }})"
|
||||
@checked($assigned)
|
||||
class="checkbox checkbox-sm checkbox-primary" />
|
||||
</td>
|
||||
<td class="font-medium">{{ $tpl->name }}</td>
|
||||
<td class="text-sm text-base-content/60">{{ $tpl->description ?? '—' }}</td>
|
||||
<td class="text-center"><span class="badge badge-ghost badge-sm">{{ count($tpl->fields ?? []) }}</span></td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/50 mt-2">{{ count($assignedIds) }} plantilla(s) asignadas a este proyecto.</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,23 +0,0 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="{{ route('projects.dashboard', $project) }}" wire:navigate class="btn btn-ghost btn-sm px-2">
|
||||
<x-heroicon-o-arrow-left class="w-4 h-4" />
|
||||
</a>
|
||||
<div>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">{{ __('Inspection templates') }}</h2>
|
||||
<p class="text-sm text-gray-500 leading-tight">{{ $project->name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-8">
|
||||
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8">
|
||||
<p class="text-sm text-base-content/60 mb-4">
|
||||
{{ __('Create generic templates that can be used in any phase of the project') }}
|
||||
</p>
|
||||
|
||||
<livewire:inspections.template-manager :project="$project" />
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
+7
-3
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Livewire\Inspections\GlobalTemplateManager;
|
||||
use App\Livewire\Projects\ProjectMap;
|
||||
use App\Livewire\Projects\ProjectTemplatesPicker;
|
||||
use App\Models\InspectionTemplate;
|
||||
use App\Models\Layer;
|
||||
use App\Models\Phase;
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Tests\TestCase;
|
||||
|
||||
class GlobalTemplatesTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function project(User $owner, string $ref = 'P'): Project
|
||||
{
|
||||
$p = Project::create([
|
||||
'reference' => $ref, 'name' => 'P-' . $ref, 'address' => 'x', 'lat' => 40, 'lng' => -3,
|
||||
'start_date' => now()->toDateString(), 'end_date_estimated' => now()->addMonth()->toDateString(),
|
||||
'status' => 'in_progress', 'created_by' => $owner->id,
|
||||
]);
|
||||
$p->users()->attach($owner->id, ['role_in_project' => 'supervisor']);
|
||||
return $p;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Livewire\Inspections\TemplateManager;
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TemplateCreationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function setup_(): array
|
||||
{
|
||||
Permission::findOrCreate('edit projects');
|
||||
$user = User::factory()->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');
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Livewire\Inspections\TemplateManager;
|
||||
use App\Livewire\Projects\ImportTemplatesTable;
|
||||
use App\Models\InspectionTemplate;
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TemplateImportTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function project(User $member, string $ref): Project
|
||||
{
|
||||
$p = Project::create([
|
||||
'reference' => $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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user