feat: Add change orders system with client approval/rejection and integrate with client portal
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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']),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,16 +8,22 @@ use Illuminate\Database\Eloquent\Model;
|
||||
class Project extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name', 'address', 'lat', 'lng', 'start_date', 'end_date_estimated', 'status', 'created_by'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'start_date' => 'date',
|
||||
'end_date_estimated' => 'date',
|
||||
"start_date" => "date",
|
||||
"end_date_estimated" => "date",
|
||||
];
|
||||
|
||||
public function changeOrders()
|
||||
{
|
||||
return $this->hasMany(ChangeOrder::class);
|
||||
}
|
||||
|
||||
// Relationships
|
||||
public function phases()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user