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

500 lines
35 KiB
TypeScript

import React, { useState } from 'react';
import { router } from '@inertiajs/react';
import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card';
import { Button } from '@/Components/ui/button';
import { Input } from '@/Components/ui/input';
import { Badge } from '@/Components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/Components/ui/table';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/Components/ui/dialog';
import {
CheckCircle2, CloudRain, Clock, Milestone as MilestoneIcon,
Plus, Pencil, Trash2, Calendar, AlertTriangle
} from 'lucide-react';
import { ProjectData, MilestoneItem, MilestoneStats, WeatherOption, DelayReasonOption } from '../../../types/project-show';
interface TimelineTabProps {
project: ProjectData;
milestones: MilestoneItem[];
milestoneStats: MilestoneStats;
weatherConditions: WeatherOption[];
delayReasons: DelayReasonOption[];
statusLabel: (s: string) => string;
}
export default function TimelineTab({
project,
milestones,
milestoneStats,
weatherConditions,
delayReasons,
statusLabel
}: TimelineTabProps) {
const [addMilestoneOpen, setAddMilestoneOpen] = useState(false);
const [editMilestoneId, setEditMilestoneId] = useState<string | null>(null);
const [addDelayTaskId, setAddDelayTaskId] = useState<string | null>(null);
const tasksWithDates = project.tasks.filter((t: any) => t.start_date || t.end_date);
const allDates = tasksWithDates.flatMap((t: any) => [t.start_date, t.end_date, t.actual_start_date, t.actual_end_date].filter(Boolean)) as string[];
const earliest = allDates.length > 0 ? new Date(Math.min(...allDates.map((d: string) => new Date(d).getTime()))) : null;
const latest = allDates.length > 0 ? new Date(Math.max(...allDates.map((d: string) => new Date(d).getTime()))) : null;
const totalDays = earliest && latest ? Math.max(1, Math.ceil((latest.getTime() - earliest.getTime()) / 86400000)) : 1;
const getPos = (date: string) => {
if (!earliest) return 0;
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));
};
const allDelays = project.tasks.flatMap((t: any) =>
(t.delays || []).map((d: any) => ({ ...d, taskName: t.name, taskUlid: t.ulid }))
).sort((a: any, b: any) => new Date(b.delay_date).getTime() - new Date(a.delay_date).getTime());
return (
<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: number, t: any) => 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_id: project.id,
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">
<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">
<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>
<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>
<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', 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', 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>
{tasksWithDates.length === 0 || !earliest || !latest ? (
<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>
) : (
<div className="space-y-1">
<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: any) => {
const hasPlanned = task.start_date && task.end_date;
const hasActual = task.actual_start_date;
const weatherDelays = (task.delays || []).filter((d: any) => 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">
{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}`}
/>
)}
{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'}`}
/>
)}
{weatherDelays.map((delay: any) => (
<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>
<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>
);
})}
<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>
{allDelays.length === 0 ? (
<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>
) : (
<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: any) => (
<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>
{/* 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>
</div>
);
}