Files
GSB-Construction/Modules/ProjectManagement/resources/js/Pages/Projects/Show.tsx

1391 lines
110 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link, router, useForm, 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 { Input } from '@/Components/ui/input';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/Components/ui/tabs';
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 { Form, FormField } from '@/Components/ui/form';
import MaterialPickerModal from '@/Components/MaterialPickerModal';
import {
ArrowLeft, Pencil, Plus, Trash2, UserPlus, UserMinus, Play, CheckCircle2,
MapPin, Calendar, DollarSign, TrendingUp, Users, ClipboardList, Eye,
Package, ChevronDown, ChevronUp, Download, Upload, Loader2,
Clock, CloudRain, AlertTriangle, Milestone as MilestoneIcon, Timer,
} from 'lucide-react';
import { PageProps } from '@/types';
import { FormEvent, Fragment, useMemo, useRef, useState } from 'react';
interface TaskMaterialItem {
id: number; ulid: string;
material: { id: number; ulid: string; name: string; unit: string; unit_cost: string };
planned_qty: string; actual_qty: string; unit_cost: string;
notes?: string;
}
interface MaterialOption {
id: number; ulid: string; name: string; unit: string; unit_cost: string;
sku?: string; category?: string;
available_qty?: number; on_hand_qty?: number; allocated_qty?: number;
}
interface DelayItem {
id: number; ulid: string;
reason_type: string;
weather_condition?: string;
delay_date: string;
lost_hours: string;
notes?: string;
reporter?: { id: number; name: string };
}
interface TaskItem {
id: number; ulid: string; name: string; description?: string; status: string;
labor_cost: string; estimated_hours: string; actual_hours: string;
completion_percentage: string; start_date?: string; end_date?: string;
actual_start_date?: string; actual_end_date?: string;
material_cost: number; total_cost: number;
task_materials: TaskMaterialItem[];
delays: DelayItem[];
users?: { id: number; ulid: string; name: string }[];
}
interface PersonnelItem {
id: number; ulid: string; name: string; email: string;
pivot?: { role: string };
}
interface ReportWorkforce {
active_workforce: number;
period_man_hours: string;
cumulative_man_hours: string;
}
interface ReportHse {
fatalities: number; major_injuries: number; first_aid_cases: number;
medical_cases: number; near_misses: number; environmental_damage: number;
property_damage: number;
}
interface ReportItem {
id: number; ulid: string;
period_start: string;
period_end: string;
status: string;
submitter?: { id: number; ulid: string; name: string };
workforce_metric?: ReportWorkforce;
hse_record?: ReportHse;
}
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;
contractor?: { id: number; ulid: string; company_name: string };
tasks: TaskItem[];
personnel: PersonnelItem[];
}
interface MilestoneItem {
id: number; ulid: string; name: string; description?: string;
planned_date?: string; actual_date?: string;
sort_order: number; weight_percentage: string;
is_default: boolean; weather_impacted: boolean;
weather_condition?: string; weather_delay_days: number;
weather_notes?: string;
is_completed: boolean; is_overdue: boolean;
status: string; status_color: string; days_delayed: number;
}
interface MilestoneStats {
total: number; completed: number;
completion_percentage: number; weather_delay_days: number;
}
interface StatusOption { value: string; label: string }
interface WeatherOption { value: string; label: string; icon: string }
interface DelayReasonOption { value: string; label: string }
interface Props extends PageProps {
project: ProjectData;
employees: { id: number; ulid: string; name: string }[];
availableMaterials: MaterialOption[];
latestReports: ReportItem[];
taskStats: { total: number; pending: number; in_progress: number; completed: number };
milestones: MilestoneItem[];
milestoneStats: MilestoneStats;
weatherConditions: WeatherOption[];
delayReasons: DelayReasonOption[];
statuses: StatusOption[];
allowedTransitions: StatusOption[];
}
const statusLabel = (s: string) => s.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
const formatCurrency = (v: string) => new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(Number(v));
const roleItems = [{ value: 'pm', label: 'Project Manager' }, { value: 'engineer', label: 'Engineer' }, { value: 'laborer', label: 'Laborer' }, { value: 'member', label: 'Member' }];
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>
);
}
function reportTotalIncidents(hse?: ReportHse): 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 Show({ project, employees, availableMaterials, latestReports, taskStats, milestones, milestoneStats, weatherConditions, delayReasons, allowedTransitions }: Props) {
const { flash } = usePage<PageProps>().props;
const [addTaskOpen, setAddTaskOpen] = useState(false);
const [addMemberOpen, setAddMemberOpen] = useState(false);
const [expandedTask, setExpandedTask] = useState<number | null>(null);
const [addMaterialTaskId, setAddMaterialTaskId] = useState<string | null>(null);
const [importOpen, setImportOpen] = useState(false);
const [importUploading, setImportUploading] = useState(false);
const importRef = useRef<HTMLInputElement>(null);
// Timeline state
const [addMilestoneOpen, setAddMilestoneOpen] = useState(false);
const [editMilestoneId, setEditMilestoneId] = useState<string | null>(null);
const [addDelayTaskId, setAddDelayTaskId] = useState<string | null>(null);
const taskForm = useForm({
name: '', description: '', assigned_users: [] as string[], labor_cost: '', estimated_hours: '', start_date: '', end_date: '',
});
const memberForm = useForm({ user_id: '', role: 'member' });
// Items arrays for Select label lookup
const employeeSelectItems = useMemo(() => employees.map(e => ({ value: e.ulid, label: e.name })), [employees]);
const handleAddTask = (e: FormEvent) => {
e.preventDefault();
taskForm.post(route('projects.tasks.store', project.ulid), {
onSuccess: () => { taskForm.reset(); setAddTaskOpen(false); },
});
};
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(); setAddMemberOpen(false); },
});
};
const handleTransition = (status: string) => {
router.patch(route('projects.transition', project.ulid), { status });
};
const handleTaskTransition = (taskUlid: string, status: string) => {
router.patch(route('projects.tasks.transition', [project.ulid, taskUlid]), { status });
};
const handleDeleteTask = (taskUlid: string) => {
if (confirm('Delete this task?')) {
router.delete(route('projects.tasks.destroy', [project.ulid, 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">
<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>
<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">{statusLabel(project.status)}</Badge>
</div>
<div className="flex items-center gap-2">
{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>
))}
<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(project.completion_percentage).toFixed(0)}%`} sub={`${taskStats.completed} / ${taskStats.total} tasks done`} />
</div>
<Tabs defaultValue="tasks">
<TabsList>
<TabsTrigger value="overview">Overview</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>
{/* Overview Tab */}
<TabsContent value="overview">
<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">
{/* Main Progress Bar — Completed Only */}
<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>
{/* Stacked Distribution Bar */}
{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>
)}
{/* Status Detail Rows */}
{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>
</TabsContent>
{/* Timeline Tab */}
<TabsContent value="timeline">
<div className="space-y-6">
{/* Summary Cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<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-emerald-50">
<CheckCircle2 className="h-5 w-5 text-emerald-600" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-gray-500">Milestone Progress</p>
<p className="text-lg font-semibold">{milestoneStats.completion_percentage.toFixed(0)}%</p>
<div className="mt-1.5 h-1.5 rounded-full bg-gray-200 overflow-hidden">
<div className="h-full rounded-full bg-emerald-500 transition-all duration-500" style={{ width: `${Math.min(milestoneStats.completion_percentage, 100)}%` }} />
</div>
<p className="text-xs text-gray-400 mt-1">{milestoneStats.completed} / {milestoneStats.total} milestones</p>
</div>
</div>
</CardContent>
</Card>
<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 ${milestoneStats.weather_delay_days > 0 ? 'bg-blue-50' : 'bg-gray-100'}`}>
<CloudRain className={`h-5 w-5 ${milestoneStats.weather_delay_days > 0 ? 'text-blue-600' : 'text-gray-400'}`} />
</div>
<div>
<p className="text-xs text-gray-500">Weather Delays</p>
<p className="text-lg font-semibold">{milestoneStats.weather_delay_days} day{milestoneStats.weather_delay_days !== 1 ? 's' : ''}</p>
<p className="text-xs text-gray-400">Total lost to weather</p>
</div>
</div>
</CardContent>
</Card>
<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">
<Clock className="h-5 w-5 text-gray-600" />
</div>
<div>
<p className="text-xs text-gray-500">Total Delay Entries</p>
<p className="text-lg font-semibold">
{project.tasks.reduce((sum, t) => sum + (t.delays?.length || 0), 0)}
</p>
<p className="text-xs text-gray-400">Across all tasks</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Milestones Card */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<MilestoneIcon className="h-5 w-5 text-gray-500" /> Milestones
</CardTitle>
<Dialog open={addMilestoneOpen} onOpenChange={setAddMilestoneOpen}>
<DialogTrigger render={<Button size="sm" />}>
<Plus className="mr-2 h-4 w-4" /> Add Milestone
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Add Custom Milestone</DialogTitle></DialogHeader>
<form onSubmit={(e) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
router.post(route('projects.milestones.store', project.ulid), {
name: fd.get('name'),
planned_date: fd.get('planned_date'),
weight_percentage: fd.get('weight_percentage'),
}, { onSuccess: () => setAddMilestoneOpen(false), preserveScroll: true });
}} className="space-y-4">
<div>
<label className="text-sm font-medium">Name *</label>
<Input name="name" required />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium">Planned Date</label>
<Input name="planned_date" type="date" />
</div>
<div>
<label className="text-sm font-medium">Weight %</label>
<Input name="weight_percentage" type="number" step="0.01" min="0" max="100" defaultValue="5" required />
</div>
</div>
<div className="flex justify-end">
<Button type="submit"><Plus className="mr-2 h-4 w-4" /> Add</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent>
{milestones.length === 0 ? (
<p className="text-sm text-gray-400 italic py-4 text-center">No milestones yet.</p>
) : (
<div className="relative">
{/* Vertical line */}
<div className="absolute left-4 top-2 bottom-2 w-0.5 bg-gray-200" />
<div className="space-y-0">
{milestones.map((m, idx) => (
<div key={m.id} className="relative flex items-start gap-4 py-3 group">
{/* Node dot */}
<div className={`relative z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-2 transition-colors ${
m.is_completed ? 'border-emerald-500 bg-emerald-50' :
m.is_overdue ? 'border-amber-500 bg-amber-50' :
'border-gray-300 bg-white'
}`}>
{m.is_completed ? (
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
) : m.is_overdue ? (
<AlertTriangle className="h-4 w-4 text-amber-600" />
) : (
<span className="text-xs font-semibold text-gray-400">{idx + 1}</span>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className={`text-sm font-medium ${m.is_completed ? 'text-emerald-700' : 'text-gray-800'}`}>
{m.name}
</h4>
<Badge variant="outline" className="text-[10px] px-1.5">
{Number(m.weight_percentage).toFixed(0)}%
</Badge>
{m.weather_impacted && (
<span className="text-blue-500 text-xs flex items-center gap-0.5" title={`Weather delay: ${m.weather_delay_days}d — ${m.weather_notes || ''}`}>
<CloudRain className="h-3.5 w-3.5" /> {m.weather_delay_days}d
</span>
)}
</div>
<div className="flex items-center gap-3 text-xs text-gray-400 mt-0.5">
{m.planned_date && (
<span>Plan: {new Date(m.planned_date).toLocaleDateString('en-PH', { month: 'short', day: 'numeric', year: 'numeric' })}</span>
)}
{m.actual_date && (
<span className="text-emerald-600">
Done: {new Date(m.actual_date).toLocaleDateString('en-PH', { month: 'short', day: 'numeric', year: 'numeric' })}
{m.days_delayed > 0 && <span className="text-amber-500 ml-1">(+{m.days_delayed}d late)</span>}
</span>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Dialog open={editMilestoneId === m.ulid} onOpenChange={(open) => setEditMilestoneId(open ? m.ulid : null)}>
<DialogTrigger render={<Button variant="ghost" size="icon-sm" title="Edit" />}>
<Pencil className="h-3.5 w-3.5" />
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Edit Milestone</DialogTitle></DialogHeader>
<form onSubmit={(e) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
router.put(route('projects.milestones.update', [project.ulid, m.ulid]), {
name: fd.get('name'),
planned_date: fd.get('planned_date') || null,
actual_date: fd.get('actual_date') || null,
weight_percentage: fd.get('weight_percentage'),
weather_impacted: fd.get('weather_impacted') === 'on',
weather_condition: fd.get('weather_condition') || null,
weather_delay_days: fd.get('weather_delay_days') || 0,
weather_notes: fd.get('weather_notes') || null,
}, { onSuccess: () => setEditMilestoneId(null), preserveScroll: true });
}} className="space-y-4">
<div>
<label className="text-sm font-medium">Name</label>
<Input name="name" defaultValue={m.name} required />
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="text-sm font-medium">Planned Date</label>
<Input name="planned_date" type="date" defaultValue={m.planned_date?.split('T')[0]} />
</div>
<div>
<label className="text-sm font-medium">Actual Date</label>
<Input name="actual_date" type="date" defaultValue={m.actual_date?.split('T')[0]} />
</div>
<div>
<label className="text-sm font-medium">Weight %</label>
<Input name="weight_percentage" type="number" step="0.01" min="0" max="100" defaultValue={m.weight_percentage} />
</div>
</div>
<div className="border-t pt-4 space-y-3">
<label className="flex items-center gap-2 text-sm font-medium">
<input type="checkbox" name="weather_impacted" defaultChecked={m.weather_impacted} className="rounded border-gray-300" />
<CloudRain className="h-4 w-4 text-blue-500" /> Weather Impacted
</label>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium">Condition</label>
<select name="weather_condition" defaultValue={m.weather_condition || ''} className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm">
<option value="">Select...</option>
{weatherConditions.map(wc => (
<option key={wc.value} value={wc.value}>{wc.icon} {wc.label}</option>
))}
</select>
</div>
<div>
<label className="text-sm font-medium">Delay Days</label>
<Input name="weather_delay_days" type="number" min="0" defaultValue={m.weather_delay_days} />
</div>
</div>
<div>
<label className="text-sm font-medium">Weather Notes</label>
<textarea name="weather_notes" rows={2} defaultValue={m.weather_notes || ''} className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm" placeholder="Describe weather impact..." />
</div>
</div>
<div className="flex justify-between">
<Button type="button" variant="ghost" size="sm" className="text-red-500" onClick={() => {
if (confirm('Delete this milestone?')) {
router.delete(route('projects.milestones.destroy', [project.ulid, m.ulid]), { preserveScroll: true });
setEditMilestoneId(null);
}
}}>
<Trash2 className="mr-1 h-3.5 w-3.5" /> Delete
</Button>
<Button type="submit">Save Changes</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Task Timeline (Gantt Bars) */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5 text-gray-500" /> Task Timeline
</CardTitle>
</CardHeader>
<CardContent>
{project.tasks.filter(t => t.start_date || t.end_date).length === 0 ? (
<p className="text-sm text-gray-400 italic py-4 text-center">No tasks with dates set. Assign start/end dates to tasks to see the timeline.</p>
) : (() => {
const tasksWithDates = project.tasks.filter(t => t.start_date || t.end_date);
const allDates = tasksWithDates.flatMap(t => [t.start_date, t.end_date, t.actual_start_date, t.actual_end_date].filter(Boolean)) as string[];
if (allDates.length === 0) return <p className="text-sm text-gray-400 italic py-4 text-center">No date data available.</p>;
const earliest = new Date(Math.min(...allDates.map(d => new Date(d).getTime())));
const latest = new Date(Math.max(...allDates.map(d => new Date(d).getTime())));
const totalDays = Math.max(1, Math.ceil((latest.getTime() - earliest.getTime()) / 86400000));
const getPos = (date: string) => {
const d = new Date(date).getTime();
return ((d - earliest.getTime()) / (totalDays * 86400000)) * 100;
};
const getWidth = (start: string, end: string) => {
return Math.max(1, getPos(end) - getPos(start));
};
return (
<div className="space-y-1">
{/* Date header */}
<div className="flex items-center justify-between text-[10px] text-gray-400 mb-3 px-1">
<span>{earliest.toLocaleDateString('en-PH', { month: 'short', day: 'numeric' })}</span>
<span>{new Date((earliest.getTime() + latest.getTime()) / 2).toLocaleDateString('en-PH', { month: 'short', day: 'numeric' })}</span>
<span>{latest.toLocaleDateString('en-PH', { month: 'short', day: 'numeric' })}</span>
</div>
{tasksWithDates.map(task => {
const hasPlanned = task.start_date && task.end_date;
const hasActual = task.actual_start_date;
const weatherDelays = (task.delays || []).filter(d => d.reason_type === 'weather');
return (
<div key={task.id} className="group">
<div className="flex items-center gap-3">
<div className="w-36 shrink-0 text-xs text-gray-600 truncate font-medium" title={task.name}>
{task.name}
</div>
<div className="flex-1 relative h-7 bg-gray-50 rounded overflow-visible">
{/* Planned bar */}
{hasPlanned && (
<div
className="absolute top-1 h-2.5 rounded-full bg-gray-200 transition-all"
style={{ left: `${getPos(task.start_date!)}%`, width: `${getWidth(task.start_date!, task.end_date!)}%` }}
title={`Planned: ${task.start_date}${task.end_date}`}
/>
)}
{/* Actual bar */}
{hasActual && (
<div
className={`absolute top-1 h-2.5 rounded-full transition-all ${
task.status === 'completed' ? 'bg-emerald-500' : 'bg-blue-500'
}`}
style={{
left: `${getPos(task.actual_start_date!)}%`,
width: `${getWidth(task.actual_start_date!, task.actual_end_date || new Date().toISOString().split('T')[0])}%`,
}}
title={`Actual: ${task.actual_start_date}${task.actual_end_date || 'ongoing'}`}
/>
)}
{/* Weather delay dots */}
{weatherDelays.map(delay => (
<div
key={delay.id}
className="absolute top-0 h-2 w-2 rounded-full bg-blue-400 border border-white ring-1 ring-blue-200 cursor-help z-10"
style={{ left: `${getPos(delay.delay_date)}%`, top: '-2px' }}
title={`🌧️ ${delay.weather_condition || 'Weather'}: ${delay.lost_hours}h lost${delay.notes ? ' — ' + delay.notes : ''}`}
/>
))}
</div>
{/* Log delay button */}
<Button
variant="ghost" size="icon-sm"
className="opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
title="Log Delay"
onClick={() => setAddDelayTaskId(task.ulid)}
>
<CloudRain className="h-3.5 w-3.5 text-blue-500" />
</Button>
</div>
</div>
);
})}
{/* Legend */}
<div className="flex items-center gap-4 mt-4 pt-3 border-t text-[10px] text-gray-400">
<span className="flex items-center gap-1"><span className="inline-block h-2 w-6 rounded-full bg-gray-200" /> Planned</span>
<span className="flex items-center gap-1"><span className="inline-block h-2 w-6 rounded-full bg-blue-500" /> In Progress</span>
<span className="flex items-center gap-1"><span className="inline-block h-2 w-6 rounded-full bg-emerald-500" /> Completed</span>
<span className="flex items-center gap-1"><span className="inline-block h-2 w-2 rounded-full bg-blue-400 ring-1 ring-blue-200" /> Weather Delay</span>
</div>
</div>
);
})()}
</CardContent>
</Card>
{/* Task Delay Log Table */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-gray-500" /> Delay Log
</CardTitle>
</CardHeader>
<CardContent>
{(() => {
const allDelays = project.tasks.flatMap(t =>
(t.delays || []).map(d => ({ ...d, taskName: t.name, taskUlid: t.ulid }))
).sort((a, b) => new Date(b.delay_date).getTime() - new Date(a.delay_date).getTime());
if (allDelays.length === 0) return (
<p className="text-sm text-gray-400 italic py-4 text-center">No delays logged. Use the timeline above to log delays on tasks.</p>
);
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Task</TableHead>
<TableHead>Reason</TableHead>
<TableHead>Condition</TableHead>
<TableHead className="text-right">Lost Hours</TableHead>
<TableHead>Notes</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{allDelays.map(delay => (
<TableRow key={delay.id}>
<TableCell className="text-sm tabular-nums">
{new Date(delay.delay_date).toLocaleDateString('en-PH', { month: 'short', day: 'numeric' })}
</TableCell>
<TableCell className="text-sm font-medium">{delay.taskName}</TableCell>
<TableCell>
<Badge variant="outline" className="text-[10px]">
{statusLabel(delay.reason_type)}
</Badge>
</TableCell>
<TableCell className="text-sm">
{delay.weather_condition ? (
<span className="flex items-center gap-1 text-blue-600">
<CloudRain className="h-3 w-3" />
{statusLabel(delay.weather_condition)}
</span>
) : '-'}
</TableCell>
<TableCell className="text-right text-sm tabular-nums font-medium">
{delay.lost_hours}h
</TableCell>
<TableCell className="text-sm text-gray-500 max-w-[200px] truncate">
{delay.notes || '-'}
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="icon-sm" title="Delete" onClick={() => {
if (confirm('Remove this delay entry?')) {
router.delete(route('projects.tasks.delays.destroy', [project.ulid, delay.taskUlid, delay.ulid]), { preserveScroll: true });
}
}}>
<Trash2 className="h-3.5 w-3.5 text-red-500" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
})()}
</CardContent>
</Card>
</div>
{/* Log Delay Dialog */}
<Dialog open={addDelayTaskId !== null} onOpenChange={(open) => !open && setAddDelayTaskId(null)}>
<DialogContent>
<DialogHeader><DialogTitle>Log Delay</DialogTitle></DialogHeader>
<form onSubmit={(e) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
router.post(route('projects.tasks.delays.store', [project.ulid, addDelayTaskId!]), {
delay_date: fd.get('delay_date'),
reason_type: fd.get('reason_type'),
weather_condition: fd.get('weather_condition') || null,
lost_hours: fd.get('lost_hours'),
notes: fd.get('notes') || null,
}, { onSuccess: () => setAddDelayTaskId(null), preserveScroll: true });
}} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium">Date *</label>
<Input name="delay_date" type="date" defaultValue={new Date().toISOString().split('T')[0]} required />
</div>
<div>
<label className="text-sm font-medium">Lost Hours *</label>
<Input name="lost_hours" type="number" step="0.5" min="0" defaultValue="8" required />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium">Reason *</label>
<select name="reason_type" required className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm" defaultValue="weather">
{delayReasons.map(r => (
<option key={r.value} value={r.value}>{r.label}</option>
))}
</select>
</div>
<div>
<label className="text-sm font-medium">Weather Condition</label>
<select name="weather_condition" className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm">
<option value="">N/A</option>
{weatherConditions.map(wc => (
<option key={wc.value} value={wc.value}>{wc.icon} {wc.label}</option>
))}
</select>
</div>
</div>
<div>
<label className="text-sm font-medium">Notes</label>
<textarea name="notes" rows={2} className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm" placeholder="Additional details about the delay..." />
</div>
<div className="flex justify-end">
<Button type="submit"><Plus className="mr-2 h-4 w-4" /> Log Delay</Button>
</div>
</form>
</DialogContent>
</Dialog>
</TabsContent>
{/* Tasks Tab */}
<TabsContent value="tasks">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Tasks</CardTitle>
<div className="flex items-center gap-2">
<a href={route('projects.tasks.template', project.ulid)}>
<Button variant="outline" size="sm"><Download className="mr-2 h-4 w-4" /> Template</Button>
</a>
<Dialog open={importOpen} onOpenChange={(open) => { setImportOpen(open); if (!open && importRef.current) importRef.current.value = ''; }}>
<DialogTrigger render={<Button variant="outline" size="sm" />}><Upload className="mr-2 h-4 w-4" /> Import Tasks</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Import Tasks from Excel</DialogTitle></DialogHeader>
<form onSubmit={(e) => {
e.preventDefault();
const file = importRef.current?.files?.[0];
if (!file) return;
setImportUploading(true);
const formData = new FormData();
formData.append('file', file);
router.post(route('projects.tasks.import', project.ulid), { file }, {
forceFormData: true,
onSuccess: () => { setImportOpen(false); if (importRef.current) importRef.current.value = ''; },
onFinish: () => setImportUploading(false),
});
}} className="space-y-4">
<div>
<Input ref={importRef} type="file" accept=".xlsx,.csv,.xls" />
</div>
<div className="rounded-md bg-blue-50 p-3 text-sm text-blue-700">
<p className="font-medium mb-1">Expected columns:</p>
<p className="text-xs">Name, Description, Start Date, End Date, Estimated Hours, Labor Cost, Sort Order</p>
<p className="text-xs mt-1 text-blue-500">Download the template above for the correct format. Assignees can be set after import.</p>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={importUploading}>
{importUploading ? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Importing...</>
: <><Upload className="mr-2 h-4 w-4" /> Import</>}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
<Dialog open={addTaskOpen} onOpenChange={setAddTaskOpen}>
<DialogTrigger render={<Button size="sm" />}>
<Plus className="mr-2 h-4 w-4" /> Add Task
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Add Task</DialogTitle></DialogHeader>
<Form onSubmit={handleAddTask}>
<FormField label="Task Name" htmlFor="task_name" required error={taskForm.errors.name}>
<Input id="task_name" value={taskForm.data.name} onChange={(e) => taskForm.setData('name', e.target.value)} />
</FormField>
<FormField label="Assign To (Multiple)">
<Select value="" onValueChange={(v) => { if (v && !taskForm.data.assigned_users.includes(v)) taskForm.setData('assigned_users', [...taskForm.data.assigned_users, v]); }} items={employeeSelectItems}>
<SelectTrigger><SelectValue placeholder="Select assignees" /></SelectTrigger>
<SelectContent>
{employees.map((e) => (
<SelectItem key={e.id} value={e.ulid}>{e.name}</SelectItem>
))}
</SelectContent>
</Select>
{taskForm.data.assigned_users.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{taskForm.data.assigned_users.map(uId => {
const emp = employees.find(e => e.ulid === uId);
return (
<Badge key={uId} variant="secondary" className="flex items-center gap-1">
{emp?.name}
<button type="button" onClick={() => taskForm.setData('assigned_users', taskForm.data.assigned_users.filter(id => id !== uId))}>&times;</button>
</Badge>
)
})}
</div>
)}
</FormField>
<div className="grid grid-cols-2 gap-4">
<FormField label="Labor Cost" htmlFor="labor_cost">
<Input id="labor_cost" type="number" step="0.01" value={taskForm.data.labor_cost} onChange={(e) => taskForm.setData('labor_cost', e.target.value)} />
</FormField>
<FormField label="Est. Hours" htmlFor="est_hours">
<Input id="est_hours" type="number" step="0.5" value={taskForm.data.estimated_hours} onChange={(e) => taskForm.setData('estimated_hours', e.target.value)} />
</FormField>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField label="Start" htmlFor="task_start">
<Input id="task_start" type="date" value={taskForm.data.start_date} onChange={(e) => taskForm.setData('start_date', e.target.value)} />
</FormField>
<FormField label="End" htmlFor="task_end">
<Input id="task_end" type="date" value={taskForm.data.end_date} onChange={(e) => taskForm.setData('end_date', e.target.value)} />
</FormField>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={taskForm.processing}><Plus className="mr-2 h-4 w-4" /> Add</Button>
</div>
</Form>
</DialogContent>
</Dialog>
</div>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead>Task</TableHead>
<TableHead>Assignee</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Labor</TableHead>
<TableHead className="text-right">Material</TableHead>
<TableHead className="text-right">Total Cost</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{project.tasks.length === 0 ? (
<TableRow><TableCell colSpan={8} className="text-center text-gray-500 py-8">No tasks yet.</TableCell></TableRow>
) : (
project.tasks.map((task) => (
<Fragment key={task.id}>
<TableRow key={task.id}>
<TableCell>
<Button variant="ghost" size="icon-sm" onClick={() => setExpandedTask(expandedTask === task.id ? null : task.id)}>
{expandedTask === task.id ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
</TableCell>
<TableCell className="font-medium">{task.name}</TableCell>
<TableCell className="text-gray-500">{task.users?.map(u => u.name).join(', ') || '-'}</TableCell>
<TableCell><Badge variant="outline">{statusLabel(task.status)}</Badge></TableCell>
<TableCell className="text-right text-sm tabular-nums">{formatCurrency(task.labor_cost)}</TableCell>
<TableCell className="text-right text-sm tabular-nums">{formatCurrency(String(task.material_cost))}</TableCell>
<TableCell className="text-right text-sm font-medium tabular-nums">{formatCurrency(String(task.total_cost))}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
{task.status === 'pending' && (
<Button variant="ghost" size="icon-sm" title="Start" onClick={() => handleTaskTransition(task.ulid, 'in_progress')}>
<Play className="h-4 w-4 text-blue-500" />
</Button>
)}
{task.status === 'in_progress' && (
<Button variant="ghost" size="icon-sm" title="Complete" onClick={() => handleTaskTransition(task.ulid, 'completed')}>
<CheckCircle2 className="h-4 w-4 text-green-500" />
</Button>
)}
<Button variant="ghost" size="icon-sm" title="Delete" onClick={() => handleDeleteTask(task.ulid)}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</TableCell>
</TableRow>
{expandedTask === task.id && (
<TableRow key={`${task.id}-materials`}>
<TableCell colSpan={8} className="bg-gray-50/50 p-4">
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium flex items-center gap-2">
<Package className="h-4 w-4 text-gray-500" /> Materials
</h4>
<Button variant="outline" size="sm" onClick={() => setAddMaterialTaskId(task.ulid)}>
<Plus className="mr-1 h-3 w-3" /> Add Materials
</Button>
<MaterialPickerModal
open={addMaterialTaskId === task.ulid}
onOpenChange={(open) => setAddMaterialTaskId(open ? task.ulid : null)}
projectUlid={project.ulid}
taskUlid={task.ulid}
availableMaterials={availableMaterials}
existingMaterialIds={task.task_materials.map(tm => tm.material?.ulid).filter(Boolean) as string[]}
/>
</div>
{task.task_materials.length === 0 ? (
<p className="text-sm text-gray-400 italic py-2">No materials assigned to this task.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Material</TableHead>
<TableHead>Unit</TableHead>
<TableHead className="text-right">Unit Cost</TableHead>
<TableHead className="text-right">Planned</TableHead>
<TableHead className="text-right">Actual</TableHead>
<TableHead className="text-right">Cost</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{task.task_materials.map((tm) => {
const actualQty = Number(tm.actual_qty);
const plannedQty = Number(tm.planned_qty);
const cost = (actualQty > 0 ? actualQty : plannedQty) * Number(tm.unit_cost);
return (
<TableRow key={tm.id}>
<TableCell className="font-medium text-sm">
{tm.material.name}
{(tm.material as any).type === 'kit' && (tm.material as any).components && (tm.material as any).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">
{(tm.material as any).components.map((comp: any) => (
<div key={comp.id}>{comp.quantity * (Number(tm.actual_qty) > 0 ? Number(tm.actual_qty) : Number(tm.planned_qty))}x {comp.component?.name} <span className="opacity-60">(from {comp.quantity} / kit)</span></div>
))}
</div>
)}
</TableCell>
<TableCell className="text-sm text-gray-500">{tm.material.unit}</TableCell>
<TableCell className="text-right text-sm tabular-nums">{formatCurrency(tm.unit_cost)}</TableCell>
<TableCell className="text-right text-sm tabular-nums">{plannedQty}</TableCell>
<TableCell className="text-right">
<Input
type="number" step="0.01" min="0"
className="w-20 text-right text-sm h-8 ml-auto"
defaultValue={tm.actual_qty}
onBlur={(e) => {
if (e.target.value !== tm.actual_qty) {
handleUpdateMaterial(task.ulid, tm.ulid, 'actual_qty', e.target.value);
}
}}
/>
</TableCell>
<TableCell className="text-right text-sm font-medium tabular-nums">{formatCurrency(String(cost))}</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="icon-sm" title="Remove" onClick={() => handleDeleteMaterial(task.ulid, tm.ulid)}>
<Trash2 className="h-3.5 w-3.5 text-red-500" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</TableCell>
</TableRow>
)}
</Fragment>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
{/* Team Tab */}
<TabsContent value="team">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Team Members</CardTitle>
<Dialog open={addMemberOpen} onOpenChange={setAddMemberOpen}>
<DialogTrigger render={<Button size="sm" />}>
<UserPlus className="mr-2 h-4 w-4" /> Add Member
</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>
</TabsContent>
{/* Materials Tab */}
<TabsContent value="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) => (
<TableRow key={m.id}>
<TableCell className="font-medium">
{m.name}
{m.sku && <div className="text-xs text-gray-400">{m.sku}</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-amber-600 tabular-nums">
{m.allocated_qty}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
{/* Reports Tab */}
<TabsContent value="reports">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Latest Status Reports</CardTitle>
<div className="flex items-center gap-2">
<Link href={route('projects.reports.index', project.ulid)}>
<Button variant="outline" size="sm">View All</Button>
</Link>
<Link href={route('projects.reports.create', project.ulid)}>
<Button size="sm"><Plus className="mr-2 h-4 w-4" /> New Report</Button>
</Link>
</div>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Period</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Man-hours</TableHead>
<TableHead className="text-right">Incidents</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{latestReports.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-gray-500 py-8">
No status reports yet.
</TableCell>
</TableRow>
) : (
latestReports.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">
{new Date(r.period_start).toLocaleDateString('en-PH', { month: 'short', day: 'numeric' })}
{' '}
{new Date(r.period_end).toLocaleDateString('en-PH', { month: 'short', day: 'numeric' })}
</TableCell>
<TableCell>
<Badge variant="outline">{statusLabel(r.status)}</Badge>
</TableCell>
<TableCell className="text-right tabular-nums">
{r.workforce_metric
? new Intl.NumberFormat('en-PH').format(Number(r.workforce_metric.period_man_hours))
: '-'}
</TableCell>
<TableCell className="text-right">
{reportTotalIncidents(r.hse_record) === 0 ? (
<span className="text-green-600 font-medium">0 </span>
) : (
<span className="text-red-600 font-medium">{reportTotalIncidents(r.hse_record)}</span>
)}
</TableCell>
<TableCell className="text-right">
<Link href={route('projects.reports.show', [project.ulid, r.ulid])}>
<Button variant="ghost" size="icon-sm" title="View">
<Eye className="h-4 w-4" />
</Button>
</Link>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</div>
</AuthenticatedLayout>
);
}