Files
GSB-Construction/Modules/ProjectManagement/resources/js/Pages/Projects/Index.tsx

247 lines
14 KiB
TypeScript

import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link, router, usePage } from '@inertiajs/react';
import { Badge } from '@/Components/ui/badge';
import { Button } from '@/Components/ui/button';
import { Card, CardContent } from '@/Components/ui/card';
import { DataTableToolbar } from '@/Components/DataTableToolbar';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/Components/ui/select';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/Components/ui/table';
import { PaginatedData, PageProps } from '@/types';
import { Plus, Eye, Pencil, Trash2, FolderKanban } from 'lucide-react';
import { FormEvent, useMemo, useState } from 'react';
interface Project {
id: number; ulid: string;
name: string;
code: string;
location?: string;
status: string;
client_name?: string;
contract_value: string;
total_capitalization: string;
capitalization_percentage: number;
is_over_budget: boolean;
completion_percentage: string;
start_date?: string;
target_end_date?: string;
created_at: string;
personnel?: { id: number; ulid: string; name: string }[];
}
interface StatusOption {
value: string;
label: string;
}
interface Props extends PageProps {
projects: PaginatedData<Project>;
filters: { search?: string; status?: string };
statuses: StatusOption[];
}
const statusVariant = (status: string) => {
switch (status) {
case 'under_bidding': return 'outline';
case 'planning': return 'secondary';
case 'in_progress': return 'default';
case 'on_hold': return 'destructive';
case 'completed': return 'default';
case 'closed': return 'secondary';
default: return 'outline';
}
};
const statusLabel = (status: string) => {
return status.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
};
const formatCurrency = (val: string) => {
return new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(Number(val));
};
export default function Index({ projects, filters, statuses }: Props) {
const { flash } = usePage<PageProps>().props;
const [search, setSearch] = useState(filters.search || '');
const [statusFilter, setStatusFilter] = useState(filters.status || 'all');
// Items array for Select label lookup
const statusFilterItems = useMemo(() => [{ value: 'all', label: 'All Statuses' }, ...statuses.map(s => ({ value: s.value, label: s.label }))], [statuses]);
const applyFilters = (e?: FormEvent) => {
e?.preventDefault();
router.get(route('projects.index'), {
search: search || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
}, { preserveState: true, replace: true });
};
const handleDelete = (project: Project) => {
if (confirm(`Delete project "${project.name}"?`)) {
router.delete(route('projects.destroy', project.ulid));
}
};
return (
<AuthenticatedLayout
header={
<div className="flex items-center gap-2">
<FolderKanban className="h-5 w-5" />
<h2 className="text-xl font-semibold leading-tight text-gray-800">Projects</h2>
</div>
}
>
<Head title="Projects" />
<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>
)}
<Card>
<DataTableToolbar
searchValue={search}
searchPlaceholder="Search by name or code..."
onSearchChange={setSearch}
onSearchSubmit={applyFilters}
filters={
<Select value={statusFilter} onValueChange={(v) => { if (v) setStatusFilter(v); }} items={statusFilterItems}>
<SelectTrigger id="filter-status" className="w-[180px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
{statuses.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
}
actions={
<Link href={route('projects.create')}>
<Button size="sm"><Plus className="mr-2 h-4 w-4" /> New Project</Button>
</Link>
}
/>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Code</TableHead>
<TableHead>Name</TableHead>
<TableHead>Client</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Contract Value</TableHead>
<TableHead className="text-right">Capitalization</TableHead>
<TableHead className="text-right">Progress</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{projects.data.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center text-gray-500 py-8">
No projects found.
</TableCell>
</TableRow>
) : (
projects.data.map((project) => (
<TableRow key={project.id}>
<TableCell className="font-mono text-sm">{project.code}</TableCell>
<TableCell className="font-medium">{project.name}</TableCell>
<TableCell className="text-gray-500">{project.client_name || '-'}</TableCell>
<TableCell>
<Badge variant={statusVariant(project.status)}>
{statusLabel(project.status)}
</Badge>
</TableCell>
<TableCell className="text-right">
{formatCurrency(project.contract_value)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<div className="w-12 h-1.5 rounded-full bg-gray-200 overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
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>
<span className={`text-xs font-medium tabular-nums ${
project.is_over_budget ? 'text-red-600' : project.capitalization_percentage >= 80 ? 'text-amber-600' : 'text-gray-600'
}`}>
{project.capitalization_percentage.toFixed(1)}%
</span>
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<div className="w-16 h-2 rounded-full bg-gray-200 overflow-hidden">
<div
className="h-full bg-green-500 rounded-full transition-all"
style={{ width: `${project.completion_percentage}%` }}
/>
</div>
<span className="text-xs text-gray-500">
{Number(project.completion_percentage).toFixed(0)}%
</span>
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Link href={route('projects.show', project.ulid)}>
<Button variant="ghost" size="icon-sm" title="View">
<Eye className="h-4 w-4" />
</Button>
</Link>
<Link href={route('projects.edit', project.ulid)}>
<Button variant="ghost" size="icon-sm" title="Edit">
<Pencil className="h-4 w-4" />
</Button>
</Link>
<Button variant="ghost" size="icon-sm" title="Delete" onClick={() => handleDelete(project)}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
{projects.last_page > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-gray-600">
Showing {projects.from} to {projects.to} of {projects.total}
</p>
<div className="flex gap-1">
{projects.prev_page_url && (
<Link href={projects.prev_page_url}>
<Button variant="outline" size="sm">Previous</Button>
</Link>
)}
{projects.next_page_url && (
<Link href={projects.next_page_url}>
<Button variant="outline" size="sm">Next</Button>
</Link>
)}
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</AuthenticatedLayout>
);
}