1391 lines
110 KiB
TypeScript
1391 lines
110 KiB
TypeScript
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))}>×</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>
|
||
);
|
||
}
|