247 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|