475 lines
32 KiB
TypeScript
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>
|
|
);
|
|
}
|