364 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|