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
+87 -50
View File
@@ -7,6 +7,7 @@ use App\Models\Project;
use App\Models\Phase;
use App\Models\Inspection;
use App\Models\Feature;
use App\Models\ChangeOrder;
use Carbon\Carbon;
class ClientProjects extends Component
@@ -49,7 +50,8 @@ class ClientProjects extends Component
$project = Project::with([
'phases.features',
'inspections.template'
'inspections.template',
'changeOrders' // Load change orders for this project
])->find($this->selectedProject);
if (!$project) {
@@ -66,68 +68,103 @@ class ClientProjects extends Component
'progress' => $project->phases->avg('progress_percent') ?? 0,
];
// Get recent images (simulated for now)
$this->galleryImages = [
[
'url' => 'https://via.placeholder.com/400x300?text=Avance+1',
'title' => 'Avance inicial',
'date' => now()->subDays(30)->format('d/m/Y')
],
[
'url' => 'https://via.placeholder.com/400x300?text=Avance+2',
'title' => 'Estructura levantada',
'date' => now()->subDays(15)->format('d/m/Y')
],
[
'url' => 'https://via.placeholder.com/400x300?text=Avance+3',
'title' => 'Instalaciones',
'date' => now()->subDays(5)->format('d/m/Y')
]
];
// 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();
// Get change orders (simulated for now)
$this->changeOrders = [
[
'id' => 124,
'title' => 'Ampliación de zona de almacenamiento',
'description' => 'Solicitud de ampliación de zona de almacenamiento debido a cambios logísticos.',
'status' => 'pending',
'requested_at' => now()->subDays(10)->format('d/m/Y'),
'amount' => 1500.00
],
[
'id' => 125,
'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
]
];
// 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 = [
[
'url' => 'https://via.placeholder.com/400x300?text=Avance+1',
'title' => 'Avance inicial',
'date' => now()->subDays(30)->format('d/m/Y')
],
[
'url' => 'https://via.placeholder.com/400x300?text=Avance+2',
'title' => 'Estructura levantada',
'date' => now()->subDays(15)->format('d/m/Y')
],
[
'url' => 'https://via.placeholder.com/400x300?text=Avance+3',
'title' => 'Instalaciones',
'date' => now()->subDays(5)->format('d/m/Y')
]
];
}
// Get change orders for this project
$this->changeOrders = $project->changeOrders
->orderBy('requested_at', 'desc')
->get()
->map(function($order) {
return [
'id' => $order->id,
'title' => $order->title,
'description' => $order->description,
'status' => $order->status,
'requested_at' => $order->requested_at->format('d/m/Y'),
'amount' => $order->amount
];
})
->toArray();
}
public function approveChangeOrder($orderId)
{
// In a real app, this would update the database
foreach ($this->changeOrders as &$order) {
if ($order['id'] == $orderId) {
$order['status'] = 'approved';
break;
// Update the change order in the database
$changeOrder = ChangeOrder::find($orderId);
if ($changeOrder) {
// Check that the change order belongs to the selected project (security)
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)
{
// In a real app, this would update the database
foreach ($this->changeOrders as &$order) {
if ($order['id'] == $orderId) {
$order['status'] = 'rejected';
break;
// Update the change order in the database
$changeOrder = ChangeOrder::find($orderId);
if ($changeOrder) {
// Check that the change order belongs to the selected project (security)
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()
-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']),
];
}
}