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

475 lines
32 KiB
TypeScript

import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link, router, usePage } from '@inertiajs/react';
import { usePermission } from '@/hooks/usePermission';
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, FolderKanban, Trash2 } 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;
milestone_completion?: number;
start_date?: string;
target_end_date?: string;
created_at: string;
contractor?: { id: number; ulid: string; company_name: string };
personnel?: { id: number; ulid: string; name: string }[];
current_wizard_step?: number;
}
interface StatusOption {
value: string;
label: string;
}
interface Props extends PageProps {
projects: PaginatedData<Project>;
history: PaginatedData<Project>;
drafts: 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, history, drafts = [], filters, statuses }: Props) {
const { flash } = usePage<PageProps>().props;
const { can } = usePermission();
const [search, setSearch] = useState(filters.search || '');
const [statusFilter, setStatusFilter] = useState(filters.status || 'all');
const [activeTab, setActiveTab] = useState<'active' | 'drafts' | 'history'>(() => {
const params = new URLSearchParams(window.location.search);
if (params.has('history_page')) {
return 'history';
}
return 'active';
});
// 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 });
};
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={
can('create', 'projects') ? (
<Link href={route('projects.create')}>
<Button size="sm"><Plus className="mr-2 h-4 w-4" /> New Project</Button>
</Link>
) : undefined
}
/>
<CardContent>
{/* Tabs Header */}
<div className="flex border-b border-gray-200 mb-6">
<button
onClick={() => setActiveTab('active')}
className={`px-4 py-2.5 text-sm font-semibold transition-colors border-b-2 -mb-[2px] ${
activeTab === 'active'
? 'border-emerald-500 text-emerald-600 font-bold'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Active Projects ({projects.total})
</button>
<button
onClick={() => setActiveTab('history')}
className={`px-4 py-2.5 text-sm font-semibold transition-colors border-b-2 -mb-[2px] ${
activeTab === 'history'
? 'border-emerald-500 text-emerald-600 font-bold'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Project History ({history.total})
</button>
<button
onClick={() => setActiveTab('drafts')}
className={`px-4 py-2.5 text-sm font-semibold transition-colors border-b-2 -mb-[2px] ${
activeTab === 'drafts'
? 'border-emerald-500 text-emerald-600 font-bold'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Draft Projects ({drafts.length})
</button>
</div>
{activeTab === 'active' && (
<>
<Table containerClassName="overflow-x-visible" className="table-fixed text-xs [&_th]:px-1.5 [&_td]:px-1.5">
<TableHeader>
<TableRow>
<TableHead className="w-[11%]">Code</TableHead>
<TableHead className="w-[22%]">Project & Contractor</TableHead>
<TableHead className="w-[13%]">Client</TableHead>
<TableHead className="w-[11%] text-center">Status</TableHead>
<TableHead className="w-[14%] text-right">Contract Value</TableHead>
<TableHead className="w-[14%] text-right">Capitalization</TableHead>
<TableHead className="w-[10%] text-right">Progress</TableHead>
<TableHead className="w-[5%] 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}
className="cursor-pointer hover:bg-blue-50/40 transition-colors group"
onClick={() => router.visit(route('projects.show', project.ulid))}
>
<TableCell className="truncate font-mono text-[11px] group-hover:text-blue-600 font-semibold transition-colors">{project.code}</TableCell>
<TableCell className="truncate font-medium">
<div className="truncate group-hover:text-blue-700 transition-colors" title={project.name}>{project.name}</div>
{project.contractor && (
<div className="text-[11px] text-blue-600 font-normal flex items-center gap-1 mt-0.5">
<span>🏢 {project.contractor.company_name}</span>
</div>
)}
</TableCell>
<TableCell className="truncate text-gray-500" title={project.client_name || undefined}>{project.client_name || '-'}</TableCell>
<TableCell className="text-center">
<div className="flex justify-center">
<Badge variant={statusVariant(project.status)}>
{statusLabel(project.status)}
</Badge>
</div>
</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-8 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-1">
<div className="w-10 h-1.5 rounded-full bg-gray-200 overflow-hidden">
<div
className="h-full bg-green-500 rounded-full transition-all"
style={{ width: `${Math.min(Number(project.milestone_completion ?? project.completion_percentage ?? 0), 100)}%` }}
/>
</div>
<span className="text-xs text-gray-600 font-semibold font-mono">
{Number(project.milestone_completion ?? project.completion_percentage ?? 0).toFixed(0)}%
</span>
</div>
</TableCell>
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-1">
{can('edit', 'projects') && (
<Link href={route('projects.edit', project.ulid)}>
<Button variant="ghost" size="icon-sm" title="Edit">
<Pencil className="h-4 w-4" />
</Button>
</Link>
)}
</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>
)}
</>
)}
{activeTab === 'history' && (
<>
<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>
{history.data.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center text-gray-500 py-8">
No history projects found.
</TableCell>
</TableRow>
) : (
history.data.map((project) => (
<TableRow
key={project.id}
className="cursor-pointer hover:bg-blue-50/40 transition-colors group"
onClick={() => router.visit(route('projects.show', project.ulid))}
>
<TableCell className="font-mono text-sm group-hover:text-blue-600 font-semibold transition-colors">{project.code}</TableCell>
<TableCell className="font-medium group-hover:text-blue-700 transition-colors">{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" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-1">
{can('edit', 'projects') && (
<Link href={route('projects.edit', project.ulid)}>
<Button variant="ghost" size="icon-sm" title="Edit">
<Pencil className="h-4 w-4" />
</Button>
</Link>
)}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
{history.last_page > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-gray-600">
Showing {history.from} to {history.to} of {history.total}
</p>
<div className="flex gap-1">
{history.prev_page_url && (
<Link href={history.prev_page_url}>
<Button variant="outline" size="sm">Previous</Button>
</Link>
)}
{history.next_page_url && (
<Link href={history.next_page_url}>
<Button variant="outline" size="sm">Next</Button>
</Link>
)}
</div>
</div>
)}
</>
)}
{activeTab === 'drafts' && (
<Table>
<TableHeader>
<TableRow>
<TableHead>Code</TableHead>
<TableHead>Name</TableHead>
<TableHead>Client</TableHead>
<TableHead>Setup Phase</TableHead>
<TableHead className="text-right">Contract Value</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{drafts.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-gray-500 py-8 italic">
No draft projects in setup wizard.
</TableCell>
</TableRow>
) : (
drafts.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 className="bg-amber-50 text-amber-700 border border-amber-100 font-medium">
Step {project.current_wizard_step || 1} / 7
</Badge>
</TableCell>
<TableCell className="text-right">
{formatCurrency(project.contract_value)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Link href={route('projects.wizard', [project.ulid, { step: project.current_wizard_step }])}>
<Button variant="outline" size="sm" className="h-8 border-emerald-500 text-emerald-600 hover:bg-emerald-50 text-xs">
Resume Setup
</Button>
</Link>
{can('delete', 'projects') && (
<Button
variant="ghost"
size="icon-sm"
title="Discard draft project"
className="text-rose-500 hover:bg-rose-50 hover:text-rose-700"
onClick={() => {
if (confirm(`Discard draft project "${project.name}"? This action cannot be undone.`)) {
router.delete(route('projects.discard', project.ulid));
}
}}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
</div>
</AuthenticatedLayout>
);
}