923 lines
67 KiB
TypeScript
923 lines
67 KiB
TypeScript
import ProjectLayout from '../../Layouts/ProjectLayout';
|
|
import EvmSCurveChart from '../../Components/EvmSCurveChart';
|
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/Components/ui/card';
|
|
import { MapPin, Users, TrendingUp, DollarSign, Calendar, AlertTriangle, ArrowRight, Activity, ShieldCheck, FileText, ClipboardList, Hammer, Truck, Layers, Milestone as MilestoneIcon, Tags, FileSpreadsheet, FileDown } from 'lucide-react';
|
|
import { PageProps } from '@/types';
|
|
import { Badge } from '@/Components/ui/badge';
|
|
import { Link } from '@inertiajs/react';
|
|
import { Button } from '@/Components/ui/button';
|
|
import { useState } from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/Components/ui/tabs';
|
|
|
|
interface ChildProject {
|
|
id: number;
|
|
ulid: string;
|
|
name: string;
|
|
code: string;
|
|
status: string;
|
|
contract_value: string;
|
|
total_capitalization: string;
|
|
start_date?: string;
|
|
target_end_date?: string;
|
|
}
|
|
|
|
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;
|
|
is_unprofitable: boolean;
|
|
project_type: string;
|
|
classifications?: string[];
|
|
extension_projects?: ChildProject[];
|
|
rollup_contract_value?: string | number;
|
|
rollup_capitalization?: string | number;
|
|
parent_project?: { id: number; ulid: string; name: string; code: string } | null;
|
|
milestones?: {
|
|
id: number;
|
|
ulid: string;
|
|
name: string;
|
|
weight_percentage: string | number;
|
|
}[];
|
|
materials_estimates?: {
|
|
id: number;
|
|
ulid: string;
|
|
estimated_qty: string | number;
|
|
unit_cost: string | number;
|
|
material: {
|
|
id: number;
|
|
ulid: string;
|
|
name: string;
|
|
sku?: string;
|
|
unit: string;
|
|
category?: string;
|
|
};
|
|
}[];
|
|
tasks: {
|
|
id: number;
|
|
ulid: string;
|
|
name: string;
|
|
description?: string;
|
|
status: string;
|
|
estimated_hours: string | number;
|
|
task_labors?: {
|
|
id: number;
|
|
estimated_hours: string | number;
|
|
labor: {
|
|
id: number;
|
|
name: string;
|
|
category: string;
|
|
hourly_rate: string | number;
|
|
};
|
|
}[];
|
|
task_equipments?: {
|
|
id: number;
|
|
estimated_hours: string | number;
|
|
equipment: {
|
|
id: number;
|
|
name: string;
|
|
owner_name?: string;
|
|
hourly_rate: string | number;
|
|
};
|
|
}[];
|
|
}[];
|
|
}
|
|
|
|
interface StatusOption { value: string; label: string }
|
|
|
|
interface Props extends PageProps {
|
|
project: ProjectData;
|
|
taskStats: { total: number; pending: number; in_progress: number; completed: number; closed?: number };
|
|
allowedTransitions: StatusOption[];
|
|
estimationTotals?: {
|
|
materials: number;
|
|
labor: number;
|
|
equipment: number;
|
|
sum: number;
|
|
margin: number;
|
|
margin_pct: number;
|
|
};
|
|
evmData?: any;
|
|
tab?: string;
|
|
}
|
|
|
|
const CLASSIFICATION_LABEL_MAP: Record<string, string> = {
|
|
'road highway, pavement, railways, airport horizontal structures and bridges': 'Road, Highway, Pavement, Railways, Airport Horizontal Structures and Bridges',
|
|
'road, highway, pavement, railways, airport horizontal structures and bridges': 'Road, Highway, Pavement, Railways, Airport Horizontal Structures and Bridges',
|
|
'irrigation and flood control': 'Irrigation and Flood Control',
|
|
'dam, reservoir, and tunneling': 'Dam, Reservoir, and Tunneling',
|
|
'dam, reservoir and tunneling': 'Dam, Reservoir, and Tunneling',
|
|
'water supply': 'Water Supply',
|
|
'port, harbor and offshore engineering': 'Port, Harbor and Offshore Engineering',
|
|
'building and industrial plant': 'Building and Industrial Plant',
|
|
'sewerage treatment/disposal plant': 'Sewerage Treatment / Disposal Plant',
|
|
'sewerage treatment / disposal plant': 'Sewerage Treatment / Disposal Plant',
|
|
'water treatment plant and system': 'Water Treatment Plant and System',
|
|
'park, playground and recreational work': 'Park, Playground and Recreational Work',
|
|
'electrical work': 'Electrical Work',
|
|
};
|
|
|
|
const formatClassificationTag = (tag: string): string => {
|
|
if (!tag) return '';
|
|
const trimmed = tag.trim();
|
|
const lower = trimmed.toLowerCase();
|
|
return CLASSIFICATION_LABEL_MAP[lower] || CLASSIFICATION_LABEL_MAP[trimmed] || trimmed;
|
|
};
|
|
|
|
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 Overview({ project, taskStats, allowedTransitions, estimationTotals, tab, evmData }: Props) {
|
|
const [activeTab, setActiveTab] = useState(tab || 'overview');
|
|
|
|
return (
|
|
<ProjectLayout project={project} allowedTransitions={allowedTransitions} currentTab="overview">
|
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
|
<TabsList className="bg-slate-100/80 border border-slate-200/50 p-1 rounded-xl">
|
|
<TabsTrigger
|
|
value="overview"
|
|
className="rounded-lg px-4 py-2 text-sm font-medium transition-all data-[state=active]:bg-white data-[state=active]:shadow-sm flex items-center gap-2"
|
|
>
|
|
<ClipboardList className="h-4 w-4" /> Overview Dashboard
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="progress"
|
|
className="rounded-lg px-4 py-2 text-sm font-medium transition-all data-[state=active]:bg-white data-[state=active]:shadow-sm flex items-center gap-2"
|
|
>
|
|
<TrendingUp className="h-4 w-4" /> Progress Monitoring (EVM)
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="estimation"
|
|
className="rounded-lg px-4 py-2 text-sm font-medium transition-all data-[state=active]:bg-white data-[state=active]:shadow-sm flex items-center gap-2"
|
|
>
|
|
<FileText className="h-4 w-4" /> Estimation Review
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<a href={route('projects.wizard.export.excel', project.ulid)} target="_blank" rel="noopener noreferrer">
|
|
<Button type="button" variant="outline" size="sm" className="h-9 border-slate-200 text-slate-700 hover:bg-slate-50 flex items-center gap-1.5 font-semibold shadow-2xs cursor-pointer">
|
|
<FileSpreadsheet className="h-4 w-4 text-emerald-600" />
|
|
Export Excel
|
|
</Button>
|
|
</a>
|
|
<a href={route('projects.wizard.export.pdf', project.ulid)} target="_blank" rel="noopener noreferrer">
|
|
<Button type="button" variant="outline" size="sm" className="h-9 border-slate-200 text-slate-700 hover:bg-slate-50 flex items-center gap-1.5 font-semibold shadow-2xs cursor-pointer">
|
|
<FileDown className="h-4 w-4 text-rose-600" />
|
|
Export PDF
|
|
</Button>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<TabsContent value="overview" className="mt-0 space-y-6 focus-visible:outline-none">
|
|
{/* Unprofitable Warning Banner */}
|
|
{project.is_unprofitable && (
|
|
<div className="p-4 rounded-xl border border-amber-200 bg-amber-50/70 text-amber-900 flex items-start gap-3 shadow-sm">
|
|
<AlertTriangle className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
|
|
<div>
|
|
<h4 className="font-semibold text-sm">Special Handling: Unprofitable / Loss-Making Project Flagged</h4>
|
|
<p className="text-xs text-amber-700 mt-1">
|
|
This project has been explicitly flagged as unprofitable. Budget overruns, procurement approvals, and requisition validation will be visually highlighted for special auditing.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Parent Project Linked Alert */}
|
|
{project.project_type === 'extension' && project.parent_project && (
|
|
<div className="p-4 rounded-xl border border-slate-200 bg-slate-50 text-slate-800 flex items-center justify-between shadow-sm">
|
|
<div className="flex items-center gap-3">
|
|
<ShieldCheck className="h-5 w-5 text-emerald-600 shrink-0" />
|
|
<div>
|
|
<h4 className="font-semibold text-sm">Extension Project / Variation Order</h4>
|
|
<p className="text-xs text-slate-500 mt-0.5">
|
|
This project is linked to parent project: <strong className="text-slate-700">{project.parent_project.code}</strong>. Tasks and capitalization values roll up recursively.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Link href={route('projects.show', project.parent_project.ulid)}>
|
|
<Button variant="outline" size="sm" className="gap-1.5">
|
|
View Parent <ArrowRight className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
)}
|
|
|
|
{/* Stat Cards */}
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
<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>
|
|
|
|
{project.classifications && project.classifications.length > 0 && (
|
|
<div>
|
|
<p className="text-xs text-gray-500 mb-1.5 flex items-center gap-1.5 font-medium">
|
|
<Tags className="h-3.5 w-3.5 text-emerald-600" /> Project Classifications
|
|
</p>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{project.classifications.map((tag: string, idx: number) => (
|
|
<Badge
|
|
key={idx}
|
|
variant="outline"
|
|
className="bg-emerald-50/90 text-emerald-900 border-emerald-200 text-xs py-0.5 px-2.5 font-medium shadow-2xs"
|
|
>
|
|
{formatClassificationTag(tag)}
|
|
</Badge>
|
|
))}
|
|
</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 || 0) + (taskStats.closed || 0)}</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 || 0) + (taskStats.closed || 0)) / taskStats.total) * 100}%` : '0%' }}
|
|
/>
|
|
</div>
|
|
<p className="text-xs text-gray-400 mt-1">
|
|
{taskStats.total > 0 ? `${((((taskStats.completed || 0) + (taskStats.closed || 0)) / 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.closed || 0) > 0 && (
|
|
<div
|
|
className="bg-orange-500 transition-all duration-500"
|
|
style={{ width: `${((taskStats.closed || 0) / taskStats.total) * 100}%` }}
|
|
/>
|
|
)}
|
|
{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: 'Closed', count: taskStats.closed || 0, color: 'bg-orange-500', textColor: 'text-orange-600' },
|
|
{ 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>
|
|
|
|
{/* Project Extensions & Financial Rollups */}
|
|
{project.project_type !== 'extension' && (
|
|
<Card className="border-slate-200/80 shadow-sm overflow-hidden">
|
|
<CardHeader className="bg-slate-50/50 border-b border-slate-100 flex flex-row items-center justify-between py-4">
|
|
<div>
|
|
<CardTitle className="text-md font-medium text-slate-800 flex items-center gap-2">
|
|
<TrendingUp className="h-5 w-5 text-emerald-600" /> Project Extensions & Variation Orders
|
|
</CardTitle>
|
|
<CardDescription className="text-xs text-slate-500">
|
|
Financial and task progress rollup aggregates for parent project and child variations.
|
|
</CardDescription>
|
|
</div>
|
|
<Badge className="bg-emerald-50 text-emerald-700 border-emerald-100 hover:bg-emerald-50 font-mono">
|
|
{project.extension_projects?.length || 0} Extension(s)
|
|
</Badge>
|
|
</CardHeader>
|
|
<CardContent className="p-6 space-y-6">
|
|
{/* Rollup Financials Summary */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
<div className="bg-slate-50/60 p-4 rounded-xl border border-slate-100">
|
|
<span className="text-xs text-slate-400 font-medium">Combined Contract Value</span>
|
|
<h4 className="text-lg font-bold text-slate-900 mt-1">{formatCurrency(project.rollup_contract_value || project.contract_value)}</h4>
|
|
<span className="text-[10px] text-slate-400 mt-1 block">Includes parent & child contracts</span>
|
|
</div>
|
|
<div className="bg-slate-50/60 p-4 rounded-xl border border-slate-100">
|
|
<span className="text-xs text-slate-400 font-medium">Combined Capitalization</span>
|
|
<h4 className="text-lg font-bold text-slate-900 mt-1">{formatCurrency(project.rollup_capitalization || project.total_capitalization)}</h4>
|
|
<span className="text-[10px] text-slate-400 mt-1 block">Combined actual spent costs</span>
|
|
</div>
|
|
<div className="bg-slate-50/60 p-4 rounded-xl border border-slate-100">
|
|
<span className="text-xs text-slate-400 font-medium">Combined Capitalization %</span>
|
|
<h4 className="text-lg font-bold text-slate-900 mt-1">
|
|
{((Number(project.rollup_capitalization || project.total_capitalization) /
|
|
Math.max(Number(project.rollup_contract_value || project.contract_value), 1)) * 100).toFixed(1)}%
|
|
</h4>
|
|
<span className="text-[10px] text-slate-400 mt-1 block">Combined budget utilization rate</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Combined Capitalization Progress Bar */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between text-xs font-semibold text-slate-700">
|
|
<span>Combined Cost Capitalization Progress</span>
|
|
<span>
|
|
{formatCurrency(project.rollup_capitalization || project.total_capitalization)} / {formatCurrency(project.rollup_contract_value || project.contract_value)}
|
|
</span>
|
|
</div>
|
|
<div className="h-2 rounded-full bg-slate-100 overflow-hidden border border-slate-200/50">
|
|
<div
|
|
className={`h-full rounded-full transition-all duration-500 ${
|
|
Number(project.rollup_capitalization || project.total_capitalization) > Number(project.rollup_contract_value || project.contract_value)
|
|
? 'bg-rose-500'
|
|
: 'bg-emerald-500'
|
|
}`}
|
|
style={{
|
|
width: `${Math.min(
|
|
(Number(project.rollup_capitalization || project.total_capitalization) /
|
|
Math.max(Number(project.rollup_contract_value || project.contract_value), 1)) * 100,
|
|
100
|
|
)}%`
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Extensions List Table */}
|
|
{project.extension_projects && project.extension_projects.length > 0 ? (
|
|
<div className="border border-slate-150 rounded-lg overflow-hidden">
|
|
<table className="w-full text-left border-collapse text-sm">
|
|
<thead>
|
|
<tr className="bg-slate-50 border-b border-slate-150 font-medium text-slate-650">
|
|
<th className="py-2.5 px-4">Code & Project Name</th>
|
|
<th className="py-2.5 px-4">Status</th>
|
|
<th className="py-2.5 px-4">Contract Value</th>
|
|
<th className="py-2.5 px-4">Capitalization</th>
|
|
<th className="py-2.5 px-4">Timeline</th>
|
|
<th className="py-2.5 px-4 text-right">Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-150">
|
|
{project.extension_projects.map((ext) => (
|
|
<tr key={ext.id} className="hover:bg-slate-50/30 transition-colors">
|
|
<td className="py-3 px-4 font-semibold text-slate-800">
|
|
<span className="font-mono text-slate-500 text-xs block">{ext.code}</span>
|
|
{ext.name}
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
<Badge className="bg-slate-100 text-slate-700 hover:bg-slate-100 capitalize">
|
|
{ext.status.replace(/_/g, ' ')}
|
|
</Badge>
|
|
</td>
|
|
<td className="py-3 px-4 text-slate-700">{formatCurrency(ext.contract_value)}</td>
|
|
<td className="py-3 px-4 text-slate-700">{formatCurrency(ext.total_capitalization)}</td>
|
|
<td className="py-3 px-4 text-xs text-slate-500">
|
|
{ext.start_date ? new Date(ext.start_date).toLocaleDateString() : 'N/A'} - {ext.target_end_date ? new Date(ext.target_end_date).toLocaleDateString() : 'N/A'}
|
|
</td>
|
|
<td className="py-3 px-4 text-right">
|
|
<Link href={route('projects.show', ext.ulid)}>
|
|
<Button variant="ghost" size="sm" className="h-8 text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50">
|
|
View <ArrowRight className="ml-1.5 h-3.5 w-3.5" />
|
|
</Button>
|
|
</Link>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
<div className="py-8 text-center text-slate-400 border border-dashed border-slate-200 rounded-xl bg-slate-50/30">
|
|
<p className="text-xs italic">No extension projects or variation orders registered for this project.</p>
|
|
<p className="text-[10px] text-slate-500 mt-1">Extensions can be created via the Create Project page and selecting this as the Parent Project.</p>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="estimation" className="mt-0 space-y-6 focus-visible:outline-none">
|
|
{/* Summary Metrics */}
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
<Card className="bg-slate-50/50 border-slate-200/80 shadow-sm">
|
|
<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-blue-100 text-blue-600">
|
|
<Layers className="h-5 w-5" />
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-gray-500 font-medium">Materials Cost</p>
|
|
<p className="text-lg font-bold text-slate-900 mt-1">
|
|
{formatCurrency(estimationTotals?.materials || 0)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="bg-slate-50/50 border-slate-200/80 shadow-sm">
|
|
<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-orange-100 text-orange-600">
|
|
<Hammer className="h-5 w-5" />
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-gray-500 font-medium">Labor Cost</p>
|
|
<p className="text-lg font-bold text-slate-900 mt-1">
|
|
{formatCurrency(estimationTotals?.labor || 0)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="bg-slate-50/50 border-slate-200/80 shadow-sm">
|
|
<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-indigo-100 text-indigo-600">
|
|
<Truck className="h-5 w-5" />
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-gray-500 font-medium">Equipment Cost</p>
|
|
<p className="text-lg font-bold text-slate-900 mt-1">
|
|
{formatCurrency(estimationTotals?.equipment || 0)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="bg-slate-50/50 border-slate-200/80 shadow-sm">
|
|
<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-slate-100 text-slate-600">
|
|
<DollarSign className="h-5 w-5" />
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-gray-500 font-medium">Total Estimate</p>
|
|
<p className="text-lg font-bold text-slate-900 mt-1">
|
|
{formatCurrency(estimationTotals?.sum || 0)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Profitability Margin Bar */}
|
|
{estimationTotals && (
|
|
<Card className={cn(
|
|
"border shadow-sm",
|
|
estimationTotals.margin >= 0 ? "border-emerald-200 bg-emerald-50/30" : "border-rose-200 bg-rose-50/30"
|
|
)}>
|
|
<CardContent className="p-5 flex flex-col md:flex-row items-center justify-between gap-4">
|
|
<div className="flex items-start gap-3">
|
|
<AlertTriangle className={cn(
|
|
"h-5 w-5 shrink-0 mt-0.5",
|
|
estimationTotals.margin >= 0 ? "text-emerald-600" : "text-rose-600"
|
|
)} />
|
|
<div>
|
|
<h4 className={cn(
|
|
"font-semibold text-sm",
|
|
estimationTotals.margin >= 0 ? "text-emerald-900" : "text-rose-900"
|
|
)}>
|
|
{estimationTotals.margin >= 0 ? "Estimated Project Margins Clear" : "Unprofitable Estimation Warning"}
|
|
</h4>
|
|
<p className={cn(
|
|
"text-xs mt-1",
|
|
estimationTotals.margin >= 0 ? "text-emerald-700" : "text-rose-700"
|
|
)}>
|
|
This estimation consumes {((estimationTotals.sum / Math.max(Number(project.contract_value), 1)) * 100).toFixed(1)}% of the registered contract value ({formatCurrency(project.contract_value)}).
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="text-right shrink-0">
|
|
<span className={cn(
|
|
"text-lg font-bold block",
|
|
estimationTotals.margin >= 0 ? "text-emerald-700" : "text-rose-700"
|
|
)}>
|
|
{estimationTotals.margin >= 0 ? "+" : ""}{formatCurrency(estimationTotals.margin)}
|
|
</span>
|
|
<span className={cn(
|
|
"text-xs font-semibold px-2.5 py-0.5 rounded-full inline-block mt-1",
|
|
estimationTotals.margin >= 0 ? "bg-emerald-100 text-emerald-800" : "bg-rose-100 text-rose-800"
|
|
)}>
|
|
Margin: {estimationTotals.margin_pct.toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Materials Estimates */}
|
|
<Card className="border-slate-200/80 shadow-sm overflow-hidden">
|
|
<CardHeader className="bg-slate-50/50 border-b border-slate-100">
|
|
<CardTitle className="text-sm font-semibold text-slate-800 flex items-center gap-2">
|
|
<Layers className="h-4 w-4 text-slate-500" /> Materials Estimates
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left border-collapse text-sm">
|
|
<thead>
|
|
<tr className="bg-slate-50 border-b border-slate-150 font-medium text-slate-650">
|
|
<th className="py-2.5 px-4 w-12">#</th>
|
|
<th className="py-2.5 px-4">Material Name</th>
|
|
<th className="py-2.5 px-4">Category</th>
|
|
<th className="py-2.5 px-4 text-center">Unit</th>
|
|
<th className="py-2.5 px-4 text-right">Est. Qty</th>
|
|
<th className="py-2.5 px-4 text-right">Unit Cost</th>
|
|
<th className="py-2.5 px-4 text-right">Total Cost</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-150">
|
|
{project.materials_estimates && project.materials_estimates.length > 0 ? (
|
|
project.materials_estimates.map((est, idx) => (
|
|
<tr key={est.id} className="hover:bg-slate-50/20 transition-colors">
|
|
<td className="py-3 px-4 font-mono text-xs text-slate-400">{idx + 1}</td>
|
|
<td className="py-3 px-4 font-medium text-slate-800">
|
|
{est.material?.name || 'Unknown Material'}
|
|
{est.material?.sku && <span className="font-mono text-[10px] text-slate-400 ml-2">({est.material.sku})</span>}
|
|
</td>
|
|
<td className="py-3 px-4 text-slate-500 capitalize">{est.material?.category || '—'}</td>
|
|
<td className="py-3 px-4 text-center text-slate-500">{est.material?.unit}</td>
|
|
<td className="py-3 px-4 text-right font-medium tabular-nums">{parseFloat(est.estimated_qty as string).toLocaleString()}</td>
|
|
<td className="py-3 px-4 text-right text-slate-600 tabular-nums">{formatCurrency(est.unit_cost)}</td>
|
|
<td className="py-3 px-4 text-right font-semibold text-slate-800 tabular-nums">
|
|
{formatCurrency(parseFloat(est.estimated_qty as string) * parseFloat(est.unit_cost as string))}
|
|
</td>
|
|
</tr>
|
|
))
|
|
) : (
|
|
<tr>
|
|
<td colSpan={7} className="py-8 text-center text-slate-400 italic">No materials estimates configured for this project.</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Manpower & Equipment Breakdown */}
|
|
<Card className="border-slate-200/80 shadow-sm">
|
|
<CardHeader className="border-b border-slate-100">
|
|
<CardTitle className="text-sm font-semibold text-slate-800 flex items-center gap-2">
|
|
<ClipboardList className="h-4 w-4 text-slate-500" /> Tasks & Resource Allocations
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-6 space-y-6">
|
|
{project.tasks && project.tasks.length > 0 ? (
|
|
project.tasks.map((task) => {
|
|
const taskLaborSubtotal = task.task_labors?.reduce((sum, tl) => sum + (parseFloat(tl.estimated_hours as string) * parseFloat(tl.labor?.hourly_rate as string || '0')), 0) || 0;
|
|
const taskEquipmentSubtotal = task.task_equipments?.reduce((sum, te) => sum + (parseFloat(te.estimated_hours as string) * parseFloat(te.equipment?.hourly_rate as string || '0')), 0) || 0;
|
|
const totalTaskCost = taskLaborSubtotal + taskEquipmentSubtotal;
|
|
|
|
return (
|
|
<div key={task.id} className="border border-slate-200 rounded-xl overflow-hidden shadow-sm">
|
|
<div className="bg-slate-50/50 px-4 py-3 border-b border-slate-200 flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
|
<div>
|
|
<h4 className="font-semibold text-sm text-slate-805">{task.name}</h4>
|
|
{task.description && <p className="text-xs text-slate-500 mt-0.5">{task.description}</p>}
|
|
</div>
|
|
<div className="text-right shrink-0">
|
|
<span className="text-xs text-slate-400 font-medium mr-2">Estimated Task Cost:</span>
|
|
<span className="font-bold text-sm text-slate-900">{formatCurrency(totalTaskCost)}</span>
|
|
</div>
|
|
</div>
|
|
<div className="p-4 grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
{/* Manpower Allocations */}
|
|
<div>
|
|
<h5 className="text-xs font-semibold text-slate-700 flex items-center gap-1.5 mb-2 border-b border-slate-100 pb-1.5">
|
|
<Hammer className="h-3.5 w-3.5 text-orange-500" /> Manpower Allocation
|
|
</h5>
|
|
{task.task_labors && task.task_labors.length > 0 ? (
|
|
<div className="space-y-2.5">
|
|
{(() => {
|
|
const bundles: { [key: string]: any[] } = {};
|
|
const singleTrades: any[] = [];
|
|
|
|
task.task_labors.forEach((tl: any) => {
|
|
if (tl.allocation_type === 'bundle' && tl.bundle_name) {
|
|
if (!bundles[tl.bundle_name]) {
|
|
bundles[tl.bundle_name] = [];
|
|
}
|
|
bundles[tl.bundle_name].push(tl);
|
|
} else {
|
|
singleTrades.push(tl);
|
|
}
|
|
});
|
|
|
|
return (
|
|
<>
|
|
{/* Render Job Crew Bundles */}
|
|
{Object.entries(bundles).map(([bundleName, items]) => {
|
|
const first = items[0];
|
|
const bundleSubtotal = items.reduce((sum, tl) =>
|
|
sum + (parseFloat(tl.estimated_hours as string) * parseFloat(tl.labor?.hourly_rate as string || '0')), 0
|
|
);
|
|
const bundleUnit = first?.bundle_unit || 'm²';
|
|
const bundleQty = first?.bundle_quantity ? Number(first.bundle_quantity) : null;
|
|
const bundleRate = first?.bundle_unit_rate ? Number(first.bundle_unit_rate) : null;
|
|
|
|
return (
|
|
<div key={bundleName} className="p-2.5 bg-indigo-50/40 border border-indigo-100 rounded-lg space-y-2">
|
|
<div className="flex items-center justify-between gap-2 border-b border-indigo-100/70 pb-1.5">
|
|
<div className="flex items-center gap-1.5">
|
|
<Layers className="h-3.5 w-3.5 text-indigo-600 shrink-0" />
|
|
<div>
|
|
<span className="font-bold text-xs text-slate-900 block leading-tight">{bundleName}</span>
|
|
<div className="flex items-center gap-1.5 mt-0.5">
|
|
<Badge className="bg-indigo-100 text-indigo-800 border-indigo-200 text-[9px] px-1 py-0 font-semibold uppercase">
|
|
Crew Bundle
|
|
</Badge>
|
|
{bundleQty !== null && (
|
|
<span className="text-[10px] text-slate-500 font-medium">
|
|
{bundleQty} {bundleUnit} {bundleRate ? `@ ${formatCurrency(bundleRate)}/${bundleUnit}` : ''}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="text-right shrink-0">
|
|
<span className="font-bold text-xs text-indigo-950 font-mono block">
|
|
{formatCurrency(bundleSubtotal)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Itemized Trades Breakdown within bundle */}
|
|
<div className="pl-2 space-y-1 border-l-2 border-indigo-200">
|
|
{items.map((tl: any) => (
|
|
<div key={tl.id} className="flex justify-between items-center text-[11px] text-slate-600">
|
|
<span>
|
|
{tl.labor?.name} <span className="text-[10px] text-slate-400">({tl.labor?.category} • {formatCurrency(tl.labor?.hourly_rate)}/hr)</span>
|
|
</span>
|
|
<span className="font-mono font-medium text-slate-700">
|
|
{tl.estimated_hours} hrs ({formatCurrency(parseFloat(tl.estimated_hours as string) * parseFloat(tl.labor?.hourly_rate as string || '0'))})
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Render Single Trades */}
|
|
{singleTrades.map((tl: any) => (
|
|
<div key={tl.id} className="flex justify-between items-center text-xs p-2 bg-slate-50/30 border border-slate-100 rounded-lg">
|
|
<div>
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="font-medium text-slate-800">{tl.labor?.name}</span>
|
|
<Badge variant="outline" className="text-[9px] px-1 py-0 text-emerald-700 bg-emerald-50 border-emerald-100 font-normal uppercase">
|
|
Trade
|
|
</Badge>
|
|
</div>
|
|
<span className="text-[10px] text-slate-400 capitalize">{tl.labor?.category} • {formatCurrency(tl.labor?.hourly_rate)}/hr</span>
|
|
</div>
|
|
<div className="text-right">
|
|
<span className="font-semibold text-slate-700 block">{tl.estimated_hours} hrs</span>
|
|
<span className="text-[10px] font-bold text-slate-550">{formatCurrency(parseFloat(tl.estimated_hours as string) * parseFloat(tl.labor?.hourly_rate as string || '0'))}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
<div className="flex justify-between items-center text-xs font-semibold border-t border-slate-100 pt-2 px-1 text-slate-700">
|
|
<span>Labor Subtotal</span>
|
|
<span>{formatCurrency(taskLaborSubtotal)}</span>
|
|
</div>
|
|
</>
|
|
);
|
|
})()}
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-slate-400 italic py-2">No labor allocated to this task.</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Equipment Allocations */}
|
|
<div>
|
|
<h5 className="text-xs font-semibold text-slate-700 flex items-center gap-1.5 mb-2 border-b border-slate-100 pb-1.5">
|
|
<Truck className="h-3.5 w-3.5 text-indigo-500" /> Equipment Allocation
|
|
</h5>
|
|
{task.task_equipments && task.task_equipments.length > 0 ? (
|
|
<div className="space-y-2">
|
|
{task.task_equipments.map((te) => (
|
|
<div key={te.id} className="flex justify-between items-center text-xs p-2 bg-slate-50/30 border border-slate-100 rounded-lg">
|
|
<div>
|
|
<span className="font-medium text-slate-800 block">{te.equipment?.name}</span>
|
|
<span className="text-[10px] text-slate-450">{te.equipment?.owner_name || 'Unspecified'} • {formatCurrency(te.equipment?.hourly_rate)}/hr</span>
|
|
</div>
|
|
<div className="text-right">
|
|
<span className="font-semibold text-slate-700 block">{te.estimated_hours} hrs</span>
|
|
<span className="text-[10px] font-bold text-slate-550">{formatCurrency(parseFloat(te.estimated_hours as string) * parseFloat(te.equipment?.hourly_rate as string || '0'))}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
<div className="flex justify-between items-center text-xs font-semibold border-t border-slate-100 pt-2 px-1 text-slate-700">
|
|
<span>Equipment Subtotal</span>
|
|
<span>{formatCurrency(taskEquipmentSubtotal)}</span>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-slate-400 italic py-2">No equipment allocated to this task.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
) : (
|
|
<p className="text-sm text-slate-400 italic text-center py-4">No tasks found for this project.</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Milestone schedule weights */}
|
|
<Card className="border-slate-200/80 shadow-sm overflow-hidden">
|
|
<CardHeader className="bg-slate-50/50 border-b border-slate-100">
|
|
<CardTitle className="text-sm font-semibold text-slate-800 flex items-center gap-2">
|
|
<MilestoneIcon className="h-4 w-4 text-slate-500" /> Milestone Schedule Weights
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left border-collapse text-sm">
|
|
<thead>
|
|
<tr className="bg-slate-50 border-b border-slate-150 font-medium text-slate-650">
|
|
<th className="py-2.5 px-4 w-12">#</th>
|
|
<th className="py-2.5 px-4">Milestone Phase</th>
|
|
<th className="py-2.5 px-4 text-right">Planned Weight</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-150">
|
|
{project.milestones && project.milestones.length > 0 ? (
|
|
project.milestones.map((m, idx) => (
|
|
<tr key={m.id} className="hover:bg-slate-50/20 transition-colors">
|
|
<td className="py-3 px-4 font-mono text-xs text-slate-400">{idx + 1}</td>
|
|
<td className="py-3 px-4 font-semibold text-slate-750">{m.name}</td>
|
|
<td className="py-3 px-4 text-right font-medium text-slate-800 tabular-nums">
|
|
{parseFloat(m.weight_percentage as string).toFixed(1)}%
|
|
</td>
|
|
</tr>
|
|
))
|
|
) : (
|
|
<tr>
|
|
<td colSpan={3} className="py-8 text-center text-slate-400 italic">No milestones defined.</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="progress" className="mt-0 space-y-6 focus-visible:outline-none">
|
|
{evmData ? (
|
|
<EvmSCurveChart evmData={evmData} />
|
|
) : (
|
|
<Card className="border-dashed border-2 bg-gray-50/50">
|
|
<CardHeader className="text-center pb-4">
|
|
<CardTitle className="text-xl">Progress Monitoring Baseline</CardTitle>
|
|
<CardDescription>
|
|
No EVM progress data is currently available for this project.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
</Card>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
</ProjectLayout>
|
|
);
|
|
}
|