chore: update document approval workflow and bug fixes

This commit is contained in:
2026-05-25 13:05:20 +08:00
parent d573c02893
commit 39a8e1d4cd
910 changed files with 49994 additions and 1010 deletions

View File

@@ -0,0 +1,315 @@
<?php
namespace Modules\ProjectReports\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Modules\ApprovalWorkflow\Services\ApprovalService;
use Modules\ProjectManagement\Enums\DelayReason;
use Modules\ProjectManagement\Enums\ReportStatus;
use Modules\ProjectManagement\Enums\WeatherCondition;
use Modules\ProjectManagement\Models\HseRecord;
use Modules\ProjectManagement\Models\Project;
use Modules\ProjectManagement\Models\TaskDelay;
use Modules\ProjectManagement\Models\WeeklyStatusReport;
use Modules\ProjectManagement\Models\WorkforceMetric;
class StatusReportController extends Controller
{
public function __construct(
private ApprovalService $approvalService,
) {}
public function index(Request $request, Project $project)
{
$reports = $project->statusReports()
->with(['workforceMetric', 'hseRecord', 'submitter:id,name'])
->when($request->status, fn ($q, $s) => $q->where('status', $s))
->paginate(15)
->withQueryString();
return Inertia::render('ProjectReports::Reports/Index', [
'project' => $project->load('status'), // we need status for transitions if we want to show it
'reports' => $reports,
'filters' => $request->only(['status']),
'statuses' => collect(ReportStatus::cases())->map(fn ($s) => [
'value' => $s->value,
'label' => $s->label(),
]),
'allowedTransitions' => collect($project->status->allowedTransitions())->map(fn ($s) => [
'value' => $s->value,
'label' => $s->label(),
]),
]);
}
public function create(Project $project)
{
$latestReport = $project->statusReports()->first();
$defaultStart = $latestReport
? $latestReport->period_end->addDay()->format('Y-m-d')
: ($project->start_date?->format('Y-m-d') ?? now()->format('Y-m-d'));
return Inertia::render('ProjectManagement::Reports/Create', [
'project' => $project->only('id', 'name', 'code'),
'defaultPeriodStart' => $defaultStart,
'defaultPeriodEnd' => now()->format('Y-m-d'),
]);
}
public function store(Request $request, Project $project)
{
$validated = $this->validateReport($request);
$report = DB::transaction(function () use ($validated, $project, $request) {
$report = $project->statusReports()->create([
'period_start' => $validated['period_start'],
'period_end' => $validated['period_end'],
'narrative_status' => $validated['narrative_status'] ?? null,
'narrative_weather' => $validated['narrative_weather'] ?? null,
'narrative_compliance' => $validated['narrative_compliance'] ?? null,
'submitted_by' => $request->user()->id,
]);
$periodManHours = (float) ($validated['period_man_hours'] ?? 0);
$cumulative = WorkforceMetric::calculateCumulative(
$project->id,
$validated['period_start'],
$periodManHours,
);
$report->workforceMetric()->create([
'active_workforce' => $validated['active_workforce'] ?? 0,
'period_man_hours' => $periodManHours,
'cumulative_man_hours' => $cumulative,
'logistics_km' => $validated['logistics_km'] ?? 0,
]);
$report->hseRecord()->create([
'toolbox_meetings' => $validated['toolbox_meetings'] ?? 0,
'safety_observations' => $validated['safety_observations'] ?? 0,
'fatalities' => $validated['fatalities'] ?? 0,
'major_injuries' => $validated['major_injuries'] ?? 0,
'first_aid_cases' => $validated['first_aid_cases'] ?? 0,
'medical_cases' => $validated['medical_cases'] ?? 0,
'near_misses' => $validated['near_misses'] ?? 0,
'environmental_damage' => $validated['environmental_damage'] ?? 0,
'property_damage' => $validated['property_damage'] ?? 0,
'fines' => $validated['fines'] ?? 0,
]);
return $report;
});
// Auto-populate weather from task delays if user left fields empty
$this->autoPopulateWeather($report, $project);
return redirect()->route('projects.reports.show', [$project, $report])
->with('success', 'Status report created.');
}
public function show(Project $project, WeeklyStatusReport $report)
{
$report->load([
'workforceMetric',
'hseRecord',
'submitter:id,name',
'approver:id,name',
'approvalChains.steps.approver:id,name',
]);
return Inertia::render('ProjectManagement::Reports/Show', [
'project' => $project->load(['customer:id,name', 'contractor:id,company_name']),
'report' => $report,
]);
}
public function edit(Project $project, WeeklyStatusReport $report)
{
if (!in_array($report->status, [ReportStatus::Draft, ReportStatus::Rejected])) {
return back()->with('error', 'Only draft or rejected reports can be edited.');
}
$report->load(['workforceMetric', 'hseRecord']);
return Inertia::render('ProjectManagement::Reports/Edit', [
'project' => $project->only('id', 'name', 'code'),
'report' => $report,
]);
}
public function update(Request $request, Project $project, WeeklyStatusReport $report)
{
if (!in_array($report->status, [ReportStatus::Draft, ReportStatus::Rejected])) {
return back()->with('error', 'Only draft or rejected reports can be edited.');
}
$validated = $this->validateReport($request);
DB::transaction(function () use ($validated, $project, $report) {
$report->update([
'period_start' => $validated['period_start'],
'period_end' => $validated['period_end'],
'narrative_status' => $validated['narrative_status'] ?? null,
'narrative_weather' => $validated['narrative_weather'] ?? null,
'narrative_compliance' => $validated['narrative_compliance'] ?? null,
'status' => ReportStatus::Draft, // reset to draft on edit
]);
$periodManHours = (float) ($validated['period_man_hours'] ?? 0);
$cumulative = WorkforceMetric::calculateCumulative(
$project->id,
$validated['period_start'],
$periodManHours,
$report->id,
);
$report->workforceMetric()->updateOrCreate(
['weekly_status_report_id' => $report->id],
[
'active_workforce' => $validated['active_workforce'] ?? 0,
'period_man_hours' => $periodManHours,
'cumulative_man_hours' => $cumulative,
'logistics_km' => $validated['logistics_km'] ?? 0,
]
);
$report->hseRecord()->updateOrCreate(
['weekly_status_report_id' => $report->id],
[
'toolbox_meetings' => $validated['toolbox_meetings'] ?? 0,
'safety_observations' => $validated['safety_observations'] ?? 0,
'fatalities' => $validated['fatalities'] ?? 0,
'major_injuries' => $validated['major_injuries'] ?? 0,
'first_aid_cases' => $validated['first_aid_cases'] ?? 0,
'medical_cases' => $validated['medical_cases'] ?? 0,
'near_misses' => $validated['near_misses'] ?? 0,
'environmental_damage' => $validated['environmental_damage'] ?? 0,
'property_damage' => $validated['property_damage'] ?? 0,
'fines' => $validated['fines'] ?? 0,
]
);
});
// Auto-populate weather from task delays if user left fields empty
$this->autoPopulateWeather($report, $project);
return redirect()->route('projects.reports.show', [$project, $report])
->with('success', 'Status report updated.');
}
public function submitForApproval(Request $request, Project $project, WeeklyStatusReport $report)
{
if ($report->status !== ReportStatus::Draft) {
return back()->with('error', 'Only draft reports can be submitted for approval.');
}
$report->transitionTo(ReportStatus::Submitted);
// Get PM as the approver
$pm = $project->personnel()->wherePivot('role', 'pm')->first();
if (!$pm) {
return back()->with('error', 'No Project Manager assigned. Cannot submit for approval.');
}
$this->approvalService->createChain(
approvable: $report,
approverIds: [$pm->id],
type: 'weekly_status_report',
initiatedBy: $request->user()->id,
notes: "Weekly status report for {$report->period_label}",
);
$report->transitionTo(ReportStatus::InReview);
return back()->with('success', 'Report submitted for approval.');
}
public function destroy(Project $project, WeeklyStatusReport $report)
{
if ($report->status !== ReportStatus::Draft) {
return back()->with('error', 'Only draft reports can be deleted.');
}
$report->delete();
return redirect()->route('projects.reports.index', $project)
->with('success', 'Report deleted.');
}
private function validateReport(Request $request): array
{
return $request->validate([
'period_start' => 'required|date',
'period_end' => 'required|date|after_or_equal:period_start',
// Narrative
'narrative_status' => 'nullable|string|max:2000',
'narrative_weather' => 'nullable|string|max:2000',
'narrative_compliance' => 'nullable|string|max:2000',
// Structured weather
'weather_work_days_lost' => 'nullable|integer|min:0',
'weather_conditions' => 'nullable|array',
'weather_conditions.*' => 'string',
// Workforce
'active_workforce' => 'nullable|integer|min:0',
'period_man_hours' => 'nullable|numeric|min:0',
'logistics_km' => 'nullable|numeric|min:0',
// HSE Proactive
'toolbox_meetings' => 'nullable|integer|min:0',
'safety_observations' => 'nullable|integer|min:0',
// HSE Reactive
'fatalities' => 'nullable|integer|min:0',
'major_injuries' => 'nullable|integer|min:0',
'first_aid_cases' => 'nullable|integer|min:0',
'medical_cases' => 'nullable|integer|min:0',
'near_misses' => 'nullable|integer|min:0',
'environmental_damage' => 'nullable|integer|min:0',
'property_damage' => 'nullable|integer|min:0',
'fines' => 'nullable|numeric|min:0',
]);
}
private function autoPopulateWeather(WeeklyStatusReport $report, Project $project): void
{
$delays = TaskDelay::whereIn('task_id', $project->tasks()->pluck('id'))
->where('reason_type', DelayReason::Weather)
->whereBetween('delay_date', [$report->period_start, $report->period_end])
->get();
if ($delays->isEmpty()) return;
$totalHours = (float) $delays->sum('lost_hours');
$daysLost = (int) ceil($totalHours / 8);
$conditions = $delays->pluck('weather_condition')
->filter()
->map(fn ($c) => $c instanceof WeatherCondition ? $c->value : $c)
->unique()
->values()
->toArray();
$updates = [];
if ($report->weather_work_days_lost === 0 && $daysLost > 0) {
$updates['weather_work_days_lost'] = $daysLost;
}
if (empty($report->weather_conditions) && !empty($conditions)) {
$updates['weather_conditions'] = $conditions;
}
// Auto-generate narrative if empty
if (empty($report->narrative_weather) && !empty($conditions)) {
$labels = collect($conditions)->map(fn ($v) => WeatherCondition::tryFrom($v)?->label() ?? $v)->join(', ');
$updates['narrative_weather'] = "Weather delays recorded: {$labels}. Total {$daysLost} work day(s) lost ({$totalHours} hours).";
}
if (!empty($updates)) {
$report->update($updates);
}
}
}