Files
GSB-Construction/Modules/ProjectManagement/resources/js/Pages/Projects/Show/TasksTab.tsx
Ajjj b98951f65f
Some checks failed
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
Tests / PHP 8.5 (push) Has been cancelled
feat: decompose project god components, add database performance indexes, and isolate contractor admin retention
2026-08-14 16:20:15 +08:00

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>
);
}