refactor: redesign table module
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Activity,
|
Activity,
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Zap,
|
Zap,
|
||||||
PencilLine,
|
PencilLine,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
Settings2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { StatsCard } from "@/components/dashboard/stats-card";
|
import { StatsCard } from "@/components/dashboard/stats-card";
|
||||||
import { CompactProjectSelector } from "@/components/dashboard/compact-project-selector";
|
import { CompactProjectSelector } from "@/components/dashboard/compact-project-selector";
|
||||||
@@ -26,9 +27,9 @@ import {
|
|||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Filter } from "lucide-react";
|
|
||||||
import { DashboardSkeleton } from "@/components/dashboard/dashboard-skeleton"
|
import { DashboardSkeleton } from "@/components/dashboard/dashboard-skeleton"
|
||||||
import { PoweredBy } from "@/components/powered-by";
|
import { PoweredBy } from "@/components/powered-by";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||||
|
|
||||||
@@ -207,14 +208,12 @@ export default function DashboardPage() {
|
|||||||
totalRoadDamage: 0,
|
totalRoadDamage: 0,
|
||||||
locationData: [],
|
locationData: [],
|
||||||
});
|
});
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Load projects on mount
|
// Load projects on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadProjects = async () => {
|
const loadProjects = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
|
||||||
const projectsData = await fetchProjects();
|
const projectsData = await fetchProjects();
|
||||||
setProjects(projectsData);
|
setProjects(projectsData);
|
||||||
|
|
||||||
@@ -223,9 +222,9 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load projects:", err);
|
console.error("Failed to load projects:", err);
|
||||||
setError(
|
toast.error("Failed to load projects", {
|
||||||
"Failed to load projects. Please check if the backend is running.",
|
description: "Please check if the backend is running."
|
||||||
);
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -274,7 +273,6 @@ export default function DashboardPage() {
|
|||||||
const loadProjectSummary = async () => {
|
const loadProjectSummary = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${API_URL}/summary/projects/${selectedProjectId}`,
|
`${API_URL}/summary/projects/${selectedProjectId}`,
|
||||||
@@ -294,7 +292,7 @@ export default function DashboardPage() {
|
|||||||
setProjectSummary(summary);
|
setProjectSummary(summary);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load project summary:", err);
|
console.error("Failed to load project summary:", err);
|
||||||
setError("Failed to load project summary.");
|
toast.error("Failed to load project summary");
|
||||||
setProjectSummary(null);
|
setProjectSummary(null);
|
||||||
} finally {
|
} finally {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -422,10 +420,10 @@ export default function DashboardPage() {
|
|||||||
}, [projectSummary, selectedPackageId, selectedLocationId]);
|
}, [projectSummary, selectedPackageId, selectedLocationId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen text-gray-900 dark:text-gray-100">
|
<>
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="min-h-screen">
|
<main>
|
||||||
<div className="p-4 px-8 max-w-340 mx-auto">
|
<div>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8 flex items-center justify-between">
|
<div className="mb-8 flex items-center justify-between">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -437,14 +435,14 @@ export default function DashboardPage() {
|
|||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" className="gap-2 font-semibold">
|
<Button variant="outline" className="gap-2 font-semibold">
|
||||||
<Filter className="h-4 w-4" />
|
<Settings2 className="h-4 w-4" />
|
||||||
<span>Filters</span>
|
<span>Filters</span>
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-[320px] p-0 overflow-hidden" align="end">
|
<PopoverContent className="w-[320px] p-0 overflow-hidden" align="end">
|
||||||
<div className="p-4 border-b bg-muted/30">
|
<div className="p-4 border-b bg-muted/30">
|
||||||
<h3 className="font-bold text-sm flex items-center gap-2 text-foreground">
|
<h3 className="font-bold text-sm flex items-center gap-2 text-foreground">
|
||||||
<Filter className="h-4 w-4 text-primary" />
|
<Settings2 className="h-4 w-4 text-primary" />
|
||||||
Filter Analysis
|
Filter Analysis
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs mt-1 text-muted-foreground">Refine your view by project, package, or location</p>
|
<p className="text-xs mt-1 text-muted-foreground">Refine your view by project, package, or location</p>
|
||||||
@@ -467,14 +465,6 @@ export default function DashboardPage() {
|
|||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error Display */}
|
|
||||||
{error && (
|
|
||||||
<div className="mb-4 flex items-center gap-2 p-3 rounded-xl bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-sm">
|
|
||||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
|
||||||
<p>{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoading && !projectSummary ? (
|
{isLoading && !projectSummary ? (
|
||||||
<DashboardSkeleton />
|
<DashboardSkeleton />
|
||||||
) : (
|
) : (
|
||||||
@@ -522,18 +512,12 @@ export default function DashboardPage() {
|
|||||||
{/* Charts Row - Side by Side */}
|
{/* Charts Row - Side by Side */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
|
||||||
{/* Left Chart - Detection Distribution */}
|
{/* Left Chart - Detection Distribution */}
|
||||||
<Card>
|
<Card className="flex flex-col">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="items-center pb-0">
|
||||||
<CardTitle className="text-base font-bold flex items-center gap-2">
|
<CardTitle>Detection Distribution</CardTitle>
|
||||||
<div className="p-2 rounded-md bg-secondary text-secondary-foreground">
|
<CardDescription>Breakdown of all detected road conditions</CardDescription>
|
||||||
<BarChart3 className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<span className="text-xl">
|
|
||||||
Detection Distribution
|
|
||||||
</span>
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-4">
|
<CardContent className="flex-1 pb-0">
|
||||||
<DetectionDonutChart
|
<DetectionDonutChart
|
||||||
defectedSignboard={stats.totalDefectedSignboard}
|
defectedSignboard={stats.totalDefectedSignboard}
|
||||||
pothole={stats.totalPothole}
|
pothole={stats.totalPothole}
|
||||||
@@ -542,19 +526,20 @@ export default function DashboardPage() {
|
|||||||
goodSignboard={stats.totalGoodSignboard}
|
goodSignboard={stats.totalGoodSignboard}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
<CardFooter className="flex-col gap-2 text-sm">
|
||||||
|
<div className="leading-none text-muted-foreground">
|
||||||
|
Total detections: {stats.totalRoadDamage + stats.totalGoodSignboard}
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Right Chart - Location Bar Chart */}
|
{/* Right Chart - Detections by Location */}
|
||||||
<Card>
|
<Card className="flex flex-col">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="items-center pb-0">
|
||||||
<CardTitle className="text-base font-bold flex items-center gap-2">
|
<CardTitle>Detections by Location</CardTitle>
|
||||||
<div className="p-2 rounded-md bg-secondary text-secondary-foreground">
|
<CardDescription>Frequency of road issues across different map segments</CardDescription>
|
||||||
<MapPinIcon className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<span className="text-xl">Detections by Location</span>
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-4">
|
<CardContent className="flex-1 pb-0">
|
||||||
<LocationBarChart
|
<LocationBarChart
|
||||||
data={stats.locationData.map((loc) => ({
|
data={stats.locationData.map((loc) => ({
|
||||||
name: loc.name,
|
name: loc.name,
|
||||||
@@ -562,11 +547,15 @@ export default function DashboardPage() {
|
|||||||
pothole: loc.pothole,
|
pothole: loc.pothole,
|
||||||
road_crack: loc.road_crack,
|
road_crack: loc.road_crack,
|
||||||
damaged_road_marking: loc.damaged_road_marking,
|
damaged_road_marking: loc.damaged_road_marking,
|
||||||
good_sign_board: loc.good_sign_board,
|
|
||||||
total: loc.total,
|
total: loc.total,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
<CardFooter className="flex-col gap-2 text-sm">
|
||||||
|
<div className="leading-none text-muted-foreground">
|
||||||
|
Analysis based on latest processed sequence
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -585,6 +574,6 @@ export default function DashboardPage() {
|
|||||||
<PoweredBy />
|
<PoweredBy />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { BreadcrumbBasic } from "@/components/app-breadcrumb";
|
||||||
import { AppSidebar } from "@/components/app-sidebar";
|
import { AppSidebar } from "@/components/app-sidebar";
|
||||||
import { ModeToggle } from "@/components/mode-toogle";
|
import { ModeToggle } from "@/components/mode-toogle";
|
||||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
const ModulesLayout = ({
|
const ModulesLayout = ({
|
||||||
@@ -11,15 +13,23 @@ const ModulesLayout = ({
|
|||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<main className="flex flex-1 flex-col p-4 pt-0 w-full h-screen overflow-hidden">
|
<main className="flex flex-1 flex-col w-full h-screen overflow-hidden">
|
||||||
<div className="flex gap-3 items-center mt-2 sticky top-0 bg-background z-50 py-2">
|
<div className="flex-1 overflow-auto">
|
||||||
<SidebarTrigger />
|
{/* Centering container wrapper */}
|
||||||
<ModeToggle />
|
<div className="max-w-380 mx-auto w-full flex flex-col min-h-full">
|
||||||
|
<div className="flex gap-3 items-center sticky top-0 bg-background/50 backdrop-blur-md z-30 py-3 px-6 border-b border-border/10">
|
||||||
|
<SidebarTrigger />
|
||||||
|
<ModeToggle />
|
||||||
|
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||||
|
<BreadcrumbBasic />
|
||||||
|
</div>
|
||||||
|
<div className="p-6 flex-1">{children}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 py-2 overflow-auto">{children}</div>
|
|
||||||
</main>
|
</main>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export default ModulesLayout;
|
export default ModulesLayout;
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import {
|
|||||||
} from "@/lib/api"
|
} from "@/lib/api"
|
||||||
import { ColumnDef } from "@tanstack/react-table"
|
import { ColumnDef } from "@tanstack/react-table"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||||
|
|
||||||
export default function LocationPage() {
|
export default function LocationPage() {
|
||||||
const [locations, setLocations] = useState<Location[]>([])
|
const [locations, setLocations] = useState<Location[]>([])
|
||||||
@@ -168,7 +170,9 @@ export default function LocationPage() {
|
|||||||
end_lng: parseFloat(endLng),
|
end_lng: parseFloat(endLng),
|
||||||
}
|
}
|
||||||
await updateLocation(currentLocation.id, data)
|
await updateLocation(currentLocation.id, data)
|
||||||
toast.success("Location updated successfully!")
|
toast.success("Location Updated", {
|
||||||
|
description: `${segmentName} has been updated successfully at ${new Date().toLocaleTimeString()}`,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
const data: LocationCreate = {
|
const data: LocationCreate = {
|
||||||
package_id: selectedPackageId,
|
package_id: selectedPackageId,
|
||||||
@@ -181,7 +185,9 @@ export default function LocationPage() {
|
|||||||
end_lng: parseFloat(endLng),
|
end_lng: parseFloat(endLng),
|
||||||
}
|
}
|
||||||
await createLocation(data)
|
await createLocation(data)
|
||||||
toast.success("Location created successfully!")
|
toast.success("Location Created", {
|
||||||
|
description: `${segmentName} has been established successfully at ${new Date().toLocaleTimeString()}`,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh locations list
|
// Refresh locations list
|
||||||
@@ -191,7 +197,11 @@ export default function LocationPage() {
|
|||||||
setIsModalOpen(false)
|
setIsModalOpen(false)
|
||||||
resetForm()
|
resetForm()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : `Failed to ${isEditing ? 'update' : 'create'} location`)
|
const message = err instanceof Error ? err.message : `Failed to ${isEditing ? 'update' : 'create'} location`
|
||||||
|
setError(message)
|
||||||
|
toast.error("Operation Failed", {
|
||||||
|
description: message,
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
@@ -224,10 +234,15 @@ export default function LocationPage() {
|
|||||||
try {
|
try {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
await deleteLocation(location.id)
|
await deleteLocation(location.id)
|
||||||
toast.success("Location deleted successfully!")
|
toast.success("Location Deleted", {
|
||||||
|
description: `${location.segment_name} has been removed from the system.`,
|
||||||
|
})
|
||||||
await loadLocations()
|
await loadLocations()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError("Failed to delete location")
|
setError("Failed to delete location")
|
||||||
|
toast.error("Deletion Failed", {
|
||||||
|
description: "The location could not be removed. Please try again.",
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
@@ -262,6 +277,50 @@ export default function LocationPage() {
|
|||||||
return project?.name || "—"
|
return project?.name || "—"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "project_state",
|
||||||
|
header: "Project State",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const location = row.original
|
||||||
|
const pkg = allPackages.find(p => p.id === location.package_id)
|
||||||
|
const project = projects.find(p => p.id === pkg?.project_id)
|
||||||
|
if (!project?.state) return <span className="text-gray-400">—</span>
|
||||||
|
|
||||||
|
const states = project.state.split(',').map(s => s.trim()).filter(Boolean)
|
||||||
|
if (states.length === 0) return <span className="text-gray-400">—</span>
|
||||||
|
|
||||||
|
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: "chainage",
|
accessorKey: "chainage",
|
||||||
header: "Chainage (km)",
|
header: "Chainage (km)",
|
||||||
@@ -269,9 +328,10 @@ export default function LocationPage() {
|
|||||||
const location = row.original
|
const location = row.original
|
||||||
if (location.chainage_start_km !== null && location.chainage_end_km !== null) {
|
if (location.chainage_start_km !== null && location.chainage_end_km !== null) {
|
||||||
return (
|
return (
|
||||||
<span className="px-2 py-0.5 rounded-full bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 font-semibold border border-amber-100 dark:border-amber-800 whitespace-nowrap">
|
<Badge variant="outline" className="flex items-center gap-1.5 border-amber-500/50 text-amber-500 font-bold whitespace-nowrap">
|
||||||
|
<Milestone className="h-3 w-3" />
|
||||||
{location.chainage_start_km} - {location.chainage_end_km}
|
{location.chainage_start_km} - {location.chainage_end_km}
|
||||||
</span>
|
</Badge>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return "—"
|
return "—"
|
||||||
@@ -281,44 +341,44 @@ export default function LocationPage() {
|
|||||||
accessorKey: "start_gps",
|
accessorKey: "start_gps",
|
||||||
header: "Start GPS",
|
header: "Start GPS",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="px-2 py-0.5 rounded-full bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border border-blue-100 dark:border-blue-800 whitespace-nowrap">
|
<Badge variant="outline" className="flex items-center gap-1.5 border-blue-500/50 text-blue-500 font-bold whitespace-nowrap">
|
||||||
|
<MapPin className="h-3 w-3" />
|
||||||
{row.original.start_lat.toFixed(4)}, {row.original.start_lng.toFixed(4)}
|
{row.original.start_lat.toFixed(4)}, {row.original.start_lng.toFixed(4)}
|
||||||
</span>
|
</Badge>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "end_gps",
|
accessorKey: "end_gps",
|
||||||
header: "End GPS",
|
header: "End GPS",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="px-2 py-0.5 rounded-full bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border border-blue-100 dark:border-blue-800 whitespace-nowrap">
|
<Badge variant="outline" className="flex items-center gap-1.5 border-blue-500/50 text-blue-500 font-bold whitespace-nowrap">
|
||||||
|
<MapPin className="h-3 w-3" />
|
||||||
{row.original.end_lat.toFixed(4)}, {row.original.end_lng.toFixed(4)}
|
{row.original.end_lat.toFixed(4)}, {row.original.end_lng.toFixed(4)}
|
||||||
</span>
|
</Badge>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen text-gray-900 dark:text-gray-100">
|
<>
|
||||||
<main className="min-h-screen relative overflow-hidden">
|
<main className="relative z-10">
|
||||||
<div className="mx-auto px-6 py-8 max-w-340 relative z-10">
|
|
||||||
{/* Refined Header */}
|
{/* Refined Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Location Management"
|
title="Location Management"
|
||||||
description="Manage road segment locations"
|
description="Manage road segment locations"
|
||||||
icon={MapPin}
|
icon={MapPin}
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
onClick={() => { setIsEditing(false); setIsModalOpen(true); }}
|
||||||
|
>
|
||||||
|
<MapPin className="mr-2 h-4 w-4" />
|
||||||
|
Add New Location
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error Message */}
|
|
||||||
{error && !isModalOpen && (
|
|
||||||
<div className="mb-6 flex items-center gap-3 p-4 rounded-xl bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800">
|
|
||||||
<div className="w-5 h-5 rounded-full bg-red-100 dark:bg-red-900 flex items-center justify-center shrink-0">
|
|
||||||
<span className="text-xs font-bold text-red-500">!</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-red-600 dark:text-red-400 break-all">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Data Table */}
|
{/* Data Table */}
|
||||||
<div>
|
<div>
|
||||||
@@ -326,10 +386,8 @@ export default function LocationPage() {
|
|||||||
title="Locations"
|
title="Locations"
|
||||||
data={locations}
|
data={locations}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
onAddNew={() => { setIsEditing(false); setIsModalOpen(true); }}
|
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
addButtonText="Add New Location"
|
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
pagination={{
|
pagination={{
|
||||||
skip,
|
skip,
|
||||||
@@ -344,8 +402,7 @@ export default function LocationPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PoweredBy />
|
<PoweredBy />
|
||||||
</div>
|
</main>
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Modal Dialog */}
|
{/* Modal Dialog */}
|
||||||
<Dialog open={isModalOpen} onOpenChange={(open) => { if (!open) { setIsModalOpen(false); resetForm(); } else { setIsModalOpen(true); } }}>
|
<Dialog open={isModalOpen} onOpenChange={(open) => { if (!open) { setIsModalOpen(false); resetForm(); } else { setIsModalOpen(true); } }}>
|
||||||
@@ -365,15 +422,6 @@ export default function LocationPage() {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
||||||
{/* Error Message in Modal */}
|
|
||||||
{error && (
|
|
||||||
<div className="flex items-center gap-3 p-4 rounded-xl bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800">
|
|
||||||
<div className="w-5 h-5 rounded-full bg-red-100 dark:bg-red-900 flex items-center justify-center shrink-0">
|
|
||||||
<span className="text-xs font-bold text-red-500">!</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-red-600 dark:text-red-400 break-all">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{!isEditing && (
|
{!isEditing && (
|
||||||
@@ -607,6 +655,6 @@ export default function LocationPage() {
|
|||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import {
|
|||||||
} from "@/lib/api"
|
} from "@/lib/api"
|
||||||
import { ColumnDef } from "@tanstack/react-table"
|
import { ColumnDef } from "@tanstack/react-table"
|
||||||
import { toast } from "sonner"
|
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 { PoweredBy } from "@/components/powered-by"
|
||||||
|
|
||||||
export default function PackagePage() {
|
export default function PackagePage() {
|
||||||
@@ -108,7 +110,9 @@ export default function PackagePage() {
|
|||||||
region: region.trim() || null,
|
region: region.trim() || null,
|
||||||
}
|
}
|
||||||
await updatePackage(currentPackage.id, data)
|
await updatePackage(currentPackage.id, data)
|
||||||
toast.success("Package updated successfully!")
|
toast.success("Package Updated", {
|
||||||
|
description: `${name} has been updated successfully at ${new Date().toLocaleTimeString()}`,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
const data: PackageCreate = {
|
const data: PackageCreate = {
|
||||||
project_id: selectedProjectId,
|
project_id: selectedProjectId,
|
||||||
@@ -116,7 +120,9 @@ export default function PackagePage() {
|
|||||||
region: region.trim() || null,
|
region: region.trim() || null,
|
||||||
}
|
}
|
||||||
await createPackage(data)
|
await createPackage(data)
|
||||||
toast.success("Package created successfully!")
|
toast.success("Package Created", {
|
||||||
|
description: `${name} has been established successfully at ${new Date().toLocaleTimeString()}`,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh packages list
|
// Refresh packages list
|
||||||
@@ -126,7 +132,11 @@ export default function PackagePage() {
|
|||||||
setIsModalOpen(false)
|
setIsModalOpen(false)
|
||||||
resetForm()
|
resetForm()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : `Failed to ${isEditing ? 'update' : 'create'} package`)
|
const message = err instanceof Error ? err.message : `Failed to ${isEditing ? 'update' : 'create'} package`
|
||||||
|
setError(message)
|
||||||
|
toast.error("Operation Failed", {
|
||||||
|
description: message,
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
@@ -147,10 +157,15 @@ export default function PackagePage() {
|
|||||||
try {
|
try {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
await deletePackage(pkg.id)
|
await deletePackage(pkg.id)
|
||||||
toast.success("Package deleted successfully!")
|
toast.success("Package Deleted", {
|
||||||
|
description: `${pkg.name} has been removed from the system.`,
|
||||||
|
})
|
||||||
await loadPackages()
|
await loadPackages()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError("Failed to delete package")
|
setError("Failed to delete package")
|
||||||
|
toast.error("Deletion Failed", {
|
||||||
|
description: "The package could not be removed. Please try again.",
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
@@ -180,16 +195,38 @@ export default function PackagePage() {
|
|||||||
const pkg = row.original
|
const pkg = row.original
|
||||||
const project = projects.find(p => p.id === pkg.project_id)
|
const project = projects.find(p => p.id === pkg.project_id)
|
||||||
if (!project?.state) return <span className="text-gray-400">—</span>
|
if (!project?.state) return <span className="text-gray-400">—</span>
|
||||||
|
|
||||||
|
const states = project.state.split(',').map(s => s.trim()).filter(Boolean)
|
||||||
|
if (states.length === 0) return <span className="text-gray-400">—</span>
|
||||||
|
|
||||||
|
const firstState = states[0]
|
||||||
|
const remainingStates = states.slice(1)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex items-center gap-1.5">
|
||||||
{project.state.split(',').map((item, idx) => (
|
<Badge variant="secondary">
|
||||||
<span
|
{firstState}
|
||||||
key={idx}
|
</Badge>
|
||||||
className="px-2 py-0.5 rounded-full bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 font-semibold border border-blue-100 dark:border-blue-800"
|
|
||||||
>
|
{remainingStates.length > 0 && (
|
||||||
{item.trim()}
|
<Popover>
|
||||||
</span>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -201,27 +238,26 @@ export default function PackagePage() {
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen text-gray-900 dark:text-gray-100">
|
<>
|
||||||
<main className="min-h-screen relative overflow-hidden">
|
|
||||||
<div className="mx-auto px-6 py-8 max-w-340 relative z-10">
|
<main className="relative z-10">
|
||||||
{/* Refined Header */}
|
{/* Refined Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Package Management"
|
title="Package Management"
|
||||||
description="Manage project packages"
|
description="Manage project packages"
|
||||||
icon={Package}
|
icon={Package}
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
onClick={() => { setIsEditing(false); setIsModalOpen(true); }}
|
||||||
|
>
|
||||||
|
<Package className="mr-2 h-4 w-4" />
|
||||||
|
Add New Package
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error Message */}
|
|
||||||
{error && !isModalOpen && (
|
|
||||||
<div className="mb-6 flex items-center gap-3 p-4 rounded-xl bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800">
|
|
||||||
<div className="w-5 h-5 rounded-full bg-red-100 dark:bg-red-900 flex items-center justify-center shrink-0">
|
|
||||||
<span className="text-xs font-bold text-red-500">!</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-red-600 dark:text-red-400 break-all">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Data Table */}
|
{/* Data Table */}
|
||||||
<div>
|
<div>
|
||||||
@@ -229,10 +265,8 @@ export default function PackagePage() {
|
|||||||
title="Packages"
|
title="Packages"
|
||||||
data={packages}
|
data={packages}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
onAddNew={() => { setIsEditing(false); setIsModalOpen(true); }}
|
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
addButtonText="Add New Package"
|
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
pagination={{
|
pagination={{
|
||||||
skip,
|
skip,
|
||||||
@@ -247,8 +281,7 @@ export default function PackagePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PoweredBy />
|
<PoweredBy />
|
||||||
</div>
|
</main>
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Modal Dialog */}
|
{/* Modal Dialog */}
|
||||||
<Dialog open={isModalOpen} onOpenChange={(open) => { if (!open) { setIsModalOpen(false); resetForm(); } else { setIsModalOpen(true); } }}>
|
<Dialog open={isModalOpen} onOpenChange={(open) => { if (!open) { setIsModalOpen(false); resetForm(); } else { setIsModalOpen(true); } }}>
|
||||||
@@ -269,15 +302,6 @@ export default function PackagePage() {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
||||||
{/* Error Message in Modal */}
|
|
||||||
{error && (
|
|
||||||
<div className="flex items-center gap-3 p-4 rounded-xl bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800">
|
|
||||||
<div className="w-5 h-5 rounded-full bg-red-100 dark:bg-red-900 flex items-center justify-center shrink-0">
|
|
||||||
<span className="text-xs font-bold text-red-500">!</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-red-600 dark:text-red-400 break-all">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-8">
|
<form onSubmit={handleSubmit} className="space-y-8">
|
||||||
{/* Step 1: Select Project */}
|
{/* Step 1: Select Project */}
|
||||||
@@ -371,7 +395,7 @@ export default function PackagePage() {
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting || !name.trim() || !selectedProjectId}
|
disabled={isSubmitting || !name.trim() || !selectedProjectId}
|
||||||
className="flex-1 h-12 font-bold uppercase tracking-wider text-xs shadow-lg shadow-primary/20"
|
className="flex-1 h-12 font-bold uppercase tracking-wider text-xs"
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -389,6 +413,6 @@ export default function PackagePage() {
|
|||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { PageHeader } from "@/components/page-header"
|
|||||||
import { createProject, fetchProjects, updateProject, deleteProject, type ProjectCreate, type Project, type ProjectUpdate } from "@/lib/api"
|
import { createProject, fetchProjects, updateProject, deleteProject, type ProjectCreate, type Project, type ProjectUpdate } from "@/lib/api"
|
||||||
import { ColumnDef } from "@tanstack/react-table"
|
import { ColumnDef } from "@tanstack/react-table"
|
||||||
import { toast } from "sonner"
|
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 { PoweredBy } from "@/components/powered-by"
|
||||||
|
|
||||||
export default function ProjectPage() {
|
export default function ProjectPage() {
|
||||||
@@ -91,7 +93,9 @@ export default function ProjectPage() {
|
|||||||
end_lng: endLng ? parseFloat(endLng) : null,
|
end_lng: endLng ? parseFloat(endLng) : null,
|
||||||
}
|
}
|
||||||
await updateProject(currentProject.id, data)
|
await updateProject(currentProject.id, data)
|
||||||
toast.success("Project updated successfully!")
|
toast.success("Project Updated", {
|
||||||
|
description: `${name} has been updated successfully at ${new Date().toLocaleTimeString()}`,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
const data: ProjectCreate = {
|
const data: ProjectCreate = {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
@@ -103,7 +107,9 @@ export default function ProjectPage() {
|
|||||||
end_lng: endLng ? parseFloat(endLng) : null,
|
end_lng: endLng ? parseFloat(endLng) : null,
|
||||||
}
|
}
|
||||||
await createProject(data)
|
await createProject(data)
|
||||||
toast.success("Project created successfully!")
|
toast.success("Project Created", {
|
||||||
|
description: `${name} has been established successfully at ${new Date().toLocaleTimeString()}`,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh projects list
|
// Refresh projects list
|
||||||
@@ -113,7 +119,11 @@ export default function ProjectPage() {
|
|||||||
setIsModalOpen(false)
|
setIsModalOpen(false)
|
||||||
resetForm()
|
resetForm()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : `Failed to ${isEditing ? 'update' : 'create'} project`)
|
const message = err instanceof Error ? err.message : `Failed to ${isEditing ? 'update' : 'create'} project`
|
||||||
|
setError(message)
|
||||||
|
toast.error("Operation Failed", {
|
||||||
|
description: message,
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
@@ -138,10 +148,15 @@ export default function ProjectPage() {
|
|||||||
try {
|
try {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
await deleteProject(project.id)
|
await deleteProject(project.id)
|
||||||
toast.success("Project deleted successfully!")
|
toast.success("Project Deleted", {
|
||||||
|
description: `${project.name} has been removed from the system.`,
|
||||||
|
})
|
||||||
await loadProjects()
|
await loadProjects()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError("Failed to delete project")
|
setError("Failed to delete project")
|
||||||
|
toast.error("Deletion Failed", {
|
||||||
|
description: "The project could not be removed. Please try again or check your permissions.",
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
@@ -152,7 +167,7 @@ export default function ProjectPage() {
|
|||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
header: "Project Name",
|
header: "Project Name",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="font-semibold text-gray-900 dark:text-gray-100">{row.original.name}</div>
|
<div className="font-semibold">{row.original.name}</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -160,17 +175,45 @@ export default function ProjectPage() {
|
|||||||
header: "State",
|
header: "State",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const project = row.original
|
const project = row.original
|
||||||
if (!project.state) return <span className="text-gray-400">—</span>
|
if (!project.state) return <span>—</span>
|
||||||
|
|
||||||
|
const states = project.state.split(',').map(s => s.trim()).filter(Boolean)
|
||||||
|
if (states.length === 0) return <span>—</span>
|
||||||
|
|
||||||
|
const firstState = states[0]
|
||||||
|
const remainingStates = states.slice(1)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{project.state.split(',').map((item, idx) => (
|
<Badge
|
||||||
<span
|
variant="secondary"
|
||||||
key={idx}
|
|
||||||
className="px-2 py-0.5 rounded-full bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 font-semibold border border-blue-100 dark:border-blue-800"
|
>
|
||||||
>
|
{firstState}
|
||||||
{item.trim()}
|
</Badge>
|
||||||
</span>
|
|
||||||
))}
|
{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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -182,27 +225,26 @@ export default function ProjectPage() {
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<>
|
||||||
<main className="min-h-screen relative overflow-hidden">
|
|
||||||
<div className="mx-auto px-6 py-8 max-w-340 relative z-10">
|
<main className="relative z-10">
|
||||||
{/* Refined Header */}
|
{/* Refined Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Project Management"
|
title="Project Management"
|
||||||
description="Manage road infrastructure projects"
|
description="Manage road infrastructure projects"
|
||||||
icon={FolderPlus}
|
icon={FolderPlus}
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
onClick={() => { setIsEditing(false); setIsModalOpen(true); }}
|
||||||
|
>
|
||||||
|
<FolderPlus className="mr-2 h-5 w-5" />
|
||||||
|
Add New Project
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error Message */}
|
|
||||||
{error && !isModalOpen && (
|
|
||||||
<div className="mb-6 flex items-center gap-3 p-4 rounded-xl bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800">
|
|
||||||
<div className="w-5 h-5 rounded-full bg-red-100 dark:bg-red-900 flex items-center justify-center shrink-0">
|
|
||||||
<span className="text-xs font-bold text-red-500">!</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-red-600 dark:text-red-400 break-all">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Data Table */}
|
{/* Data Table */}
|
||||||
<div>
|
<div>
|
||||||
@@ -210,10 +252,8 @@ export default function ProjectPage() {
|
|||||||
title="Projects"
|
title="Projects"
|
||||||
data={projects}
|
data={projects}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
onAddNew={() => { setIsEditing(false); setIsModalOpen(true); }}
|
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
addButtonText="Add New Project"
|
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
pagination={{
|
pagination={{
|
||||||
skip,
|
skip,
|
||||||
@@ -228,8 +268,8 @@ export default function ProjectPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PoweredBy />
|
<PoweredBy />
|
||||||
</div>
|
</main>
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Modal Dialog */}
|
{/* Modal Dialog */}
|
||||||
<Dialog open={isModalOpen} onOpenChange={(open) => { if (!open) { setIsModalOpen(false); resetForm(); } else { setIsModalOpen(true); } }}>
|
<Dialog open={isModalOpen} onOpenChange={(open) => { if (!open) { setIsModalOpen(false); resetForm(); } else { setIsModalOpen(true); } }}>
|
||||||
@@ -250,15 +290,6 @@ export default function ProjectPage() {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
||||||
{/* Error Message in Modal */}
|
|
||||||
{error && (
|
|
||||||
<div className="flex items-center gap-3 p-4 rounded-xl bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800">
|
|
||||||
<div className="w-5 h-5 rounded-full bg-red-100 dark:bg-red-900 flex items-center justify-center shrink-0">
|
|
||||||
<span className="text-xs font-bold text-red-500">!</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-red-600 dark:text-red-400 break-all">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{/* Project Name */}
|
{/* Project Name */}
|
||||||
@@ -409,6 +440,6 @@ export default function ProjectPage() {
|
|||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -149,6 +149,29 @@
|
|||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Modern Scrollbar Styles */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px; /* Slightly wider for better accessibility */
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--muted-foreground);
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: content-box;
|
||||||
|
opacity: 0.5; /* More visible default */
|
||||||
|
}
|
||||||
|
/* Firefox Support */
|
||||||
|
* {
|
||||||
|
/* scrollbar-width: ; */
|
||||||
|
scrollbar-color: var(--muted-foreground) transparent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
import { Toaster } from "@/components/ui/sonner"
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -35,6 +36,7 @@ export default function RootLayout({
|
|||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
<Toaster position="top-center" />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
57
src/components/app-breadcrumb.tsx
Normal file
57
src/components/app-breadcrumb.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
} from "@/components/ui/breadcrumb";
|
||||||
|
|
||||||
|
export function BreadcrumbBasic() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const segments = pathname.split("/").filter((segment) => segment !== "");
|
||||||
|
|
||||||
|
// Helper to format segment (e.g., "new-analysis" -> "New Analysis")
|
||||||
|
const formatSegment = (segment: string) => {
|
||||||
|
return segment
|
||||||
|
.split("-")
|
||||||
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList>
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbLink href="/">Home</BreadcrumbLink>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
{segments.length > 0 && <BreadcrumbSeparator />}
|
||||||
|
|
||||||
|
{segments.map((segment, index) => {
|
||||||
|
const href = `/${segments.slice(0, index + 1).join("/")}`;
|
||||||
|
const isLast = index === segments.length - 1;
|
||||||
|
|
||||||
|
// Skip segments that represent module groups or generic IDs if needed
|
||||||
|
// For now, mapping all segments
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={href}>
|
||||||
|
<BreadcrumbItem>
|
||||||
|
{isLast ? (
|
||||||
|
<BreadcrumbPage>{formatSegment(segment)}</BreadcrumbPage>
|
||||||
|
) : (
|
||||||
|
<BreadcrumbLink href={href}>{formatSegment(segment)}</BreadcrumbLink>
|
||||||
|
)}
|
||||||
|
</BreadcrumbItem>
|
||||||
|
{!isLast && <BreadcrumbSeparator />}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -78,7 +78,6 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel>Application</SidebarGroupLabel>
|
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{data.navMain.map((item) => (
|
{data.navMain.map((item) => (
|
||||||
<SidebarMenuItem key={item.title}>
|
<SidebarMenuItem key={item.title}>
|
||||||
|
|||||||
@@ -1,159 +1,156 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from "recharts"
|
import * as React from "react"
|
||||||
import { Loader2 } from "lucide-react"
|
import { Loader2 } from "lucide-react"
|
||||||
|
import { Label, Pie, PieChart } from "recharts"
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
type ChartConfig,
|
||||||
|
} from "@/components/ui/chart"
|
||||||
|
|
||||||
interface DetectionDonutChartProps {
|
interface DetectionDonutChartProps {
|
||||||
defectedSignboard: number
|
defectedSignboard: number
|
||||||
pothole: number
|
pothole: number
|
||||||
roadCrack: number
|
roadCrack: number
|
||||||
damagedRoadMarking: number
|
damagedRoadMarking: number
|
||||||
goodSignboard: number
|
goodSignboard: number
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLORS = {
|
const chartConfig = {
|
||||||
defectedSignboard: "#3b82f6", // Blue
|
pothole: {
|
||||||
pothole: "#ef4444", // Red
|
label: "Potholes",
|
||||||
roadCrack: "#f59e0b", // Amber/Orange
|
color: "var(--chart-1)",
|
||||||
damagedRoadMarking: "#6366f1", // Indigo
|
},
|
||||||
goodSignboard: "#10b981" // Emerald
|
defectedSignboard: {
|
||||||
}
|
label: "Defected Signboards",
|
||||||
|
color: "var(--chart-2)",
|
||||||
|
},
|
||||||
|
roadCrack: {
|
||||||
|
label: "Road Cracks",
|
||||||
|
color: "var(--chart-3)",
|
||||||
|
},
|
||||||
|
damagedRoadMarking: {
|
||||||
|
label: "Damaged Markings",
|
||||||
|
color: "var(--chart-4)",
|
||||||
|
},
|
||||||
|
goodSignboard: {
|
||||||
|
label: "Good Signboards",
|
||||||
|
color: "var(--chart-5)",
|
||||||
|
},
|
||||||
|
} satisfies ChartConfig
|
||||||
|
|
||||||
export function DetectionDonutChart({
|
export function DetectionDonutChart({
|
||||||
defectedSignboard,
|
defectedSignboard,
|
||||||
pothole,
|
pothole,
|
||||||
roadCrack,
|
roadCrack,
|
||||||
damagedRoadMarking,
|
damagedRoadMarking,
|
||||||
goodSignboard,
|
goodSignboard,
|
||||||
isLoading = false
|
isLoading = false,
|
||||||
}: DetectionDonutChartProps) {
|
}: DetectionDonutChartProps) {
|
||||||
if (isLoading) {
|
const chartData = React.useMemo(
|
||||||
return (
|
() =>
|
||||||
<div className="h-[250px] flex items-center justify-center">
|
[
|
||||||
<Loader2 className="h-8 w-8 text-primary/50" />
|
{ type: "pothole", count: pothole, fill: "var(--color-pothole)" },
|
||||||
</div>
|
{
|
||||||
)
|
type: "defectedSignboard",
|
||||||
}
|
count: defectedSignboard,
|
||||||
|
fill: "var(--color-defectedSignboard)",
|
||||||
|
},
|
||||||
|
{ type: "roadCrack", count: roadCrack, fill: "var(--color-roadCrack)" },
|
||||||
|
{
|
||||||
|
type: "damagedRoadMarking",
|
||||||
|
count: damagedRoadMarking,
|
||||||
|
fill: "var(--color-damagedRoadMarking)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "goodSignboard",
|
||||||
|
count: goodSignboard,
|
||||||
|
fill: "var(--color-goodSignboard)",
|
||||||
|
},
|
||||||
|
].filter((item) => item.count > 0),
|
||||||
|
[
|
||||||
|
pothole,
|
||||||
|
defectedSignboard,
|
||||||
|
roadCrack,
|
||||||
|
damagedRoadMarking,
|
||||||
|
goodSignboard,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
const total = defectedSignboard + pothole + roadCrack + damagedRoadMarking + goodSignboard
|
const totalDetections = React.useMemo(() => {
|
||||||
|
return chartData.reduce((acc, curr) => acc + curr.count, 0)
|
||||||
if (total === 0) {
|
}, [chartData])
|
||||||
return (
|
|
||||||
<div className="h-[250px] flex flex-col items-center justify-center text-muted-foreground">
|
|
||||||
<p className="text-sm">No detections found</p>
|
|
||||||
<p className="text-xs mt-1">Process videos to see data</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = [
|
|
||||||
{ name: "Defected Signboards", value: defectedSignboard, color: COLORS.defectedSignboard },
|
|
||||||
{ name: "Potholes", value: pothole, color: COLORS.pothole },
|
|
||||||
{ name: "Road Cracks", value: roadCrack, color: COLORS.roadCrack },
|
|
||||||
{ name: "Damaged Markings", value: damagedRoadMarking, color: COLORS.damagedRoadMarking },
|
|
||||||
{ name: "Good Signboards", value: goodSignboard, color: COLORS.goodSignboard }
|
|
||||||
].filter(item => item.value > 0)
|
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="h-[220px] relative">
|
<div className="h-[250px] flex items-center justify-center">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<Loader2 className="h-8 w-8 text-primary/50 animate-spin" />
|
||||||
<PieChart>
|
</div>
|
||||||
<defs>
|
|
||||||
<linearGradient id="gradPothole" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor="#ff8a8a" />
|
|
||||||
<stop offset="100%" stopColor="#ef4444" />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="gradDefectedSign" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor="#60a5fa" />
|
|
||||||
<stop offset="100%" stopColor="#3b82f6" />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="gradCrack" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor="#fbbf24" />
|
|
||||||
<stop offset="100%" stopColor="#f59e0b" />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="gradMarking" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor="#818cf8" />
|
|
||||||
<stop offset="100%" stopColor="#6366f1" />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="gradGoodSign" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor="#34d399" />
|
|
||||||
<stop offset="100%" stopColor="#10b981" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<Pie
|
|
||||||
data={data}
|
|
||||||
cx="50%"
|
|
||||||
cy="45%"
|
|
||||||
innerRadius={50}
|
|
||||||
outerRadius={70}
|
|
||||||
paddingAngle={5}
|
|
||||||
dataKey="value"
|
|
||||||
strokeWidth={0}
|
|
||||||
isAnimationActive={false}
|
|
||||||
>
|
|
||||||
{data.map((entry, index) => {
|
|
||||||
const gradId = entry.name === "Potholes" ? "gradPothole" :
|
|
||||||
entry.name === "Defected Signboards" ? "gradDefectedSign" :
|
|
||||||
entry.name === "Road Cracks" ? "gradCrack" :
|
|
||||||
entry.name === "Damaged Markings" ? "gradMarking" : "gradGoodSign"
|
|
||||||
return (
|
|
||||||
<Cell
|
|
||||||
key={`cell-${index}`}
|
|
||||||
fill={`url(#${gradId})`}
|
|
||||||
fillOpacity={1}
|
|
||||||
className="hover:fill-opacity-80"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip
|
|
||||||
content={({ active, payload }) => {
|
|
||||||
if (active && payload && payload.length) {
|
|
||||||
const data = payload[0]
|
|
||||||
return (
|
|
||||||
<div className="bg-popover border border-border rounded-lg px-3 py-2 shadow-lg">
|
|
||||||
<p className="font-medium text-sm">{data.name}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Count: <span className="font-semibold text-foreground">{data.value}</span>
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{((Number(data.value) / total) * 100).toFixed(1)}% of total
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Legend
|
|
||||||
verticalAlign="bottom"
|
|
||||||
height={40}
|
|
||||||
content={({ payload }) => (
|
|
||||||
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1 mt-2">
|
|
||||||
{payload?.map((entry, index) => (
|
|
||||||
<div key={`legend-${index}`} className="flex items-center gap-1.5">
|
|
||||||
<div
|
|
||||||
className="w-2.5 h-2.5 rounded-full"
|
|
||||||
style={{ backgroundColor: entry.color }}
|
|
||||||
/>
|
|
||||||
<span className="text-[9px] text-muted-foreground whitespace-nowrap">
|
|
||||||
{entry.value}: {data[index].value}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
{/* Center label */}
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none" style={{ marginTop: '-55px' }}>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-xl font-extrabold text-[#2563eb]">{total}</p>
|
|
||||||
<p className="text-[9px] uppercase tracking-wider text-muted-foreground">Total</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalDetections === 0) {
|
||||||
|
return (
|
||||||
|
<div className="h-[250px] flex flex-col items-center justify-center text-muted-foreground">
|
||||||
|
<p className="text-sm">No detections found</p>
|
||||||
|
<p className="text-xs mt-1">Process videos to see data</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContainer
|
||||||
|
config={chartConfig}
|
||||||
|
className="mx-auto aspect-square max-h-[250px]"
|
||||||
|
>
|
||||||
|
<PieChart>
|
||||||
|
<ChartTooltip
|
||||||
|
cursor={false}
|
||||||
|
content={<ChartTooltipContent hideLabel />}
|
||||||
|
/>
|
||||||
|
<Pie
|
||||||
|
data={chartData}
|
||||||
|
dataKey="count"
|
||||||
|
nameKey="type"
|
||||||
|
innerRadius={60}
|
||||||
|
strokeWidth={5}
|
||||||
|
>
|
||||||
|
<Label
|
||||||
|
content={({ viewBox }) => {
|
||||||
|
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
|
||||||
|
return (
|
||||||
|
<text
|
||||||
|
x={viewBox.cx}
|
||||||
|
y={viewBox.cy}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
>
|
||||||
|
<tspan
|
||||||
|
x={viewBox.cx}
|
||||||
|
y={viewBox.cy}
|
||||||
|
className="fill-foreground text-3xl font-bold"
|
||||||
|
>
|
||||||
|
{totalDetections.toLocaleString()}
|
||||||
|
</tspan>
|
||||||
|
<tspan
|
||||||
|
x={viewBox.cx}
|
||||||
|
y={(viewBox.cy || 0) + 24}
|
||||||
|
className="fill-muted-foreground"
|
||||||
|
>
|
||||||
|
Total
|
||||||
|
</tspan>
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
|
</ChartContainer>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,179 +1,105 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts"
|
import * as React from "react"
|
||||||
|
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"
|
||||||
import { Loader2 } from "lucide-react"
|
import { Loader2 } from "lucide-react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
type ChartConfig,
|
||||||
|
} from "@/components/ui/chart"
|
||||||
|
|
||||||
interface LocationData {
|
interface LocationData {
|
||||||
name: string
|
name: string
|
||||||
defected_sign_board: number
|
defected_sign_board: number
|
||||||
pothole: number
|
pothole: number
|
||||||
road_crack: number
|
road_crack: number
|
||||||
damaged_road_marking: number
|
damaged_road_marking: number
|
||||||
good_sign_board: number
|
total: number
|
||||||
total: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LocationBarChartProps {
|
interface LocationBarChartProps {
|
||||||
data: LocationData[]
|
data: LocationData[]
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLORS = {
|
const chartConfig = {
|
||||||
defected_sign_board: "#60a5fa", // Lighter Blue
|
pothole: {
|
||||||
pothole: "#ff8a8a", // Lighter Red
|
label: "Potholes",
|
||||||
road_crack: "#fbbf24", // Lighter Orange
|
color: "var(--chart-1)",
|
||||||
damaged_road_marking: "#818cf8", // Lighter Indigo
|
},
|
||||||
good_sign_board: "#34d399" // Lighter Emerald
|
defected_sign_board: {
|
||||||
}
|
label: "Defected Signboards",
|
||||||
|
color: "var(--chart-2)",
|
||||||
|
},
|
||||||
|
road_crack: {
|
||||||
|
label: "Road Cracks",
|
||||||
|
color: "var(--chart-3)",
|
||||||
|
},
|
||||||
|
damaged_road_marking: {
|
||||||
|
label: "Damaged Markings",
|
||||||
|
color: "var(--chart-4)",
|
||||||
|
},
|
||||||
|
} satisfies ChartConfig
|
||||||
|
|
||||||
export function LocationBarChart({ data, isLoading = false }: LocationBarChartProps) {
|
export function LocationBarChart({ data, isLoading = false }: LocationBarChartProps) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
|
||||||
<div className="h-[300px] flex items-center justify-center">
|
|
||||||
<Loader2 className="h-8 w-8 text-primary/50" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="h-[300px] flex flex-col items-center justify-center text-muted-foreground">
|
|
||||||
<p className="text-sm">No location data available</p>
|
|
||||||
<p className="text-xs mt-1">Process videos to see detections by location</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-[200px]">
|
<div className="h-[250px] flex items-center justify-center">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<Loader2 className="h-8 w-8 text-primary/50 animate-spin" />
|
||||||
<BarChart
|
</div>
|
||||||
data={data}
|
|
||||||
margin={{ top: 10, right: 10, left: 0, bottom: 20 }}
|
|
||||||
barCategoryGap="10%"
|
|
||||||
barGap={2}
|
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="barPothole" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor="#ff8a8a" />
|
|
||||||
<stop offset="100%" stopColor="#ef4444" />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="barDefectedSign" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor="#60a5fa" />
|
|
||||||
<stop offset="100%" stopColor="#3b82f6" />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="barCrack" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor="#fbbf24" />
|
|
||||||
<stop offset="100%" stopColor="#f59e0b" />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="barMarking" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor="#818cf8" />
|
|
||||||
<stop offset="100%" stopColor="#6366f1" />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="barGoodSign" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor="#34d399" />
|
|
||||||
<stop offset="100%" stopColor="#10b981" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<CartesianGrid
|
|
||||||
strokeDasharray="3 3"
|
|
||||||
vertical={false}
|
|
||||||
stroke="hsl(var(--muted-foreground) / 0.1)"
|
|
||||||
/>
|
|
||||||
<XAxis
|
|
||||||
dataKey="name"
|
|
||||||
tick={{ fontSize: 9, fill: 'hsl(var(--muted-foreground))' }}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
angle={-45}
|
|
||||||
textAnchor="end"
|
|
||||||
height={50}
|
|
||||||
interval={0}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
tick={{ fontSize: 9, fill: 'hsl(var(--muted-foreground))' }}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
width={25}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
content={({ active, payload, label }) => {
|
|
||||||
if (active && payload && payload.length) {
|
|
||||||
return (
|
|
||||||
<div className="bg-popover border border-border rounded-lg px-3 py-2 shadow-lg">
|
|
||||||
<p className="font-medium text-xs mb-1">{label}</p>
|
|
||||||
{payload.map((entry, index) => (
|
|
||||||
<div key={index} className="flex items-center gap-2 text-xs">
|
|
||||||
<div
|
|
||||||
className="w-2 h-2 rounded-full"
|
|
||||||
style={{ backgroundColor: entry.color }}
|
|
||||||
/>
|
|
||||||
<span className="text-muted-foreground">{(entry.name as string).replace(/_/g, ' ')}:</span>
|
|
||||||
<span className="font-semibold">{entry.value}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Legend
|
|
||||||
verticalAlign="top"
|
|
||||||
height={30}
|
|
||||||
content={({ payload }) => (
|
|
||||||
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1 mb-2">
|
|
||||||
{payload?.map((entry, index) => (
|
|
||||||
<div key={`legend-${index}`} className="flex items-center gap-1.5">
|
|
||||||
<div
|
|
||||||
className="w-2.5 h-2.5 rounded-sm"
|
|
||||||
style={{ backgroundColor: entry.color }}
|
|
||||||
/>
|
|
||||||
<span className="text-[9px] text-muted-foreground whitespace-nowrap">
|
|
||||||
{(entry.value as string).replace(/_/g, ' ')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Bar
|
|
||||||
dataKey="pothole"
|
|
||||||
name="Pothole"
|
|
||||||
fill="url(#barPothole)"
|
|
||||||
radius={[4, 4, 0, 0]}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
<Bar
|
|
||||||
dataKey="defected_sign_board"
|
|
||||||
name="Defected Signboard"
|
|
||||||
fill="url(#barDefectedSign)"
|
|
||||||
radius={[4, 4, 0, 0]}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
<Bar
|
|
||||||
dataKey="road_crack"
|
|
||||||
name="Road Crack"
|
|
||||||
fill="url(#barCrack)"
|
|
||||||
radius={[4, 4, 0, 0]}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
<Bar
|
|
||||||
dataKey="damaged_road_marking"
|
|
||||||
name="Damaged Marking"
|
|
||||||
fill="url(#barMarking)"
|
|
||||||
radius={[4, 4, 0, 0]}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
<Bar
|
|
||||||
dataKey="good_sign_board"
|
|
||||||
name="Good Signboard"
|
|
||||||
fill="url(#barGoodSign)"
|
|
||||||
radius={[4, 4, 0, 0]}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="h-[250px] flex flex-col items-center justify-center text-muted-foreground">
|
||||||
|
<p className="text-sm">No location data available</p>
|
||||||
|
<p className="text-xs mt-1">Process videos to see detections by location</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out good signboards for a more "damage-focused" standard chart as per multiple bar example
|
||||||
|
const chartData = data.map(item => ({
|
||||||
|
name: item.name,
|
||||||
|
pothole: item.pothole,
|
||||||
|
defected_sign_board: item.defected_sign_board,
|
||||||
|
road_crack: item.road_crack,
|
||||||
|
damaged_road_marking: item.damaged_road_marking,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-[250px] w-full">
|
||||||
|
<ChartContainer config={chartConfig} className="h-full w-full">
|
||||||
|
<BarChart accessibilityLayer data={chartData}>
|
||||||
|
<CartesianGrid vertical={false} strokeOpacity={0.1} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
tickLine={false}
|
||||||
|
tickMargin={10}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={(value) => value.length > 8 ? `${value.slice(0, 8)}...` : value}
|
||||||
|
fontSize={12}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
fontSize={12}
|
||||||
|
tickMargin={10}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
cursor={false}
|
||||||
|
content={<ChartTooltipContent indicator="dashed" />}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="pothole" fill="var(--color-pothole)" radius={4} />
|
||||||
|
<Bar dataKey="defected_sign_board" fill="var(--color-defected_sign_board)" radius={4} />
|
||||||
|
<Bar dataKey="road_crack" fill="var(--color-road_crack)" radius={4} />
|
||||||
|
<Bar dataKey="damaged_road_marking" fill="var(--color-damaged_road_marking)" radius={4} />
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function StatsCard({
|
|||||||
isLoading = false
|
isLoading = false
|
||||||
}: StatsCardProps) {
|
}: StatsCardProps) {
|
||||||
return (
|
return (
|
||||||
<Card className="py-6 px-6">
|
<Card className="py-6 px-6 hover:scale-105 transition-all duration-300 ease-in-out">
|
||||||
<CardContent className="p-0 flex items-center justify-between gap-6">
|
<CardContent className="p-0 flex items-center justify-between gap-6">
|
||||||
<div className="flex flex-col gap-1 min-w-0">
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
|
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
|
||||||
|
|||||||
@@ -1,64 +1,69 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { Table } from "@tanstack/react-table";
|
import { Table } from "@tanstack/react-table";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
export function TableFooter<TData>({ table }: { table: Table<TData> }) {
|
export function TableFooter<TData>({ table }: { table: Table<TData> }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-end px-4 py-4 border-t gap-6 lg:gap-8">
|
<div className="flex items-center justify-between px-6 py-4 border-t border-border/40 bg-muted/5">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center gap-6">
|
||||||
<p className="text-sm font-medium">Rows per page</p>
|
<div className="flex items-center space-x-2">
|
||||||
<Select
|
<p className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider">Rows per page</p>
|
||||||
value={`${table.getState().pagination.pageSize}`}
|
<Select
|
||||||
onValueChange={(value) => {
|
value={`${table.getState().pagination.pageSize}`}
|
||||||
table.setPageSize(Number(value));
|
onValueChange={(value) => {
|
||||||
}}
|
table.setPageSize(Number(value));
|
||||||
>
|
}}
|
||||||
<SelectTrigger className="h-8 w-[70px]">
|
>
|
||||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
<SelectTrigger className="h-8 w-[70px] bg-transparent border-border/40 text-xs font-semibold">
|
||||||
</SelectTrigger>
|
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||||
<SelectContent side="top">
|
</SelectTrigger>
|
||||||
{[10, 20, 30, 40, 50].map((pageSize) => (
|
<SelectContent side="top" className="min-w-[70px]">
|
||||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||||
{pageSize}
|
<SelectItem key={pageSize} value={`${pageSize}`} className="text-xs">
|
||||||
</SelectItem>
|
{pageSize}
|
||||||
))}
|
</SelectItem>
|
||||||
</SelectContent>
|
))}
|
||||||
</Select>
|
</SelectContent>
|
||||||
</div>
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
|
<div className="flex items-center gap-8">
|
||||||
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
<div className="flex items-center text-[11px] font-bold text-muted-foreground uppercase tracking-widest gap-1">
|
||||||
{table.getPageCount() || 1}
|
<span className="text-foreground">Page {table.getState().pagination.pageIndex + 1}</span>
|
||||||
</div>
|
<span className="opacity-40">/</span>
|
||||||
|
<span>{table.getPageCount() || 1}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0 border-border/40 bg-transparent hover:bg-muted/50 transition-colors"
|
||||||
onClick={() => table.previousPage()}
|
onClick={() => table.previousPage()}
|
||||||
disabled={!table.getCanPreviousPage()}
|
disabled={!table.getCanPreviousPage()}
|
||||||
>
|
>
|
||||||
<span className="sr-only">Go to previous page</span>
|
<span className="sr-only">Go to previous page</span>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4 opacity-70" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0 border-border/40 bg-transparent hover:bg-muted/50 transition-colors"
|
||||||
onClick={() => table.nextPage()}
|
onClick={() => table.nextPage()}
|
||||||
disabled={!table.getCanNextPage()}
|
disabled={!table.getCanNextPage()}
|
||||||
>
|
>
|
||||||
<span className="sr-only">Go to next page</span>
|
<span className="sr-only">Go to next page</span>
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4 opacity-70" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,15 @@ import { SearchIcon } from "lucide-react";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
const SearchBar = () => {
|
const SearchBar = () => {
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<SearchIcon className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search..."
|
placeholder="Search resources..."
|
||||||
className="pl-8 h-9 text-sm"
|
className="pl-9 h-10 text-sm bg-muted/20 border-border/40 focus:bg-background transition-all"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SearchBar;
|
export default SearchBar;
|
||||||
|
|||||||
@@ -1,45 +1,47 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import React from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import SearchBar from "./SearchBar";
|
import SearchBar from "./SearchBar";
|
||||||
import { PlusIcon } from "lucide-react";
|
import { FolderPlus, Settings2, SlidersHorizontal } from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
interface TopHeaderProps {
|
interface TopHeaderProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
itemCount?: number;
|
itemCount?: number;
|
||||||
onAddNew?: () => void;
|
onAddNew?: () => void;
|
||||||
addButtonText?: string;
|
addButtonText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TopHeader = ({ title, itemCount, onAddNew, addButtonText = "Add New" }: TopHeaderProps) => {
|
const TopHeader = ({ title, itemCount, onAddNew, addButtonText = "Add New" }: TopHeaderProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 p-4">
|
<div className="flex flex-col gap-1 p-3 w-full">
|
||||||
<div className="flex items-center justify-between">
|
{/* Connected Summary Block */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="w-full bg-muted/30 border border-border/50 rounded-lg p-4 flex items-center">
|
||||||
<h2 className="text-xl font-semibold tracking-tight">{title}</h2>
|
<div className="flex items-center gap-2 text-muted-foreground font-semibold tracking-tight">
|
||||||
{itemCount !== undefined && (
|
<span className="text-base text-foreground/80">Total {title || "Items"} :</span>
|
||||||
<span className="flex items-center justify-center bg-secondary text-secondary-foreground min-w-[24px] h-[24px] px-2 text-xs font-bold rounded-full">
|
<span className="text-primary font-bold text-lg">{itemCount || 0}</span>
|
||||||
{itemCount}
|
</div>
|
||||||
</span>
|
</div>
|
||||||
)}
|
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
{/* Search Bar Hidden for now as per requirement */}
|
||||||
|
{/* <div className="flex w-full max-w-sm">
|
||||||
|
<SearchBar />
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
{/* Add New Button moved to PageHeader */}
|
||||||
|
{/* {onAddNew && (
|
||||||
|
<Button
|
||||||
|
onClick={onAddNew}
|
||||||
|
className="h-11 px-6 rounded-xl bg-primary hover:bg-primary/90 text-primary-foreground font-bold text-sm shadow-md shadow-primary/20 transition-all hover:-translate-y-0.5"
|
||||||
|
>
|
||||||
|
<FolderPlus className="mr-2 h-5 w-5" />
|
||||||
|
{addButtonText}
|
||||||
|
</Button>
|
||||||
|
)} */}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
{onAddNew && (
|
|
||||||
<Button
|
|
||||||
onClick={onAddNew}
|
|
||||||
size="sm"
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<PlusIcon className="w-4 h-4" />
|
|
||||||
{addButtonText}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex w-full max-w-sm ml-auto">
|
|
||||||
<SearchBar />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TopHeader;
|
export default TopHeader;
|
||||||
|
|||||||
@@ -1,33 +1,38 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { flexRender } from "@tanstack/react-table";
|
import { flexRender } from "@tanstack/react-table";
|
||||||
import {
|
import {
|
||||||
TableHead,
|
TableHead,
|
||||||
TableHeader as ShadTableHeader,
|
TableHeader as ShadTableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Table } from "@tanstack/react-table";
|
import { Table } from "@tanstack/react-table";
|
||||||
|
import { ChevronDown, ChevronsUpDown } from "lucide-react";
|
||||||
|
|
||||||
const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
|
const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
|
||||||
return (
|
return (
|
||||||
<ShadTableHeader>
|
<ShadTableHeader>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow key={headerGroup.id}>
|
<TableRow key={headerGroup.id} className="hover:bg-transparent border-b border-border/30">
|
||||||
{headerGroup.headers.map((header) => {
|
{headerGroup.headers.map((header) => {
|
||||||
return (
|
return (
|
||||||
<TableHead key={header.id}>
|
<TableHead key={header.id} className="h-14 px-6 text-muted-foreground border-b border-border/30 font-medium text-sm">
|
||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: (
|
||||||
header.column.columnDef.header,
|
<div className="flex items-center gap-2 group cursor-pointer select-none">
|
||||||
header.getContext()
|
{flexRender(
|
||||||
)}
|
header.column.columnDef.header,
|
||||||
</TableHead>
|
header.getContext()
|
||||||
);
|
)}
|
||||||
})}
|
</div>
|
||||||
</TableRow>
|
)}
|
||||||
))}
|
</TableHead>
|
||||||
</ShadTableHeader>
|
);
|
||||||
);
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</ShadTableHeader>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TableHeader;
|
export default TableHeader;
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
SortingState,
|
SortingState,
|
||||||
flexRender,
|
flexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
getPaginationRowModel,
|
getPaginationRowModel,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
|
|
||||||
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Edit3, MoreHorizontal, Trash2 } from "lucide-react";
|
||||||
import { Edit2, Trash2 } from "lucide-react";
|
|
||||||
|
|
||||||
import TopHeader from "./Header";
|
import TopHeader from "./Header";
|
||||||
import TableHeader from "./TableHeader";
|
import TableHeader from "./TableHeader";
|
||||||
@@ -22,165 +21,169 @@ import { TableFooter } from "./Footer";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface DataTableProps<TData, TValue> {
|
export interface DataTableProps<TData, TValue> {
|
||||||
columns: ColumnDef<TData, TValue>[];
|
columns: ColumnDef<TData, TValue>[];
|
||||||
data: TData[];
|
data: TData[];
|
||||||
title?: string;
|
title?: string;
|
||||||
onAddNew?: () => void;
|
onAddNew?: () => void;
|
||||||
addButtonText?: string;
|
addButtonText?: string;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
onEdit?: (item: TData) => void;
|
onEdit?: (item: TData) => void;
|
||||||
onDelete?: (item: TData) => void;
|
onDelete?: (item: TData) => void;
|
||||||
pagination?: {
|
pagination?: {
|
||||||
skip: number;
|
skip: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
totalItems?: number;
|
totalItems?: number;
|
||||||
onPageChange: (newSkip: number) => void;
|
onPageChange: (newSkip: number) => void;
|
||||||
onLimitChange: (newLimit: number) => void;
|
onLimitChange: (newLimit: number) => void;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataTable<TData, TValue>({
|
export function DataTable<TData, TValue>({
|
||||||
columns: initialColumns,
|
columns: initialColumns,
|
||||||
data,
|
|
||||||
title,
|
|
||||||
onAddNew,
|
|
||||||
addButtonText,
|
|
||||||
isLoading = false,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
pagination,
|
|
||||||
}: DataTableProps<TData, TValue>) {
|
|
||||||
const [rowSelection, setRowSelection] = React.useState({});
|
|
||||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
|
||||||
|
|
||||||
const columns = React.useMemo(() => {
|
|
||||||
const cols = [...initialColumns];
|
|
||||||
if (onEdit || onDelete) {
|
|
||||||
cols.push({
|
|
||||||
id: "actions",
|
|
||||||
header: () => <div className="text-right">Actions</div>,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const item = row.original;
|
|
||||||
return (
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
{onEdit && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => onEdit(item)}
|
|
||||||
className="h-8 w-8"
|
|
||||||
>
|
|
||||||
<Edit2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{onDelete && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => onDelete(item)}
|
|
||||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return cols;
|
|
||||||
}, [initialColumns, onEdit, onDelete]);
|
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
data,
|
data,
|
||||||
columns,
|
title,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
onAddNew,
|
||||||
onRowSelectionChange: setRowSelection,
|
addButtonText,
|
||||||
onSortingChange: setSorting,
|
isLoading = false,
|
||||||
getSortedRowModel: getSortedRowModel(),
|
onEdit,
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
onDelete,
|
||||||
manualPagination: !!pagination,
|
pagination,
|
||||||
pageCount: pagination?.totalItems ? Math.ceil(pagination.totalItems / pagination.limit) : -1,
|
}: DataTableProps<TData, TValue>) {
|
||||||
state: {
|
const [rowSelection, setRowSelection] = React.useState({});
|
||||||
rowSelection,
|
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||||
sorting,
|
|
||||||
pagination: pagination ? {
|
const columns = React.useMemo(() => {
|
||||||
pageIndex: Math.floor(pagination.skip / pagination.limit),
|
const cols: ColumnDef<TData, TValue>[] = [
|
||||||
pageSize: pagination.limit,
|
...initialColumns,
|
||||||
} : undefined,
|
];
|
||||||
},
|
|
||||||
onPaginationChange: (updater) => {
|
if (onEdit || onDelete) {
|
||||||
if (typeof updater === 'function' && pagination) {
|
cols.push({
|
||||||
const newState = updater({
|
id: "actions",
|
||||||
|
header: () => <div className="text-right px-4">Action</div>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const item = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end gap-3 px-4">
|
||||||
|
{onEdit && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit(item);
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-center h-8 w-8 rounded-md text-primary hover:bg-foreground/10 transition-all duration-200"
|
||||||
|
>
|
||||||
|
<Edit3 className="h-4.5 w-4.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(item);
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-center h-8 w-8 rounded-md text-red-500 hover:bg-foreground/10 transition-all duration-200"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4.5 w-4.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cols;
|
||||||
|
}, [initialColumns, onEdit, onDelete]);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
manualPagination: !!pagination,
|
||||||
|
pageCount: pagination?.totalItems ? Math.ceil(pagination.totalItems / pagination.limit) : -1,
|
||||||
|
state: {
|
||||||
|
rowSelection,
|
||||||
|
sorting,
|
||||||
|
pagination: pagination ? {
|
||||||
pageIndex: Math.floor(pagination.skip / pagination.limit),
|
pageIndex: Math.floor(pagination.skip / pagination.limit),
|
||||||
pageSize: pagination.limit,
|
pageSize: pagination.limit,
|
||||||
});
|
} : undefined,
|
||||||
pagination.onLimitChange(newState.pageSize);
|
},
|
||||||
pagination.onPageChange(newState.pageIndex * newState.pageSize);
|
onPaginationChange: (updater) => {
|
||||||
}
|
if (typeof updater === 'function' && pagination) {
|
||||||
},
|
const newState = updater({
|
||||||
initialState: {
|
pageIndex: Math.floor(pagination.skip / pagination.limit),
|
||||||
pagination: {
|
pageSize: pagination.limit,
|
||||||
pageSize: 10,
|
});
|
||||||
pageIndex: 0,
|
pagination.onLimitChange(newState.pageSize);
|
||||||
},
|
pagination.onPageChange(newState.pageIndex * newState.pageSize);
|
||||||
},
|
}
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border">
|
<div className="rounded-xl border border-border/50 bg-muted/20 p-1 space-y-6">
|
||||||
<TopHeader
|
<div className="bg-background rounded-xl border border-border/50 overflow-hidden transition-all duration-300">
|
||||||
title={title}
|
<TopHeader
|
||||||
itemCount={data.length}
|
title={title}
|
||||||
onAddNew={onAddNew}
|
itemCount={data.length}
|
||||||
addButtonText={addButtonText}
|
onAddNew={onAddNew}
|
||||||
/>
|
addButtonText={addButtonText}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="px-6 py-2">
|
||||||
<Table>
|
<Table containerClassName="max-h-[calc(100vh-300px)] overflow-y-auto scrollbar-thin" className="border-separate border-spacing-0">
|
||||||
<TableHeader table={table} />
|
<TableHeader table={table} />
|
||||||
<TableBody className="bg-transparent">
|
<TableBody>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
Array.from({ length: 5 }).map((_, idx) => (
|
Array.from({ length: 5 }).map((_, idx) => (
|
||||||
<TableRow key={idx}>
|
<TableRow key={idx} className="border-b border-border/30 last:border-0 hover:bg-muted/5">
|
||||||
{columns.map((_, colIdx) => (
|
{columns.map((_, colIdx) => (
|
||||||
<TableCell key={colIdx} className="px-8 py-4">
|
<TableCell key={colIdx} className="px-6 py-6 border-b border-border/30">
|
||||||
<Skeleton className="h-4 w-full max-w-[120px]" />
|
<Skeleton className="h-4 w-full max-w-[140px] opacity-20" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
) : table.getRowModel().rows?.length ? (
|
) : table.getRowModel().rows?.length ? (
|
||||||
table.getRowModel().rows.map((row) => (
|
table.getRowModel().rows.map((row) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={row.id}
|
key={row.id}
|
||||||
data-state={row.getIsSelected() && "selected"}
|
data-state={row.getIsSelected() && "selected"}
|
||||||
>
|
className="group hover:bg-muted/10 transition-colors"
|
||||||
{row.getVisibleCells().map((cell) => (
|
>
|
||||||
<TableCell key={cell.id}>
|
{row.getVisibleCells().map((cell) => (
|
||||||
{flexRender(
|
<TableCell key={cell.id} className="px-4 py-3 border-b border-border/50 align-middle text-muted-foreground text-sm font-medium group-hover:text-foreground">
|
||||||
cell.column.columnDef.cell,
|
{flexRender(
|
||||||
cell.getContext()
|
cell.column.columnDef.cell,
|
||||||
)}
|
cell.getContext()
|
||||||
</TableCell>
|
)}
|
||||||
))}
|
</TableCell>
|
||||||
</TableRow>
|
))}
|
||||||
))
|
</TableRow>
|
||||||
) : (
|
))
|
||||||
<TableRow>
|
) : (
|
||||||
<TableCell
|
<TableRow>
|
||||||
colSpan={columns.length}
|
<TableCell
|
||||||
className="h-32 text-center text-sm"
|
colSpan={columns.length}
|
||||||
>
|
className="h-48 text-center"
|
||||||
No results found.
|
>
|
||||||
</TableCell>
|
<div className="flex flex-col items-center justify-center text-muted-foreground gap-1">
|
||||||
</TableRow>
|
<p className="font-bold text-sm tracking-tight">No results found.</p>
|
||||||
)}
|
<p className="text-xs opacity-60 font-medium">Try adjusting your filters or search terms.</p>
|
||||||
</TableBody>
|
</div>
|
||||||
</Table>
|
</TableCell>
|
||||||
</div>
|
</TableRow>
|
||||||
|
)}
|
||||||
<TableFooter table={table} />
|
</TableBody>
|
||||||
</div>
|
</Table>
|
||||||
);
|
</div>
|
||||||
|
<TableFooter table={table} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,23 +7,31 @@ interface PageHeaderProps {
|
|||||||
description: string
|
description: string
|
||||||
icon?: LucideIcon
|
icon?: LucideIcon
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
|
actions?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageHeader({ title, description, icon: Icon, children }: PageHeaderProps) {
|
export function PageHeader({ title, description, icon: Icon, children, actions }: PageHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center justify-between w-full">
|
||||||
<div className="p-3.5 rounded-xl bg-primary text-primary-foreground flex items-center justify-center shadow-lg shadow-primary/5 ring-1 ring-white/10">
|
<div className="flex items-center gap-6">
|
||||||
{Icon && <Icon className="h-7 w-7" />}
|
{/* <div className="p-3.5 rounded-xl bg-primary text-primary-foreground flex items-center justify-center shadow-lg shadow-primary/5 ring-1 ring-white/10">
|
||||||
{children}
|
{Icon && <Icon className="h-7 w-7" />}
|
||||||
</div>
|
{children}
|
||||||
<div className="flex flex-col">
|
</div> */}
|
||||||
<h1 className="text-3xl font-extrabold tracking-tight ">
|
<div className="flex flex-col">
|
||||||
{title}
|
<h1 className="text-3xl font-extrabold tracking-tight ">
|
||||||
</h1>
|
{title}
|
||||||
<p className="text-muted-foreground mt-1 text-sm font-semibold tracking-wide opacity-80">
|
</h1>
|
||||||
{description}
|
<p className="text-muted-foreground mt-1 text-sm font-semibold tracking-wide opacity-80">
|
||||||
</p>
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{actions && (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,44 @@
|
|||||||
import * as React from 'react'
|
import * as React from "react"
|
||||||
import { Slot } from '@radix-ui/react-slot'
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
import { cva, type VariantProps } from 'class-variance-authority'
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const badgeVariants = cva(
|
const badgeVariants = cva(
|
||||||
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
|
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
|
|
||||||
secondary:
|
secondary:
|
||||||
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
|
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
destructive:
|
destructive:
|
||||||
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
|
||||||
outline:
|
outline:
|
||||||
'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
|
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: 'default',
|
variant: "default",
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
function Badge({
|
function Badge({
|
||||||
className,
|
className,
|
||||||
variant,
|
variant = "default",
|
||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'span'> &
|
}: React.ComponentProps<"span"> &
|
||||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
const Comp = asChild ? Slot : 'span'
|
const Comp = asChild ? Slot.Root : "span"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
data-slot="badge"
|
data-slot="badge"
|
||||||
|
data-variant={variant}
|
||||||
className={cn(badgeVariants({ variant }), className)}
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
|
||||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
|||||||
<ol
|
<ol
|
||||||
data-slot="breadcrumb-list"
|
data-slot="breadcrumb-list"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground sm:gap-2.5",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -38,12 +38,12 @@ function BreadcrumbLink({
|
|||||||
}: React.ComponentProps<"a"> & {
|
}: React.ComponentProps<"a"> & {
|
||||||
asChild?: boolean
|
asChild?: boolean
|
||||||
}) {
|
}) {
|
||||||
const Comp = asChild ? Slot : "a"
|
const Comp = asChild ? Slot.Root : "a"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
data-slot="breadcrumb-link"
|
data-slot="breadcrumb-link"
|
||||||
className={cn("hover:text-foreground transition-colors", className)}
|
className={cn("transition-colors hover:text-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -56,7 +56,7 @@ function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
|||||||
role="link"
|
role="link"
|
||||||
aria-disabled="true"
|
aria-disabled="true"
|
||||||
aria-current="page"
|
aria-current="page"
|
||||||
className={cn("text-foreground font-normal", className)}
|
className={cn("font-normal text-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
'use client'
|
"use client"
|
||||||
|
|
||||||
import * as React from 'react'
|
import * as React from "react"
|
||||||
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
import { Popover as PopoverPrimitive } from "radix-ui"
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function Popover({
|
function Popover({
|
||||||
...props
|
...props
|
||||||
@@ -19,7 +19,7 @@ function PopoverTrigger({
|
|||||||
|
|
||||||
function PopoverContent({
|
function PopoverContent({
|
||||||
className,
|
className,
|
||||||
align = 'center',
|
align = "center",
|
||||||
sideOffset = 4,
|
sideOffset = 4,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||||
@@ -30,8 +30,8 @@ function PopoverContent({
|
|||||||
align={align}
|
align={align}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-popover text-popover-foreground z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
|
"z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -45,4 +45,45 @@ function PopoverAnchor({
|
|||||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="popover-header"
|
||||||
|
className={cn("flex flex-col gap-1 text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="popover-title"
|
||||||
|
className={cn("font-medium", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"p">) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="popover-description"
|
||||||
|
className={cn("text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Popover,
|
||||||
|
PopoverTrigger,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverAnchor,
|
||||||
|
PopoverHeader,
|
||||||
|
PopoverTitle,
|
||||||
|
PopoverDescription,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
'use client'
|
"use client"
|
||||||
|
|
||||||
import * as React from 'react'
|
import * as React from "react"
|
||||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
|
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function ScrollArea({
|
function ScrollArea({
|
||||||
className,
|
className,
|
||||||
@@ -13,12 +13,12 @@ function ScrollArea({
|
|||||||
return (
|
return (
|
||||||
<ScrollAreaPrimitive.Root
|
<ScrollAreaPrimitive.Root
|
||||||
data-slot="scroll-area"
|
data-slot="scroll-area"
|
||||||
className={cn('relative', className)}
|
className={cn("relative", className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ScrollAreaPrimitive.Viewport
|
<ScrollAreaPrimitive.Viewport
|
||||||
data-slot="scroll-area-viewport"
|
data-slot="scroll-area-viewport"
|
||||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ScrollAreaPrimitive.Viewport>
|
</ScrollAreaPrimitive.Viewport>
|
||||||
@@ -30,7 +30,7 @@ function ScrollArea({
|
|||||||
|
|
||||||
function ScrollBar({
|
function ScrollBar({
|
||||||
className,
|
className,
|
||||||
orientation = 'vertical',
|
orientation = "vertical",
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||||
return (
|
return (
|
||||||
@@ -38,18 +38,18 @@ function ScrollBar({
|
|||||||
data-slot="scroll-area-scrollbar"
|
data-slot="scroll-area-scrollbar"
|
||||||
orientation={orientation}
|
orientation={orientation}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex touch-none p-px select-none',
|
"flex touch-none p-px transition-colors select-none",
|
||||||
orientation === 'vertical' &&
|
orientation === "vertical" &&
|
||||||
'h-full w-2.5 border-l border-l-transparent',
|
"h-full w-2.5 border-l border-l-transparent",
|
||||||
orientation === 'horizontal' &&
|
orientation === "horizontal" &&
|
||||||
'h-2.5 flex-col border-t border-t-transparent',
|
"h-2.5 flex-col border-t border-t-transparent",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||||
data-slot="scroll-area-thumb"
|
data-slot="scroll-area-thumb"
|
||||||
className="bg-border relative flex-1 rounded-full"
|
className="relative flex-1 rounded-full bg-border"
|
||||||
/>
|
/>
|
||||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,11 +4,15 @@ import * as React from "react"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
function Table({
|
||||||
|
className,
|
||||||
|
containerClassName,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"table"> & { containerClassName?: string }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="table-container"
|
data-slot="table-container"
|
||||||
className="relative w-full overflow-x-auto"
|
className={cn("relative w-full overflow-x-auto", containerClassName)}
|
||||||
>
|
>
|
||||||
<table
|
<table
|
||||||
data-slot="table"
|
data-slot="table"
|
||||||
|
|||||||
Reference in New Issue
Block a user