Files
GSB-Construction/Modules/ProjectManagement/resources/js/Pages/Projects/Show.tsx
Ajjj b98951f65f
Some checks failed
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
Tests / PHP 8.5 (push) Has been cancelled
feat: decompose project god components, add database performance indexes, and isolate contractor admin retention
2026-08-14 16:20:15 +08:00

364 lines
17 KiB
TypeScript

import React, { FormEvent, useState } from 'react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link, router, useForm, usePage } from '@inertiajs/react';
import { usePermission } from '@/hooks/usePermission';
import { Badge } from '@/Components/ui/badge';
import { Button } from '@/Components/ui/button';
import { Card, CardContent } from '@/Components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/Components/ui/tabs';
import {
ArrowLeft, Pencil, Play, CheckCircle2,
Calendar, DollarSign, TrendingUp, Users, ClipboardList,
Package, Timer
} from 'lucide-react';
import { PageProps } from '@/types';
import { ProjectShowProps, TaskItem } from '../../types/project-show';
import OverviewTab from './Show/OverviewTab';
import TimelineTab from './Show/TimelineTab';
import TasksTab from './Show/TasksTab';
import TeamTab from './Show/TeamTab';
import MaterialsTab from './Show/MaterialsTab';
import ReportsTab from './Show/ReportsTab';
import ProgressTab from './Show/ProgressTab';
const statusLabel = (s: string) => s.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
const renderStatusBadge = (status: string) => {
const label = status.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
switch (status) {
case 'pending':
return <Badge className="bg-slate-100 text-slate-800 border-slate-200 hover:bg-slate-100">{label}</Badge>;
case 'in_progress':
return <Badge className="bg-blue-100 text-blue-800 border-blue-200 hover:bg-blue-100">{label}</Badge>;
case 'completed':
return <Badge className="bg-emerald-100 text-emerald-800 border-emerald-200 hover:bg-emerald-100">{label}</Badge>;
case 'blocked':
return <Badge className="bg-rose-100 text-rose-800 border-rose-200 hover:bg-rose-100">{label}</Badge>;
case 'closed':
return <Badge className="bg-orange-100 text-orange-800 border-orange-200 hover:bg-orange-100">{label}</Badge>;
default:
return <Badge variant="outline">{label}</Badge>;
}
};
const formatCurrency = (v: string | number) =>
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 Show({
project,
employees,
availableMaterials,
latestReports,
taskStats,
milestones,
milestoneStats,
weatherConditions,
delayReasons,
allowedTransitions,
evmData
}: ProjectShowProps) {
const { flash } = usePage<PageProps>().props;
const { can } = usePermission();
const taskForm = useForm({
project_id: project?.id || '',
name: '',
description: '',
assigned_users: [] as string[],
labor_cost: '',
equipment_cost: '',
estimated_hours: '',
start_date: '',
end_date: '',
milestone_id: '',
});
const editForm = useForm({
name: '',
description: '',
assigned_users: [] as string[],
labor_cost: '',
equipment_cost: '',
estimated_hours: '',
start_date: '',
end_date: '',
milestone_id: '',
});
const [editingTask, setEditingTask] = useState<TaskItem | null>(null);
const openEditModal = (task: TaskItem) => {
setEditingTask(task);
editForm.setData({
name: task.name || '',
description: task.description || '',
assigned_users: task.users?.map((u) => u.ulid) || [],
labor_cost: task.labor_cost || '',
equipment_cost: task.equipment_cost || '',
estimated_hours: task.estimated_hours || '',
start_date: task.start_date ? task.start_date.split('T')[0] : '',
end_date: task.end_date ? task.end_date.split('T')[0] : '',
milestone_id: task.milestone?.ulid || '',
});
};
const handleEditTask = (e: FormEvent) => {
e.preventDefault();
if (!editingTask) return;
editForm.put(route('projects.tasks.update', editingTask.ulid), {
onSuccess: () => { setEditingTask(null); },
});
};
const memberForm = useForm({ user_id: '', role: 'member' });
const handleAddTask = (e: FormEvent) => {
e.preventDefault();
taskForm.post(route('projects.tasks.store', project.ulid), {
onSuccess: () => { taskForm.reset(); },
});
};
const handleUpdateMaterial = (taskUlid: string, tmUlid: string, field: string, value: string) => {
router.put(route('projects.tasks.materials.update', [project.ulid, taskUlid, tmUlid]), {
[field]: value,
}, { preserveScroll: true });
};
const handleDeleteMaterial = (taskUlid: string, tmUlid: string) => {
if (confirm('Remove this material from the task?')) {
router.delete(route('projects.tasks.materials.destroy', [project.ulid, taskUlid, tmUlid]), { preserveScroll: true });
}
};
const handleAddMember = (e: FormEvent) => {
e.preventDefault();
memberForm.post(route('projects.personnel.add', project.ulid), {
onSuccess: () => { memberForm.reset(); },
});
};
const handleTransition = (status: string) => {
router.patch(route('projects.transition', project.ulid), { status });
};
const handleTaskTransition = (taskUlid: string, status: string) => {
router.patch(route('projects.tasks.transition', taskUlid), { status });
};
const handleDeleteTask = (taskUlid: string) => {
if (confirm('Delete this task?')) {
router.delete(route('projects.tasks.destroy', taskUlid));
}
};
const handleRemoveMember = (userUlid: string) => {
if (confirm('Remove this team member?')) {
router.delete(route('projects.personnel.remove', [project.ulid, userUlid]));
}
};
const params = new URLSearchParams(typeof window !== 'undefined' ? window.location.search : '');
const backTo = params.get('back_to');
const backUrl = backTo === 'inventory' ? route('inventory.index') : route('projects.index');
return (
<AuthenticatedLayout
header={
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-4">
<Link href={backUrl}>
<Button variant="ghost" size="icon-sm"><ArrowLeft className="h-4 w-4" /></Button>
</Link>
<div>
<div className="flex items-baseline gap-2">
<h2 className="text-xl font-semibold leading-tight text-gray-800">{project.name}</h2>
<Badge variant="outline" className="capitalize shrink-0">{statusLabel(project.status)}</Badge>
</div>
<p className="text-sm text-gray-500 mt-0.5">{project.code}</p>
</div>
</div>
<div className="flex items-center gap-2">
{can('edit', 'projects') && allowedTransitions.map((t) => (
<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>
))}
{can('edit', 'projects') && (
<Link href={route('projects.edit', project.ulid)}>
<Button size="sm"><Pencil className="mr-2 h-4 w-4" /> Edit</Button>
</Link>
)}
</div>
</div>
}
>
<Head title={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>}
{/* 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(milestoneStats.completion_percentage).toFixed(0)}%`}
sub={`${taskStats.completed} / ${taskStats.total} tasks done`}
/>
</div>
<Tabs defaultValue="tasks">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="progress">
<TrendingUp className="mr-1 h-4 w-4" /> Progress (EVM)
</TabsTrigger>
<TabsTrigger value="timeline">
<Timer className="mr-1 h-4 w-4" /> Timeline
</TabsTrigger>
<TabsTrigger value="tasks">Tasks ({project.tasks.length})</TabsTrigger>
<TabsTrigger value="team">Team ({project.personnel.length})</TabsTrigger>
<TabsTrigger value="materials">
<Package className="mr-1 h-4 w-4" /> Materials ({availableMaterials.length})
</TabsTrigger>
<TabsTrigger value="reports">
<ClipboardList className="mr-1 h-4 w-4" /> Reports ({latestReports.length})
</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<OverviewTab project={project} taskStats={taskStats} formatCurrency={formatCurrency} />
</TabsContent>
<TabsContent value="progress">
<ProgressTab project={project} evmData={evmData} />
</TabsContent>
<TabsContent value="timeline">
<TimelineTab
project={project}
milestones={milestones}
milestoneStats={milestoneStats}
weatherConditions={weatherConditions}
delayReasons={delayReasons}
statusLabel={statusLabel}
/>
</TabsContent>
<TabsContent value="tasks">
<TasksTab
project={project}
employees={employees}
availableMaterials={availableMaterials}
milestones={milestones}
can={can}
formatCurrency={formatCurrency}
renderStatusBadge={renderStatusBadge}
taskForm={taskForm}
handleAddTask={handleAddTask}
editForm={editForm}
handleEditTask={handleEditTask}
editingTask={editingTask}
setEditingTask={setEditingTask}
openEditModal={openEditModal}
handleTaskTransition={handleTaskTransition}
handleDeleteTask={handleDeleteTask}
handleUpdateMaterial={handleUpdateMaterial}
handleDeleteMaterial={handleDeleteMaterial}
/>
</TabsContent>
<TabsContent value="team">
<TeamTab
project={project}
employees={employees}
memberForm={memberForm}
handleAddMember={handleAddMember}
handleRemoveMember={handleRemoveMember}
statusLabel={statusLabel}
/>
</TabsContent>
<TabsContent value="materials">
<MaterialsTab availableMaterials={availableMaterials} />
</TabsContent>
<TabsContent value="reports">
<ReportsTab project={project} latestReports={latestReports} statusLabel={statusLabel} />
</TabsContent>
</Tabs>
</div>
</div>
</AuthenticatedLayout>
);
}