367 lines
23 KiB
TypeScript
367 lines
23 KiB
TypeScript
import React, { useState, FormEvent } from 'react';
|
|
import { InertiaFormProps } 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 {
|
|
ListTodo, Plus, ChevronDown, ChevronRight,
|
|
Pencil, Trash2, Package
|
|
} from 'lucide-react';
|
|
import {
|
|
ProjectData, TaskItem, TaskMaterialItem, MilestoneItem, MaterialOption
|
|
} from '../../../types/project-show';
|
|
|
|
export interface TasksTabProps {
|
|
project: ProjectData;
|
|
employees: { id: number; ulid: string; name: string }[];
|
|
availableMaterials: MaterialOption[];
|
|
milestones: MilestoneItem[];
|
|
can: (action: string, module: string) => boolean;
|
|
formatCurrency: (v: string | number) => string;
|
|
renderStatusBadge: (s: string) => React.ReactNode;
|
|
taskForm: InertiaFormProps<any>;
|
|
handleAddTask: (e: FormEvent) => void;
|
|
editForm: InertiaFormProps<any>;
|
|
handleEditTask: (e: FormEvent) => void;
|
|
editingTask: TaskItem | null;
|
|
setEditingTask: (t: TaskItem | null) => void;
|
|
openEditModal: (t: TaskItem) => void;
|
|
handleTaskTransition: (taskUlid: string, status: string) => void;
|
|
handleDeleteTask: (taskUlid: string) => void;
|
|
handleUpdateMaterial: (taskUlid: string, tmUlid: string, field: string, value: string) => void;
|
|
handleDeleteMaterial: (taskUlid: string, tmUlid: string) => void;
|
|
}
|
|
|
|
export default function TasksTab({
|
|
project,
|
|
employees,
|
|
availableMaterials,
|
|
milestones,
|
|
can,
|
|
formatCurrency,
|
|
renderStatusBadge,
|
|
taskForm,
|
|
handleAddTask,
|
|
editForm,
|
|
handleEditTask,
|
|
editingTask,
|
|
setEditingTask,
|
|
openEditModal,
|
|
handleTaskTransition,
|
|
handleDeleteTask,
|
|
handleUpdateMaterial,
|
|
handleDeleteMaterial
|
|
}: TasksTabProps) {
|
|
const [createTaskOpen, setCreateTaskOpen] = useState(false);
|
|
const [expandedTasks, setExpandedTasks] = useState<Set<number>>(new Set());
|
|
|
|
const toggleExpand = (id: number) => {
|
|
setExpandedTasks(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
<div>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<ListTodo className="h-5 w-5 text-gray-500" /> Project Tasks ({project.tasks.length})
|
|
</CardTitle>
|
|
<p className="text-xs text-gray-400 mt-1">Scheduled tasks, assignees, and materials allocation</p>
|
|
</div>
|
|
{can('update', 'ProjectManagement') && (
|
|
<Dialog open={createTaskOpen} onOpenChange={setCreateTaskOpen}>
|
|
<DialogTrigger render={<Button size="sm" />}>
|
|
<Plus className="mr-2 h-4 w-4" /> Add Task
|
|
</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader><DialogTitle>Create New Task</DialogTitle></DialogHeader>
|
|
<form onSubmit={(e) => {
|
|
handleAddTask(e);
|
|
setCreateTaskOpen(false);
|
|
}} className="space-y-4">
|
|
<div>
|
|
<label className="text-sm font-medium">Task Name *</label>
|
|
<Input
|
|
value={taskForm.data.name}
|
|
onChange={e => taskForm.setData('name', e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm font-medium">Description</label>
|
|
<textarea
|
|
value={taskForm.data.description}
|
|
onChange={e => taskForm.setData('description', e.target.value)}
|
|
rows={3}
|
|
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm font-medium">Milestone</label>
|
|
<select
|
|
value={taskForm.data.milestone_id}
|
|
onChange={e => taskForm.setData('milestone_id', e.target.value)}
|
|
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
|
|
>
|
|
<option value="">No Milestone</option>
|
|
{milestones.map(m => (
|
|
<option key={m.ulid} value={m.ulid}>{m.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="text-sm font-medium">Start Date</label>
|
|
<Input
|
|
type="date"
|
|
value={taskForm.data.start_date}
|
|
onChange={e => taskForm.setData('start_date', e.target.value)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm font-medium">End Date</label>
|
|
<Input
|
|
type="date"
|
|
value={taskForm.data.end_date}
|
|
onChange={e => taskForm.setData('end_date', e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end">
|
|
<Button type="submit" disabled={taskForm.processing}>
|
|
<Plus className="mr-2 h-4 w-4" /> Create Task
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
</CardHeader>
|
|
<CardContent>
|
|
{project.tasks.length === 0 ? (
|
|
<div className="text-center py-12 text-gray-500">
|
|
<ListTodo className="mx-auto h-12 w-12 text-gray-400" />
|
|
<p className="mt-2 text-sm">No tasks added to this project yet.</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-8"></TableHead>
|
|
<TableHead>Task Name</TableHead>
|
|
<TableHead>Milestone</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Dates</TableHead>
|
|
<TableHead>Assigned</TableHead>
|
|
<TableHead className="text-right">Est. Cost</TableHead>
|
|
<TableHead className="text-right">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{project.tasks.map((task: TaskItem) => {
|
|
const isExpanded = expandedTasks.has(task.id);
|
|
const matCount = task.task_materials?.length || 0;
|
|
|
|
return (
|
|
<React.Fragment key={task.ulid}>
|
|
<TableRow className="cursor-pointer hover:bg-gray-50/75" onClick={() => toggleExpand(task.id)}>
|
|
<TableCell className="px-2">
|
|
<button type="button" className="p-0.5 text-gray-400 hover:text-gray-600">
|
|
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
|
</button>
|
|
</TableCell>
|
|
<TableCell className="font-medium">
|
|
<div>
|
|
<span className="text-slate-900">{task.name}</span>
|
|
{task.description && (
|
|
<p className="text-xs text-gray-400 truncate max-w-xs">{task.description}</p>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
{task.milestone ? (
|
|
<Badge variant="outline" className="text-xs font-normal">
|
|
{task.milestone.name}
|
|
</Badge>
|
|
) : (
|
|
<span className="text-xs text-gray-400">-</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>{renderStatusBadge(task.status)}</TableCell>
|
|
<TableCell className="text-xs text-gray-500 whitespace-nowrap">
|
|
{task.start_date && task.end_date ? (
|
|
<span>{task.start_date.split('T')[0]} → {task.end_date.split('T')[0]}</span>
|
|
) : (
|
|
<span className="text-gray-400">Not set</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex -space-x-1 overflow-hidden" onClick={e => e.stopPropagation()}>
|
|
{task.users && task.users.length > 0 ? (
|
|
task.users.map((u) => (
|
|
<span key={u.ulid} className="inline-block h-6 w-6 rounded-full ring-2 ring-white bg-slate-200 text-[10px] font-medium text-slate-700 flex items-center justify-center" title={u.name}>
|
|
{u.name.charAt(0)}
|
|
</span>
|
|
))
|
|
) : (
|
|
<span className="text-xs text-gray-400">None</span>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-right text-xs font-mono font-medium">
|
|
{formatCurrency(task.total_cost || 0)}
|
|
</TableCell>
|
|
<TableCell className="text-right" onClick={e => e.stopPropagation()}>
|
|
<div className="flex items-center justify-end gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
title="Edit Task"
|
|
onClick={() => openEditModal(task)}
|
|
>
|
|
<Pencil className="h-3.5 w-3.5 text-slate-600" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
title="Delete Task"
|
|
className="text-rose-500 hover:text-rose-700"
|
|
onClick={() => handleDeleteTask(task.ulid)}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
|
|
{/* Expanded Material Details View */}
|
|
{isExpanded && (
|
|
<TableRow key={`${task.ulid}-expanded`} className="bg-gray-50/50 hover:bg-gray-50/50">
|
|
<TableCell colSpan={8} className="p-4 space-y-3">
|
|
<div className="bg-white p-3 rounded-lg border space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-xs font-semibold text-gray-700 flex items-center gap-1.5">
|
|
<Package className="h-3.5 w-3.5 text-emerald-600" /> Allocated Task Materials ({matCount})
|
|
</p>
|
|
</div>
|
|
{task.task_materials && task.task_materials.length > 0 ? (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="text-[11px]">
|
|
<TableHead>Material</TableHead>
|
|
<TableHead className="text-right">Planned Qty</TableHead>
|
|
<TableHead className="text-right">Actual Qty</TableHead>
|
|
<TableHead className="text-right">Unit Cost</TableHead>
|
|
<TableHead className="text-right">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{task.task_materials.map((tm: TaskMaterialItem) => (
|
|
<TableRow key={tm.ulid} className="text-xs">
|
|
<TableCell className="font-medium">{tm.material?.name}</TableCell>
|
|
<TableCell className="text-right font-mono">{tm.planned_qty} {tm.material?.unit}</TableCell>
|
|
<TableCell className="text-right font-mono">{tm.actual_qty} {tm.material?.unit}</TableCell>
|
|
<TableCell className="text-right font-mono">{formatCurrency(tm.unit_cost)}</TableCell>
|
|
<TableCell className="text-right">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={() => handleDeleteMaterial(task.ulid, tm.ulid)}
|
|
>
|
|
<Trash2 className="h-3 w-3 text-rose-500" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
) : (
|
|
<p className="text-xs text-gray-400 italic">No materials assigned to this task</p>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
|
|
{/* Edit Task Modal */}
|
|
<Dialog open={editingTask !== null} onOpenChange={(open) => !open && setEditingTask(null)}>
|
|
<DialogContent>
|
|
<DialogHeader><DialogTitle>Edit Task: {editingTask?.name}</DialogTitle></DialogHeader>
|
|
<form onSubmit={handleEditTask} className="space-y-4">
|
|
<div>
|
|
<label className="text-sm font-medium">Task Name *</label>
|
|
<Input
|
|
value={editForm.data.name}
|
|
onChange={e => editForm.setData('name', e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm font-medium">Description</label>
|
|
<textarea
|
|
value={editForm.data.description}
|
|
onChange={e => editForm.setData('description', e.target.value)}
|
|
rows={3}
|
|
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm font-medium">Milestone</label>
|
|
<select
|
|
value={editForm.data.milestone_id}
|
|
onChange={e => editForm.setData('milestone_id', e.target.value)}
|
|
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
|
|
>
|
|
<option value="">No Milestone</option>
|
|
{milestones.map(m => (
|
|
<option key={m.ulid} value={m.ulid}>{m.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="text-sm font-medium">Start Date</label>
|
|
<Input
|
|
type="date"
|
|
value={editForm.data.start_date}
|
|
onChange={e => editForm.setData('start_date', e.target.value)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm font-medium">End Date</label>
|
|
<Input
|
|
type="date"
|
|
value={editForm.data.end_date}
|
|
onChange={e => editForm.setData('end_date', e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end">
|
|
<Button type="submit" disabled={editForm.processing}>
|
|
Save Changes
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</Card>
|
|
);
|
|
}
|