Files
GSB-Construction/Modules/TaskManagement/resources/js/Pages/Tasks/Index.tsx

459 lines
35 KiB
TypeScript

import ProjectLayout from '../../../../../ProjectManagement/resources/js/Layouts/ProjectLayout';
import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/Components/ui/table';
import { Button } from '@/Components/ui/button';
import { Badge } from '@/Components/ui/badge';
import { ChevronDown, ChevronUp, Play, CheckCircle2, Trash2, Download, Upload, Loader2, Plus, Package } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/Components/ui/dialog';
import { Input } from '@/Components/ui/input';
import { Form, FormField } from '@/Components/ui/form';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/Components/ui/select';
import { useForm, router } from '@inertiajs/react';
import { Fragment, useState, useRef, FormEvent, useMemo } from 'react';
import MaterialPickerModal from '@/Components/MaterialPickerModal';
import KanbanBoard from '../../Components/KanbanBoard';
import { LayoutList, Kanban, Activity as ActivityIcon } from 'lucide-react';
import { Textarea } from '@/Components/ui/textarea';
export default function Tasks({ project, employees, availableMaterials, delayReasons, weatherConditions }: any) {
const [addTaskOpen, setAddTaskOpen] = useState(false);
const [expandedTask, setExpandedTask] = useState<number | null>(null);
const [addMaterialTaskId, setAddMaterialTaskId] = useState<string | null>(null);
const [addDelayTaskId, setAddDelayTaskId] = useState<string | null>(null);
const [importOpen, setImportOpen] = useState(false);
const [importUploading, setImportUploading] = useState(false);
const [viewMode, setViewMode] = useState<'board' | 'table'>('board');
const [detailsTaskUlid, setDetailsTaskUlid] = useState<string | null>(null);
const importRef = useRef<HTMLInputElement>(null);
const taskForm = useForm({
name: '', description: '', assigned_users: [] as string[], labor_cost: '', estimated_hours: '', start_date: '', end_date: '',
});
const employeeSelectItems = useMemo(() => employees?.map((e: any) => ({ value: e.ulid, label: e.name })) || [], [employees]);
const formatCurrency = (v: string) => new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(Number(v));
const statusLabel = (s: string) => s.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
const handleAddTask = (e: FormEvent) => {
e.preventDefault();
taskForm.post(route('projects.tasks.store', project.ulid), {
onSuccess: () => { taskForm.reset(); setAddTaskOpen(false); },
});
};
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 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 handleReorder = (reorderedTasks: any[]) => {
router.post(route('projects.tasks.reorder', project.ulid), { tasks: reorderedTasks }, { preserveScroll: true, preserveState: true });
};
const detailsTask = detailsTaskUlid ? project?.tasks?.find((t: any) => t.ulid === detailsTaskUlid) : null;
if (!project) {
return <ProjectLayout project={null} currentTab="tasks" children={null} />;
}
return (
<ProjectLayout project={project} currentTab="tasks">
<Card className={viewMode === 'board' ? "flex flex-col flex-1 border-0 shadow-none bg-transparent" : ""}>
<CardHeader className={viewMode === 'board' ? "px-0 pt-0 pb-4" : ""}>
<div className="flex items-center justify-between">
<CardTitle>Tasks</CardTitle>
<div className="flex items-center gap-2">
<div className="flex bg-gray-100 p-1 rounded-md mr-2">
<Button
variant={viewMode === 'board' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 px-2"
onClick={() => setViewMode('board')}
>
<Kanban className="h-4 w-4 mr-1" /> Board
</Button>
<Button
variant={viewMode === 'table' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 px-2"
onClick={() => setViewMode('table')}
>
<LayoutList className="h-4 w-4 mr-1" /> Table
</Button>
</div>
<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><Button variant="outline" size="sm"><Upload className="mr-2 h-4 w-4" /> Import Tasks</Button></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="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>
<Button size="sm"><Plus className="mr-2 h-4 w-4" /> Add Task</Button>
</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="Description" htmlFor="description" error={taskForm.errors.description}>
<Textarea id="description" value={taskForm.data.description} onChange={(e) => taskForm.setData('description', e.target.value)} />
</FormField>
<div className="grid grid-cols-2 gap-4">
<FormField label="Start Date" htmlFor="start_date" error={taskForm.errors.start_date}>
<Input type="date" id="start_date" value={taskForm.data.start_date} onChange={(e) => taskForm.setData('start_date', e.target.value)} />
</FormField>
<FormField label="End Date" htmlFor="end_date" error={taskForm.errors.end_date}>
<Input type="date" id="end_date" value={taskForm.data.end_date} onChange={(e) => taskForm.setData('end_date', e.target.value)} />
</FormField>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField label="Estimated Hours" htmlFor="estimated_hours" error={taskForm.errors.estimated_hours}>
<Input type="number" step="0.1" id="estimated_hours" value={taskForm.data.estimated_hours} onChange={(e) => taskForm.setData('estimated_hours', e.target.value)} />
</FormField>
<FormField label="Assignees (Ctrl/Cmd+Click)" htmlFor="assigned_users" error={taskForm.errors.assigned_users as any}>
<select
id="assigned_users"
multiple
value={taskForm.data.assigned_users}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
const values = Array.from(e.target.selectedOptions, option => option.value);
taskForm.setData('assigned_users', values);
}}
className="flex w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 min-h-[80px]"
>
{employeeSelectItems.map((opt: any) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</FormField>
</div>
<div className="flex justify-end mt-4">
<Button type="submit" disabled={taskForm.processing}><Plus className="mr-2 h-4 w-4" /> Add</Button>
</div>
</Form>
</DialogContent>
</Dialog>
</div>
</div>
</CardHeader>
<CardContent className={viewMode === 'board' ? "flex-1 p-0 overflow-hidden" : ""}>
{viewMode === 'board' ? (
<KanbanBoard
tasks={project?.tasks || []}
onReorder={handleReorder}
onTaskClick={(ulid: string) => setDetailsTaskUlid(ulid)}
/>
) : (
<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 || project.tasks?.length === 0 ? (
<TableRow><TableCell colSpan={8} className="text-center text-gray-500 py-8">{!project ? 'Please select a project to view tasks.' : 'No tasks yet.'}</TableCell></TableRow>
) : (
project.tasks?.map((task: any) => (
<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: any) => 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: any) => 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: any) => {
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}
</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>
{/* Task Details Dialog for Kanban Mode */}
<Dialog open={!!detailsTaskUlid} onOpenChange={(open) => !open && setDetailsTaskUlid(null)}>
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{detailsTask?.name} - Details</DialogTitle>
</DialogHeader>
{detailsTask && (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-500">Status</p>
<Badge variant="outline">{statusLabel(detailsTask.status)}</Badge>
</div>
<div>
<p className="text-sm text-gray-500">Assignees</p>
<p className="text-sm">{detailsTask.users?.map((u: any) => u.name).join(', ') || 'Unassigned'}</p>
</div>
<div>
<p className="text-sm text-gray-500">Total Cost</p>
<p className="text-sm font-medium">{formatCurrency(detailsTask.total_cost)}</p>
</div>
<div>
<p className="text-sm text-gray-500">Estimated Hours</p>
<p className="text-sm">{detailsTask.estimated_hours || 0}h</p>
</div>
</div>
<div className="border-t pt-4 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(detailsTask.ulid)}>
<Plus className="mr-1 h-3 w-3" /> Add Materials
</Button>
<MaterialPickerModal
open={addMaterialTaskId === detailsTask.ulid}
onOpenChange={(open) => setAddMaterialTaskId(open ? detailsTask.ulid : null)}
projectUlid={project.ulid}
taskUlid={detailsTask.ulid}
availableMaterials={availableMaterials}
existingMaterialIds={detailsTask.task_materials?.map((tm: any) => tm.material?.ulid).filter(Boolean) as string[]}
/>
</div>
{detailsTask.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>
{detailsTask.task_materials?.map((tm: any) => {
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}
</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(detailsTask.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(detailsTask.ulid, tm.ulid)}>
<Trash2 className="h-3.5 w-3.5 text-red-500" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
<div className="border-t pt-4 space-y-3">
<h4 className="text-sm font-medium flex items-center gap-2">
<ActivityIcon className="h-4 w-4 text-gray-500" /> Activity Log
</h4>
{detailsTask.activities?.length === 0 ? (
<p className="text-sm text-gray-400 italic py-2">No activities logged yet.</p>
) : (
<div className="space-y-4">
<div className="relative border-l-2 border-gray-100 ml-2.5 space-y-6">
{detailsTask.activities?.map((activity: any, idx: number) => (
<div key={activity.id} className="relative ml-6">
<span className="absolute -left-[35px] flex items-center justify-center w-5 h-5 bg-white rounded-full ring-4 ring-white border">
<ActivityIcon className="w-3 h-3 text-blue-500" />
</span>
<div className="text-sm">
<span className="font-semibold text-gray-900">{activity.user?.name}</span>
<span className="text-gray-600"> {activity.description}</span>
</div>
<time className="block text-xs font-medium text-gray-400 mt-1">
{new Date(activity.created_at).toLocaleString()}
</time>
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
</DialogContent>
</Dialog>
</ProjectLayout>
);
}