258 lines
20 KiB
TypeScript
258 lines
20 KiB
TypeScript
import ProjectLayout from '../../../../../ProjectManagement/resources/js/Layouts/ProjectLayout';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card';
|
|
import { Button } from '@/Components/ui/button';
|
|
import { Badge } from '@/Components/ui/badge';
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/Components/ui/dialog';
|
|
import { Input } from '@/Components/ui/input';
|
|
import { useForm, router } from '@inertiajs/react';
|
|
import { CheckCircle2, CloudRain, Clock, Milestone as MilestoneIcon, Plus, AlertTriangle, Pencil } from 'lucide-react';
|
|
import { useState } from 'react';
|
|
|
|
export default function Timeline({ project, milestones, milestoneStats, weatherConditions }: any) {
|
|
const [addMilestoneOpen, setAddMilestoneOpen] = useState(false);
|
|
const [editMilestoneId, setEditMilestoneId] = useState<string | null>(null);
|
|
|
|
if (!project) {
|
|
return <ProjectLayout project={null} currentTab="timeline" children={null} />;
|
|
}
|
|
|
|
return (
|
|
<ProjectLayout project={project} currentTab="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) || 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 || 0, 100)}%` }} />
|
|
</div>
|
|
<p className="text-xs text-gray-400 mt-1">{milestoneStats?.completed || 0} / {milestoneStats?.total || 0} 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 || 0} day{(milestoneStats?.weather_delay_days || 0) !== 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) || 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>
|
|
<Button size="sm">
|
|
<Plus className="mr-2 h-4 w-4" /> Add Milestone
|
|
</Button>
|
|
</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: any, idx: number) => (
|
|
<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>
|
|
<Button variant="ghost" size="icon-sm" title="Edit">
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</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="">N/A</option>
|
|
{weatherConditions?.map((wc: any) => (
|
|
<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>
|
|
<Input name="weather_notes" defaultValue={m.weather_notes || ''} />
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end pt-2 border-t mt-4">
|
|
<Button type="submit">Save Changes</Button>
|
|
</div>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</ProjectLayout>
|
|
);
|
|
}
|