chore: update document approval workflow and bug fixes

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

View File

@@ -0,0 +1,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',
};
}
}

View 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 => [],
};
}
}

View 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',
};
}
}

View 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],
};
}
}

View 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 => '☁️',
};
}
}

View File

@@ -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,
) {}
}

View 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,
) {}
}

View 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]],
];
}
}

View File

@@ -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,
]);
}
}
}

View File

@@ -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) {}
}

View 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;
}
}

View 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],
];
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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',
]);
}
}

View 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);
}
}

View File

@@ -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();
// }
}

View File

@@ -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'));
}
}

View 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/"
}
}
}

View File

@@ -0,0 +1,5 @@
<?php
return [
'name' => 'ProjectManagement',
];

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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');
});
}
};

View File

@@ -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');
}
};

View File

@@ -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');
});
}
}
};

View File

@@ -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']);
});
}
};

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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']);
});
}
};

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View 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']);
}
}
}

View File

@@ -0,0 +1,11 @@
{
"name": "ProjectManagement",
"alias": "projectmanagement",
"description": "",
"keywords": [],
"priority": 0,
"providers": [
"Modules\\ProjectManagement\\Providers\\ProjectManagementServiceProvider"
],
"files": []
}

View 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"
}
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>

View File

@@ -0,0 +1,5 @@
<x-projectmanagement::layouts.master>
<h1>Hello World</h1>
<p>Module: {!! config('projectmanagement.name') !!}</p>
</x-projectmanagement::layouts.master>

View 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');
});

View 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');
});

View 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',
},
},
});