chore: update document approval workflow and bug fixes
This commit is contained in:
25
Modules/ProjectManagement/app/Enums/DelayReason.php
Normal file
25
Modules/ProjectManagement/app/Enums/DelayReason.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectManagement\Enums;
|
||||
|
||||
enum DelayReason: string
|
||||
{
|
||||
case Weather = 'weather';
|
||||
case Supply = 'supply';
|
||||
case Manpower = 'manpower';
|
||||
case Permit = 'permit';
|
||||
case Equipment = 'equipment';
|
||||
case Other = 'other';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Weather => 'Weather',
|
||||
self::Supply => 'Supply / Material',
|
||||
self::Manpower => 'Manpower',
|
||||
self::Permit => 'Permit / Regulatory',
|
||||
self::Equipment => 'Equipment',
|
||||
self::Other => 'Other',
|
||||
};
|
||||
}
|
||||
}
|
||||
37
Modules/ProjectManagement/app/Enums/ProjectStatus.php
Normal file
37
Modules/ProjectManagement/app/Enums/ProjectStatus.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectManagement\Enums;
|
||||
|
||||
enum ProjectStatus: string
|
||||
{
|
||||
case UnderBidding = 'under_bidding';
|
||||
case Planning = 'planning';
|
||||
case InProgress = 'in_progress';
|
||||
case OnHold = 'on_hold';
|
||||
case Completed = 'completed';
|
||||
case Closed = 'closed';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::UnderBidding => 'Under Bidding',
|
||||
self::Planning => 'Planning',
|
||||
self::InProgress => 'In Progress',
|
||||
self::OnHold => 'On Hold',
|
||||
self::Completed => 'Completed',
|
||||
self::Closed => 'Closed',
|
||||
};
|
||||
}
|
||||
|
||||
public function allowedTransitions(): array
|
||||
{
|
||||
return match ($this) {
|
||||
self::UnderBidding => [self::Planning, self::Closed],
|
||||
self::Planning => [self::InProgress, self::OnHold, self::Closed],
|
||||
self::InProgress => [self::OnHold, self::Completed],
|
||||
self::OnHold => [self::InProgress, self::Closed],
|
||||
self::Completed => [self::Closed],
|
||||
self::Closed => [],
|
||||
};
|
||||
}
|
||||
}
|
||||
45
Modules/ProjectManagement/app/Enums/ReportStatus.php
Normal file
45
Modules/ProjectManagement/app/Enums/ReportStatus.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectManagement\Enums;
|
||||
|
||||
enum ReportStatus: string
|
||||
{
|
||||
case Draft = 'draft';
|
||||
case Submitted = 'submitted';
|
||||
case InReview = 'in_review';
|
||||
case Approved = 'approved';
|
||||
case Rejected = 'rejected';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Draft => 'Draft',
|
||||
self::Submitted => 'Submitted',
|
||||
self::InReview => 'In Review',
|
||||
self::Approved => 'Approved',
|
||||
self::Rejected => 'Rejected',
|
||||
};
|
||||
}
|
||||
|
||||
public function allowedTransitions(): array
|
||||
{
|
||||
return match ($this) {
|
||||
self::Draft => [self::Submitted],
|
||||
self::Submitted => [self::InReview, self::Rejected],
|
||||
self::InReview => [self::Approved, self::Rejected],
|
||||
self::Approved => [],
|
||||
self::Rejected => [self::Draft],
|
||||
};
|
||||
}
|
||||
|
||||
public function color(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Draft => 'gray',
|
||||
self::Submitted => 'blue',
|
||||
self::InReview => 'yellow',
|
||||
self::Approved => 'green',
|
||||
self::Rejected => 'red',
|
||||
};
|
||||
}
|
||||
}
|
||||
31
Modules/ProjectManagement/app/Enums/TaskStatus.php
Normal file
31
Modules/ProjectManagement/app/Enums/TaskStatus.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectManagement\Enums;
|
||||
|
||||
enum TaskStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case InProgress = 'in_progress';
|
||||
case Completed = 'completed';
|
||||
case Blocked = 'blocked';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Pending => 'Pending',
|
||||
self::InProgress => 'In Progress',
|
||||
self::Completed => 'Completed',
|
||||
self::Blocked => 'Blocked',
|
||||
};
|
||||
}
|
||||
|
||||
public function allowedTransitions(): array
|
||||
{
|
||||
return match ($this) {
|
||||
self::Pending => [self::InProgress, self::Blocked],
|
||||
self::InProgress => [self::Completed, self::Blocked, self::Pending],
|
||||
self::Completed => [],
|
||||
self::Blocked => [self::Pending, self::InProgress],
|
||||
};
|
||||
}
|
||||
}
|
||||
43
Modules/ProjectManagement/app/Enums/WeatherCondition.php
Normal file
43
Modules/ProjectManagement/app/Enums/WeatherCondition.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectManagement\Enums;
|
||||
|
||||
enum WeatherCondition: string
|
||||
{
|
||||
case Rain = 'rain';
|
||||
case HeavyRain = 'heavy_rain';
|
||||
case Typhoon = 'typhoon';
|
||||
case Flooding = 'flooding';
|
||||
case ExtremeHeat = 'extreme_heat';
|
||||
case StrongWind = 'strong_wind';
|
||||
case Lightning = 'lightning';
|
||||
case Other = 'other';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Rain => 'Rain',
|
||||
self::HeavyRain => 'Heavy Rain',
|
||||
self::Typhoon => 'Typhoon',
|
||||
self::Flooding => 'Flooding',
|
||||
self::ExtremeHeat => 'Extreme Heat',
|
||||
self::StrongWind => 'Strong Wind',
|
||||
self::Lightning => 'Lightning',
|
||||
self::Other => 'Other',
|
||||
};
|
||||
}
|
||||
|
||||
public function icon(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Rain => '🌧️',
|
||||
self::HeavyRain => '⛈️',
|
||||
self::Typhoon => '🌀',
|
||||
self::Flooding => '🌊',
|
||||
self::ExtremeHeat => '🔥',
|
||||
self::StrongWind => '💨',
|
||||
self::Lightning => '⚡',
|
||||
self::Other => '☁️',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectManagement\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Modules\ProjectManagement\Models\Project;
|
||||
|
||||
class ProjectStatusChanged
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Project $project,
|
||||
public string $oldStatus,
|
||||
public string $newStatus,
|
||||
) {}
|
||||
}
|
||||
16
Modules/ProjectManagement/app/Events/TaskCompleted.php
Normal file
16
Modules/ProjectManagement/app/Events/TaskCompleted.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectManagement\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Modules\ProjectManagement\Models\Task;
|
||||
|
||||
class TaskCompleted
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Task $task,
|
||||
) {}
|
||||
}
|
||||
39
Modules/ProjectManagement/app/Exports/TaskTemplateExport.php
Normal file
39
Modules/ProjectManagement/app/Exports/TaskTemplateExport.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectManagement\Exports;
|
||||
|
||||
use Maatwebsite\Excel\Concerns\FromArray;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
|
||||
class TaskTemplateExport implements FromArray, WithHeadings, WithStyles
|
||||
{
|
||||
public function headings(): array
|
||||
{
|
||||
return [
|
||||
'Name',
|
||||
'Description',
|
||||
'Start Date (YYYY-MM-DD)',
|
||||
'End Date (YYYY-MM-DD)',
|
||||
'Estimated Hours',
|
||||
'Labor Cost',
|
||||
'Sort Order',
|
||||
];
|
||||
}
|
||||
|
||||
public function array(): array
|
||||
{
|
||||
return [
|
||||
['Foundation Work', 'Excavation and foundation laying', '2026-04-01', '2026-04-15', 40, 15000, 1],
|
||||
['Concrete Pouring', 'Main structure concrete work', '2026-04-16', '2026-04-30', 80, 30000, 2],
|
||||
];
|
||||
}
|
||||
|
||||
public function styles(Worksheet $sheet): array
|
||||
{
|
||||
return [
|
||||
1 => ['font' => ['bold' => true, 'size' => 11]],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectManagement\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Modules\MasterData\Models\Material;
|
||||
use Modules\MaterialLogistics\Models\ProjectInventory;
|
||||
use Modules\ProjectManagement\Enums\DelayReason;
|
||||
use Modules\ProjectManagement\Enums\ProjectStatus;
|
||||
use Modules\ProjectManagement\Enums\WeatherCondition;
|
||||
use Modules\ProjectManagement\Events\ProjectStatusChanged;
|
||||
use Modules\ProjectManagement\Models\Project;
|
||||
|
||||
class ProjectController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
$query = Project::query()
|
||||
->with(['personnel:id,name']);
|
||||
|
||||
// Non-platform admins/owners only see projects they are assigned to
|
||||
if ($user && !$user->hasRole('Super Admin') && !$user->hasRole('Owner')) {
|
||||
$query->whereHas('personnel', function ($q) use ($user) {
|
||||
$q->where('users.id', $user->id);
|
||||
});
|
||||
}
|
||||
|
||||
$projects = $query
|
||||
->when($request->search, fn ($q, $s) => $q->where('name', 'like', "%{$s}%")->orWhere('code', 'like', "%{$s}%"))
|
||||
->when($request->status, fn ($q, $s) => $q->where('status', $s))
|
||||
->latest()
|
||||
->paginate(15)
|
||||
->withQueryString()
|
||||
->through(fn ($p) => $p->append(['capitalization_percentage', 'is_over_budget']));
|
||||
|
||||
return Inertia::render('ProjectManagement::Projects/Index', [
|
||||
'projects' => $projects,
|
||||
'filters' => $request->only(['search', 'status']),
|
||||
'statuses' => collect(ProjectStatus::cases())->map(fn ($s) => [
|
||||
'value' => $s->value,
|
||||
'label' => $s->label(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$employees = User::whereIn('user_type', ['admin', 'employee'])->with('employeeProfile')->select('id', 'ulid', 'name', 'email')->get();
|
||||
|
||||
return Inertia::render('ProjectManagement::Projects/Create', [
|
||||
'employees' => $employees,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'client_name' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'location' => 'nullable|string|max:255',
|
||||
'contract_value' => 'nullable|numeric|min:0',
|
||||
'contract_duration' => 'nullable|integer|min:0',
|
||||
'start_date' => 'nullable|date',
|
||||
'target_end_date' => 'nullable|date|after_or_equal:start_date',
|
||||
]);
|
||||
|
||||
$project = Project::create($validated);
|
||||
|
||||
// Assign PM if provided
|
||||
if ($request->pm_id) {
|
||||
$pmId = User::resolveUlidToId($request->pm_id);
|
||||
if ($pmId) {
|
||||
$project->personnel()->attach($pmId, ['role' => 'pm']);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-seed default milestones
|
||||
$this->seedDefaultMilestones($project);
|
||||
|
||||
return redirect()->route('projects.show', $project)
|
||||
->with('success', "Project \"{$project->name}\" created.");
|
||||
}
|
||||
|
||||
public function show(Project $project)
|
||||
{
|
||||
$project->load([
|
||||
'contractor:id,company_name',
|
||||
'tasks:id,project_id,status', // Only load status for counts
|
||||
]);
|
||||
|
||||
$project->append(['capitalization_percentage', 'is_over_budget']);
|
||||
|
||||
$taskStats = [
|
||||
'total' => $project->tasks->count(),
|
||||
'pending' => $project->tasks->where('status', 'pending')->count(),
|
||||
'in_progress' => $project->tasks->where('status', 'in_progress')->count(),
|
||||
'completed' => $project->tasks->where('status', 'completed')->count(),
|
||||
];
|
||||
|
||||
return Inertia::render('ProjectManagement::Projects/Overview', [
|
||||
'project' => $project,
|
||||
'taskStats' => $taskStats,
|
||||
'allowedTransitions' => collect($project->status->allowedTransitions())->map(fn ($s) => [
|
||||
'value' => $s->value,
|
||||
'label' => $s->label(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function team(Project $project)
|
||||
{
|
||||
$project->load(['personnel:id,name,email']);
|
||||
|
||||
$employees = User::whereIn('user_type', ['admin', 'employee'])
|
||||
->with('employeeProfile')
|
||||
->select('id', 'ulid', 'name', 'email')
|
||||
->get();
|
||||
|
||||
return Inertia::render('ProjectManagement::Projects/Team', [
|
||||
'project' => $project,
|
||||
'employees' => $employees,
|
||||
'allowedTransitions' => collect($project->status->allowedTransitions())->map(fn ($s) => [
|
||||
'value' => $s->value,
|
||||
'label' => $s->label(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function materials(Project $project)
|
||||
{
|
||||
// Materials available on-site (dispatched & received at project)
|
||||
$availableMaterials = ProjectInventory::where('project_id', $project->id)
|
||||
->where('on_hand_qty', '>', 0)
|
||||
->with([
|
||||
'material:id,ulid,name,sku,unit,unit_cost,category,type',
|
||||
'material.components.component:id,name,unit'
|
||||
])
|
||||
->get()
|
||||
->map(fn ($inv) => [
|
||||
'id' => $inv->material->id,
|
||||
'ulid' => $inv->material->ulid,
|
||||
'name' => $inv->material->name,
|
||||
'sku' => $inv->material->sku,
|
||||
'unit' => $inv->material->unit,
|
||||
'unit_cost' => $inv->material->unit_cost,
|
||||
'category' => $inv->material->category,
|
||||
'type' => $inv->material->type,
|
||||
'components' => $inv->material->components,
|
||||
'available_qty' => $inv->available_qty,
|
||||
'on_hand_qty' => (float) $inv->on_hand_qty,
|
||||
'allocated_qty' => (float) $inv->allocated_qty,
|
||||
]);
|
||||
|
||||
return Inertia::render('ProjectManagement::Projects/Materials', [
|
||||
'project' => $project,
|
||||
'availableMaterials' => $availableMaterials,
|
||||
'allowedTransitions' => collect($project->status->allowedTransitions())->map(fn ($s) => [
|
||||
'value' => $s->value,
|
||||
'label' => $s->label(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(Project $project)
|
||||
{
|
||||
$employees = User::whereIn('user_type', ['admin', 'employee'])->with('employeeProfile')->select('id', 'ulid', 'name', 'email')->get();
|
||||
|
||||
$project->load('personnel:id,name');
|
||||
|
||||
return Inertia::render('ProjectManagement::Projects/Edit', [
|
||||
'project' => $project,
|
||||
'employees' => $employees,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Project $project)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'client_name' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'location' => 'nullable|string|max:255',
|
||||
'contract_value' => 'nullable|numeric|min:0',
|
||||
'contract_duration' => 'nullable|integer|min:0',
|
||||
'start_date' => 'nullable|date',
|
||||
'target_end_date' => 'nullable|date|after_or_equal:start_date',
|
||||
]);
|
||||
|
||||
$project->update($validated);
|
||||
|
||||
return redirect()->route('projects.show', $project)
|
||||
->with('success', "Project \"{$project->name}\" updated.");
|
||||
}
|
||||
|
||||
public function destroy(Project $project)
|
||||
{
|
||||
$name = $project->name;
|
||||
$project->delete();
|
||||
|
||||
return redirect()->route('projects.index')
|
||||
->with('success', "Project \"{$name}\" deleted.");
|
||||
}
|
||||
|
||||
public function transition(Request $request, Project $project)
|
||||
{
|
||||
$request->validate(['status' => 'required|string']);
|
||||
|
||||
$newStatus = ProjectStatus::from($request->status);
|
||||
$oldStatus = $project->status->value;
|
||||
|
||||
try {
|
||||
$project->transitionTo($newStatus);
|
||||
ProjectStatusChanged::dispatch($project, $oldStatus, $newStatus->value);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return back()->with('success', "Project status changed to {$newStatus->label()}.");
|
||||
}
|
||||
|
||||
public function addPersonnel(Request $request, Project $project)
|
||||
{
|
||||
$request->validate([
|
||||
'user_id' => 'required|string',
|
||||
'role' => 'required|in:pm,engineer,laborer,member',
|
||||
]);
|
||||
|
||||
$userId = User::resolveUlidToId($request->user_id);
|
||||
|
||||
if (!$userId) {
|
||||
return back()->with('error', 'User not found.');
|
||||
}
|
||||
|
||||
if ($project->personnel()->where('user_id', $userId)->exists()) {
|
||||
return back()->with('error', 'User is already assigned to this project.');
|
||||
}
|
||||
|
||||
$project->personnel()->attach($userId, ['role' => $request->role]);
|
||||
|
||||
return back()->with('success', 'Team member added.');
|
||||
}
|
||||
|
||||
public function removePersonnel(Project $project, User $user)
|
||||
{
|
||||
$project->personnel()->detach($user->id);
|
||||
|
||||
return back()->with('success', 'Team member removed.');
|
||||
}
|
||||
|
||||
private function seedDefaultMilestones(Project $project): void
|
||||
{
|
||||
$defaults = [
|
||||
['name' => 'Mobilization', 'weight_percentage' => 5],
|
||||
['name' => 'Earthworks & Foundation', 'weight_percentage' => 15],
|
||||
['name' => 'Structural Works', 'weight_percentage' => 25],
|
||||
['name' => 'Roofing & Waterproofing', 'weight_percentage' => 15],
|
||||
['name' => 'Architectural Finishing', 'weight_percentage' => 20],
|
||||
['name' => 'MEP Rough-In', 'weight_percentage' => 10],
|
||||
['name' => 'Final Inspection & Punch List', 'weight_percentage' => 5],
|
||||
['name' => 'Turnover', 'weight_percentage' => 5],
|
||||
];
|
||||
|
||||
$startDate = $project->start_date;
|
||||
$duration = $project->contract_duration; // in calendar days
|
||||
$count = count($defaults);
|
||||
|
||||
foreach ($defaults as $i => $milestone) {
|
||||
$plannedDate = null;
|
||||
if ($startDate && $duration && $duration > 0) {
|
||||
$daysOffset = (int) round(($i + 1) / $count * $duration);
|
||||
$plannedDate = $startDate->copy()->addDays($daysOffset);
|
||||
}
|
||||
|
||||
$project->milestones()->create([
|
||||
'name' => $milestone['name'],
|
||||
'weight_percentage' => $milestone['weight_percentage'],
|
||||
'sort_order' => $i,
|
||||
'is_default' => true,
|
||||
'planned_date' => $plannedDate,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectManagement\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ProjectManagementController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
return view('projectmanagement::index');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new resource.
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
return view('projectmanagement::create');
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(Request $request) {}
|
||||
|
||||
/**
|
||||
* Show the specified resource.
|
||||
*/
|
||||
public function show($id)
|
||||
{
|
||||
return view('projectmanagement::show');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified resource.
|
||||
*/
|
||||
public function edit($id)
|
||||
{
|
||||
return view('projectmanagement::edit');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*/
|
||||
public function update(Request $request, $id) {}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*/
|
||||
public function destroy($id) {}
|
||||
}
|
||||
66
Modules/ProjectManagement/app/Imports/TaskImport.php
Normal file
66
Modules/ProjectManagement/app/Imports/TaskImport.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectManagement\Imports;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Maatwebsite\Excel\Concerns\ToArray;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
||||
use Modules\ProjectManagement\Models\Project;
|
||||
use Modules\ProjectManagement\Models\Task;
|
||||
|
||||
class TaskImport implements ToArray, WithHeadingRow
|
||||
{
|
||||
private Project $project;
|
||||
private int $imported = 0;
|
||||
private int $skipped = 0;
|
||||
|
||||
public function __construct(Project $project)
|
||||
{
|
||||
$this->project = $project;
|
||||
}
|
||||
|
||||
public function array(array $rows): void
|
||||
{
|
||||
foreach ($rows as $row) {
|
||||
$name = trim($row['name'] ?? '');
|
||||
if (empty($name)) {
|
||||
$this->skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'project_id' => $this->project->id,
|
||||
'name' => $name,
|
||||
'description' => $row['description'] ?? null,
|
||||
'status' => 'pending',
|
||||
'labor_cost' => is_numeric($row['labor_cost'] ?? null) ? $row['labor_cost'] : 0,
|
||||
'estimated_hours' => is_numeric($row['estimated_hours'] ?? null) ? $row['estimated_hours'] : 0,
|
||||
'sort_order' => is_numeric($row['sort_order'] ?? null) ? (int) $row['sort_order'] : 0,
|
||||
];
|
||||
|
||||
// Parse dates safely
|
||||
$startDate = $row['start_date_yyyy_mm_dd'] ?? $row['start_date'] ?? null;
|
||||
$endDate = $row['end_date_yyyy_mm_dd'] ?? $row['end_date'] ?? null;
|
||||
|
||||
if ($startDate) {
|
||||
try { $data['start_date'] = Carbon::parse($startDate)->format('Y-m-d'); } catch (\Exception) {}
|
||||
}
|
||||
if ($endDate) {
|
||||
try { $data['end_date'] = Carbon::parse($endDate)->format('Y-m-d'); } catch (\Exception) {}
|
||||
}
|
||||
|
||||
Task::create($data);
|
||||
$this->imported++;
|
||||
}
|
||||
}
|
||||
|
||||
public function getImportedCount(): int
|
||||
{
|
||||
return $this->imported;
|
||||
}
|
||||
|
||||
public function getSkippedCount(): int
|
||||
{
|
||||
return $this->skipped;
|
||||
}
|
||||
}
|
||||
75
Modules/ProjectManagement/app/Models/HseRecord.php
Normal file
75
Modules/ProjectManagement/app/Models/HseRecord.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectManagement\Models;
|
||||
|
||||
use App\Traits\HasPublicIdentifier;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class HseRecord extends Model
|
||||
{
|
||||
use HasPublicIdentifier;
|
||||
|
||||
protected $fillable = [
|
||||
'weekly_status_report_id',
|
||||
// Proactive
|
||||
'toolbox_meetings',
|
||||
'safety_observations',
|
||||
// Reactive
|
||||
'fatalities',
|
||||
'major_injuries',
|
||||
'first_aid_cases',
|
||||
'medical_cases',
|
||||
'near_misses',
|
||||
'environmental_damage',
|
||||
'property_damage',
|
||||
'fines',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'fines' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
public function report(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(WeeklyStatusReport::class, 'weekly_status_report_id');
|
||||
}
|
||||
|
||||
// --- Accessors ---
|
||||
|
||||
public function getTotalIncidentsAttribute(): int
|
||||
{
|
||||
return $this->fatalities
|
||||
+ $this->major_injuries
|
||||
+ $this->first_aid_cases
|
||||
+ $this->medical_cases
|
||||
+ $this->near_misses
|
||||
+ $this->environmental_damage
|
||||
+ $this->property_damage;
|
||||
}
|
||||
|
||||
public function getHasZeroIncidentsAttribute(): bool
|
||||
{
|
||||
return $this->total_incidents === 0 && (float) $this->fines === 0.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns each reactive field as a labeled array for UI rendering.
|
||||
*/
|
||||
public function getReactiveBreakdownAttribute(): array
|
||||
{
|
||||
return [
|
||||
['label' => 'Fatalities', 'value' => $this->fatalities],
|
||||
['label' => 'Major Injuries', 'value' => $this->major_injuries],
|
||||
['label' => 'First Aid Cases', 'value' => $this->first_aid_cases],
|
||||
['label' => 'Medical Cases', 'value' => $this->medical_cases],
|
||||
['label' => 'Near Misses', 'value' => $this->near_misses],
|
||||
['label' => 'Environmental Damage', 'value' => $this->environmental_damage],
|
||||
['label' => 'Property Damage', 'value' => $this->property_damage],
|
||||
['label' => 'Fines/Costs', 'value' => $this->fines, 'isCurrency' => true],
|
||||
];
|
||||
}
|
||||
}
|
||||
206
Modules/ProjectManagement/app/Models/Project.php
Normal file
206
Modules/ProjectManagement/app/Models/Project.php
Normal file
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectManagement\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Traits\HasPublicIdentifier;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Modules\BiddingManagement\Models\BidPackage;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Modules\ContractorManagement\Models\Contractor;
|
||||
use Modules\MasterData\Models\MaterialGroup;
|
||||
use Modules\ProjectManagement\Enums\ProjectStatus;
|
||||
use Modules\MaterialLogistics\Models\Warehouse;
|
||||
|
||||
class Project extends Model
|
||||
{
|
||||
use BelongsToTenant, HasPublicIdentifier, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'code',
|
||||
'description',
|
||||
'customer_id',
|
||||
'client_name',
|
||||
'contractor_id',
|
||||
'location',
|
||||
'status',
|
||||
'contract_value',
|
||||
'contract_duration',
|
||||
'start_date',
|
||||
'target_end_date',
|
||||
'actual_end_date',
|
||||
'completion_percentage',
|
||||
'total_capitalization',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => ProjectStatus::class,
|
||||
'contract_value' => 'decimal:2',
|
||||
'total_capitalization' => 'decimal:2',
|
||||
'completion_percentage' => 'decimal:2',
|
||||
'contract_duration' => 'integer',
|
||||
'start_date' => 'date',
|
||||
'target_end_date' => 'date',
|
||||
'actual_end_date' => 'date',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (self $project) {
|
||||
if (empty($project->code)) {
|
||||
$year = now()->year;
|
||||
$lastCode = static::withTrashed()
|
||||
->where('code', 'like', "PRJ-{$year}-%")
|
||||
->orderByDesc('code')
|
||||
->value('code');
|
||||
|
||||
$next = 1;
|
||||
if ($lastCode && preg_match('/PRJ-\d{4}-(\d+)/', $lastCode, $m)) {
|
||||
$next = (int) $m[1] + 1;
|
||||
}
|
||||
|
||||
$project->code = sprintf('PRJ-%d-%03d', $year, $next);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Relationships ---
|
||||
|
||||
public function customer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'customer_id');
|
||||
}
|
||||
|
||||
public function warehouse()
|
||||
{
|
||||
return $this->hasOne(Warehouse::class);
|
||||
}
|
||||
|
||||
public function inventories(): HasMany
|
||||
{
|
||||
return $this->hasMany(\Modules\MaterialLogistics\Models\ProjectInventory::class);
|
||||
}
|
||||
|
||||
public function contractor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Contractor::class);
|
||||
}
|
||||
|
||||
public function tasks(): HasMany
|
||||
{
|
||||
return $this->hasMany(Task::class)->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function milestones(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProjectMilestone::class)->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function statusReports(): HasMany
|
||||
{
|
||||
return $this->hasMany(WeeklyStatusReport::class)->orderByDesc('period_end');
|
||||
}
|
||||
|
||||
public function personnel(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'project_user')
|
||||
->withPivot('role')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function materialGroups(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(MaterialGroup::class, 'project_material_groups')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function bidPackages(): HasMany
|
||||
{
|
||||
return $this->hasMany(BidPackage::class);
|
||||
}
|
||||
|
||||
// --- State Machine Helpers ---
|
||||
|
||||
public function transitionTo(ProjectStatus $newStatus): void
|
||||
{
|
||||
$currentStatus = $this->status;
|
||||
|
||||
if (!in_array($newStatus, $currentStatus->allowedTransitions())) {
|
||||
throw new \InvalidArgumentException(
|
||||
"Cannot transition from {$currentStatus->label()} to {$newStatus->label()}"
|
||||
);
|
||||
}
|
||||
|
||||
// Guard: can't move to InProgress without assigned PM
|
||||
if ($newStatus === ProjectStatus::InProgress) {
|
||||
$hasPm = $this->personnel()->wherePivot('role', 'pm')->exists();
|
||||
if (!$hasPm) {
|
||||
throw new \InvalidArgumentException('Cannot start project without a Project Manager assigned');
|
||||
}
|
||||
}
|
||||
|
||||
$this->update(['status' => $newStatus]);
|
||||
}
|
||||
|
||||
// --- Scopes ---
|
||||
|
||||
public function scopeStatus($query, ProjectStatus $status)
|
||||
{
|
||||
return $query->where('status', $status);
|
||||
}
|
||||
|
||||
// --- Accessors ---
|
||||
|
||||
public function getProjectManagerAttribute(): ?User
|
||||
{
|
||||
return $this->personnel()->wherePivot('role', 'pm')->first();
|
||||
}
|
||||
|
||||
public function getCapitalizationPercentageAttribute(): float
|
||||
{
|
||||
if ($this->contract_value <= 0) return 0;
|
||||
return round(($this->total_capitalization / $this->contract_value) * 100, 2);
|
||||
}
|
||||
|
||||
public function getIsOverBudgetAttribute(): bool
|
||||
{
|
||||
return $this->total_capitalization > $this->contract_value && $this->contract_value > 0;
|
||||
}
|
||||
|
||||
public function recalculateCapitalization(): void
|
||||
{
|
||||
$total = $this->tasks->sum(fn (Task $task) => $task->total_cost);
|
||||
$this->update(['total_capitalization' => $total]);
|
||||
}
|
||||
|
||||
public function getMilestoneCompletionAttribute(): float
|
||||
{
|
||||
$milestones = $this->milestones;
|
||||
if ($milestones->isEmpty()) return 0;
|
||||
|
||||
return (float) $milestones
|
||||
->where('actual_date', '!=', null)
|
||||
->sum('weight_percentage');
|
||||
}
|
||||
|
||||
public function getWeatherDelayDaysAttribute(): int
|
||||
{
|
||||
$milestoneDays = (int) $this->milestones->sum('weather_delay_days');
|
||||
|
||||
$taskDelayHours = (float) $this->tasks->flatMap(
|
||||
fn (Task $task) => $task->delays
|
||||
)->where('reason_type', \Modules\ProjectManagement\Enums\DelayReason::Weather)
|
||||
->sum('lost_hours');
|
||||
|
||||
return $milestoneDays + (int) ceil($taskDelayHours / 8);
|
||||
}
|
||||
}
|
||||
83
Modules/ProjectManagement/app/Models/ProjectMilestone.php
Normal file
83
Modules/ProjectManagement/app/Models/ProjectMilestone.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectManagement\Models;
|
||||
|
||||
use App\Traits\HasPublicIdentifier;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Modules\ProjectManagement\Enums\WeatherCondition;
|
||||
|
||||
class ProjectMilestone extends Model
|
||||
{
|
||||
use HasPublicIdentifier;
|
||||
|
||||
protected $fillable = [
|
||||
'project_id',
|
||||
'name',
|
||||
'description',
|
||||
'planned_date',
|
||||
'actual_date',
|
||||
'sort_order',
|
||||
'weight_percentage',
|
||||
'is_default',
|
||||
'weather_impacted',
|
||||
'weather_condition',
|
||||
'weather_delay_days',
|
||||
'weather_notes',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'planned_date' => 'date',
|
||||
'actual_date' => 'date',
|
||||
'weight_percentage' => 'decimal:2',
|
||||
'is_default' => 'boolean',
|
||||
'weather_impacted' => 'boolean',
|
||||
'weather_condition' => WeatherCondition::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
// --- Accessors ---
|
||||
|
||||
public function getIsCompletedAttribute(): bool
|
||||
{
|
||||
return $this->actual_date !== null;
|
||||
}
|
||||
|
||||
public function getIsOverdueAttribute(): bool
|
||||
{
|
||||
return !$this->is_completed
|
||||
&& $this->planned_date
|
||||
&& $this->planned_date->isPast();
|
||||
}
|
||||
|
||||
public function getStatusAttribute(): string
|
||||
{
|
||||
if ($this->is_completed) return 'completed';
|
||||
if ($this->is_overdue) return 'overdue';
|
||||
return 'upcoming';
|
||||
}
|
||||
|
||||
public function getStatusColorAttribute(): string
|
||||
{
|
||||
return match ($this->status) {
|
||||
'completed' => 'emerald',
|
||||
'overdue' => 'amber',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
public function getDaysDelayedAttribute(): int
|
||||
{
|
||||
if (!$this->is_completed || !$this->planned_date) return 0;
|
||||
|
||||
$diff = $this->planned_date->diffInDays($this->actual_date, false);
|
||||
return max(0, $diff);
|
||||
}
|
||||
}
|
||||
126
Modules/ProjectManagement/app/Models/Task.php
Normal file
126
Modules/ProjectManagement/app/Models/Task.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectManagement\Models;
|
||||
use Modules\MasterData\Models\Material;
|
||||
use Modules\MasterData\Models\MaterialGroup;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Traits\HasPublicIdentifier;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Modules\ProjectManagement\Enums\DelayReason;
|
||||
use Modules\ProjectManagement\Enums\TaskStatus;
|
||||
|
||||
class Task extends Model
|
||||
{
|
||||
use HasPublicIdentifier;
|
||||
|
||||
protected $fillable = [
|
||||
'project_id',
|
||||
'name',
|
||||
'description',
|
||||
'area',
|
||||
'status',
|
||||
'sort_order',
|
||||
'labor_cost',
|
||||
'estimated_hours',
|
||||
'actual_hours',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'actual_start_date',
|
||||
'actual_end_date',
|
||||
'completion_percentage',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => TaskStatus::class,
|
||||
'labor_cost' => 'decimal:2',
|
||||
'estimated_hours' => 'decimal:2',
|
||||
'actual_hours' => 'decimal:2',
|
||||
'completion_percentage' => 'decimal:2',
|
||||
'start_date' => 'date',
|
||||
'end_date' => 'date',
|
||||
'actual_start_date' => 'date',
|
||||
'actual_end_date' => 'date',
|
||||
];
|
||||
}
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
public function users(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class);
|
||||
}
|
||||
|
||||
public function taskMaterials(): HasMany
|
||||
{
|
||||
return $this->hasMany(TaskMaterial::class);
|
||||
}
|
||||
|
||||
public function activities(): HasMany
|
||||
{
|
||||
return $this->hasMany(TaskActivity::class)->orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
public function delays(): HasMany
|
||||
{
|
||||
return $this->hasMany(TaskDelay::class)->orderByDesc('delay_date');
|
||||
}
|
||||
|
||||
public function getTotalDelayDaysAttribute(): float
|
||||
{
|
||||
return round((float) $this->delays->sum('lost_hours') / 8, 1);
|
||||
}
|
||||
|
||||
public function getWeatherDelayCountAttribute(): int
|
||||
{
|
||||
return $this->delays->where('reason_type', DelayReason::Weather)->count();
|
||||
}
|
||||
|
||||
public function getMaterialCostAttribute(): float
|
||||
{
|
||||
return $this->taskMaterials->sum(fn (TaskMaterial $tm) => $tm->effective_cost);
|
||||
}
|
||||
|
||||
public function getTotalCostAttribute(): float
|
||||
{
|
||||
return (float) $this->labor_cost + $this->material_cost;
|
||||
}
|
||||
|
||||
public function transitionTo(TaskStatus $newStatus): void
|
||||
{
|
||||
$currentStatus = $this->status;
|
||||
|
||||
if (!in_array($newStatus, $currentStatus->allowedTransitions())) {
|
||||
throw new \InvalidArgumentException(
|
||||
"Cannot transition task from {$currentStatus->label()} to {$newStatus->label()}"
|
||||
);
|
||||
}
|
||||
|
||||
$updates = ['status' => $newStatus];
|
||||
|
||||
if ($newStatus === TaskStatus::InProgress && !$this->actual_start_date) {
|
||||
$updates['actual_start_date'] = now();
|
||||
}
|
||||
|
||||
if ($newStatus === TaskStatus::Completed) {
|
||||
$updates['actual_end_date'] = now();
|
||||
$updates['completion_percentage'] = 100;
|
||||
}
|
||||
|
||||
$this->update($updates);
|
||||
}
|
||||
|
||||
public function scopeStatus($query, TaskStatus $status)
|
||||
{
|
||||
return $query->where('status', $status);
|
||||
}
|
||||
}
|
||||
|
||||
30
Modules/ProjectManagement/app/Models/TaskActivity.php
Normal file
30
Modules/ProjectManagement/app/Models/TaskActivity.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectManagement\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use App\Models\User;
|
||||
|
||||
class TaskActivity extends Model
|
||||
{
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'task_id',
|
||||
'user_id',
|
||||
'description',
|
||||
'type',
|
||||
];
|
||||
|
||||
public function task(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Task::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
57
Modules/ProjectManagement/app/Models/TaskDelay.php
Normal file
57
Modules/ProjectManagement/app/Models/TaskDelay.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectManagement\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Traits\HasPublicIdentifier;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Modules\ProjectManagement\Enums\DelayReason;
|
||||
use Modules\ProjectManagement\Enums\WeatherCondition;
|
||||
|
||||
class TaskDelay extends Model
|
||||
{
|
||||
use HasPublicIdentifier;
|
||||
|
||||
protected $fillable = [
|
||||
'task_id',
|
||||
'reason_type',
|
||||
'weather_condition',
|
||||
'delay_date',
|
||||
'lost_hours',
|
||||
'notes',
|
||||
'reported_by',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'reason_type' => DelayReason::class,
|
||||
'weather_condition' => WeatherCondition::class,
|
||||
'delay_date' => 'date',
|
||||
'lost_hours' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
public function task(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Task::class);
|
||||
}
|
||||
|
||||
public function reporter(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'reported_by');
|
||||
}
|
||||
|
||||
// --- Accessors ---
|
||||
|
||||
public function getIsWeatherRelatedAttribute(): bool
|
||||
{
|
||||
return $this->reason_type === DelayReason::Weather;
|
||||
}
|
||||
|
||||
public function getLostDaysAttribute(): float
|
||||
{
|
||||
return round((float) $this->lost_hours / 8, 1);
|
||||
}
|
||||
}
|
||||
63
Modules/ProjectManagement/app/Models/TaskMaterial.php
Normal file
63
Modules/ProjectManagement/app/Models/TaskMaterial.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectManagement\Models;
|
||||
|
||||
use App\Traits\HasPublicIdentifier;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Modules\MasterData\Models\Material;
|
||||
|
||||
class TaskMaterial extends Model
|
||||
{
|
||||
use HasPublicIdentifier;
|
||||
|
||||
protected $fillable = [
|
||||
'task_id',
|
||||
'material_id',
|
||||
'planned_qty',
|
||||
'actual_qty',
|
||||
'unit_cost',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'planned_qty' => 'decimal:2',
|
||||
'actual_qty' => 'decimal:2',
|
||||
'unit_cost' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
public function task(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Task::class);
|
||||
}
|
||||
|
||||
public function material(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Material::class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function getPlannedCostAttribute(): float
|
||||
{
|
||||
return (float) $this->planned_qty * (float) $this->unit_cost;
|
||||
}
|
||||
|
||||
public function getActualCostAttribute(): float
|
||||
{
|
||||
return (float) $this->actual_qty * (float) $this->unit_cost;
|
||||
}
|
||||
|
||||
public function getEffectiveCostAttribute(): float
|
||||
{
|
||||
return $this->actual_qty > 0 ? $this->actual_cost : $this->planned_cost;
|
||||
}
|
||||
|
||||
public function getVarianceAttribute(): float
|
||||
{
|
||||
return $this->planned_cost - $this->actual_cost;
|
||||
}
|
||||
}
|
||||
102
Modules/ProjectManagement/app/Models/WeeklyStatusReport.php
Normal file
102
Modules/ProjectManagement/app/Models/WeeklyStatusReport.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectManagement\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Traits\HasPublicIdentifier;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Modules\ApprovalWorkflow\Traits\HasApprovable;
|
||||
use Modules\ProjectManagement\Enums\ReportStatus;
|
||||
|
||||
class WeeklyStatusReport extends Model
|
||||
{
|
||||
use HasPublicIdentifier, SoftDeletes, HasApprovable;
|
||||
|
||||
protected $fillable = [
|
||||
'project_id',
|
||||
'period_start',
|
||||
'period_end',
|
||||
'status',
|
||||
'narrative_status',
|
||||
'narrative_weather',
|
||||
'narrative_compliance',
|
||||
'submitted_by',
|
||||
'approved_by',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => ReportStatus::class,
|
||||
'period_start' => 'date',
|
||||
'period_end' => 'date',
|
||||
];
|
||||
}
|
||||
|
||||
// --- Relationships ---
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
public function workforceMetric(): HasOne
|
||||
{
|
||||
return $this->hasOne(WorkforceMetric::class);
|
||||
}
|
||||
|
||||
public function hseRecord(): HasOne
|
||||
{
|
||||
return $this->hasOne(HseRecord::class);
|
||||
}
|
||||
|
||||
public function submitter(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'submitted_by');
|
||||
}
|
||||
|
||||
public function approver(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'approved_by');
|
||||
}
|
||||
|
||||
// --- State Machine ---
|
||||
|
||||
public function transitionTo(ReportStatus $newStatus): void
|
||||
{
|
||||
if (!in_array($newStatus, $this->status->allowedTransitions())) {
|
||||
throw new \InvalidArgumentException(
|
||||
"Cannot transition report from {$this->status->label()} to {$newStatus->label()}"
|
||||
);
|
||||
}
|
||||
|
||||
$this->update(['status' => $newStatus]);
|
||||
}
|
||||
|
||||
// --- Scopes ---
|
||||
|
||||
public function scopeStatus($query, ReportStatus $status)
|
||||
{
|
||||
return $query->where('status', $status);
|
||||
}
|
||||
|
||||
public function scopeApproved($query)
|
||||
{
|
||||
return $query->where('status', ReportStatus::Approved);
|
||||
}
|
||||
|
||||
// --- Accessors ---
|
||||
|
||||
public function getPeriodLabelAttribute(): string
|
||||
{
|
||||
return $this->period_start->format('M d') . ' – ' . $this->period_end->format('M d, Y');
|
||||
}
|
||||
|
||||
public function getDaysInPeriodAttribute(): int
|
||||
{
|
||||
return $this->period_start->diffInDays($this->period_end) + 1;
|
||||
}
|
||||
}
|
||||
54
Modules/ProjectManagement/app/Models/WorkforceMetric.php
Normal file
54
Modules/ProjectManagement/app/Models/WorkforceMetric.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectManagement\Models;
|
||||
|
||||
use App\Traits\HasPublicIdentifier;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class WorkforceMetric extends Model
|
||||
{
|
||||
use HasPublicIdentifier;
|
||||
|
||||
protected $fillable = [
|
||||
'weekly_status_report_id',
|
||||
'active_workforce',
|
||||
'period_man_hours',
|
||||
'cumulative_man_hours',
|
||||
'logistics_km',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'period_man_hours' => 'decimal:2',
|
||||
'cumulative_man_hours' => 'decimal:2',
|
||||
'logistics_km' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
public function report(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(WeeklyStatusReport::class, 'weekly_status_report_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate cumulative man-hours from all previous approved reports + current period.
|
||||
*/
|
||||
public static function calculateCumulative(int $projectId, string $periodStart, float $currentPeriodHours, ?int $excludeReportId = null): float
|
||||
{
|
||||
$query = self::whereHas('report', fn ($q) => $q
|
||||
->where('project_id', $projectId)
|
||||
->where('status', 'approved')
|
||||
->where('period_end', '<', $periodStart)
|
||||
);
|
||||
|
||||
if ($excludeReportId) {
|
||||
$query->where('weekly_status_report_id', '!=', $excludeReportId);
|
||||
}
|
||||
|
||||
$previousTotal = $query->sum('period_man_hours');
|
||||
|
||||
return (float) $previousTotal + $currentPeriodHours;
|
||||
}
|
||||
}
|
||||
24
Modules/ProjectManagement/app/Observers/ProjectObserver.php
Normal file
24
Modules/ProjectManagement/app/Observers/ProjectObserver.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectManagement\Observers;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\ProjectManagement\Models\Project;
|
||||
use Modules\MaterialLogistics\Models\Warehouse;
|
||||
|
||||
class ProjectObserver
|
||||
{
|
||||
/**
|
||||
* Handle the Project "created" event.
|
||||
*/
|
||||
public function created(Project $project): void
|
||||
{
|
||||
$project->warehouse()->create([
|
||||
'ulid' => (string) Str::ulid(),
|
||||
'name' => "Warehouse - {$project->name}",
|
||||
'code' => "WH-" . $project->code,
|
||||
'type' => 'staging',
|
||||
'status' => 'active',
|
||||
]);
|
||||
}
|
||||
}
|
||||
0
Modules/ProjectManagement/app/Providers/.gitkeep
Normal file
0
Modules/ProjectManagement/app/Providers/.gitkeep
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectManagement\Providers;
|
||||
|
||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
||||
use Modules\ProjectManagement\Models\Project;
|
||||
use Modules\ProjectManagement\Observers\ProjectObserver;
|
||||
|
||||
class EventServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* The event handler mappings for the application.
|
||||
*
|
||||
* @var array<string, array<int, string>>
|
||||
*/
|
||||
protected $listen = [];
|
||||
|
||||
/**
|
||||
* Indicates if events should be discovered.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
protected static $shouldDiscoverEvents = true;
|
||||
|
||||
/**
|
||||
* Configure the proper event listeners for email verification.
|
||||
*/
|
||||
protected function configureEmailVerification(): void {}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
Project::observe(ProjectObserver::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectManagement\Providers;
|
||||
|
||||
use Nwidart\Modules\Support\ModuleServiceProvider;
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
|
||||
class ProjectManagementServiceProvider extends ModuleServiceProvider
|
||||
{
|
||||
/**
|
||||
* The name of the module.
|
||||
*/
|
||||
protected string $name = 'ProjectManagement';
|
||||
|
||||
/**
|
||||
* The lowercase version of the module name.
|
||||
*/
|
||||
protected string $nameLower = 'projectmanagement';
|
||||
|
||||
/**
|
||||
* Command classes to register.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
// protected array $commands = [];
|
||||
|
||||
/**
|
||||
* Provider classes to register.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected array $providers = [
|
||||
EventServiceProvider::class,
|
||||
RouteServiceProvider::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* Define module schedules.
|
||||
*
|
||||
* @param $schedule
|
||||
*/
|
||||
// protected function configureSchedules(Schedule $schedule): void
|
||||
// {
|
||||
// $schedule->command('inspire')->hourly();
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectManagement\Providers;
|
||||
|
||||
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
class RouteServiceProvider extends ServiceProvider
|
||||
{
|
||||
protected string $name = 'ProjectManagement';
|
||||
|
||||
/**
|
||||
* Called before routes are registered.
|
||||
*
|
||||
* Register any model bindings or pattern based filters.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the routes for the application.
|
||||
*/
|
||||
public function map(): void
|
||||
{
|
||||
$this->mapApiRoutes();
|
||||
$this->mapWebRoutes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the "web" routes for the application.
|
||||
*
|
||||
* These routes all receive session state, CSRF protection, etc.
|
||||
*/
|
||||
protected function mapWebRoutes(): void
|
||||
{
|
||||
Route::middleware('web')->group(module_path($this->name, '/routes/web.php'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the "api" routes for the application.
|
||||
*
|
||||
* These routes are typically stateless.
|
||||
*/
|
||||
protected function mapApiRoutes(): void
|
||||
{
|
||||
Route::middleware('api')->prefix('api')->name('api.')->group(module_path($this->name, '/routes/api.php'));
|
||||
}
|
||||
}
|
||||
30
Modules/ProjectManagement/composer.json
Normal file
30
Modules/ProjectManagement/composer.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "nwidart/projectmanagement",
|
||||
"description": "",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Widart",
|
||||
"email": "n.widart@gmail.com"
|
||||
}
|
||||
],
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [],
|
||||
"aliases": {
|
||||
|
||||
}
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Modules\\ProjectManagement\\": "app/",
|
||||
"Modules\\ProjectManagement\\Database\\Factories\\": "database/factories/",
|
||||
"Modules\\ProjectManagement\\Database\\Seeders\\": "database/seeders/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Modules\\ProjectManagement\\Tests\\": "tests/"
|
||||
}
|
||||
}
|
||||
}
|
||||
0
Modules/ProjectManagement/config/.gitkeep
Normal file
0
Modules/ProjectManagement/config/.gitkeep
Normal file
5
Modules/ProjectManagement/config/config.php
Normal file
5
Modules/ProjectManagement/config/config.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => 'ProjectManagement',
|
||||
];
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('projects', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('code')->unique();
|
||||
$table->text('description')->nullable();
|
||||
$table->foreignId('customer_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->string('location')->nullable();
|
||||
$table->string('status')->default('under_bidding');
|
||||
$table->decimal('contract_value', 15, 2)->default(0);
|
||||
$table->decimal('total_capitalization', 15, 2)->default(0);
|
||||
$table->date('start_date')->nullable();
|
||||
$table->date('target_end_date')->nullable();
|
||||
$table->date('actual_end_date')->nullable();
|
||||
$table->decimal('completion_percentage', 5, 2)->default(0);
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
|
||||
Schema::create('project_user', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('project_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('role')->default('member'); // pm, engineer, laborer, member
|
||||
$table->timestamps();
|
||||
$table->unique(['project_id', 'user_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('project_user');
|
||||
Schema::dropIfExists('projects');
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('tasks', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('project_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->string('name');
|
||||
$table->text('description')->nullable();
|
||||
$table->string('status')->default('pending');
|
||||
$table->integer('sort_order')->default(0);
|
||||
$table->decimal('labor_cost', 15, 2)->default(0);
|
||||
$table->decimal('estimated_hours', 8, 2)->default(0);
|
||||
$table->decimal('actual_hours', 8, 2)->default(0);
|
||||
$table->date('start_date')->nullable();
|
||||
$table->date('end_date')->nullable();
|
||||
$table->date('actual_start_date')->nullable();
|
||||
$table->date('actual_end_date')->nullable();
|
||||
$table->decimal('completion_percentage', 5, 2)->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('tasks');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('weekly_status_reports', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('project_id')->constrained()->cascadeOnDelete();
|
||||
$table->date('period_start');
|
||||
$table->date('period_end');
|
||||
$table->string('status')->default('draft');
|
||||
$table->text('narrative_status')->nullable();
|
||||
$table->text('narrative_weather')->nullable();
|
||||
$table->text('narrative_compliance')->nullable();
|
||||
$table->foreignId('submitted_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->foreignId('approved_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['project_id', 'period_end']);
|
||||
$table->index(['project_id', 'status']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('weekly_status_reports');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('workforce_metrics', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('weekly_status_report_id')->constrained()->cascadeOnDelete();
|
||||
$table->unsignedInteger('active_workforce')->default(0);
|
||||
$table->decimal('period_man_hours', 12, 2)->default(0);
|
||||
$table->decimal('cumulative_man_hours', 12, 2)->default(0);
|
||||
$table->decimal('logistics_km', 10, 2)->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('workforce_metrics');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('hse_records', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('weekly_status_report_id')->constrained()->cascadeOnDelete();
|
||||
// Proactive safety
|
||||
$table->unsignedInteger('toolbox_meetings')->default(0);
|
||||
$table->unsignedInteger('safety_observations')->default(0);
|
||||
// Reactive safety (zero targets)
|
||||
$table->unsignedInteger('fatalities')->default(0);
|
||||
$table->unsignedInteger('major_injuries')->default(0);
|
||||
$table->unsignedInteger('first_aid_cases')->default(0);
|
||||
$table->unsignedInteger('medical_cases')->default(0);
|
||||
$table->unsignedInteger('near_misses')->default(0);
|
||||
$table->unsignedInteger('environmental_damage')->default(0);
|
||||
$table->unsignedInteger('property_damage')->default(0);
|
||||
$table->decimal('fines', 12, 2)->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('hse_records');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table) {
|
||||
$table->foreignId('contractor_id')->nullable()->after('customer_id')
|
||||
->constrained('contractors')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('contractor_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('task_materials', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('ulid', 26)->unique();
|
||||
$table->foreignId('task_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('material_id')->constrained()->cascadeOnDelete();
|
||||
$table->decimal('planned_qty', 12, 2)->default(0);
|
||||
$table->decimal('actual_qty', 12, 2)->default(0);
|
||||
$table->decimal('unit_cost', 12, 2)->default(0);
|
||||
$table->text('notes')->nullable();
|
||||
$table->timestamps();
|
||||
$table->unique(['task_id', 'material_id']);
|
||||
});
|
||||
|
||||
if (!Schema::hasColumn('material_deployments', 'task_id')) {
|
||||
Schema::table('material_deployments', function (Blueprint $table) {
|
||||
$table->foreignId('task_id')->nullable()->after('project_id')
|
||||
->constrained('tasks')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasColumn('material_deployments', 'task_id')) {
|
||||
Schema::table('material_deployments', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('task_id');
|
||||
});
|
||||
}
|
||||
|
||||
Schema::dropIfExists('task_materials');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (!Schema::hasColumn('task_materials', 'purchase_order_id')) {
|
||||
Schema::table('task_materials', function (Blueprint $table) {
|
||||
$table->foreignId('purchase_order_id')->nullable()->after('material_id')
|
||||
->constrained('purchase_orders')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasColumn('task_materials', 'purchase_order_id')) {
|
||||
Schema::table('task_materials', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('purchase_order_id');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table) {
|
||||
$table->string('client_name')->nullable()->after('customer_id');
|
||||
$table->integer('contract_duration')->nullable()->after('target_end_date');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table) {
|
||||
$table->dropColumn(['client_name', 'contract_duration']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('project_milestones', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('ulid', 26)->unique();
|
||||
$table->foreignId('project_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name');
|
||||
$table->text('description')->nullable();
|
||||
$table->date('planned_date')->nullable();
|
||||
$table->date('actual_date')->nullable();
|
||||
$table->integer('sort_order')->default(0);
|
||||
$table->decimal('weight_percentage', 5, 2)->default(0);
|
||||
$table->boolean('is_default')->default(false);
|
||||
$table->boolean('weather_impacted')->default(false);
|
||||
$table->string('weather_condition')->nullable();
|
||||
$table->integer('weather_delay_days')->default(0);
|
||||
$table->text('weather_notes')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['project_id', 'sort_order']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('project_milestones');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('task_delays', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('ulid', 26)->unique();
|
||||
$table->foreignId('task_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('reason_type');
|
||||
$table->string('weather_condition')->nullable();
|
||||
$table->date('delay_date');
|
||||
$table->decimal('lost_hours', 8, 2)->default(0);
|
||||
$table->text('notes')->nullable();
|
||||
$table->foreignId('reported_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['task_id', 'delay_date']);
|
||||
$table->index(['task_id', 'reason_type']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('task_delays');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('weekly_status_reports', function (Blueprint $table) {
|
||||
$table->integer('weather_work_days_lost')->default(0)->after('narrative_compliance');
|
||||
$table->json('weather_conditions')->nullable()->after('weather_work_days_lost');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('weekly_status_reports', function (Blueprint $table) {
|
||||
$table->dropColumn(['weather_work_days_lost', 'weather_conditions']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('task_user', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('task_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::table('tasks', function (Blueprint $table) {
|
||||
$table->dropForeign(['assigned_to']);
|
||||
$table->dropColumn('assigned_to');
|
||||
});
|
||||
|
||||
Schema::table('task_materials', function (Blueprint $table) {
|
||||
$table->dropForeign(['purchase_order_id']);
|
||||
$table->dropColumn('purchase_order_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('task_materials', function (Blueprint $table) {
|
||||
$table->foreignId('purchase_order_id')->nullable()->constrained('purchase_orders')->nullOnDelete();
|
||||
});
|
||||
|
||||
Schema::table('tasks', function (Blueprint $table) {
|
||||
$table->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete();
|
||||
});
|
||||
|
||||
Schema::dropIfExists('task_user');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('task_activities', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->ulid('ulid')->unique();
|
||||
$table->foreignId('task_id')->constrained('tasks')->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->string('description');
|
||||
$table->string('type')->default('assignment');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('task_activities');
|
||||
}
|
||||
};
|
||||
0
Modules/ProjectManagement/database/seeders/.gitkeep
Normal file
0
Modules/ProjectManagement/database/seeders/.gitkeep
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectManagement\Database\Seeders;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Modules\ProjectManagement\Models\Project;
|
||||
|
||||
class ProjectManagementDatabaseSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
// Create a sample project
|
||||
$project = Project::create([
|
||||
'name' => 'GSB Tower Construction',
|
||||
'code' => 'PRJ-2026-001',
|
||||
'description' => 'Main tower construction project for GSB headquarters.',
|
||||
'location' => 'Manila, Philippines',
|
||||
'status' => 'planning',
|
||||
'contract_value' => 50000000.00,
|
||||
'start_date' => '2026-04-01',
|
||||
'target_end_date' => '2027-12-31',
|
||||
]);
|
||||
|
||||
// Assign admin as PM
|
||||
$admin = User::where('email', 'admin@gsb-cons.com')->first();
|
||||
if ($admin) {
|
||||
$project->personnel()->attach($admin->id, ['role' => 'pm']);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Modules/ProjectManagement/module.json
Normal file
11
Modules/ProjectManagement/module.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "ProjectManagement",
|
||||
"alias": "projectmanagement",
|
||||
"description": "",
|
||||
"keywords": [],
|
||||
"priority": 0,
|
||||
"providers": [
|
||||
"Modules\\ProjectManagement\\Providers\\ProjectManagementServiceProvider"
|
||||
],
|
||||
"files": []
|
||||
}
|
||||
15
Modules/ProjectManagement/package.json
Normal file
15
Modules/ProjectManagement/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"axios": "^1.1.2",
|
||||
"laravel-vite-plugin": "^0.7.5",
|
||||
"sass": "^1.69.5",
|
||||
"postcss": "^8.3.7",
|
||||
"vite": "^4.0.0"
|
||||
}
|
||||
}
|
||||
0
Modules/ProjectManagement/resources/assets/.gitkeep
Normal file
0
Modules/ProjectManagement/resources/assets/.gitkeep
Normal file
@@ -0,0 +1,379 @@
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Label } from '@/Components/ui/label';
|
||||
import { Textarea } from '@/Components/ui/textarea';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/Components/ui/select';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { ChevronDown, ChevronUp, Building2, Search, CheckCircle2 } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from '@/Components/ui/dialog';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/Components/ui/avatar';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
const MAIN_CONTRACTOR = 'Great SwissMetal Builders Corporation';
|
||||
|
||||
interface Employee {
|
||||
id: number;
|
||||
ulid: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
employee_profile?: {
|
||||
department?: string;
|
||||
position?: string;
|
||||
phone?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function ProjectManagerLookup({ employees, selectedId, onSelect }: { employees: Employee[], selectedId: string, onSelect: (id: string) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [tempSelectedId, setTempSelectedId] = useState(selectedId);
|
||||
|
||||
const filteredEmployees = useMemo(() => {
|
||||
if (!search) return employees;
|
||||
const lowerSearch = search.toLowerCase();
|
||||
return employees.filter(e =>
|
||||
e.name.toLowerCase().includes(lowerSearch) ||
|
||||
(e.email && e.email.toLowerCase().includes(lowerSearch)) ||
|
||||
(e.employee_profile?.position && e.employee_profile.position.toLowerCase().includes(lowerSearch))
|
||||
);
|
||||
}, [employees, search]);
|
||||
|
||||
const handleConfirm = () => {
|
||||
onSelect(tempSelectedId);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setOpen(newOpen);
|
||||
if (newOpen) {
|
||||
setTempSelectedId(selectedId);
|
||||
setSearch('');
|
||||
}
|
||||
};
|
||||
|
||||
const selectedEmployee = employees.find(e => e.ulid === selectedId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
onClick={() => handleOpenChange(true)}
|
||||
className={`w-full justify-between font-normal ${!selectedId ? 'text-muted-foreground' : ''}`}
|
||||
>
|
||||
{selectedEmployee ? (
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarFallback className="text-[10px]">{selectedEmployee.name.substring(0,2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate">{selectedEmployee.name}</span>
|
||||
{selectedEmployee.employee_profile?.position && (
|
||||
<span className="text-xs text-muted-foreground hidden sm:inline-block truncate">
|
||||
- {selectedEmployee.employee_profile.position}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
"Select Project Manager"
|
||||
)}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col p-0 gap-0">
|
||||
<DialogHeader className="px-6 py-4 border-b">
|
||||
<DialogTitle>Select Project Manager</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 py-4 border-b bg-muted/30">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by name, email, or position..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9 bg-background"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 max-h-[400px]">
|
||||
{filteredEmployees.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No project managers found matching your search.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{filteredEmployees.map((emp) => {
|
||||
const isSelected = tempSelectedId === emp.ulid;
|
||||
return (
|
||||
<div
|
||||
key={emp.ulid}
|
||||
onClick={() => setTempSelectedId(emp.ulid)}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
||||
: 'border-border hover:bg-accent/50'
|
||||
}`}
|
||||
>
|
||||
<Avatar className="h-10 w-10 shrink-0">
|
||||
<AvatarFallback className="bg-primary/10 text-primary font-medium">
|
||||
{emp.name.substring(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<p className="text-sm font-medium truncate">{emp.name}</p>
|
||||
{isSelected && <CheckCircle2 className="h-4 w-4 text-primary shrink-0" />}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate" title={emp.email}>
|
||||
{emp.email || 'No email provided'}
|
||||
</p>
|
||||
{emp.employee_profile?.position && (
|
||||
<Badge variant="secondary" className="mt-1.5 text-[10px] font-normal px-1.5 py-0">
|
||||
{emp.employee_profile.position}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-6 py-4 border-t bg-muted/20">
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleConfirm} disabled={!tempSelectedId}>Confirm Selection</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ProjectFormData {
|
||||
name: string;
|
||||
client_name: string;
|
||||
location: string;
|
||||
start_date: string;
|
||||
target_end_date: string;
|
||||
contract_duration: string;
|
||||
// Additional details
|
||||
description: string;
|
||||
pm_id: string;
|
||||
contract_value: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: ProjectFormData;
|
||||
setData: <K extends keyof ProjectFormData>(key: K, value: ProjectFormData[K]) => void;
|
||||
errors: Partial<Record<keyof ProjectFormData, string>>;
|
||||
employees: Employee[];
|
||||
}
|
||||
|
||||
export function ProjectForm({ data, setData, errors, employees }: Props) {
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
const employeeSelectItems = useMemo(
|
||||
() => employees.map(e => ({ value: e.ulid, label: e.name })),
|
||||
[employees],
|
||||
);
|
||||
|
||||
// Auto-calculate contract duration when both dates are set
|
||||
useEffect(() => {
|
||||
if (data.start_date && data.target_end_date) {
|
||||
const start = new Date(data.start_date);
|
||||
const end = new Date(data.target_end_date);
|
||||
const diffMs = end.getTime() - start.getTime();
|
||||
if (diffMs >= 0) {
|
||||
const days = Math.round(diffMs / (1000 * 60 * 60 * 24)) + 1; // inclusive of start & end
|
||||
setData('contract_duration', String(days));
|
||||
}
|
||||
}
|
||||
}, [data.start_date, data.target_end_date]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Primary Fields — 1.1 to 1.7 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Project Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 1.1 Project Name */}
|
||||
<div>
|
||||
<Label htmlFor="name">
|
||||
<span className="text-xs text-muted-foreground mr-1.5">1.1</span>
|
||||
Project Name <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={data.name}
|
||||
onChange={(e) => setData('name', e.target.value)}
|
||||
placeholder="e.g., Construction of FGEN-PPA Access Road"
|
||||
/>
|
||||
{errors.name && <p className="mt-1 text-sm text-red-500">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
{/* 1.2 Project Location */}
|
||||
<div>
|
||||
<Label htmlFor="location">
|
||||
<span className="text-xs text-muted-foreground mr-1.5">1.2</span>
|
||||
Project Location
|
||||
</Label>
|
||||
<Input
|
||||
id="location"
|
||||
value={data.location}
|
||||
onChange={(e) => setData('location', e.target.value)}
|
||||
placeholder="e.g., Bolbok, Batangas City"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 1.3 Client */}
|
||||
<div>
|
||||
<Label htmlFor="client_name">
|
||||
<span className="text-xs text-muted-foreground mr-1.5">1.3</span>
|
||||
Client
|
||||
</Label>
|
||||
<Input
|
||||
id="client_name"
|
||||
value={data.client_name}
|
||||
onChange={(e) => setData('client_name', e.target.value)}
|
||||
placeholder="e.g., First Gen Corporation"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 1.4 Main Contractor — Hard-coded */}
|
||||
<div>
|
||||
<Label>
|
||||
<span className="text-xs text-muted-foreground mr-1.5">1.4</span>
|
||||
Main Contractor
|
||||
</Label>
|
||||
<div className="flex items-center gap-2 rounded-md border border-input bg-muted/50 px-3 py-2 text-sm">
|
||||
<Building2 className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="font-medium">{MAIN_CONTRACTOR}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 1.5 & 1.6 — Start Date / Project Completion */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="start_date">
|
||||
<span className="text-xs text-muted-foreground mr-1.5">1.5</span>
|
||||
Start Date
|
||||
</Label>
|
||||
<Input
|
||||
id="start_date"
|
||||
type="date"
|
||||
value={data.start_date}
|
||||
onChange={(e) => setData('start_date', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="target_end_date">
|
||||
<span className="text-xs text-muted-foreground mr-1.5">1.6</span>
|
||||
Project Completion
|
||||
</Label>
|
||||
<Input
|
||||
id="target_end_date"
|
||||
type="date"
|
||||
value={data.target_end_date}
|
||||
onChange={(e) => setData('target_end_date', e.target.value)}
|
||||
/>
|
||||
{errors.target_end_date && <p className="mt-1 text-sm text-red-500">{errors.target_end_date}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 1.7 Contract Duration */}
|
||||
<div>
|
||||
<Label htmlFor="contract_duration">
|
||||
<span className="text-xs text-muted-foreground mr-1.5">1.7</span>
|
||||
Contract Duration
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="contract_duration"
|
||||
type="number"
|
||||
min="0"
|
||||
value={data.contract_duration}
|
||||
onChange={(e) => setData('contract_duration', e.target.value)}
|
||||
className="max-w-[140px]"
|
||||
placeholder="0"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">days</span>
|
||||
</div>
|
||||
{data.start_date && data.target_end_date && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Auto-calculated from dates. You can override manually.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Collapsible Additional Details */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
className="cursor-pointer select-none"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">Additional Details</CardTitle>
|
||||
<Button variant="ghost" size="icon-sm" type="button">
|
||||
{showAdvanced
|
||||
? <ChevronUp className="h-4 w-4" />
|
||||
: <ChevronDown className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{showAdvanced && (
|
||||
<CardContent className="space-y-4 pt-0">
|
||||
{/* Description */}
|
||||
<div>
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={data.description}
|
||||
onChange={(e) => setData('description', e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Brief project description..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{/* Project Manager */}
|
||||
<div>
|
||||
<Label>Project Manager</Label>
|
||||
<div className="mt-1">
|
||||
<ProjectManagerLookup
|
||||
employees={employees}
|
||||
selectedId={data.pm_id}
|
||||
onSelect={(v) => setData('pm_id', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contract Value */}
|
||||
<div>
|
||||
<Label htmlFor="contract_value">Contract Value (PHP)</Label>
|
||||
<Input
|
||||
id="contract_value"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={data.contract_value}
|
||||
onChange={(e) => setData('contract_value', e.target.value)}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
{errors.contract_value && <p className="mt-1 text-sm text-red-500">{errors.contract_value}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
import { Search, Loader2 } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface MaterialItem {
|
||||
id: number; ulid: string; name: string; unit: string; unit_cost: string;
|
||||
}
|
||||
|
||||
interface PurchaseOrderResult {
|
||||
id: number; ulid: string; document_number: string; supplier?: string;
|
||||
items: { id: number; material: MaterialItem; quantity: string; unit_cost: string }[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
projectUlid: string;
|
||||
onSelect: (po: PurchaseOrderResult) => void;
|
||||
selectedPo?: PurchaseOrderResult | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function PurchaseOrderLookup({ projectUlid, onSelect, selectedPo, className }: Props) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<PurchaseOrderResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const fetchResults = useCallback(async (q: string) => {
|
||||
if (!projectUlid) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ project_ulid: projectUlid });
|
||||
if (q) params.set('q', q);
|
||||
const response = await fetch(`${route('purchase-orders.search')}?${params}`, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-CSRF-TOKEN': document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content || '',
|
||||
},
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setResults(data);
|
||||
}
|
||||
} catch { /* silently fail */ } finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectUlid]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
if (!query && !open) return;
|
||||
debounceRef.current = setTimeout(() => fetchResults(query), 300);
|
||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
||||
}, [query, fetchResults]);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
const handleSelect = (po: PurchaseOrderResult) => {
|
||||
setQuery(po.document_number);
|
||||
setOpen(false);
|
||||
onSelect(po);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
setOpen(true);
|
||||
if (results.length === 0) fetchResults(query);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className={`relative ${className || ''}`}>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
value={selectedPo ? selectedPo.document_number : query}
|
||||
onChange={e => {
|
||||
setQuery(e.target.value);
|
||||
setOpen(true);
|
||||
if (selectedPo) onSelect(null as unknown as PurchaseOrderResult);
|
||||
}}
|
||||
onFocus={handleFocus}
|
||||
placeholder="Search by PO number (e.g. PO-2026)..."
|
||||
className="pl-10 max-w-lg"
|
||||
/>
|
||||
{loading && <Loader2 className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-gray-400" />}
|
||||
</div>
|
||||
|
||||
{open && results.length > 0 && !selectedPo && (
|
||||
<div className="absolute z-50 mt-1 w-full max-w-lg rounded-md border bg-white shadow-lg max-h-48 overflow-y-auto">
|
||||
{results.map(po => (
|
||||
<button
|
||||
key={po.ulid}
|
||||
type="button"
|
||||
className="w-full px-3 py-2 text-left hover:bg-gray-50 flex items-center justify-between text-sm border-b last:border-b-0"
|
||||
onClick={() => handleSelect(po)}
|
||||
>
|
||||
<div>
|
||||
<span className="font-mono font-medium">{po.document_number}</span>
|
||||
{po.supplier && <span className="text-gray-500 ml-2">— {po.supplier}</span>}
|
||||
</div>
|
||||
<Badge variant="outline">{po.items.length} items</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open && results.length === 0 && !loading && query && (
|
||||
<div className="absolute z-50 mt-1 w-full max-w-lg rounded-md border bg-white shadow-lg p-3 text-sm text-gray-500">
|
||||
No approved purchase orders found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
Modules/ProjectManagement/resources/js/Layouts/ProjectLayout.tsx
Normal file
124
Modules/ProjectManagement/resources/js/Layouts/ProjectLayout.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, Link, router, usePage } from '@inertiajs/react';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/Components/ui/select';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/Components/ui/tabs';
|
||||
import { ArrowLeft, Timer, Package, ClipboardList, Play, CheckCircle2, Pencil, ArrowRight } from 'lucide-react';
|
||||
|
||||
export default function ProjectLayout({ project, allowedTransitions, children, currentTab }: { project: any, allowedTransitions?: any[], children: React.ReactNode, currentTab: string }) {
|
||||
const params = new URLSearchParams(typeof window !== 'undefined' ? window.location.search : '');
|
||||
const backTo = params.get('back_to');
|
||||
|
||||
const handleBack = (e: React.MouseEvent) => {
|
||||
if (backTo === 'inventory') {
|
||||
router.visit(route('inventory.index'));
|
||||
} else if (typeof window !== 'undefined') {
|
||||
if (window.history.length > 1) {
|
||||
window.history.back();
|
||||
} else {
|
||||
router.visit(route('projects.index'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const statusLabel = (s: string) => s?.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) || '';
|
||||
|
||||
const handleTransition = (status: string) => {
|
||||
router.patch(route('projects.transition', project.ulid), { status });
|
||||
};
|
||||
|
||||
const handleTabChange = (val: string) => {
|
||||
// Kept for backward compatibility if needed, but tabs are removed
|
||||
};
|
||||
|
||||
const { projects } = usePage<any>().props;
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
header={
|
||||
project ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon-sm" onClick={handleBack}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold leading-tight text-gray-800">
|
||||
{project.name}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">{project.code}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="capitalize">{statusLabel(project.status)}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{allowedTransitions && allowedTransitions.map((t: any) => (
|
||||
<Button key={t.value} variant="outline" size="sm" onClick={() => handleTransition(t.value)}>
|
||||
{t.value === 'in_progress' && <Play className="mr-1 h-3 w-3" />}
|
||||
{t.value === 'completed' && <CheckCircle2 className="mr-1 h-3 w-3" />}
|
||||
{t.label}
|
||||
</Button>
|
||||
))}
|
||||
<Link href={route('projects.edit', project.ulid)}>
|
||||
<Button size="sm"><Pencil className="mr-2 h-4 w-4" /> Edit</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<Head title={project ? `${project.name} - ${currentTab.charAt(0).toUpperCase() + currentTab.slice(1)}` : 'Select Project'} />
|
||||
|
||||
<div className={currentTab === 'tasks' ? "h-[calc(100vh-73px)] flex flex-col" : "py-6"}>
|
||||
<div className={currentTab === 'tasks' ? "w-full h-full p-4 flex flex-col" : "mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"}>
|
||||
{!project ? (
|
||||
<div className="py-10">
|
||||
<div className="text-center mb-8">
|
||||
<h3 className="text-2xl font-semibold text-gray-900">Select a Project</h3>
|
||||
<p className="mt-2 text-sm text-gray-500">Choose a project to view its {currentTab.replace('-', ' ')} data.</p>
|
||||
</div>
|
||||
|
||||
{projects && projects.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
|
||||
{projects.map((p: any) => (
|
||||
<div
|
||||
key={p.ulid}
|
||||
onClick={() => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('project', p.ulid);
|
||||
window.location.href = url.toString();
|
||||
}}
|
||||
className="group relative flex flex-col items-start justify-between rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all hover:border-emerald-500 hover:shadow-md cursor-pointer overflow-hidden"
|
||||
>
|
||||
<div className="absolute inset-x-0 top-0 h-1 bg-transparent transition-colors group-hover:bg-emerald-500" />
|
||||
<div className="flex w-full items-start justify-between gap-4 mb-6">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-lg text-gray-900 group-hover:text-emerald-700 transition-colors line-clamp-2">{p.name}</h4>
|
||||
<p className="text-sm font-mono text-gray-500 mt-1">{p.code}</p>
|
||||
</div>
|
||||
{p.status && <Badge variant="outline" className="shrink-0 capitalize">{statusLabel(p.status)}</Badge>}
|
||||
</div>
|
||||
<div className="mt-auto flex w-full items-center justify-between">
|
||||
<span className="text-xs text-gray-400">Click to select</span>
|
||||
<div className="flex items-center text-sm font-medium text-emerald-600 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
Open <ArrowRight className="ml-1 h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20 bg-white shadow-sm sm:rounded-lg border border-dashed border-gray-300 max-w-3xl mx-auto">
|
||||
<h3 className="text-lg font-medium text-gray-900">No projects found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">There are no active projects available for your account.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { ArrowLeft, Save } from 'lucide-react';
|
||||
import { PageProps } from '@/types';
|
||||
import { FormEvent } from 'react';
|
||||
import { ProjectForm, ProjectFormData } from '../../Components/ProjectForm';
|
||||
|
||||
interface Employee { id: number; ulid: string; name: string }
|
||||
|
||||
interface Props extends PageProps {
|
||||
employees: Employee[];
|
||||
}
|
||||
|
||||
export default function Create({ employees }: Props) {
|
||||
const { data, setData, post, processing, errors } = useForm<ProjectFormData>({
|
||||
name: '',
|
||||
client_name: '',
|
||||
location: '',
|
||||
start_date: '',
|
||||
target_end_date: '',
|
||||
contract_duration: '',
|
||||
description: '',
|
||||
pm_id: '',
|
||||
contract_value: '',
|
||||
});
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
post(route('projects.store'));
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
header={
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={route('projects.index')}>
|
||||
<Button variant="ghost" size="icon-sm"><ArrowLeft className="h-4 w-4" /></Button>
|
||||
</Link>
|
||||
<h2 className="text-xl font-semibold leading-tight text-gray-800">
|
||||
Create Project
|
||||
</h2>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Head title="Create Project" />
|
||||
|
||||
<div className="py-6">
|
||||
<div className="mx-auto max-w-3xl px-4 sm:px-6 lg:px-8">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<ProjectForm
|
||||
data={data}
|
||||
setData={setData}
|
||||
errors={errors}
|
||||
employees={employees}
|
||||
/>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Link href={route('projects.index')}>
|
||||
<Button variant="outline" type="button">Cancel</Button>
|
||||
</Link>
|
||||
<Button type="submit" disabled={processing}>
|
||||
<Save className="mr-2 h-4 w-4" /> Create Project
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { ArrowLeft, Save } from 'lucide-react';
|
||||
import { PageProps } from '@/types';
|
||||
import { FormEvent } from 'react';
|
||||
import { ProjectForm, ProjectFormData } from '../../Components/ProjectForm';
|
||||
|
||||
interface Employee { id: number; ulid: string; name: string }
|
||||
interface ProjectData {
|
||||
id: number; ulid: string; name: string; code: string;
|
||||
description?: string; client_name?: string; location?: string;
|
||||
contract_value: string; contract_duration?: number;
|
||||
start_date?: string; target_end_date?: string;
|
||||
personnel?: { id: number; ulid: string; name: string; pivot?: { role: string } }[];
|
||||
}
|
||||
|
||||
interface Props extends PageProps {
|
||||
project: ProjectData;
|
||||
employees: Employee[];
|
||||
}
|
||||
|
||||
export default function Edit({ project, employees }: Props) {
|
||||
const pm = project.personnel?.find(p => p.pivot?.role === 'pm');
|
||||
|
||||
const { data, setData, put, processing, errors } = useForm<ProjectFormData>({
|
||||
name: project.name,
|
||||
client_name: project.client_name || '',
|
||||
location: project.location || '',
|
||||
start_date: project.start_date || '',
|
||||
target_end_date: project.target_end_date || '',
|
||||
contract_duration: project.contract_duration != null ? String(project.contract_duration) : '',
|
||||
description: project.description || '',
|
||||
pm_id: pm?.ulid || '',
|
||||
contract_value: project.contract_value || '',
|
||||
});
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
put(route('projects.update', project.ulid));
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
header={
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={route('projects.show', project.ulid)}>
|
||||
<Button variant="ghost" size="icon-sm"><ArrowLeft className="h-4 w-4" /></Button>
|
||||
</Link>
|
||||
<h2 className="text-xl font-semibold leading-tight text-gray-800">
|
||||
Edit: {project.name}
|
||||
</h2>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Head title={`Edit ${project.name}`} />
|
||||
|
||||
<div className="py-6">
|
||||
<div className="mx-auto max-w-3xl px-4 sm:px-6 lg:px-8">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<ProjectForm
|
||||
data={data}
|
||||
setData={setData}
|
||||
errors={errors}
|
||||
employees={employees}
|
||||
/>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Link href={route('projects.show', project.ulid)}>
|
||||
<Button variant="outline" type="button">Cancel</Button>
|
||||
</Link>
|
||||
<Button type="submit" disabled={processing}>
|
||||
<Save className="mr-2 h-4 w-4" /> Update Project
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
246
Modules/ProjectManagement/resources/js/Pages/Projects/Index.tsx
Normal file
246
Modules/ProjectManagement/resources/js/Pages/Projects/Index.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, Link, router, usePage } from '@inertiajs/react';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Card, CardContent } from '@/Components/ui/card';
|
||||
import { DataTableToolbar } from '@/Components/DataTableToolbar';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/Components/ui/select';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/Components/ui/table';
|
||||
import { PaginatedData, PageProps } from '@/types';
|
||||
import { Plus, Eye, Pencil, Trash2, FolderKanban } from 'lucide-react';
|
||||
import { FormEvent, useMemo, useState } from 'react';
|
||||
|
||||
interface Project {
|
||||
id: number; ulid: string;
|
||||
name: string;
|
||||
code: string;
|
||||
location?: string;
|
||||
status: string;
|
||||
client_name?: string;
|
||||
contract_value: string;
|
||||
total_capitalization: string;
|
||||
capitalization_percentage: number;
|
||||
is_over_budget: boolean;
|
||||
completion_percentage: string;
|
||||
start_date?: string;
|
||||
target_end_date?: string;
|
||||
created_at: string;
|
||||
personnel?: { id: number; ulid: string; name: string }[];
|
||||
}
|
||||
|
||||
interface StatusOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props extends PageProps {
|
||||
projects: PaginatedData<Project>;
|
||||
filters: { search?: string; status?: string };
|
||||
statuses: StatusOption[];
|
||||
}
|
||||
|
||||
const statusVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case 'under_bidding': return 'outline';
|
||||
case 'planning': return 'secondary';
|
||||
case 'in_progress': return 'default';
|
||||
case 'on_hold': return 'destructive';
|
||||
case 'completed': return 'default';
|
||||
case 'closed': return 'secondary';
|
||||
default: return 'outline';
|
||||
}
|
||||
};
|
||||
|
||||
const statusLabel = (status: string) => {
|
||||
return status.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
};
|
||||
|
||||
const formatCurrency = (val: string) => {
|
||||
return new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(Number(val));
|
||||
};
|
||||
|
||||
export default function Index({ projects, filters, statuses }: Props) {
|
||||
const { flash } = usePage<PageProps>().props;
|
||||
const [search, setSearch] = useState(filters.search || '');
|
||||
const [statusFilter, setStatusFilter] = useState(filters.status || 'all');
|
||||
|
||||
// Items array for Select label lookup
|
||||
const statusFilterItems = useMemo(() => [{ value: 'all', label: 'All Statuses' }, ...statuses.map(s => ({ value: s.value, label: s.label }))], [statuses]);
|
||||
|
||||
const applyFilters = (e?: FormEvent) => {
|
||||
e?.preventDefault();
|
||||
router.get(route('projects.index'), {
|
||||
search: search || undefined,
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
}, { preserveState: true, replace: true });
|
||||
};
|
||||
|
||||
const handleDelete = (project: Project) => {
|
||||
if (confirm(`Delete project "${project.name}"?`)) {
|
||||
router.delete(route('projects.destroy', project.ulid));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
header={
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderKanban className="h-5 w-5" />
|
||||
<h2 className="text-xl font-semibold leading-tight text-gray-800">Projects</h2>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Head title="Projects" />
|
||||
|
||||
<div className="py-6">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
{flash?.success && (
|
||||
<div className="mb-4 rounded-md bg-green-50 p-4 text-sm text-green-700">{flash.success}</div>
|
||||
)}
|
||||
{flash?.error && (
|
||||
<div className="mb-4 rounded-md bg-red-50 p-4 text-sm text-red-700">{flash.error}</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<DataTableToolbar
|
||||
searchValue={search}
|
||||
searchPlaceholder="Search by name or code..."
|
||||
onSearchChange={setSearch}
|
||||
onSearchSubmit={applyFilters}
|
||||
filters={
|
||||
<Select value={statusFilter} onValueChange={(v) => { if (v) setStatusFilter(v); }} items={statusFilterItems}>
|
||||
<SelectTrigger id="filter-status" className="w-[180px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
{statuses.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
actions={
|
||||
<Link href={route('projects.create')}>
|
||||
<Button size="sm"><Plus className="mr-2 h-4 w-4" /> New Project</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Code</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Client</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Contract Value</TableHead>
|
||||
<TableHead className="text-right">Capitalization</TableHead>
|
||||
<TableHead className="text-right">Progress</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{projects.data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center text-gray-500 py-8">
|
||||
No projects found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
projects.data.map((project) => (
|
||||
<TableRow key={project.id}>
|
||||
<TableCell className="font-mono text-sm">{project.code}</TableCell>
|
||||
<TableCell className="font-medium">{project.name}</TableCell>
|
||||
<TableCell className="text-gray-500">{project.client_name || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant(project.status)}>
|
||||
{statusLabel(project.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatCurrency(project.contract_value)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<div className="w-12 h-1.5 rounded-full bg-gray-200 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
project.is_over_budget ? 'bg-red-500' : project.capitalization_percentage >= 80 ? 'bg-amber-500' : 'bg-emerald-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(project.capitalization_percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-xs font-medium tabular-nums ${
|
||||
project.is_over_budget ? 'text-red-600' : project.capitalization_percentage >= 80 ? 'text-amber-600' : 'text-gray-600'
|
||||
}`}>
|
||||
{project.capitalization_percentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<div className="w-16 h-2 rounded-full bg-gray-200 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-green-500 rounded-full transition-all"
|
||||
style={{ width: `${project.completion_percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
{Number(project.completion_percentage).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Link href={route('projects.show', project.ulid)}>
|
||||
<Button variant="ghost" size="icon-sm" title="View">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={route('projects.edit', project.ulid)}>
|
||||
<Button variant="ghost" size="icon-sm" title="Edit">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button variant="ghost" size="icon-sm" title="Delete" onClick={() => handleDelete(project)}>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{projects.last_page > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<p className="text-sm text-gray-600">
|
||||
Showing {projects.from} to {projects.to} of {projects.total}
|
||||
</p>
|
||||
<div className="flex gap-1">
|
||||
{projects.prev_page_url && (
|
||||
<Link href={projects.prev_page_url}>
|
||||
<Button variant="outline" size="sm">Previous</Button>
|
||||
</Link>
|
||||
)}
|
||||
{projects.next_page_url && (
|
||||
<Link href={projects.next_page_url}>
|
||||
<Button variant="outline" size="sm">Next</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import ProjectLayout from '../../Layouts/ProjectLayout';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/Components/ui/table';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
import { Package } from 'lucide-react';
|
||||
|
||||
export default function Materials({ project, availableMaterials }: any) {
|
||||
return (
|
||||
<ProjectLayout project={project} currentTab="materials">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Package className="h-5 w-5 text-gray-500" /> Project Materials
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Material</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Unit</TableHead>
|
||||
<TableHead className="text-right">Available</TableHead>
|
||||
<TableHead className="text-right">On Hand</TableHead>
|
||||
<TableHead className="text-right">Allocated</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{availableMaterials?.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-gray-500 py-8">
|
||||
No materials available on-site. Dispatch materials from a warehouse to use them in tasks.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
availableMaterials?.map((m: any) => (
|
||||
<TableRow key={m.id}>
|
||||
<TableCell className="font-medium">
|
||||
{m.name}
|
||||
{m.sku && <div className="text-xs text-gray-400">{m.sku}</div>}
|
||||
{m.type === 'kit' && m.components && m.components.length > 0 && (
|
||||
<div className="text-xs text-gray-500 mt-1 space-y-0.5 border-l-2 border-emerald-300 pl-2 ml-1">
|
||||
{m.components.map((comp: any) => (
|
||||
<div key={comp.id}>{comp.quantity}x {comp.component?.name}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{m.category ? <Badge variant="outline">{m.category}</Badge> : <span className="text-gray-400">—</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-500 text-sm">{m.unit}</TableCell>
|
||||
<TableCell className="text-right text-green-700 font-medium tabular-nums">
|
||||
{m.available_qty}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{m.on_hand_qty}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-orange-600 tabular-nums">
|
||||
{m.allocated_qty}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ProjectLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import ProjectLayout from '../../../Layouts/ProjectLayout';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/Components/ui/card';
|
||||
import { Construction } from 'lucide-react';
|
||||
|
||||
export default function DailyReports({ project, currentTab }: { project: any, currentTab: string }) {
|
||||
if (!project) return <ProjectLayout project={null} currentTab={currentTab} children={null} />;
|
||||
|
||||
return (
|
||||
<ProjectLayout project={project} currentTab={currentTab}>
|
||||
<Card className="border-dashed border-2 bg-gray-50/50">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<div className="mx-auto bg-emerald-100 text-emerald-600 p-3 rounded-full w-fit mb-4">
|
||||
<Construction className="w-8 h-8" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Daily Reports Module</CardTitle>
|
||||
<CardDescription>
|
||||
This module is currently being scaffolded.
|
||||
Soon you will be able to create daily site reports, add weather logs, and upload photos directly from the site.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center text-sm text-gray-500">
|
||||
Integration in progress. Please check back later.
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ProjectLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import ProjectLayout from '../../../Layouts/ProjectLayout';
|
||||
import { Head, Link, router, useForm, usePage } from '@inertiajs/react';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Card, CardContent } from '@/Components/ui/card';
|
||||
import { DataTableToolbar } from '@/Components/DataTableToolbar';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Label } from '@/Components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/Components/ui/select';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/Components/ui/table';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/Components/ui/dialog';
|
||||
import { PaginatedData, PageProps } from '@/types';
|
||||
import { FileText, Upload, Download, History, Trash2, Image, File, CheckCircle, XCircle } from 'lucide-react';
|
||||
import { FormEvent, useState } from 'react';
|
||||
|
||||
interface Category { id: number; name: string; }
|
||||
interface Project { id: number; name: string; }
|
||||
interface DocItem {
|
||||
id: number; ulid: string; title: string; category_id: number; project_id?: number; description?: string;
|
||||
current_file_name?: string; mime_type?: string; file_size: number;
|
||||
version_count: number; created_at: string; status: string;
|
||||
uploader?: { id: number; ulid: string; name: string };
|
||||
category?: Category;
|
||||
project?: Project;
|
||||
}
|
||||
|
||||
interface Props extends PageProps {
|
||||
project: any;
|
||||
currentTab: string;
|
||||
documents: PaginatedData<DocItem>;
|
||||
categories: Category[];
|
||||
projects: Project[];
|
||||
filters: { search?: string; category_id?: string; status?: string };
|
||||
}
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return bytes + ' B';
|
||||
};
|
||||
|
||||
const isImage = (mime?: string) => mime?.startsWith('image/');
|
||||
|
||||
const StatusBadge = ({ status }: { status: string }) => {
|
||||
switch (status) {
|
||||
case 'Approved': return <Badge variant="outline" className="text-green-600 bg-green-50 border-green-200">Approved</Badge>;
|
||||
case 'Rejected': return <Badge variant="outline" className="text-red-600 bg-red-50 border-red-200">Rejected</Badge>;
|
||||
default: return <Badge variant="outline" className="text-orange-600 bg-orange-50 border-orange-200">Pending</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
export default function Documents({ project, currentTab, documents, categories, projects, filters }: Props) {
|
||||
if (!project) return <ProjectLayout project={null} currentTab={currentTab} children={null} />;
|
||||
|
||||
const { flash, auth } = usePage<PageProps>().props;
|
||||
const [search, setSearch] = useState(filters.search || '');
|
||||
const [catFilter, setCatFilter] = useState(filters.category_id || 'all');
|
||||
const [statusFilter, setStatusFilter] = useState(filters.status || 'all');
|
||||
const [dialog, setDialog] = useState(false);
|
||||
|
||||
const isContractorAdmin = auth.user.user_type === 'Contractor Admin';
|
||||
|
||||
const form = useForm<{ title: string; category_id: string; project_id: string; description: string; file: window.File | null }>({
|
||||
title: '', category_id: '', project_id: project.id.toString(), description: '', file: null,
|
||||
});
|
||||
|
||||
const routeParams = { project: project.ulid };
|
||||
|
||||
const handleSearch = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
router.get(route('project-documents.index'), { ...routeParams, search: search || undefined, category_id: catFilter !== 'all' ? catFilter : undefined, status: statusFilter !== 'all' ? statusFilter : undefined }, { preserveState: true, replace: true });
|
||||
};
|
||||
|
||||
const handleCatFilter = (val: string | null) => {
|
||||
const v = val ?? 'all';
|
||||
setCatFilter(v);
|
||||
router.get(route('project-documents.index'), { ...routeParams, search: filters.search, category_id: v !== 'all' ? v : undefined, status: statusFilter !== 'all' ? statusFilter : undefined }, { preserveState: true, replace: true });
|
||||
};
|
||||
|
||||
const handleStatusFilter = (val: string | null) => {
|
||||
const v = val ?? 'all';
|
||||
setStatusFilter(v);
|
||||
router.get(route('project-documents.index'), { ...routeParams, search: filters.search, category_id: catFilter !== 'all' ? catFilter : undefined, status: v !== 'all' ? v : undefined }, { preserveState: true, replace: true });
|
||||
};
|
||||
|
||||
const handleUpload = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
form.post(route('documents.upload'), {
|
||||
forceFormData: true,
|
||||
onSuccess: () => { form.reset(); form.setData('project_id', project.id.toString()); setDialog(false); },
|
||||
});
|
||||
};
|
||||
|
||||
const approveDocument = (docId: string, status: 'Approved' | 'Rejected') => {
|
||||
router.post(route('documents.approve', docId), { status }, { preserveScroll: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<ProjectLayout project={project} currentTab={currentTab}>
|
||||
<div className="py-6">
|
||||
{flash?.success && <div className="mb-4 rounded-md bg-green-50 p-4 text-sm text-green-700">{flash.success}</div>}
|
||||
{flash?.error && <div className="mb-4 rounded-md bg-red-50 p-4 text-sm text-red-700">{flash.error}</div>}
|
||||
|
||||
<Card>
|
||||
<DataTableToolbar
|
||||
searchValue={search}
|
||||
searchPlaceholder="Search documents..."
|
||||
onSearchChange={setSearch}
|
||||
onSearchSubmit={handleSearch}
|
||||
filters={
|
||||
<>
|
||||
<Select value={catFilter} onValueChange={handleCatFilter}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="All Categories">
|
||||
{catFilter === 'all' ? 'All Categories' : categories?.find(c => c.id.toString() === catFilter)?.name || 'All Categories'}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Categories</SelectItem>
|
||||
{categories && categories.map(c => <SelectItem key={c.id} value={c.id.toString()}>{c.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={handleStatusFilter}>
|
||||
<SelectTrigger className="w-[140px]"><SelectValue placeholder="All Status" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="Pending">Pending</SelectItem>
|
||||
<SelectItem value="Approved">Approved</SelectItem>
|
||||
<SelectItem value="Rejected">Rejected</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<Dialog open={dialog} onOpenChange={setDialog}>
|
||||
<DialogTrigger render={<Button size="sm" />}><Upload className="mr-2 h-4 w-4" /> Upload Document</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Upload Document</DialogTitle></DialogHeader>
|
||||
<form onSubmit={handleUpload} className="space-y-4">
|
||||
<div><Label>Title *</Label><Input value={form.data.title} onChange={(e) => form.setData('title', e.target.value)} /></div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div><Label>Category *</Label>
|
||||
<Select value={form.data.category_id} onValueChange={(v) => { if (v) form.setData('category_id', v); }}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Category">
|
||||
{form.data.category_id ? categories?.find(c => c.id.toString() === form.data.category_id)?.name : 'Select Category'}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>{categories && categories.map(c => <SelectItem key={c.id} value={c.id.toString()}>{c.name}</SelectItem>)}</SelectContent>
|
||||
</Select></div>
|
||||
<div><Label>Project</Label>
|
||||
<Input value={project.name} disabled />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>File *</Label>
|
||||
<Input type="file" accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,.xls,.xlsx,.dwg" onChange={(e) => { if (e.target.files?.[0]) form.setData('file', e.target.files[0]); }} />
|
||||
{form.errors.file && <p className="text-xs text-red-500 mt-1">{form.errors.file}</p>}
|
||||
</div>
|
||||
<div className="flex justify-end"><Button type="submit" disabled={form.processing}><Upload className="mr-2 h-4 w-4" /> Upload</Button></div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}
|
||||
/>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader><TableRow>
|
||||
<TableHead></TableHead><TableHead>Title</TableHead><TableHead>Category</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Versions</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{(!documents || documents.data.length === 0) ? (
|
||||
<TableRow><TableCell colSpan={6} className="text-center text-gray-500 py-8">No documents found.</TableCell></TableRow>
|
||||
) : documents.data.map((doc) => (
|
||||
<TableRow key={doc.id}>
|
||||
<TableCell>{isImage(doc.mime_type) ? <Image className="h-5 w-5 text-blue-500" /> : <File className="h-5 w-5 text-gray-400" />}</TableCell>
|
||||
<TableCell className="font-medium">{doc.title}</TableCell>
|
||||
<TableCell><Badge variant="outline">{doc.category?.name || 'Uncategorized'}</Badge></TableCell>
|
||||
<TableCell><StatusBadge status={doc.status} /></TableCell>
|
||||
<TableCell>
|
||||
<Link href={route('documents.versions', doc.ulid)} className="text-blue-600 hover:underline">v{doc.version_count}</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{isContractorAdmin && doc.status === 'Pending' && (
|
||||
<>
|
||||
<Button variant="ghost" size="icon-sm" title="Approve" onClick={() => approveDocument(doc.ulid, 'Approved')}><CheckCircle className="h-4 w-4 text-green-600" /></Button>
|
||||
<Button variant="ghost" size="icon-sm" title="Reject" onClick={() => approveDocument(doc.ulid, 'Rejected')}><XCircle className="h-4 w-4 text-red-600" /></Button>
|
||||
</>
|
||||
)}
|
||||
<a href={route('documents.download', doc.ulid)}><Button variant="ghost" size="icon-sm" title="Download"><Download className="h-4 w-4" /></Button></a>
|
||||
<Link href={route('documents.versions', doc.ulid)}><Button variant="ghost" size="icon-sm" title="Versions"><History className="h-4 w-4" /></Button></Link>
|
||||
<Button variant="ghost" size="icon-sm" title="Delete" onClick={() => { if (confirm('Delete document and all versions?')) router.delete(route('documents.destroy', doc.ulid)); }}><Trash2 className="h-4 w-4 text-red-500" /></Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{documents && documents.last_page > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<p className="text-sm text-gray-600">Showing {documents.from} to {documents.to} of {documents.total}</p>
|
||||
<div className="flex gap-1">
|
||||
{documents.prev_page_url && <a href={documents.prev_page_url}><Button variant="outline" size="sm">Previous</Button></a>}
|
||||
{documents.next_page_url && <a href={documents.next_page_url}><Button variant="outline" size="sm">Next</Button></a>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</ProjectLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import ProjectLayout from '../../../Layouts/ProjectLayout';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/Components/ui/card';
|
||||
import { Construction } from 'lucide-react';
|
||||
|
||||
export default function Progress({ project, currentTab }: { project: any, currentTab: string }) {
|
||||
if (!project) return <ProjectLayout project={null} currentTab={currentTab} children={null} />;
|
||||
|
||||
return (
|
||||
<ProjectLayout project={project} currentTab={currentTab}>
|
||||
<Card className="border-dashed border-2 bg-gray-50/50">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<div className="mx-auto bg-emerald-100 text-emerald-600 p-3 rounded-full w-fit mb-4">
|
||||
<Construction className="w-8 h-8" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Progress Monitoring Module</CardTitle>
|
||||
<CardDescription>
|
||||
This module is currently being scaffolded.
|
||||
Soon you will be able to update quantities accomplished and track visual progress against the baseline.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center text-sm text-gray-500">
|
||||
Integration in progress. Please check back later.
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ProjectLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import ProjectLayout from '../../../Layouts/ProjectLayout';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/Components/ui/card';
|
||||
import { Construction } from 'lucide-react';
|
||||
|
||||
export default function QualityControl({ project, currentTab }: { project: any, currentTab: string }) {
|
||||
if (!project) return <ProjectLayout project={null} currentTab={currentTab} children={null} />;
|
||||
|
||||
return (
|
||||
<ProjectLayout project={project} currentTab={currentTab}>
|
||||
<Card className="border-dashed border-2 bg-gray-50/50">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<div className="mx-auto bg-emerald-100 text-emerald-600 p-3 rounded-full w-fit mb-4">
|
||||
<Construction className="w-8 h-8" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Quality Control Module</CardTitle>
|
||||
<CardDescription>
|
||||
This module is currently being scaffolded.
|
||||
Soon you will be able to perform inspections, raise NCRs, and track quality metrics.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center text-sm text-gray-500">
|
||||
Integration in progress. Please check back later.
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ProjectLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import ProjectLayout from '../../../Layouts/ProjectLayout';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/Components/ui/card';
|
||||
import { Construction } from 'lucide-react';
|
||||
|
||||
export default function RFIs({ project, currentTab }: { project: any, currentTab: string }) {
|
||||
if (!project) return <ProjectLayout project={null} currentTab={currentTab} children={null} />;
|
||||
|
||||
return (
|
||||
<ProjectLayout project={project} currentTab={currentTab}>
|
||||
<Card className="border-dashed border-2 bg-gray-50/50">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<div className="mx-auto bg-emerald-100 text-emerald-600 p-3 rounded-full w-fit mb-4">
|
||||
<Construction className="w-8 h-8" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">RFI / Submittals Module</CardTitle>
|
||||
<CardDescription>
|
||||
This module is currently being scaffolded.
|
||||
Soon you will be able to create, track, and manage Requests for Information (RFIs) and Submittals.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center text-sm text-gray-500">
|
||||
Integration in progress. Please check back later.
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ProjectLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import ProjectLayout from '../../../Layouts/ProjectLayout';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/Components/ui/card';
|
||||
import { Construction } from 'lucide-react';
|
||||
|
||||
export default function Safety({ project, currentTab }: { project: any, currentTab: string }) {
|
||||
if (!project) return <ProjectLayout project={null} currentTab={currentTab} children={null} />;
|
||||
|
||||
return (
|
||||
<ProjectLayout project={project} currentTab={currentTab}>
|
||||
<Card className="border-dashed border-2 bg-gray-50/50">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<div className="mx-auto bg-emerald-100 text-emerald-600 p-3 rounded-full w-fit mb-4">
|
||||
<Construction className="w-8 h-8" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Safety Module</CardTitle>
|
||||
<CardDescription>
|
||||
This module is currently being scaffolded.
|
||||
Soon you will be able to conduct tool box talks, report incidents, and manage safety compliance.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center text-sm text-gray-500">
|
||||
Integration in progress. Please check back later.
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ProjectLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import ProjectLayout from '../../Layouts/ProjectLayout';
|
||||
import { Card, CardContent } from '@/Components/ui/card';
|
||||
import { MapPin, Users, TrendingUp, DollarSign, Calendar } from 'lucide-react';
|
||||
import { PageProps } from '@/types';
|
||||
|
||||
interface ProjectData {
|
||||
id: number; ulid: string; name: string; code: string; description?: string;
|
||||
status: string; location?: string; client_name?: string;
|
||||
contract_value: string; total_capitalization: string; completion_percentage: string;
|
||||
capitalization_percentage: number; is_over_budget: boolean;
|
||||
contract_duration?: number;
|
||||
start_date?: string; target_end_date?: string; actual_end_date?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface StatusOption { value: string; label: string }
|
||||
|
||||
interface Props extends PageProps {
|
||||
project: ProjectData;
|
||||
taskStats: { total: number; pending: number; in_progress: number; completed: number };
|
||||
allowedTransitions: StatusOption[];
|
||||
}
|
||||
|
||||
const formatCurrency = (v: string) => new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(Number(v));
|
||||
|
||||
function StatCard({ icon: Icon, label, value, sub }: { icon: React.ComponentType<{ className?: string }>; label: string; value: string; sub?: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-gray-100">
|
||||
<Icon className="h-5 w-5 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">{label}</p>
|
||||
<p className="text-lg font-semibold">{value}</p>
|
||||
{sub && <p className="text-xs text-gray-400">{sub}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Overview({ project, taskStats, allowedTransitions }: Props) {
|
||||
return (
|
||||
<ProjectLayout project={project} allowedTransitions={allowedTransitions} currentTab="overview">
|
||||
{/* Stat Cards */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4 mb-6">
|
||||
<StatCard icon={DollarSign} label="Contract Value" value={formatCurrency(project.contract_value)} />
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${
|
||||
project.is_over_budget ? 'bg-red-100' : project.capitalization_percentage >= 80 ? 'bg-amber-100' : 'bg-gray-100'
|
||||
}`}>
|
||||
<TrendingUp className={`h-5 w-5 ${
|
||||
project.is_over_budget ? 'text-red-600' : project.capitalization_percentage >= 80 ? 'text-amber-600' : 'text-gray-600'
|
||||
}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-gray-500">Capitalization</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="text-lg font-semibold">{project.capitalization_percentage.toFixed(1)}%</p>
|
||||
<p className="text-xs text-gray-400 truncate">{formatCurrency(project.total_capitalization)}</p>
|
||||
</div>
|
||||
<div className="mt-1.5 h-1.5 rounded-full bg-gray-200 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${
|
||||
project.is_over_budget ? 'bg-red-500' : project.capitalization_percentage >= 80 ? 'bg-amber-500' : 'bg-emerald-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(project.capitalization_percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<StatCard icon={Calendar} label="Timeline" value={project.start_date ? new Date(project.start_date).toLocaleDateString() : 'Not set'} sub={project.target_end_date ? `→ ${new Date(project.target_end_date).toLocaleDateString()}` : undefined} />
|
||||
<StatCard icon={Users} label="Progress" value={`${Number(project.completion_percentage).toFixed(0)}%`} sub={`${taskStats.completed} / ${taskStats.total} tasks done`} />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
{project.description && <div><p className="text-xs text-gray-500 mb-1">Description</p><p className="text-sm">{project.description}</p></div>}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{project.location && <div className="flex items-center gap-2 text-sm"><MapPin className="h-4 w-4 text-gray-400" />{project.location}</div>}
|
||||
{project.client_name && <div className="flex items-center gap-2 text-sm"><Users className="h-4 w-4 text-gray-400" />Client: {project.client_name}</div>}
|
||||
</div>
|
||||
|
||||
{/* Capitalization Section */}
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<p className="text-sm font-medium text-gray-700">Cost Capitalization</p>
|
||||
<p className="text-sm">
|
||||
<span className={`font-semibold ${
|
||||
project.is_over_budget ? 'text-red-600' : project.capitalization_percentage >= 80 ? 'text-amber-600' : 'text-gray-800'
|
||||
}`}>{project.capitalization_percentage.toFixed(1)}%</span>
|
||||
<span className="text-gray-400"> of contract value</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-3 rounded-full bg-gray-200 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${
|
||||
project.is_over_budget ? 'bg-red-500' : project.capitalization_percentage >= 80 ? 'bg-amber-500' : 'bg-emerald-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(project.capitalization_percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<p className="text-xs text-gray-400">
|
||||
{formatCurrency(project.total_capitalization)} / {formatCurrency(project.contract_value)}
|
||||
</p>
|
||||
{project.is_over_budget && (
|
||||
<p className="text-xs font-medium text-red-500">⚠ Over budget</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task Progress Section */}
|
||||
<div className="mt-4 space-y-4">
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<p className="text-sm font-medium text-gray-700">Progress</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
<span className="font-semibold text-gray-800">{taskStats.completed}</span>
|
||||
<span className="text-gray-400"> / {taskStats.total} tasks completed</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-3 rounded-full bg-gray-200 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-emerald-500 rounded-full transition-all duration-500"
|
||||
style={{ width: taskStats.total > 0 ? `${(taskStats.completed / taskStats.total) * 100}%` : '0%' }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{taskStats.total > 0 ? `${((taskStats.completed / taskStats.total) * 100).toFixed(0)}%` : '0%'} complete
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{taskStats.total > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-1.5">Task Distribution</p>
|
||||
<div className="flex h-2 rounded-full overflow-hidden bg-gray-200">
|
||||
{taskStats.completed > 0 && (
|
||||
<div
|
||||
className="bg-emerald-500 transition-all duration-500"
|
||||
style={{ width: `${(taskStats.completed / taskStats.total) * 100}%` }}
|
||||
/>
|
||||
)}
|
||||
{taskStats.in_progress > 0 && (
|
||||
<div
|
||||
className="bg-blue-500 transition-all duration-500"
|
||||
style={{ width: `${(taskStats.in_progress / taskStats.total) * 100}%` }}
|
||||
/>
|
||||
)}
|
||||
{taskStats.pending > 0 && (
|
||||
<div
|
||||
className="bg-gray-300 transition-all duration-500"
|
||||
style={{ width: `${(taskStats.pending / taskStats.total) * 100}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{taskStats.total > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ label: 'Completed', count: taskStats.completed, color: 'bg-emerald-500', textColor: 'text-emerald-600' },
|
||||
{ label: 'In Progress', count: taskStats.in_progress, color: 'bg-blue-500', textColor: 'text-blue-600' },
|
||||
{ label: 'Pending', count: taskStats.pending, color: 'bg-gray-300', textColor: 'text-gray-500' },
|
||||
].map((item) => {
|
||||
const pct = taskStats.total > 0 ? (item.count / taskStats.total) * 100 : 0;
|
||||
return (
|
||||
<div key={item.label} className="flex items-center gap-3">
|
||||
<div className={`h-2.5 w-2.5 rounded-full ${item.color} shrink-0`} />
|
||||
<span className="text-xs text-gray-600 w-20">{item.label}</span>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-gray-100 overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${item.color} rounded-full transition-all duration-500`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-xs font-medium tabular-nums w-20 text-right ${item.textColor}`}>
|
||||
{item.count} task{item.count !== 1 ? 's' : ''} ({pct.toFixed(0)}%)
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400 italic">No tasks yet. Add tasks to track progress.</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ProjectLayout>
|
||||
);
|
||||
}
|
||||
1390
Modules/ProjectManagement/resources/js/Pages/Projects/Show.tsx
Normal file
1390
Modules/ProjectManagement/resources/js/Pages/Projects/Show.tsx
Normal file
File diff suppressed because it is too large
Load Diff
132
Modules/ProjectManagement/resources/js/Pages/Projects/Team.tsx
Normal file
132
Modules/ProjectManagement/resources/js/Pages/Projects/Team.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import ProjectLayout from '../../Layouts/ProjectLayout';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/Components/ui/table';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/Components/ui/dialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/Components/ui/select';
|
||||
import { Form, FormField } from '@/Components/ui/form';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
import { UserPlus, UserMinus } from 'lucide-react';
|
||||
import { useForm, router } from '@inertiajs/react';
|
||||
import { FormEvent, useMemo, useState } from 'react';
|
||||
import { PageProps } from '@/types';
|
||||
|
||||
interface PersonnelItem {
|
||||
id: number; ulid: string; name: string; email: string;
|
||||
pivot?: { role: string };
|
||||
}
|
||||
|
||||
interface ProjectData {
|
||||
id: number; ulid: string; name: string; code: string; status: string;
|
||||
personnel: PersonnelItem[];
|
||||
}
|
||||
|
||||
interface Props extends PageProps {
|
||||
project: ProjectData;
|
||||
employees: { id: number; ulid: string; name: string }[];
|
||||
}
|
||||
|
||||
const statusLabel = (s: string) => s.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
const roleItems = [{ value: 'pm', label: 'Project Manager' }, { value: 'engineer', label: 'Engineer' }, { value: 'laborer', label: 'Laborer' }, { value: 'member', label: 'Member' }];
|
||||
|
||||
export default function Team({ project, employees }: Props) {
|
||||
const [addMemberOpen, setAddMemberOpen] = useState(false);
|
||||
const memberForm = useForm({ user_id: '', role: 'member' });
|
||||
|
||||
const employeeSelectItems = useMemo(() => employees.map(e => ({ value: e.ulid, label: e.name })), [employees]);
|
||||
|
||||
const handleAddMember = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
memberForm.post(route('projects.personnel.add', project.ulid), {
|
||||
onSuccess: () => { memberForm.reset(); setAddMemberOpen(false); },
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveMember = (userUlid: string) => {
|
||||
if (confirm('Remove this team member?')) {
|
||||
router.delete(route('projects.personnel.remove', [project.ulid, userUlid]));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ProjectLayout project={project} currentTab="team">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Team Members</CardTitle>
|
||||
<Dialog open={addMemberOpen} onOpenChange={setAddMemberOpen}>
|
||||
<DialogTrigger>
|
||||
<Button size="sm">
|
||||
<UserPlus className="mr-2 h-4 w-4" /> Add Member
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Add Team Member</DialogTitle></DialogHeader>
|
||||
<Form onSubmit={handleAddMember}>
|
||||
<FormField label="Employee" required>
|
||||
<Select value={memberForm.data.user_id} onValueChange={(v) => { if (v) memberForm.setData('user_id', v); }} items={employeeSelectItems}>
|
||||
<SelectTrigger><SelectValue placeholder="Select employee" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{employees.map((e) => (
|
||||
<SelectItem key={e.id} value={e.ulid}>{e.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="Role">
|
||||
<Select value={memberForm.data.role} onValueChange={(v) => { if (v) memberForm.setData('role', v); }} items={roleItems}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pm">Project Manager</SelectItem>
|
||||
<SelectItem value="engineer">Engineer</SelectItem>
|
||||
<SelectItem value="laborer">Laborer</SelectItem>
|
||||
<SelectItem value="member">Member</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={memberForm.processing}><UserPlus className="mr-2 h-4 w-4" /> Add</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{project.personnel.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={4} className="text-center text-gray-500 py-8">No team members assigned.</TableCell></TableRow>
|
||||
) : (
|
||||
project.personnel.map((member) => (
|
||||
<TableRow key={member.id}>
|
||||
<TableCell className="font-medium">{member.name}</TableCell>
|
||||
<TableCell className="text-gray-500">{member.email}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={member.pivot?.role === 'pm' ? 'default' : 'outline'}>
|
||||
{member.pivot?.role === 'pm' ? 'Project Manager' : statusLabel(member.pivot?.role || 'member')}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon-sm" title="Remove" onClick={() => handleRemoveMember(member.ulid)}>
|
||||
<UserMinus className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ProjectLayout>
|
||||
);
|
||||
}
|
||||
259
Modules/ProjectManagement/resources/js/Pages/Reports/Create.tsx
Normal file
259
Modules/ProjectManagement/resources/js/Pages/Reports/Create.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Label } from '@/Components/ui/label';
|
||||
import { Separator } from '@/Components/ui/separator';
|
||||
import { ArrowLeft, Save, Send, HardHat, ShieldCheck, FileText } from 'lucide-react';
|
||||
import { PageProps } from '@/types';
|
||||
import { FormEvent } from 'react';
|
||||
|
||||
interface Props extends PageProps {
|
||||
project: { id: number; ulid: string; name: string; code: string };
|
||||
defaultPeriodStart: string;
|
||||
defaultPeriodEnd: string;
|
||||
}
|
||||
|
||||
export default function Create({ project, defaultPeriodStart, defaultPeriodEnd }: Props) {
|
||||
const form = useForm({
|
||||
period_start: defaultPeriodStart,
|
||||
period_end: defaultPeriodEnd,
|
||||
// Workforce
|
||||
active_workforce: '',
|
||||
period_man_hours: '',
|
||||
logistics_km: '',
|
||||
// HSE Proactive
|
||||
toolbox_meetings: '',
|
||||
safety_observations: '',
|
||||
// HSE Reactive
|
||||
fatalities: '0',
|
||||
major_injuries: '0',
|
||||
first_aid_cases: '0',
|
||||
medical_cases: '0',
|
||||
near_misses: '0',
|
||||
environmental_damage: '0',
|
||||
property_damage: '0',
|
||||
fines: '0',
|
||||
// Narrative
|
||||
narrative_status: '',
|
||||
narrative_weather: '',
|
||||
narrative_compliance: '',
|
||||
});
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
form.post(route('projects.reports.store', project.ulid));
|
||||
};
|
||||
|
||||
const fieldError = (field: string) => {
|
||||
const err = (form.errors as Record<string, string>)[field];
|
||||
return err ? <p className="mt-1 text-sm text-red-500">{err}</p> : null;
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
header={
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={route('projects.reports.index', project.ulid)}>
|
||||
<Button variant="ghost" size="icon-sm"><ArrowLeft className="h-4 w-4" /></Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold leading-tight text-gray-800">New Status Report</h2>
|
||||
<p className="text-sm text-gray-500">{project.name} ({project.code})</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Head title={`New Report - ${project.name}`} />
|
||||
|
||||
<div className="py-6">
|
||||
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Period */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Reporting Period</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="period_start">Period Start *</Label>
|
||||
<Input id="period_start" type="date" value={form.data.period_start}
|
||||
onChange={e => form.setData('period_start', e.target.value)} />
|
||||
{fieldError('period_start')}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="period_end">Period End *</Label>
|
||||
<Input id="period_end" type="date" value={form.data.period_end}
|
||||
onChange={e => form.setData('period_end', e.target.value)} />
|
||||
{fieldError('period_end')}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Workforce */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<HardHat className="h-5 w-5 text-amber-600" />
|
||||
Workforce & Productivity
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<Label htmlFor="active_workforce">Active Workforce</Label>
|
||||
<Input id="active_workforce" type="number" min="0" placeholder="e.g. 78"
|
||||
value={form.data.active_workforce}
|
||||
onChange={e => form.setData('active_workforce', e.target.value)} />
|
||||
{fieldError('active_workforce')}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="period_man_hours">Period Man-hours</Label>
|
||||
<Input id="period_man_hours" type="number" min="0" step="0.01" placeholder="e.g. 4368"
|
||||
value={form.data.period_man_hours}
|
||||
onChange={e => form.setData('period_man_hours', e.target.value)} />
|
||||
{fieldError('period_man_hours')}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="logistics_km">Logistics (km driven)</Label>
|
||||
<Input id="logistics_km" type="number" min="0" step="0.01" placeholder="e.g. 25200"
|
||||
value={form.data.logistics_km}
|
||||
onChange={e => form.setData('logistics_km', e.target.value)} />
|
||||
{fieldError('logistics_km')}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-gray-400">
|
||||
Cumulative man-hours will be calculated automatically from previous approved reports.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* HSE */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ShieldCheck className="h-5 w-5 text-emerald-600" />
|
||||
HSE Performance
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Proactive */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">Proactive Safety Measures</h4>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="toolbox_meetings">Toolbox / Tailgate Meetings</Label>
|
||||
<Input id="toolbox_meetings" type="number" min="0" placeholder="e.g. 6"
|
||||
value={form.data.toolbox_meetings}
|
||||
onChange={e => form.setData('toolbox_meetings', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="safety_observations">Safety Observations</Label>
|
||||
<Input id="safety_observations" type="number" min="0" placeholder="e.g. 7"
|
||||
value={form.data.safety_observations}
|
||||
onChange={e => form.setData('safety_observations', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Reactive */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-1">Reactive Safety Incidents</h4>
|
||||
<p className="text-xs text-gray-400 mb-3">All fields should ideally remain at zero.</p>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{[
|
||||
{ id: 'fatalities', label: 'Fatalities' },
|
||||
{ id: 'major_injuries', label: 'Major Injuries' },
|
||||
{ id: 'first_aid_cases', label: 'First Aid Cases' },
|
||||
{ id: 'medical_cases', label: 'Medical Cases' },
|
||||
{ id: 'near_misses', label: 'Near Misses' },
|
||||
{ id: 'environmental_damage', label: 'Environmental' },
|
||||
{ id: 'property_damage', label: 'Property Damage' },
|
||||
{ id: 'fines', label: 'Fines / Costs (₱)' },
|
||||
].map(field => (
|
||||
<div key={field.id}>
|
||||
<Label htmlFor={field.id}>{field.label}</Label>
|
||||
<Input
|
||||
id={field.id}
|
||||
type="number"
|
||||
min="0"
|
||||
step={field.id === 'fines' ? '0.01' : '1'}
|
||||
value={(form.data as Record<string, string>)[field.id]}
|
||||
onChange={e => form.setData(field.id as keyof typeof form.data, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Narrative */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<FileText className="h-5 w-5 text-blue-600" />
|
||||
Weekly Narrative
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="narrative_status">Operations Status</Label>
|
||||
<textarea
|
||||
id="narrative_status"
|
||||
rows={3}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-gray-500 focus:ring-1 focus:ring-gray-500"
|
||||
placeholder="e.g. Normal operations. All activities on track..."
|
||||
value={form.data.narrative_status}
|
||||
onChange={e => form.setData('narrative_status', e.target.value)}
|
||||
/>
|
||||
{fieldError('narrative_status')}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="narrative_weather">Weather Impact</Label>
|
||||
<textarea
|
||||
id="narrative_weather"
|
||||
rows={2}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-gray-500 focus:ring-1 focus:ring-gray-500"
|
||||
placeholder="e.g. Intermittent rainfall due to tropical weather..."
|
||||
value={form.data.narrative_weather}
|
||||
onChange={e => form.setData('narrative_weather', e.target.value)}
|
||||
/>
|
||||
{fieldError('narrative_weather')}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="narrative_compliance">Compliance Notes</Label>
|
||||
<textarea
|
||||
id="narrative_compliance"
|
||||
rows={2}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-gray-500 focus:ring-1 focus:ring-gray-500"
|
||||
placeholder="e.g. Toolbox meetings conducted consistently for worker alignment..."
|
||||
value={form.data.narrative_compliance}
|
||||
onChange={e => form.setData('narrative_compliance', e.target.value)}
|
||||
/>
|
||||
{fieldError('narrative_compliance')}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<Link href={route('projects.reports.index', project.ulid)}>
|
||||
<Button type="button" variant="outline">Cancel</Button>
|
||||
</Link>
|
||||
<Button type="submit" disabled={form.processing}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save as Draft
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
248
Modules/ProjectManagement/resources/js/Pages/Reports/Edit.tsx
Normal file
248
Modules/ProjectManagement/resources/js/Pages/Reports/Edit.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Label } from '@/Components/ui/label';
|
||||
import { Separator } from '@/Components/ui/separator';
|
||||
import { ArrowLeft, Save, HardHat, ShieldCheck, FileText } from 'lucide-react';
|
||||
import { PageProps } from '@/types';
|
||||
import { FormEvent } from 'react';
|
||||
|
||||
interface WorkforceMetric {
|
||||
active_workforce: number;
|
||||
period_man_hours: string;
|
||||
cumulative_man_hours: string;
|
||||
logistics_km: string;
|
||||
}
|
||||
|
||||
interface HseRecord {
|
||||
toolbox_meetings: number;
|
||||
safety_observations: number;
|
||||
fatalities: number;
|
||||
major_injuries: number;
|
||||
first_aid_cases: number;
|
||||
medical_cases: number;
|
||||
near_misses: number;
|
||||
environmental_damage: number;
|
||||
property_damage: number;
|
||||
fines: string;
|
||||
}
|
||||
|
||||
interface ReportData {
|
||||
id: number; ulid: string;
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
narrative_status?: string;
|
||||
narrative_weather?: string;
|
||||
narrative_compliance?: string;
|
||||
workforce_metric?: WorkforceMetric;
|
||||
hse_record?: HseRecord;
|
||||
}
|
||||
|
||||
interface Props extends PageProps {
|
||||
project: { id: number; ulid: string; name: string; code: string };
|
||||
report: ReportData;
|
||||
}
|
||||
|
||||
export default function Edit({ project, report }: Props) {
|
||||
const wf = report.workforce_metric;
|
||||
const hse = report.hse_record;
|
||||
|
||||
const form = useForm({
|
||||
period_start: report.period_start?.split('T')[0] || '',
|
||||
period_end: report.period_end?.split('T')[0] || '',
|
||||
active_workforce: String(wf?.active_workforce ?? ''),
|
||||
period_man_hours: String(wf?.period_man_hours ?? ''),
|
||||
logistics_km: String(wf?.logistics_km ?? ''),
|
||||
toolbox_meetings: String(hse?.toolbox_meetings ?? ''),
|
||||
safety_observations: String(hse?.safety_observations ?? ''),
|
||||
fatalities: String(hse?.fatalities ?? 0),
|
||||
major_injuries: String(hse?.major_injuries ?? 0),
|
||||
first_aid_cases: String(hse?.first_aid_cases ?? 0),
|
||||
medical_cases: String(hse?.medical_cases ?? 0),
|
||||
near_misses: String(hse?.near_misses ?? 0),
|
||||
environmental_damage: String(hse?.environmental_damage ?? 0),
|
||||
property_damage: String(hse?.property_damage ?? 0),
|
||||
fines: String(hse?.fines ?? 0),
|
||||
narrative_status: report.narrative_status || '',
|
||||
narrative_weather: report.narrative_weather || '',
|
||||
narrative_compliance: report.narrative_compliance || '',
|
||||
});
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
form.put(route('projects.reports.update', [project.ulid, report.ulid]));
|
||||
};
|
||||
|
||||
const fieldError = (field: string) => {
|
||||
const err = (form.errors as Record<string, string>)[field];
|
||||
return err ? <p className="mt-1 text-sm text-red-500">{err}</p> : null;
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
header={
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={route('projects.reports.show', [project.ulid, report.ulid])}>
|
||||
<Button variant="ghost" size="icon-sm"><ArrowLeft className="h-4 w-4" /></Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold leading-tight text-gray-800">Edit Status Report</h2>
|
||||
<p className="text-sm text-gray-500">{project.name} ({project.code})</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Head title={`Edit Report - ${project.name}`} />
|
||||
|
||||
<div className="py-6">
|
||||
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Reporting Period</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="period_start">Period Start *</Label>
|
||||
<Input id="period_start" type="date" value={form.data.period_start}
|
||||
onChange={e => form.setData('period_start', e.target.value)} />
|
||||
{fieldError('period_start')}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="period_end">Period End *</Label>
|
||||
<Input id="period_end" type="date" value={form.data.period_end}
|
||||
onChange={e => form.setData('period_end', e.target.value)} />
|
||||
{fieldError('period_end')}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<HardHat className="h-5 w-5 text-amber-600" /> Workforce & Productivity
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<Label htmlFor="active_workforce">Active Workforce</Label>
|
||||
<Input id="active_workforce" type="number" min="0"
|
||||
value={form.data.active_workforce}
|
||||
onChange={e => form.setData('active_workforce', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="period_man_hours">Period Man-hours</Label>
|
||||
<Input id="period_man_hours" type="number" min="0" step="0.01"
|
||||
value={form.data.period_man_hours}
|
||||
onChange={e => form.setData('period_man_hours', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="logistics_km">Logistics (km)</Label>
|
||||
<Input id="logistics_km" type="number" min="0" step="0.01"
|
||||
value={form.data.logistics_km}
|
||||
onChange={e => form.setData('logistics_km', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-gray-400">Cumulative man-hours will be recalculated on save.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ShieldCheck className="h-5 w-5 text-emerald-600" /> HSE Performance
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">Proactive Safety</h4>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="toolbox_meetings">Toolbox Meetings</Label>
|
||||
<Input id="toolbox_meetings" type="number" min="0"
|
||||
value={form.data.toolbox_meetings}
|
||||
onChange={e => form.setData('toolbox_meetings', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="safety_observations">Safety Observations</Label>
|
||||
<Input id="safety_observations" type="number" min="0"
|
||||
value={form.data.safety_observations}
|
||||
onChange={e => form.setData('safety_observations', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">Reactive Incidents</h4>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{[
|
||||
{ id: 'fatalities', label: 'Fatalities' },
|
||||
{ id: 'major_injuries', label: 'Major Injuries' },
|
||||
{ id: 'first_aid_cases', label: 'First Aid' },
|
||||
{ id: 'medical_cases', label: 'Medical Cases' },
|
||||
{ id: 'near_misses', label: 'Near Misses' },
|
||||
{ id: 'environmental_damage', label: 'Environmental' },
|
||||
{ id: 'property_damage', label: 'Property Damage' },
|
||||
{ id: 'fines', label: 'Fines (₱)' },
|
||||
].map(field => (
|
||||
<div key={field.id}>
|
||||
<Label htmlFor={field.id}>{field.label}</Label>
|
||||
<Input id={field.id} type="number" min="0"
|
||||
step={field.id === 'fines' ? '0.01' : '1'}
|
||||
value={(form.data as Record<string, string>)[field.id]}
|
||||
onChange={e => form.setData(field.id as keyof typeof form.data, e.target.value)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<FileText className="h-5 w-5 text-blue-600" /> Weekly Narrative
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="narrative_status">Operations Status</Label>
|
||||
<textarea id="narrative_status" rows={3}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-gray-500 focus:ring-1 focus:ring-gray-500"
|
||||
value={form.data.narrative_status}
|
||||
onChange={e => form.setData('narrative_status', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="narrative_weather">Weather Impact</Label>
|
||||
<textarea id="narrative_weather" rows={2}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-gray-500 focus:ring-1 focus:ring-gray-500"
|
||||
value={form.data.narrative_weather}
|
||||
onChange={e => form.setData('narrative_weather', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="narrative_compliance">Compliance</Label>
|
||||
<textarea id="narrative_compliance" rows={2}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-gray-500 focus:ring-1 focus:ring-gray-500"
|
||||
value={form.data.narrative_compliance}
|
||||
onChange={e => form.setData('narrative_compliance', e.target.value)} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Link href={route('projects.reports.show', [project.ulid, report.ulid])}>
|
||||
<Button type="button" variant="outline">Cancel</Button>
|
||||
</Link>
|
||||
<Button type="submit" disabled={form.processing}>
|
||||
<Save className="mr-2 h-4 w-4" /> Update Report
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
243
Modules/ProjectManagement/resources/js/Pages/Reports/Index.tsx
Normal file
243
Modules/ProjectManagement/resources/js/Pages/Reports/Index.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, Link, router, usePage } from '@inertiajs/react';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Card, CardContent } from '@/Components/ui/card';
|
||||
import { DataTableToolbar } from '@/Components/DataTableToolbar';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/Components/ui/select';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/Components/ui/table';
|
||||
import { PaginatedData, PageProps } from '@/types';
|
||||
import { Plus, ArrowLeft, Eye, Pencil, Trash2, ClipboardList } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
interface WorkforceMetric {
|
||||
active_workforce: number;
|
||||
period_man_hours: string;
|
||||
cumulative_man_hours: string;
|
||||
logistics_km: string;
|
||||
}
|
||||
|
||||
interface HseRecord {
|
||||
toolbox_meetings: number;
|
||||
safety_observations: number;
|
||||
fatalities: number;
|
||||
major_injuries: number;
|
||||
first_aid_cases: number;
|
||||
medical_cases: number;
|
||||
near_misses: number;
|
||||
environmental_damage: number;
|
||||
property_damage: number;
|
||||
fines: string;
|
||||
}
|
||||
|
||||
interface Report {
|
||||
id: number; ulid: string;
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
status: string;
|
||||
submitted_by: number | null;
|
||||
created_at: string;
|
||||
submitter?: { id: number; ulid: string; name: string };
|
||||
workforce_metric?: WorkforceMetric;
|
||||
hse_record?: HseRecord;
|
||||
}
|
||||
|
||||
interface StatusOption { value: string; label: string }
|
||||
|
||||
interface Props extends PageProps {
|
||||
project: { id: number; ulid: string; name: string; code: string };
|
||||
reports: PaginatedData<Report>;
|
||||
filters: { status?: string };
|
||||
statuses: StatusOption[];
|
||||
}
|
||||
|
||||
const statusVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case 'draft': return 'outline' as const;
|
||||
case 'submitted': return 'secondary' as const;
|
||||
case 'in_review': return 'default' as const;
|
||||
case 'approved': return 'default' as const;
|
||||
case 'rejected': return 'destructive' as const;
|
||||
default: return 'outline' as const;
|
||||
}
|
||||
};
|
||||
|
||||
const statusLabel = (s: string) => s.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
const formatNumber = (v: string | number) => new Intl.NumberFormat('en-PH').format(Number(v));
|
||||
|
||||
function totalIncidents(hse?: HseRecord): number {
|
||||
if (!hse) return 0;
|
||||
return hse.fatalities + hse.major_injuries + hse.first_aid_cases
|
||||
+ hse.medical_cases + hse.near_misses + hse.environmental_damage + hse.property_damage;
|
||||
}
|
||||
|
||||
export default function Index({ project, reports, filters, statuses }: Props) {
|
||||
const { flash } = usePage<PageProps>().props;
|
||||
const [statusFilter, setStatusFilter] = useState(filters.status || 'all');
|
||||
|
||||
// Items array for Select label lookup
|
||||
const statusFilterItems = useMemo(() => [{ value: 'all', label: 'All Statuses' }, ...statuses.map(s => ({ value: s.value, label: s.label }))], [statuses]);
|
||||
|
||||
const applyFilter = (value: string | null) => {
|
||||
const v = value ?? 'all';
|
||||
setStatusFilter(v);
|
||||
router.get(route('projects.reports.index', project.ulid), {
|
||||
status: v !== 'all' ? v : undefined,
|
||||
}, { preserveState: true, replace: true });
|
||||
};
|
||||
|
||||
const handleDelete = (report: Report) => {
|
||||
if (confirm('Delete this draft report?')) {
|
||||
router.delete(route('projects.reports.destroy', [project.ulid, report.ulid]));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
header={
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={route('projects.show', project.ulid)}>
|
||||
<Button variant="ghost" size="icon-sm"><ArrowLeft className="h-4 w-4" /></Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold leading-tight text-gray-800">
|
||||
<ClipboardList className="inline mr-2 h-5 w-5" />
|
||||
Status Reports
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">{project.name} ({project.code})</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Head title={`Reports - ${project.name}`} />
|
||||
|
||||
<div className="py-6">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
{flash?.success && <div className="mb-4 rounded-md bg-green-50 p-4 text-sm text-green-700">{flash.success}</div>}
|
||||
{flash?.error && <div className="mb-4 rounded-md bg-red-50 p-4 text-sm text-red-700">{flash.error}</div>}
|
||||
|
||||
<Card>
|
||||
<DataTableToolbar
|
||||
filters={
|
||||
<Select value={statusFilter} onValueChange={applyFilter} items={statusFilterItems}>
|
||||
<SelectTrigger id="filter-report-status" className="w-[180px]">
|
||||
<SelectValue placeholder="Filter by status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
{statuses.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
actions={
|
||||
<Link href={route('projects.reports.create', project.ulid)}>
|
||||
<Button size="sm"><Plus className="mr-2 h-4 w-4" /> New Report</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Period</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Man-hours</TableHead>
|
||||
<TableHead className="text-right">Workers</TableHead>
|
||||
<TableHead className="text-right">Incidents</TableHead>
|
||||
<TableHead>Submitted By</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{reports.data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-gray-500 py-8">
|
||||
No reports yet. Create your first status report.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
reports.data.map((report) => (
|
||||
<TableRow key={report.id}>
|
||||
<TableCell className="font-medium">
|
||||
{new Date(report.period_start).toLocaleDateString('en-PH', { month: 'short', day: 'numeric' })}
|
||||
{' – '}
|
||||
{new Date(report.period_end).toLocaleDateString('en-PH', { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant(report.status)}>
|
||||
{statusLabel(report.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{report.workforce_metric ? formatNumber(report.workforce_metric.period_man_hours) : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{report.workforce_metric?.active_workforce ?? '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{totalIncidents(report.hse_record) === 0 ? (
|
||||
<span className="text-green-600 font-medium">0 ✓</span>
|
||||
) : (
|
||||
<span className="text-red-600 font-medium">{totalIncidents(report.hse_record)}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-500">{report.submitter?.name || '-'}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Link href={route('projects.reports.show', [project.ulid, report.ulid])}>
|
||||
<Button variant="ghost" size="icon-sm" title="View">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
{(report.status === 'draft' || report.status === 'rejected') && (
|
||||
<Link href={route('projects.reports.edit', [project.ulid, report.ulid])}>
|
||||
<Button variant="ghost" size="icon-sm" title="Edit">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{report.status === 'draft' && (
|
||||
<Button variant="ghost" size="icon-sm" title="Delete" onClick={() => handleDelete(report)}>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{reports.last_page > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<p className="text-sm text-gray-600">
|
||||
Showing {reports.from} to {reports.to} of {reports.total}
|
||||
</p>
|
||||
<div className="flex gap-1">
|
||||
{reports.prev_page_url && (
|
||||
<Link href={reports.prev_page_url}>
|
||||
<Button variant="outline" size="sm">Previous</Button>
|
||||
</Link>
|
||||
)}
|
||||
{reports.next_page_url && (
|
||||
<Link href={reports.next_page_url}>
|
||||
<Button variant="outline" size="sm">Next</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
394
Modules/ProjectManagement/resources/js/Pages/Reports/Show.tsx
Normal file
394
Modules/ProjectManagement/resources/js/Pages/Reports/Show.tsx
Normal file
@@ -0,0 +1,394 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, Link, router, usePage } from '@inertiajs/react';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card';
|
||||
import { Separator } from '@/Components/ui/separator';
|
||||
import {
|
||||
ArrowLeft, Pencil, Send, Users, Clock, ShieldCheck, ShieldAlert,
|
||||
HardHat, Truck, CheckCircle2, XCircle, MapPin, Calendar, FileText, AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { PageProps } from '@/types';
|
||||
|
||||
interface WorkforceMetric {
|
||||
active_workforce: number;
|
||||
period_man_hours: string;
|
||||
cumulative_man_hours: string;
|
||||
logistics_km: string;
|
||||
}
|
||||
|
||||
interface HseRecord {
|
||||
toolbox_meetings: number;
|
||||
safety_observations: number;
|
||||
fatalities: number;
|
||||
major_injuries: number;
|
||||
first_aid_cases: number;
|
||||
medical_cases: number;
|
||||
near_misses: number;
|
||||
environmental_damage: number;
|
||||
property_damage: number;
|
||||
fines: string;
|
||||
}
|
||||
|
||||
interface ApprovalStep {
|
||||
id: number; ulid: string;
|
||||
order: number;
|
||||
status: string;
|
||||
notes?: string;
|
||||
acted_at?: string;
|
||||
approver: { id: number; ulid: string; name: string };
|
||||
}
|
||||
|
||||
interface ApprovalChain {
|
||||
id: number; ulid: string;
|
||||
status: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
steps: ApprovalStep[];
|
||||
}
|
||||
|
||||
interface Report {
|
||||
id: number; ulid: string;
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
status: string;
|
||||
narrative_status?: string;
|
||||
narrative_weather?: string;
|
||||
narrative_compliance?: string;
|
||||
created_at: string;
|
||||
workforce_metric?: WorkforceMetric;
|
||||
hse_record?: HseRecord;
|
||||
submitter?: { id: number; ulid: string; name: string };
|
||||
approver?: { id: number; ulid: string; name: string };
|
||||
approval_chains?: ApprovalChain[];
|
||||
}
|
||||
|
||||
interface ProjectData {
|
||||
id: number; ulid: string;
|
||||
name: string;
|
||||
code: string;
|
||||
location?: string;
|
||||
start_date?: string;
|
||||
target_end_date?: string;
|
||||
customer?: { id: number; ulid: string; name: string };
|
||||
contractor?: { id: number; ulid: string; company_name: string };
|
||||
}
|
||||
|
||||
interface Props extends PageProps {
|
||||
project: ProjectData;
|
||||
report: Report;
|
||||
}
|
||||
|
||||
const statusLabel = (s: string) => s.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
const formatNumber = (v: string | number) => new Intl.NumberFormat('en-PH').format(Number(v));
|
||||
const formatCurrency = (v: string | number) => new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(Number(v));
|
||||
|
||||
const statusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'draft': return 'bg-gray-100 text-gray-700';
|
||||
case 'submitted': return 'bg-blue-100 text-blue-700';
|
||||
case 'in_review': return 'bg-yellow-100 text-yellow-700';
|
||||
case 'approved': return 'bg-green-100 text-green-700';
|
||||
case 'rejected': return 'bg-red-100 text-red-700';
|
||||
default: return 'bg-gray-100 text-gray-700';
|
||||
}
|
||||
};
|
||||
|
||||
function KpiCard({ icon: Icon, label, value, sub, className }: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-xl ${className || 'bg-gray-100'}`}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-0.5">{label}</p>
|
||||
<p className="text-xl font-bold tracking-tight">{value}</p>
|
||||
{sub && <p className="text-xs text-gray-400 mt-0.5">{sub}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function IncidentRow({ label, value, isCurrency }: { label: string; value: number | string; isCurrency?: boolean }) {
|
||||
const numVal = Number(value);
|
||||
const isZero = numVal === 0;
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<span className="text-sm text-gray-600">{label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm font-semibold tabular-nums ${isZero ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{isCurrency ? formatCurrency(value) : numVal}
|
||||
</span>
|
||||
{isZero ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Show({ project, report }: Props) {
|
||||
const { flash } = usePage<PageProps>().props;
|
||||
const wf = report.workforce_metric;
|
||||
const hse = report.hse_record;
|
||||
const canEdit = report.status === 'draft' || report.status === 'rejected';
|
||||
const canSubmit = report.status === 'draft';
|
||||
|
||||
const daysRemaining = project.target_end_date
|
||||
? Math.max(0, Math.ceil((new Date(project.target_end_date).getTime() - Date.now()) / 86400000))
|
||||
: null;
|
||||
|
||||
const totalIncidents = hse
|
||||
? hse.fatalities + hse.major_injuries + hse.first_aid_cases + hse.medical_cases
|
||||
+ hse.near_misses + hse.environmental_damage + hse.property_damage
|
||||
: 0;
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (confirm('Submit this report for approval?')) {
|
||||
router.patch(route('projects.reports.submit', [project.ulid, report.ulid]));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
header={
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={route('projects.reports.index', project.ulid)}>
|
||||
<Button variant="ghost" size="icon-sm"><ArrowLeft className="h-4 w-4" /></Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold leading-tight text-gray-800">{project.name}</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
{new Date(report.period_start).toLocaleDateString('en-PH', { month: 'short', day: 'numeric' })}
|
||||
{' – '}
|
||||
{new Date(report.period_end).toLocaleDateString('en-PH', { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-medium ${statusColor(report.status)}`}>
|
||||
{statusLabel(report.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{canSubmit && (
|
||||
<Button size="sm" onClick={handleSubmit}>
|
||||
<Send className="mr-2 h-4 w-4" /> Submit for Approval
|
||||
</Button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Link href={route('projects.reports.edit', [project.ulid, report.ulid])}>
|
||||
<Button variant="outline" size="sm"><Pencil className="mr-2 h-4 w-4" /> Edit</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Head title={`Report - ${project.name}`} />
|
||||
|
||||
<div className="py-6">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
{flash?.success && <div className="mb-4 rounded-md bg-green-50 p-4 text-sm text-green-700">{flash.success}</div>}
|
||||
{flash?.error && <div className="mb-4 rounded-md bg-red-50 p-4 text-sm text-red-700">{flash.error}</div>}
|
||||
|
||||
{/* Project Header Info */}
|
||||
<div className="mb-6 flex flex-wrap items-center gap-4 text-sm text-gray-500">
|
||||
{project.location && (
|
||||
<span className="flex items-center gap-1"><MapPin className="h-4 w-4" />{project.location}</span>
|
||||
)}
|
||||
{project.customer && (
|
||||
<span className="flex items-center gap-1"><Users className="h-4 w-4" />Client: {project.customer.name}</span>
|
||||
)}
|
||||
{project.contractor && (
|
||||
<span className="flex items-center gap-1"><HardHat className="h-4 w-4" />Contractor: {project.contractor.company_name}</span>
|
||||
)}
|
||||
{daysRemaining !== null && (
|
||||
<span className="flex items-center gap-1"><Calendar className="h-4 w-4" />{daysRemaining} days remaining</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* KPI Cards Row */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4 mb-6">
|
||||
<KpiCard
|
||||
icon={Clock}
|
||||
label="Total Man-hours"
|
||||
value={wf ? formatNumber(wf.cumulative_man_hours) : '—'}
|
||||
sub={wf ? `${formatNumber(wf.period_man_hours)} this period` : undefined}
|
||||
className="bg-blue-50 text-blue-600"
|
||||
/>
|
||||
<KpiCard
|
||||
icon={Users}
|
||||
label="Active Workers"
|
||||
value={wf ? String(wf.active_workforce) : '—'}
|
||||
className="bg-amber-50 text-amber-600"
|
||||
/>
|
||||
<KpiCard
|
||||
icon={ShieldCheck}
|
||||
label="Safety Status"
|
||||
value={totalIncidents === 0 ? 'Zero Incidents ✓' : `${totalIncidents} Incident(s)`}
|
||||
className={totalIncidents === 0 ? 'bg-green-50 text-green-600' : 'bg-red-50 text-red-600'}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={Truck}
|
||||
label="Logistics"
|
||||
value={wf ? `${formatNumber(wf.logistics_km)} km` : '—'}
|
||||
className="bg-indigo-50 text-indigo-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* HSE Performance */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ShieldCheck className="h-5 w-5 text-emerald-600" />
|
||||
HSE Performance
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Proactive */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-2">Proactive Measures</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-lg bg-emerald-50 p-3 text-center">
|
||||
<p className="text-2xl font-bold text-emerald-700">{hse?.toolbox_meetings ?? 0}</p>
|
||||
<p className="text-xs text-emerald-600">Toolbox Meetings</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-emerald-50 p-3 text-center">
|
||||
<p className="text-2xl font-bold text-emerald-700">{hse?.safety_observations ?? 0}</p>
|
||||
<p className="text-xs text-emerald-600">Safety Observations</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Reactive Breakdown */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-2">
|
||||
Incident Tracker
|
||||
{totalIncidents === 0 && (
|
||||
<span className="ml-2 text-green-500 normal-case font-medium">All Clear</span>
|
||||
)}
|
||||
</h4>
|
||||
<div className="space-y-0.5">
|
||||
<IncidentRow label="Fatalities / Major Injuries" value={(hse?.fatalities ?? 0) + (hse?.major_injuries ?? 0)} />
|
||||
<IncidentRow label="First Aid Cases" value={hse?.first_aid_cases ?? 0} />
|
||||
<IncidentRow label="Medical Cases" value={hse?.medical_cases ?? 0} />
|
||||
<IncidentRow label="Near Misses" value={hse?.near_misses ?? 0} />
|
||||
<IncidentRow label="Environmental Damage" value={hse?.environmental_damage ?? 0} />
|
||||
<IncidentRow label="Property Damage" value={hse?.property_damage ?? 0} />
|
||||
<IncidentRow label="Fines / Costs" value={hse?.fines ?? 0} isCurrency />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Narrative */}
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<FileText className="h-5 w-5 text-blue-600" />
|
||||
Weekly Narrative
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{report.narrative_status && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-1">Operations Status</h4>
|
||||
<p className="text-sm text-gray-700 leading-relaxed">{report.narrative_status}</p>
|
||||
</div>
|
||||
)}
|
||||
{report.narrative_weather && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-1">Weather Impact</h4>
|
||||
<p className="text-sm text-gray-700 leading-relaxed">{report.narrative_weather}</p>
|
||||
</div>
|
||||
)}
|
||||
{report.narrative_compliance && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-1">Compliance</h4>
|
||||
<p className="text-sm text-gray-700 leading-relaxed">{report.narrative_compliance}</p>
|
||||
</div>
|
||||
)}
|
||||
{!report.narrative_status && !report.narrative_weather && !report.narrative_compliance && (
|
||||
<p className="text-sm text-gray-400 italic">No narrative provided.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Approval Chain */}
|
||||
{report.approval_chains && report.approval_chains.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
||||
Approval Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{report.approval_chains.map(chain => (
|
||||
<div key={chain.id} className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={chain.status === 'approved' ? 'default' : chain.status === 'rejected' ? 'destructive' : 'outline'}>
|
||||
{statusLabel(chain.status)}
|
||||
</Badge>
|
||||
<span className="text-xs text-gray-400">
|
||||
{new Date(chain.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
{chain.steps.map(step => (
|
||||
<div key={step.id} className="flex items-center gap-3 pl-4 text-sm">
|
||||
{step.status === 'approved' && <CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />}
|
||||
{step.status === 'rejected' && <XCircle className="h-4 w-4 text-red-500 shrink-0" />}
|
||||
{step.status === 'pending' && <Clock className="h-4 w-4 text-gray-400 shrink-0" />}
|
||||
{step.status === 'skipped' && <XCircle className="h-4 w-4 text-gray-300 shrink-0" />}
|
||||
<span className="text-gray-700">{step.approver.name}</span>
|
||||
{step.notes && <span className="text-gray-400 text-xs">— {step.notes}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Report Meta */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">Submitted By</p>
|
||||
<p className="font-medium">{report.submitter?.name || '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">Approved By</p>
|
||||
<p className="font-medium">{report.approver?.name || '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">Created</p>
|
||||
<p className="font-medium">{new Date(report.created_at).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
0
Modules/ProjectManagement/resources/views/.gitkeep
Normal file
0
Modules/ProjectManagement/resources/views/.gitkeep
Normal file
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
|
||||
<title>ProjectManagement Module - {{ config('app.name', 'Laravel') }}</title>
|
||||
|
||||
<meta name="description" content="{{ $description ?? '' }}">
|
||||
<meta name="keywords" content="{{ $keywords ?? '' }}">
|
||||
<meta name="author" content="{{ $author ?? '' }}">
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
|
||||
|
||||
{{-- Vite CSS --}}
|
||||
{{-- {{ module_vite('build-projectmanagement', 'resources/assets/sass/app.scss') }} --}}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{{ $slot }}
|
||||
|
||||
{{-- Vite JS --}}
|
||||
{{-- {{ module_vite('build-projectmanagement', 'resources/assets/js/app.js') }} --}}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,5 @@
|
||||
<x-projectmanagement::layouts.master>
|
||||
<h1>Hello World</h1>
|
||||
|
||||
<p>Module: {!! config('projectmanagement.name') !!}</p>
|
||||
</x-projectmanagement::layouts.master>
|
||||
0
Modules/ProjectManagement/routes/.gitkeep
Normal file
0
Modules/ProjectManagement/routes/.gitkeep
Normal file
8
Modules/ProjectManagement/routes/api.php
Normal file
8
Modules/ProjectManagement/routes/api.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\ProjectManagement\Http\Controllers\ProjectManagementController;
|
||||
|
||||
Route::middleware(['auth:sanctum'])->prefix('v1')->group(function () {
|
||||
Route::apiResource('projectmanagements', ProjectManagementController::class)->names('projectmanagement');
|
||||
});
|
||||
17
Modules/ProjectManagement/routes/web.php
Normal file
17
Modules/ProjectManagement/routes/web.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\ProjectManagement\Http\Controllers\ProjectController;
|
||||
|
||||
Route::middleware(['web', 'auth', 'permission:projects.access'])->group(function () {
|
||||
Route::resource('projects', ProjectController::class);
|
||||
Route::patch('projects/{project}/transition', [ProjectController::class, 'transition'])->name('projects.transition');
|
||||
|
||||
// Additional Project Views
|
||||
Route::get('projects/{project}/team', [ProjectController::class, 'team'])->name('projects.team');
|
||||
Route::get('projects/{project}/materials', [ProjectController::class, 'materials'])->name('projects.materials');
|
||||
|
||||
// Personnel Management
|
||||
Route::post('projects/{project}/personnel', [ProjectController::class, 'addPersonnel'])->name('projects.personnel.add');
|
||||
Route::delete('projects/{project}/personnel/{user}', [ProjectController::class, 'removePersonnel'])->name('projects.personnel.remove');
|
||||
});
|
||||
0
Modules/ProjectManagement/tests/Feature/.gitkeep
Normal file
0
Modules/ProjectManagement/tests/Feature/.gitkeep
Normal file
0
Modules/ProjectManagement/tests/Unit/.gitkeep
Normal file
0
Modules/ProjectManagement/tests/Unit/.gitkeep
Normal file
41
Modules/ProjectManagement/vite.config.js
Normal file
41
Modules/ProjectManagement/vite.config.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import laravel from 'laravel-vite-plugin';
|
||||
// Uncomment the import for your frontend framework:
|
||||
// import vue from '@vitejs/plugin-vue';
|
||||
// import react from '@vitejs/plugin-react';
|
||||
// import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: '../../public/build-projectmanagement',
|
||||
emptyOutDir: true,
|
||||
manifest: true,
|
||||
},
|
||||
plugins: [
|
||||
laravel({
|
||||
publicDirectory: '../../public',
|
||||
buildDirectory: 'build-projectmanagement',
|
||||
input: [
|
||||
__dirname + '/resources/assets/sass/app.scss',
|
||||
__dirname + '/resources/assets/js/app.js'
|
||||
],
|
||||
refresh: true,
|
||||
}),
|
||||
// Uncomment the plugin for your frontend framework:
|
||||
// vue({
|
||||
// template: {
|
||||
// transformAssetUrls: {
|
||||
// base: null,
|
||||
// includeAbsolute: false,
|
||||
// },
|
||||
// },
|
||||
// }),
|
||||
// react(),
|
||||
// svelte(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': __dirname + '/resources/js',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user