refactor: modularize road management modules

This commit is contained in:
2026-06-17 13:31:25 +05:30
parent 9bb740e446
commit 182d4ac293
41 changed files with 2386 additions and 1894 deletions

View File

@@ -1,513 +1,132 @@
'use client';
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Loader2, CheckCircle2, Package, FolderKanban, Globe } from 'lucide-react';
import { DataTable } from '@/components/data-table';
import { useCallback, useState } from 'react';
import { Package as PackageIcon, Plus } from 'lucide-react';
import { PageHeader } from '@/components/page-header';
import { projectService, packageService } from '@/services/api';
import { Project, Package as PackageType, PackageCreate, PackageUpdate } from '@/types';
import { ColumnDef } from '@tanstack/react-table';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { PoweredBy } from '@/components/powered-by';
import { Button } from '@/components/ui/button';
import type { Package } from '@/types';
import { PackageDialog } from './components/PackageDialog';
import { PackageTable } from './components/PackageTable';
import { usePackageColumns } from './components/PackageColumns';
import { usePackageFilters } from './hooks/usePackageFilters';
import { usePackageForm } from './hooks/usePackageForm';
import {
useDeletePackageMutation,
usePackagesQuery,
useProjectOptionsQuery,
} from './hooks/usePackageQueries';
export default function PackagePage() {
const [packages, setPackages] = useState<PackageType[]>([]);
const [totalItems, setTotalItems] = useState(0);
const [projects, setProjects] = useState<Project[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loadingProjects, setLoadingProjects] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const { skip, setSkip, limit, setLimit } = usePackageFilters();
const packagesQuery = usePackagesQuery({ skip, limit });
const projectsQuery = useProjectOptionsQuery();
const deletePackageMutation = useDeletePackageMutation();
const packageForm = usePackageForm({
onSaved: () => setIsDialogOpen(false),
});
const {
register,
onSubmit,
openCreate: prepareCreatePackage,
openEdit: prepareEditPackage,
resetForm,
packageId,
projectId,
setProjectId,
errors,
touchedFields,
canSubmit,
isSaving,
} = packageForm;
// Pagination state
const [skip, setSkip] = useState(0);
const [limit, setLimit] = useState(10);
const openCreate = useCallback(() => {
prepareCreatePackage();
setIsDialogOpen(true);
}, [prepareCreatePackage]);
// Editing state
const [isEditing, setIsEditing] = useState(false);
const [currentPackage, setCurrentPackage] = useState<PackageType | null>(null);
const openEdit = useCallback(
(pkg: Package) => {
prepareEditPackage(pkg);
setIsDialogOpen(true);
},
[prepareEditPackage],
);
// Form fields
const [selectedProjectId, setSelectedProjectId] = useState('');
const [name, setName] = useState('');
const [region, setRegion] = useState('');
const [chainageStartKm, setChainageStartKm] = useState<string>('');
const [chainageEndKm, setChainageEndKm] = useState<string>('');
// Load packages and projects
const loadPackages = async (currentSkip = skip, currentLimit = limit) => {
try {
setIsLoading(true);
setError(null);
const data = await packageService.getPackages({ skip: currentSkip, limit: currentLimit });
setPackages(data.items);
setTotalItems(data.totalItems);
} catch (err) {
setError('Failed to load packages. Please check if the backend is running.');
} finally {
// Add a small delay for animation stability
setTimeout(() => {
setIsLoading(false);
}, 800);
}
};
const loadProjects = async () => {
try {
setLoadingProjects(true);
const data = await projectService.getProjects({ skip: 0, limit: 1000 }); // Load all projects for selector
setProjects(data.items);
} catch (err) {
setError('Failed to load projects.');
} finally {
setLoadingProjects(false);
}
};
useEffect(() => {
loadPackages(skip, limit);
loadProjects();
}, [skip, limit]);
const resetForm = () => {
setSelectedProjectId('');
setName('');
setRegion('');
setChainageStartKm('');
setChainageEndKm('');
setError(null);
setIsEditing(false);
setCurrentPackage(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedProjectId) {
setError('Please select a project first');
return;
}
if (!name.trim()) {
setError('Package name is required');
return;
}
setIsSubmitting(true);
setError(null);
try {
if (isEditing && currentPackage) {
const data: PackageUpdate = {
name: name.trim(),
region: region.trim() || null,
chainage_start_km: chainageStartKm ? parseFloat(chainageStartKm) : 0,
chainage_end_km: chainageEndKm ? parseFloat(chainageEndKm) : 0,
};
await packageService.updatePackage(currentPackage.id, data);
toast.success('Package Updated', {
description: `${name} has been updated successfully at ${new Date().toLocaleTimeString()}`,
});
} else {
const data: PackageCreate = {
project_id: selectedProjectId,
name: name.trim(),
region: region.trim() || null,
chainage_start_km: chainageStartKm ? parseFloat(chainageStartKm) : 0,
chainage_end_km: chainageEndKm ? parseFloat(chainageEndKm) : 0,
};
await packageService.createPackage(data);
toast.success('Package Created', {
description: `${name} has been established successfully at ${new Date().toLocaleTimeString()}`,
});
const handleDialogOpenChange = useCallback(
(open: boolean) => {
setIsDialogOpen(open);
if (!open) {
resetForm();
}
// Refresh packages list
await loadPackages();
// Close modal and reset form immediately
setIsModalOpen(false);
resetForm();
} catch (err) {
const message =
err instanceof Error ? err.message : `Failed to ${isEditing ? 'update' : 'create'} package`;
setError(message);
toast.error('Operation Failed', {
description: message,
});
} finally {
setIsSubmitting(false);
}
};
const handleEdit = (pkg: PackageType) => {
setIsEditing(true);
setCurrentPackage(pkg);
setSelectedProjectId(pkg.project_id);
setName(pkg.name || '');
setRegion(pkg.region || '');
setChainageStartKm(pkg.chainage_start_km?.toString() || '0');
setChainageEndKm(pkg.chainage_end_km?.toString() || '0');
setIsModalOpen(true);
};
const handleDelete = async (pkg: PackageType) => {
if (!confirm(`Are you sure you want to delete package "${pkg.name}"?`)) return;
try {
setIsLoading(true);
await packageService.deletePackage(pkg.id);
toast.success('Package Deleted', {
description: `${pkg.name} has been removed from the system.`,
});
await loadPackages();
} catch (err) {
setError('Failed to delete package');
toast.error('Deletion Failed', {
description: 'The package could not be removed. Please try again.',
});
} finally {
setIsLoading(false);
}
};
const getProjectName = (projectId: string) => {
return projects.find((p) => p.id === projectId)?.name || projectId;
};
const columns: ColumnDef<PackageType>[] = [
{
accessorKey: 'name',
header: 'Package Name',
cell: ({ row }) => (
<div className="font-semibold text-gray-900 dark:text-gray-100">{row.original.name}</div>
),
},
{
accessorKey: 'project_id',
header: 'Project',
cell: ({ row }) => getProjectName(row.original.project_id),
},
{
accessorKey: 'project_state',
header: 'Project State',
cell: ({ row }) => {
const pkg = row.original;
const project = projects.find((p) => p.id === pkg.project_id);
if (!project?.state) return <span className="text-gray-400"></span>;
[resetForm],
);
const states = project.state
.split(',')
.map((s) => s.trim())
.filter(Boolean);
if (states.length === 0) return <span className="text-gray-400"></span>;
const deletePackage = useCallback(
(pkg: Package) => {
if (!confirm(`Are you sure you want to delete package "${pkg.name}"?`)) {
return;
}
deletePackageMutation.mutate(pkg);
},
[deletePackageMutation],
);
const firstState = states[0];
const remainingStates = states.slice(1);
return (
<div className="flex items-center gap-1.5">
<Badge variant="secondary">{firstState}</Badge>
{remainingStates.length > 0 && (
<Popover>
<PopoverTrigger asChild>
<button className="flex items-center justify-center rounded-full bg-muted/50 hover:bg-muted px-1.5 py-0.5 text-[10px] font-bold text-muted-foreground transition-colors border border-border/40">
+{remainingStates.length}
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-2" align="start">
<div className="flex flex-col gap-1.5">
<p className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider mb-0.5 px-1">
Other States
</p>
{remainingStates.map((item, idx) => (
<Badge key={idx} variant="secondary">
{item}
</Badge>
))}
</div>
</PopoverContent>
</Popover>
)}
</div>
);
},
},
{
accessorKey: 'region',
header: 'Region',
},
{
accessorKey: 'chainage_start_km',
header: 'Start (km)',
cell: ({ row }) => row.original.chainage_start_km?.toFixed(2) ?? '0.00',
},
{
accessorKey: 'chainage_end_km',
header: 'End (km)',
cell: ({ row }) => row.original.chainage_end_km?.toFixed(2) ?? '0.00',
},
];
const projects = projectsQuery.data?.items ?? [];
const packages = packagesQuery.data?.items ?? [];
const total = packagesQuery.data?.totalItems ?? 0;
const columns = usePackageColumns(projects);
return (
<>
<main className="relative z-10">
{/* Refined Header */}
<div className="mb-8">
<PageHeader
title="Package Management"
description="Manage project packages"
icon={Package}
actions={
<Button
onClick={() => {
setIsEditing(false);
setIsModalOpen(true);
}}
>
<Package className="mr-2 h-4 w-4" />
Add New Package
</Button>
}
/>
</div>
<main className="relative z-10 space-y-5">
<PageHeader
title="Package Management"
description="Manage project packages"
icon={PackageIcon}
actions={
<Button onClick={openCreate} size="sm">
<Plus />
Add New Package
</Button>
}
/>
{/* Data Table */}
<div>
<DataTable
title="Packages"
data={packages}
columns={columns}
onEdit={handleEdit}
onDelete={handleDelete}
isLoading={isLoading}
pagination={{
skip,
limit,
totalItems,
onPageChange: setSkip,
onLimitChange: (newLimit) => {
setLimit(newLimit);
setSkip(0); // Reset skip when limit changes
},
}}
/>
</div>
<PackageTable
columns={columns}
packages={packages}
isLoading={packagesQuery.isLoading || deletePackageMutation.isPending}
skip={skip}
limit={limit}
total={total}
onPageChange={setSkip}
onLimitChange={setLimit}
onEdit={openEdit}
onDelete={deletePackage}
/>
<PoweredBy />
</main>
{/* Modal Dialog */}
<Dialog
open={isModalOpen}
onOpenChange={(open) => {
if (!open) {
setIsModalOpen(false);
resetForm();
} else {
setIsModalOpen(true);
}
}}
>
<DialogContent className="max-w-2xl" onOpenAutoFocus={(e) => e.preventDefault()}>
<DialogHeader className="gap-2">
<DialogTitle className="flex items-center gap-3 text-xl">
<div className="p-2 rounded-lg bg-primary text-primary-foreground shadow-sm">
<Package className="h-5 w-5" />
</div>
{isEditing ? 'Edit Package Details' : 'Create New Package'}
</DialogTitle>
<DialogDescription className="text-sm">
{isEditing
? 'Update the technical specifications for your road infrastructure package.'
: 'Select a project and provide the essential data to establish a new package.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-8">
{/* Step 1: Select Project */}
{!isEditing && (
<div className="space-y-3">
<div className="flex items-center gap-2.5">
<div className="w-6 h-6 rounded-full bg-primary/10 text-primary flex items-center justify-center text-[10px] font-bold uppercase">
01
</div>
<p className="text-[11px] font-black text-muted-foreground uppercase tracking-widest">
Select Parent Project
</p>
</div>
<Select value={selectedProjectId} onValueChange={setSelectedProjectId}>
<SelectTrigger className="h-11 bg-muted/10 border-border/40 focus:ring-primary/20">
{loadingProjects ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<span className="text-muted-foreground text-sm">Loading...</span>
</div>
) : (
<SelectValue placeholder="Choose a project..." />
)}
</SelectTrigger>
<SelectContent>
{projects.map((project) => (
<SelectItem key={project.id} value={project.id} className="py-3">
<span className="font-semibold">{project.name}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Step 2: Package Info */}
<div
className={`space-y-4 ${selectedProjectId || isEditing ? 'opacity-100' : 'opacity-40 pointer-events-none'}`}
>
{!isEditing && (
<div className="flex items-center gap-2.5">
<div
className={`w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold uppercase ${selectedProjectId ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}
>
02
</div>
<p className="text-[11px] font-black text-muted-foreground uppercase tracking-widest">
Package Information
</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="space-y-2">
<Label
htmlFor="pkg-name"
className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider"
>
Package Name <span className="text-destructive">*</span>
</Label>
<Input
id="pkg-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Package 01"
className="h-11 bg-muted/20 border-border/60"
required
/>
</div>
<div className="space-y-2">
<Label
htmlFor="region"
className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider flex items-center gap-2"
>
<Globe className="h-3.5 w-3.5 opacity-60" />
Region{' '}
<span className="text-[10px] lowercase font-normal opacity-70">(Optional)</span>
</Label>
<Input
id="region"
value={region}
onChange={(e) => setRegion(e.target.value)}
placeholder="e.g. North Zone"
className="h-11 bg-muted/20 border-border/60"
/>
</div>
<div className="space-y-2">
<Label
htmlFor="chainage-start"
className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider"
>
Segment Start (km)
</Label>
<Input
id="chainage-start"
type="number"
step="0.01"
value={chainageStartKm}
onChange={(e) => setChainageStartKm(e.target.value)}
placeholder="0.00"
className="h-11 bg-muted/20 border-border/60"
/>
</div>
<div className="space-y-2">
<Label
htmlFor="chainage-end"
className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider"
>
Segment End (km)
</Label>
<Input
id="chainage-end"
type="number"
step="0.01"
value={chainageEndKm}
onChange={(e) => setChainageEndKm(e.target.value)}
placeholder="0.00"
className="h-11 bg-muted/20 border-border/60"
/>
</div>
</div>
</div>
{/* Submit Button */}
<div className="flex gap-4 pt-4">
<Button
type="button"
variant="outline"
onClick={() => {
setIsModalOpen(false);
resetForm();
}}
disabled={isSubmitting}
className="flex-1"
>
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting || !name.trim() || !selectedProjectId}
className="flex-1"
>
{isSubmitting ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>{isEditing ? 'Updating...' : 'Creating...'}</span>
</div>
) : (
<div className="flex items-center gap-2">
{isEditing ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<Package className="h-4 w-4" />
)}
<span>{isEditing ? 'Update Package' : 'Create Package'}</span>
</div>
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
<PackageDialog
open={isDialogOpen}
onOpenChange={handleDialogOpenChange}
packageId={packageId}
projects={projects}
isProjectsLoading={projectsQuery.isLoading}
projectId={projectId}
onProjectChange={setProjectId}
register={register}
errors={errors}
touchedFields={touchedFields}
onSubmit={onSubmit}
canSubmit={canSubmit}
isSaving={isSaving}
/>
</>
);
}