feat: Add change orders system with client approval/rejection and integrate with client portal

This commit is contained in:
2026-05-25 19:08:06 +02:00
parent 07ffce437f
commit 4ab7935c17
6 changed files with 166 additions and 323 deletions
+71 -34
View File
@@ -7,6 +7,7 @@ use App\Models\Project;
use App\Models\Phase; use App\Models\Phase;
use App\Models\Inspection; use App\Models\Inspection;
use App\Models\Feature; use App\Models\Feature;
use App\Models\ChangeOrder;
use Carbon\Carbon; use Carbon\Carbon;
class ClientProjects extends Component class ClientProjects extends Component
@@ -49,7 +50,8 @@ class ClientProjects extends Component
$project = Project::with([ $project = Project::with([
'phases.features', 'phases.features',
'inspections.template' 'inspections.template',
'changeOrders' // Load change orders for this project
])->find($this->selectedProject); ])->find($this->selectedProject);
if (!$project) { if (!$project) {
@@ -66,7 +68,27 @@ class ClientProjects extends Component
'progress' => $project->phases->avg('progress_percent') ?? 0, 'progress' => $project->phases->avg('progress_percent') ?? 0,
]; ];
// Get recent images (simulated for now) // Get recent images (we can fetch from media table if needed, but for now we'll keep simulated or link to real)
// For simplicity, we'll try to get some media images for the project
$mediaImages = $project->media()
->where('category', 'image')
->latest()
->take(3)
->get()
->map(function($media) {
return [
'url' => $media->url,
'title' => $media->name,
'date' => $media->created_at->format('d/m/Y')
];
})
->toArray();
// If we don't have 3 images, we can fallback to placeholders or just use what we have
if (count($mediaImages) > 0) {
$this->galleryImages = $mediaImages;
} else {
// Fallback to placeholders
$this->galleryImages = [ $this->galleryImages = [
[ [
'url' => 'https://via.placeholder.com/400x300?text=Avance+1', 'url' => 'https://via.placeholder.com/400x300?text=Avance+1',
@@ -84,51 +106,66 @@ class ClientProjects extends Component
'date' => now()->subDays(5)->format('d/m/Y') 'date' => now()->subDays(5)->format('d/m/Y')
] ]
]; ];
}
// Get change orders (simulated for now) // Get change orders for this project
$this->changeOrders = [ $this->changeOrders = $project->changeOrders
[ ->orderBy('requested_at', 'desc')
'id' => 124, ->get()
'title' => 'Ampliación de zona de almacenamiento', ->map(function($order) {
'description' => 'Solicitud de ampliación de zona de almacenamiento debido a cambios logísticos.', return [
'status' => 'pending', 'id' => $order->id,
'requested_at' => now()->subDays(10)->format('d/m/Y'), 'title' => $order->title,
'amount' => 1500.00 'description' => $order->description,
], 'status' => $order->status,
[ 'requested_at' => $order->requested_at->format('d/m/Y'),
'id' => 125, 'amount' => $order->amount
'title' => 'Cambio de material en acabados interiores',
'description' => 'Cambio de cerámica estándar a porcelanato en baños y cocinas.',
'status' => 'pending',
'requested_at' => now()->subDays(5)->format('d/m/Y'),
'amount' => 3200.00
]
]; ];
})
->toArray();
} }
public function approveChangeOrder($orderId) public function approveChangeOrder($orderId)
{ {
// In a real app, this would update the database // Update the change order in the database
foreach ($this->changeOrders as &$order) { $changeOrder = ChangeOrder::find($orderId);
if ($order['id'] == $orderId) { if ($changeOrder) {
$order['status'] = 'approved'; // Check that the change order belongs to the selected project (security)
break; if ($changeOrder->project_id == $this->selectedProject) {
} $changeOrder->status = 'approved';
} $changeOrder->responded_at = now()->toDateString();
$changeOrder->responded_by = auth()->id();
$changeOrder->save();
// Refresh the change orders list
$this->loadProjectDetails();
// Notify any listeners (optional)
$this->dispatch('changeOrderUpdated', ['id' => $orderId, 'status' => 'approved']); $this->dispatch('changeOrderUpdated', ['id' => $orderId, 'status' => 'approved']);
} }
}
}
public function rejectChangeOrder($orderId) public function rejectChangeOrder($orderId)
{ {
// In a real app, this would update the database // Update the change order in the database
foreach ($this->changeOrders as &$order) { $changeOrder = ChangeOrder::find($orderId);
if ($order['id'] == $orderId) { if ($changeOrder) {
$order['status'] = 'rejected'; // Check that the change order belongs to the selected project (security)
break; if ($changeOrder->project_id == $this->selectedProject) {
} $changeOrder->status = 'rejected';
} $changeOrder->responded_at = now()->toDateString();
$changeOrder->responded_by = auth()->id();
$changeOrder->save();
// Refresh the change orders list
$this->loadProjectDetails();
// Notify any listeners (optional)
$this->dispatch('changeOrderUpdated', ['id' => $orderId, 'status' => 'rejected']); $this->dispatch('changeOrderUpdated', ['id' => $orderId, 'status' => 'rejected']);
} }
}
}
public function render() public function render()
{ {
-111
View File
@@ -1,111 +0,0 @@
<?php
namespace App\Livewire;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
use App\Models\Project;
class ProjectTable extends DataTableComponent
{
protected $model = Project::class;
public function configure(): void
{
$this->setPrimaryKey('id')
->setDefaultSort('created_at', 'desc')
->setTableAttributes(['class' => 'table-auto w-full'])
->setThAttributes(['class' => 'px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider'])
->setTdAttributes(['class' => 'px-4 py-2 whitespace-nowrap text-sm text-gray-900']);
$this->addColumn('name', __('Project Name'))
->setSortable()
->setSearchable();
$this->addColumn('address', __('Address'))
->setSortable()
->setSearchable();
$this->addColumn('status', __('Status'))
->setSortable()
->setFilterable([
'planning' => __('Planning'),
'in_progress' => __('In progress'),
'paused' => __('Paused'),
'completed' => __('Completed'),
])
->setLabel(fn ($value, $row, $column, $component) =>
match ($value) {
'planning' => '<span class="badge badge-primary">'.__('Planning').'</span>',
'in_progress' => '<span class="badge badge-success">'.__('In progress').'</span>',
'paused' => '<span class="badge badge-warning">'.__('Paused').'</span>',
'completed' => '<span class="badge badge-secondary">'.__('Completed').'</span>',
default => $value
}
);
$this->addColumn('start_date', __('Start Date'))
->setSortable()
->setFormat(fn ($value, $row, $column) => $value ? $value->format('Y-m-d') : '');
$this->addColumn('end_date_estimated', __('Estimated End Date'))
->setSortable()
->setFormat(fn ($value, $row, $column) => $value ? $value->format('Y-m-d') : '');
$this->addColumn('actions', __('Actions'))
->setLabel(fn ($row) => '<div class="flex space-x-2">
<a href="'.route('projects.edit', $row->id).'" class="btn btn-sm btn-primary">'.__('Edit').'</a>
<form action="'.route('projects.destroy', $row->id).'" method="POST" onsubmit="return confirm(''.__('Are you sure you want to delete this project?').'');">
'.csrf_field().'
<input type="hidden" name="_method" value="DELETE">
<button type="submit" class="btn btn-sm btn-error">'.__('Delete').'</button>
</form>
</div>')
->setHtmlAttribute(['class' => 'text-right']);
}
public function columns(): array
{
return [
Column::make(__('Project Name'), 'name')
->sortable()
->searchable(),
Column::make(__('Address'), 'address')
->sortable()
->searchable(),
Column::make(__('Status'), 'status')
->sortable()
->filterable([
'planning' => __('Planning'),
'in_progress' => __('In progress'),
'paused' => __('Paused'),
'completed' => __('Completed'),
])
->label(fn ($value, $row, $column) =>
match ($value) {
'planning' => '<span class="badge badge-primary">'.__('Planning').'</span>',
'in_progress' => '<span class="badge badge-success">'.__('In progress').'</span>',
'paused' => '<span class="badge badge-warning">'.__('Paused').'</span>',
'completed' => '<span class="badge badge-secondary">'.__('Completed').'</span>',
default => $value
}
),
Column::make(__('Start Date'), 'start_date')
->sortable()
->format(fn ($value, $row, $column) => $value ? $value->format('Y-m-d') : ''),
Column::make(__('Estimated End Date'), 'end_date_estimated')
->sortable()
->format(fn ($value, $row, $column) => $value ? $value->format('Y-m-d') : ''),
Column::make(__('Actions'))
->label(fn ($row) => '<div class="flex space-x-2">
<a href="'.route('projects.edit', $row->id).'" class="btn btn-sm btn-primary">'.__('Edit').'</a>
<form action="'.route('projects.destroy', $row->id).'" method="POST" onsubmit="return confirm(''.__('Are you sure you want to delete this project?').'');">
'.csrf_field().'
<input type="hidden" name="_method" value="DELETE">
<button type="submit" class="btn btn-sm btn-error">'.__('Delete').'</button>
</form>
</div>')
->htmlAttribute(['class' => 'text-right']),
];
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ChangeOrder extends Model
{
protected $fillable = [
'project_id',
'title',
'description',
'amount',
'status',
'requested_at',
'responded_at',
'responded_by',
];
protected $casts = [
'requested_at' => 'date',
'responded_at' => 'date',
'amount' => 'decimal:2',
];
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}
public function responder(): BelongsTo
{
return $this->belongsTo(User::class, 'responded_by');
}
}
+8 -2
View File
@@ -8,16 +8,22 @@ use Illuminate\Database\Eloquent\Model;
class Project extends Model class Project extends Model
{ {
use HasFactory; use HasFactory;
use HasFactory;
protected $fillable = [ protected $fillable = [
'name', 'address', 'lat', 'lng', 'start_date', 'end_date_estimated', 'status', 'created_by' 'name', 'address', 'lat', 'lng', 'start_date', 'end_date_estimated', 'status', 'created_by'
]; ];
protected $casts = [ protected $casts = [
'start_date' => 'date', "start_date" => "date",
'end_date_estimated' => 'date', "end_date_estimated" => "date",
]; ];
public function changeOrders()
{
return $this->hasMany(ChangeOrder::class);
}
// Relationships // Relationships
public function phases() public function phases()
{ {
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('change_orders', function (Blueprint $table) {
$table->id();
$table->foreignId('project_id')->constrained()->onDelete('cascade');
$table->string('title');
$table->text('description');
$table->decimal('amount', 10, 2)->default(0.00);
$table->enum('status', ['pending', 'approved', 'rejected'])->default('pending');
$table->date('requested_at');
$table->date('responded_at')->nullable();
$table->foreignId('responded_by')->nullable()->constrained('users')->onDelete('set null');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('change_orders');
}
};
@@ -1,160 +0,0 @@
<div>
<div class="bg-base-100 p-4 rounded shadow">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">📋 Templates de inspección</h2>
<div>
<button wire:click="newTemplate" class="btn btn-primary btn-sm">
Nuevo template
</button>
</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">
{{__('Nombre del template')}}
</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">
{{__('Descripción')}}
</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">
{{__('Fase asociada (opcional)')}}
</td>
<td class="py-3">
<select wire:model="form.phase_id" class="select select-bordered w-full">
<option value="">Ninguna (global para el proyecto)</option>
@foreach($phases as $phase)
<option value="{{ $phase->id }}" {{ old('form.phase_id') == $phase->id ? 'selected' : '' }}>
{{ $phase->name }}
</option>
@endforeach
</select>
</td>
</tr>
</tbody>
</table>
{{-- Campos dinámicos --}}
<div class="border-t pt-4 mt-2">
<h3 class="font-bold mb-3">Campos del formulario</h3>
@foreach($form['fields'] as $index => $field)
<div class="border p-3 rounded mb-3 bg-base-100">
{{-- Fila: nombre interno --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
<div class="font-medium">Nombre interno</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">Etiqueta visible</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: tipo --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
<div class="font-medium">Tipo de campo</div>
<div>
<select wire:model="form.fields.{{ $index }}.type" class="select select-sm w-full">
@foreach($fieldTypes as $typeValue => $typeLabel)
<option value="{{ $typeValue }}">{{ $typeLabel }}</option>
@endforeach
</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">Requerido</div>
<div class="flex justify-between items-center">
<input type="checkbox" wire:model="form.fields.{{ $index }}.required" class="checkbox checkbox-sm">
<button type="button" wire:click="removeField({{ $index }})" class="btn btn-xs btn-error">Eliminar campo</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">Mínimo / Máximo / Paso</div>
<div class="flex gap-2">
<input type="number" wire:model="form.fields.{{ $index }}.min" placeholder="Mín" class="input input-xs w-20">
<input type="number" wire:model="form.fields.{{ $index }}.max" placeholder="Máx" class="input input-xs w-20">
<input type="number" step="any" wire:model="form.fields.{{ $index }}.step" placeholder="Paso" class="input input-xs w-20">
</div>
</div>
@elseif($field['type'] === 'select')
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
<div class="font-medium">Opciones (separadas por coma)</div>
<div><input type="text" wire:model="form.fields.{{ $index }}.options" placeholder="ej: Bueno,Regular,Malo" class="input input-sm w-full"></div>
</div>
@endif
</div>
@endforeach
<button type="button" wire:click="addField" class="btn btn-sm btn-secondary mt-2">+ Agregar campo</button>
</div>
<div class="flex gap-2 mt-4">
<button type="submit" class="btn btn-primary">Guardar template</button>
<button type="button" wire:click="cancelForm" class="btn">Cancelar</button>
</div>
</form>
@endif
{{-- Tabla de templates existentes --}}
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>Nombre</th>
<th>Descripción</th>
<th>Fase</th>
<th>Campos</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
@forelse($templates as $template)
<tr>
<td>{{ $template->name }}</td>
<td>{{ $template->description ?? '-' }}</td>
<td>{{ $template->phase ? $template->phase->name : 'Global' }}</td>
<td>{{ count($template->fields) }}</td>
<td>
<button wire:click="editTemplate({{ $template->id }})" class="btn btn-xs btn-warning">
Editar
</button>
<button wire:click="deleteTemplate({{ $template->id }})" class="btn btn-xs btn-error" onclick="return confirm('¿Eliminar template?')">Eliminar</button>
</td>
</tr>
@empty
<tr>
<td colspan="5" class="text-center">No hay templates creados. Presiona "Nuevo template" para comenzar.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>