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:
2026-06-25 17:26:12 +02:00
co-authored by Claude Opus 4.7
parent 7256c87182
commit f8a68312a3
17 changed files with 386 additions and 639 deletions
@@ -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);
}
@@ -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);
}
}
+4 -1
View File
@@ -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 -4
View File
@@ -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);
}
}
}
+6
View File
@@ -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');
}
};
@@ -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
View File
@@ -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');
+4 -4
View File
@@ -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();
+101
View File
@@ -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);
}
}
-110
View File
@@ -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');
}
}
-91
View File
@@ -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);
}
}