initial commit

This commit is contained in:
mark 2025-05-27 10:08:39 +08:00
parent 1cda1cbf72
commit 5d1c3576cf
85 changed files with 8000 additions and 5130 deletions

2407
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -12,30 +12,37 @@
"dependencies": { "dependencies": {
"@radix-ui/react-avatar": "^1.1.9", "@radix-ui/react-avatar": "^1.1.9",
"@radix-ui/react-checkbox": "^1.3.1", "@radix-ui/react-checkbox": "^1.3.1",
"@radix-ui/react-dialog": "^1.1.13", "@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.14", "@radix-ui/react-dropdown-menu": "^2.1.14",
"@radix-ui/react-label": "^2.1.6", "@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.6", "@radix-ui/react-radio-group": "^1.3.6",
"@radix-ui/react-select": "^2.2.4", "@radix-ui/react-select": "^2.2.4",
"@radix-ui/react-separator": "^1.1.6", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.2", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.4", "@radix-ui/react-switch": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.11", "@radix-ui/react-tabs": "^1.1.11",
"@radix-ui/react-toast": "^1.2.13", "@radix-ui/react-toast": "^1.2.13",
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.6", "@tailwindcss/vite": "^4.1.6",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"axios": "^1.9.0", "axios": "^1.9.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.11.0", "framer-motion": "^12.11.0",
"html2canvas": "^1.4.1",
"lucide-react": "^0.510.0", "lucide-react": "^0.510.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hot-toast": "^2.5.2",
"react-popover": "^0.5.10", "react-popover": "^0.5.10",
"react-router-dom": "^7.6.0", "react-router-dom": "^7.6.0",
"recharts": "^2.15.3",
"sonner": "^2.0.3", "sonner": "^2.0.3",
"tailwind-merge": "^3.3.0" "tailwind-merge": "^3.3.0",
"zod": "^3.25.28"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.25.0", "@eslint/js": "^9.25.0",

BIN
public/ital.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
public/slide1.avif Normal file

Binary file not shown.

BIN
public/slide2.avif Normal file

Binary file not shown.

BIN
public/slide3.avif Normal file

Binary file not shown.

BIN
public/slide4.avif Normal file

Binary file not shown.

View File

@ -1,2 +1,6 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
html {
scroll-behavior: smooth;
}

View File

@ -1,5 +1,7 @@
import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import MigrantProfilePage from "./pages/MigrantProfilePage"; // import { Toaster } from "react-hot-toast"; // ✅ Import Toaster
// import MigrantProfilePage from "./pages/MigrantProfilePage";
import NotFoundPage from "./pages/NotFoundPage"; import NotFoundPage from "./pages/NotFoundPage";
import LoginPage from "./components/admin/LoginPage"; import LoginPage from "./components/admin/LoginPage";
import Migrants from "./components/admin/Migrants"; import Migrants from "./components/admin/Migrants";
@ -11,12 +13,24 @@ import AddMigrantPage from "./components/admin/AddMigrant";
import SettingsPage from "./components/admin/Setting"; import SettingsPage from "./components/admin/Setting";
import ReportsPage from "./components/admin/Reports"; import ReportsPage from "./components/admin/Reports";
import EditMigrant from "./components/admin/EditMigrant"; import EditMigrant from "./components/admin/EditMigrant";
import "./App.css"; import SearchResults from "./components/home/SearchResults";
import Sample from "./components/admin/Table";
import UserCreate from "./components/admin/users/UserCreate";
import { Toaster } from 'react-hot-toast';import "./App.css";
import { MigrationChart } from "./components/charts/MigrationChart";
import { ResidenceChart } from "./components/charts/ResidenceChart";
import MigrantProfilePage from "./pages/MigrantProfilePage";
function App() { function App() {
return ( return (
<Router> <Router>
{/* ✅ Add the Toaster at root level so it works everywhere */}
<Toaster position="top-right" reverseOrder={false} />
<Routes> <Routes>
<Route path="/migrant-profile/:id" element={<MigrantProfilePage />} />
<Route path="/migration-chart" element={<MigrationChart />} />
<Route path="/residence-chart" element={<ResidenceChart />} />
<Route path="/search-results" element={<SearchResults />} />
<Route path="/sample" element={<Sample />} />
<Route path="/admin/settings/profile" element={<ProfileSettings />} /> <Route path="/admin/settings/profile" element={<ProfileSettings />} />
<Route path="/admin/migrants" element={<Migrants />} /> <Route path="/admin/migrants" element={<Migrants />} />
<Route path="/admin" element={<AdminDashboardPage />} /> <Route path="/admin" element={<AdminDashboardPage />} />
@ -24,10 +38,10 @@ function App() {
<Route path="/admin/settings" element={<SettingsPage />} /> <Route path="/admin/settings" element={<SettingsPage />} />
<Route path="/admin/reports" element={<ReportsPage />} /> <Route path="/admin/reports" element={<ReportsPage />} />
<Route path="/admin/migrants/edit/:id" element={<EditMigrant />} /> <Route path="/admin/migrants/edit/:id" element={<EditMigrant />} />
<Route path="/admin/users/create" element={<UserCreate />} />
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} /> <Route path="/register" element={<RegisterPage />} />
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/migrants/:id" element={<MigrantProfilePage />} />
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />
</Routes> </Routes>
</Router> </Router>

View File

@ -1,76 +1,30 @@
import { useState, useRef } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import apiService from "@/services/apiService";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import Header from "../layout/Header"; import Header from "../layout/Header";
import Sidebar from "../layout/Sidebar"; import Sidebar from "../layout/Sidebar";
import MigrantForm from "@/components/admin/migrant/MigrationForm"; import MigrationForm from "@/components/admin/migrant/MigrationForm";
import type { MigrantFormRef } from "@/components/admin/migrant/MigrationForm";
import AddDialog from "@/components/admin/migrant/table/AddDialog";
export default function AddMigrant() { export default function AddMigrant() {
const formRef = useRef<MigrantFormRef>(null);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [formData, setFormData] = useState<any>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleCreate = async (data: any) => {
// Temporarily store the form data and show confirmation dialog
setFormData(data);
setShowConfirmDialog(true);
};
const handleConfirmCreate = async () => {
if (!formData) return;
setIsSubmitting(true);
try {
await apiService.createPerson(formData);
alert("Migrant created successfully!");
setFormData(null);
setShowConfirmDialog(false);
// Reset the form to its initial state
if (formRef.current) {
formRef.current.resetForm();
}
// Optionally: clear form or redirect
} catch (err) {
alert("Failed to create migrant.");
console.error(err);
} finally {
setIsSubmitting(false);
}
};
return ( return (
<div className="flex min-h-dvh bg-[#f8f5f2]"> <div className="flex min-h-dvh bg-gray-950">
<Sidebar /> <Sidebar />
<div className="flex-1 md:ml-16 lg:ml-64 w-full transition-all duration-300"> <div className="flex-1 md:ml-16 lg:ml-64 w-full transition-all duration-300">
<Header title="Add New Migrant" /> <Header title="Add New Migrant" />
<main className="p-6"> <main className="p-6">
<div className="flex items-center mb-6"> <div className="flex items-center mb-6">
<Link to="/admin/migrants"> <Link to="/admin/migrants">
<Button variant="ghost" size="sm" className="gap-1"> <Button variant="ghost" size="sm" className="gap-1 text-gray-300">
<ArrowLeft className="size-4" /> Back to Migrants <ArrowLeft className="size-4" /> Back to Migrants
</Button> </Button>
</Link> </Link>
<h1 className="text-3xl font-serif font-bold text-neutral-800 ml-4"> <h1 className="text-3xl font-serif font-bold text-white ml-4">
Add New Migrant Add New Migrant
</h1> </h1>
</div> </div>
<MigrationForm/>
<MigrantForm ref={formRef} mode="add" onSubmit={handleCreate} />
</main> </main>
</div> </div>
{/* Add Confirmation Dialog */}
<AddDialog
open={showConfirmDialog}
onOpenChange={setShowConfirmDialog}
onConfirm={handleConfirmCreate}
isSubmitting={isSubmitting}
/>
</div> </div>
); );
} }

View File

@ -1,283 +1,272 @@
import { useState } from "react"; "use client"
import {
BarChart3,
Calendar,
Clock,
Database,
PlusCircle,
Search,
User,
Users,
Flag,
AlertCircle,
} from "lucide-react";
import { Link, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button"; import { useState } from "react"
import { import { BarChart3, Calendar, TrendingDown, TrendingUp } from "lucide-react"
Card, import { Link } from "react-router-dom"
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import Header from "../layout/Header"; import { Button } from "@/components/ui/button"
import Sidebar from "../layout/Sidebar"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import RecentActivityList from "../common/RecentActivity"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import StatCard from "../common/StatCard"; import { ResidenceChart } from "../charts/ResidenceChart"
import ApiService from "@/services/apiService"; import Header from "../layout/Header"
import type { DashboardStats } from "@/types/api"; import Sidebar from "../layout/Sidebar"
import { useEffect } from "react"; import RecentActivityList from "./RecentActivity"
import ApiService from "@/services/apiService"
import type { DashboardStats } from "@/types/api"
import { MigrationChart } from "../charts/MigrationChart"
import { useEffect } from "react"
export default function DashboardPage() { export default function DashboardPage() {
const navigate = useNavigate(); const [stats, setStats] = useState<DashboardStats | null>(null)
const [searchQuery, setSearchQuery] = useState(""); const [loading, setLoading] = useState<boolean>(true)
const [results, setResults] = useState<any[]>([]); // TODO: Replace any with proper type if available const [error, setError] = useState<string | null>(null)
const [stats, setStats] = useState<DashboardStats | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [searchLoading, setSearchLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const delayDebounce = setTimeout(() => {
if (searchQuery.trim()) {
handleSearch();
} else {
setResults([]);
}
}, 300);
return () => clearTimeout(delayDebounce);
}, [searchQuery]);
const handleSearch = async () => {
const trimmed = searchQuery.trim();
if (!trimmed) {
setResults([]);
return;
}
setSearchLoading(true);
try {
const data = await ApiService.searchPeople({ query: trimmed });
setResults(data);
} catch (error) {
console.error("Search failed:", error);
setResults([]);
} finally {
setSearchLoading(false);
}
};
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
handleSearch();
};
useEffect(() => { useEffect(() => {
const fetchStats = async () => { const fetchStats = async () => {
try { try {
setLoading(true); setLoading(true)
const response = await ApiService.getDashboardStats(); const response = await ApiService.getDashboardStats()
if (response.success) { if (response.success) {
setStats(response.data); setStats(response.data)
} else { } else {
setError('Failed to load dashboard data'); setError("Failed to load dashboard data")
} }
} catch (err) { } catch (err) {
setError('An error occurred while fetching dashboard data'); setError("An error occurred while fetching dashboard data")
console.error(err); console.error(err)
} finally { } finally {
setLoading(false); setLoading(false)
} }
}; }
fetchStats()
}, [])
fetchStats();
}, []);
if (loading) { if (loading) {
return ( return (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4 mb-8"> <div className="flex min-h-dvh bg-gray-950">
{[...Array(4)].map((_, index) => ( <Sidebar />
<div key={index} className="bg-white rounded-lg shadow p-6 animate-pulse"> <div className="flex-1 md:ml-16 lg:ml-64 w-full transition-all duration-300">
<div className="h-4 bg-gray-200 rounded w-1/2 mb-4"></div> <Header title="Dashboard" />
<div className="h-8 bg-gray-200 rounded w-1/4 mb-4"></div> <main className="p-6">
<div className="h-4 bg-gray-200 rounded w-3/4"></div> <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4 mb-8">
</div> {[...Array(4)].map((_, index) => (
))} <div key={index} className="bg-gray-900 border border-gray-800 rounded-lg shadow-lg p-6 animate-pulse">
<div className="h-4 bg-gray-700 rounded w-1/2 mb-4"></div>
<div className="h-8 bg-gray-700 rounded w-1/4 mb-4"></div>
<div className="h-4 bg-gray-700 rounded w-3/4"></div>
</div>
))}
</div>
</main>
</div>
</div> </div>
); )
} }
if (error) { if (error) {
return ( return (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md mb-8"> <div className="flex min-h-dvh bg-gray-950">
{error} <Sidebar />
<div className="flex-1 md:ml-16 lg:ml-64 w-full transition-all duration-300">
<Header title="Dashboard" />
<main className="p-6">
<div className="bg-red-900/20 border border-red-800 text-red-400 px-4 py-3 rounded-md mb-8">{error}</div>
</main>
</div>
</div> </div>
); )
} }
return ( return (
<div className="flex min-h-dvh bg-[#f8f5f2]"> <div className="flex min-h-dvh bg-gray-950">
<Sidebar /> <Sidebar />
<div className="flex-1 md:ml-16 lg:ml-64 w-full transition-all duration-300"> <div className="flex-1 md:ml-16 lg:ml-64 w-full transition-all duration-300">
<Header title="Dashboard" /> <Header title="Dashboard" />
<main className="p-6"> <main className="p-6">
<div className="mb-8"> <div className="mb-8">
<h1 className="text-3xl font-serif font-bold text-neutral-800 mb-2"> <h1 className="text-3xl font-serif font-bold text-white mb-2">Welcome, Admin</h1>
Welcome, Admin <p className="text-gray-400">Here's an overview of your Italian Migrants Database</p>
</h1>
<p className="text-neutral-600">
Here's an overview of your Italian Migrants Database
</p>
</div> </div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-8">
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4 mb-8"> <Card className="bg-gray-900 border-gray-800 shadow-xl">
<StatCard <CardContent className="p-6">
title="Total Migrants" <div className="flex items-center justify-between">
value={stats?.total_migrants || 0} <div>
description={`+${stats?.new_this_month || 0} this month`} <p className="text-sm font-medium text-gray-400">Total Records</p>
icon={<Users className="size-5 text-green-600" />} <h3 className="text-2xl font-bold mt-1 text-white">{stats?.total_migrants || 0}</h3>
/>
<StatCard
title="Recent Additions"
value={stats?.recent_additions || 0}
description="Last 30 days"
icon={<PlusCircle className="size-5 text-blue-600" />}
/>
<StatCard
title="Pending Reviews"
value={stats?.pending_reviews || 0}
description="Needs attention"
icon={<Clock className="size-5 text-amber-600" />}
/>
<StatCard
title="Incomplete Records"
value={stats?.incomplete_records || 0}
description="Need more information"
icon={<AlertCircle className="size-5 text-red-600" />}
/>
</div>
<div className="grid gap-6 md:grid-cols-2 mb-8">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-xl font-serif">Quick Search</CardTitle>
<CardDescription>Find migrant records quickly</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSearchSubmit}>
<div className="relative">
<Search className="absolute left-3 top-2.5 size-5 text-neutral-500" />
<Input
placeholder="Search by name, birthplace, or arrival date..."
className="pl-10 border-neutral-300"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" className="text-sm">
<Calendar className="mr-1 size-4" /> By Date
</Button>
<Button type="button" variant="outline" size="sm" className="text-sm">
<User className="mr-1 size-4" /> By Name
</Button>
<Button type="button" variant="outline" size="sm" className="text-sm">
<Database className="mr-1 size-4" /> Advanced
</Button>
</div>
</form>
{/* Results */}
<div className="mt-6 space-y-2">
{searchLoading ? (
<div className="text-center py-4">
<div className="inline-block h-6 w-6 animate-spin rounded-full border-2 border-solid border-green-600 border-r-transparent"></div>
<p className="mt-2 text-sm text-gray-500">Searching...</p>
</div> </div>
) : results.length > 0 ? ( <div className="h-12 w-12 rounded-full bg-[#9B2335]/20 border border-[#9B2335]/30 flex items-center justify-center">
results.map((person) => ( <TrendingUp className="h-6 w-6 text-[#9B2335]" />
<div </div>
key={person.person_id} </div>
onClick={() => navigate(`/migrants/${person.person_id}`)} <div className="mt-4">
className="cursor-pointer border rounded px-4 py-3 hover:bg-neutral-100 transition" <div className="flex items-center justify-between text-xs">
> <span className="text-gray-400">Growth Rate</span>
<div className="font-medium">{person.full_name}</div> <span className="font-medium text-[#9B2335]">+24% YoY</span>
{person.migration?.date_of_arrival_nt && ( </div>
<div className="text-sm text-gray-600"> <div className="w-full bg-gray-800 rounded-full h-1.5 mt-1">
Date of Arrival: {person.migration.date_of_arrival_nt} <div className="bg-[#9B2335] h-1.5 rounded-full" style={{ width: "24%" }}></div>
</div> </div>
)} </div>
</div> </CardContent>
)) </Card>
) : (
searchQuery.trim() && (
<p className="text-sm text-gray-500 mt-4">No results found.</p>
)
)}
</div>
</CardContent>
</Card>
<Card> <Card className="bg-gray-900 border-gray-800 shadow-xl">
<CardHeader className="pb-2"> <CardContent className="p-6">
<CardTitle className="text-xl font-serif">Migration Trends</CardTitle> <div className="flex items-center justify-between">
<CardDescription>Yearly migration patterns</CardDescription> <div>
</CardHeader> <p className="text-sm font-medium text-gray-400">Recent Addition</p>
<CardContent className="pt-4"> <h3 className="text-2xl font-bold mt-1 text-white">{stats?.recent_additions || 0}</h3>
<div className="h-[180px] flex items-end justify-between gap-2"> </div>
{[35, 45, 20, 30, 75, 60, 40, 80, 90, 50].map((height, i) => ( <div className="h-12 w-12 rounded-full bg-blue-500/20 border border-blue-500/30 flex items-center justify-center">
<div key={i} className="relative group flex flex-col items-center"> <TrendingDown className="h-6 w-6 text-blue-400" />
<div className="absolute -top-7 opacity-0 group-hover:opacity-100 transition-opacity bg-neutral-800 text-white px-2 py-1 rounded text-xs"> </div>
{1950 + i * 5}: {height} migrants </div>
</div> <div className="mt-4">
<div <div className="flex items-center justify-between text-xs">
className="w-7 bg-gradient-to-t from-green-600 to-green-400 rounded-t" <span className="text-gray-400">Change</span>
style={{ height: `${height * 1.8}px` }} <span className="font-medium text-blue-400">-1.2 years</span>
></div> </div>
<span className="text-xs mt-1 text-neutral-500">{1950 + i * 5}</span> <div className="w-full bg-gray-800 rounded-full h-1.5 mt-1">
</div> <div className="bg-blue-500 h-1.5 rounded-full" style={{ width: "65%" }}></div>
))} </div>
</div>
</CardContent>
</Card>
<Card className="bg-gray-900 border-gray-800 shadow-xl">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-400">Peak Migration Year</p>
<h3 className="text-2xl font-bold mt-1 text-white">{stats?.peak_migration_year?.year ?? "N/A"}</h3>
</div>
<div className="h-12 w-12 rounded-full bg-amber-500/20 border border-amber-500/30 flex items-center justify-center">
<Calendar className="h-6 w-6 text-amber-400" />
</div>
</div>
<div className="mt-4">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-400">Migrants that year</span>
<span className="font-medium text-amber-400">120</span>
</div>
<div className="w-full bg-gray-800 rounded-full h-1.5 mt-1">
<div className="bg-amber-500 h-1.5 rounded-full" style={{ width: "85%" }}></div>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-gray-900 border-gray-800 shadow-xl">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-400">Most Common Origin</p>
<h3 className="text-2xl font-bold mt-1 text-white">{stats?.most_common_origin?.place || "N/A"}</h3>
</div>
<div className="h-12 w-12 rounded-full bg-purple-500/20 border border-purple-500/30 flex items-center justify-center">
<svg
className="h-6 w-6 text-purple-400"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3 12H21"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12 3C14.5013 5.76254 15.9228 9.29498 16 13C16.0772 16.705 14.6557 20.2375 12.1544 23C9.65304 20.2375 8.23152 16.705 8.15432 13C8.07712 9.29498 9.49864 5.76254 12 3V3Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
</div>
<div className="mt-4">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-400">Percentage</span>
<span className="font-medium text-purple-400">35%</span>
</div>
<div className="w-full bg-gray-800 rounded-full h-1.5 mt-1">
<div className="bg-purple-500 h-1.5 rounded-full" style={{ width: "35%" }}></div>
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Migration Statistics */}
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 mb-8">
<Card className="col-span-full lg:col-span-2 bg-gray-900 border-gray-800 shadow-xl">
<CardHeader className="pb-2 border-b border-gray-800">
<CardTitle className="text-lg font-medium text-white">Yearly Migration Trends</CardTitle>
<CardDescription className="text-gray-400">
Number of migrants by year of arrival (1900-1950)
</CardDescription>
</CardHeader>
<CardContent className="flex-1 h-[300px]">
<MigrationChart />
</CardContent>
</Card>
<ResidenceChart />
</div>
<div className="mb-8"> <div className="mb-8">
<Tabs defaultValue="recent"> <Tabs defaultValue="recent">
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<TabsList className="bg-neutral-100"> <TabsList className="bg-gray-800 border-gray-700">
<TabsTrigger value="recent">Recent Activity</TabsTrigger> <TabsTrigger
<TabsTrigger value="pending">Pending Reviews</TabsTrigger> value="recent"
className="data-[state=active]:bg-[#9B2335] data-[state=active]:text-white"
>
Recent Activity
</TabsTrigger>
<TabsTrigger
value="pending"
className="data-[state=active]:bg-[#9B2335] data-[state=active]:text-white"
>
Pending Reviews
</TabsTrigger>
</TabsList> </TabsList>
<Link to="/admin/migrants"> <Link to="/admin/migrants">
<Button variant="outline" size="sm" className="text-sm"> <Button
variant="outline"
size="sm"
className="text-sm border-gray-700 text-gray-300 hover:bg-gray-800 hover:text-white"
>
View All Records View All Records
</Button> </Button>
</Link> </Link>
</div> </div>
<TabsContent value="recent" className="m-0"> <TabsContent value="recent" className="m-0">
<Card> <Card className="bg-gray-900 border-gray-800 shadow-xl">
<CardContent className="p-0"> <CardContent className="p-0">
<RecentActivityList /> <RecentActivityList />
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
<TabsContent value="pending" className="m-0"> <TabsContent value="pending" className="m-0">
<Card> <Card className="bg-gray-900 border-gray-800 shadow-xl">
<CardContent className="p-6"> <CardContent className="p-6">
<div className="text-center py-8 text-neutral-500"> <div className="text-center py-8 text-gray-400">
<BarChart3 className="mx-auto size-12 mb-4 text-neutral-400" /> <BarChart3 className="mx-auto size-12 mb-4 text-gray-600" />
<h3 className="text-lg font-medium mb-2">No Pending Reviews</h3> <h3 className="text-lg font-medium mb-2 text-white">No Pending Reviews</h3>
<p>All migrant records have been reviewed.</p> <p>All migrant records have been reviewed.</p>
</div> </div>
</CardContent> </CardContent>
@ -288,5 +277,5 @@ export default function DashboardPage() {
</main> </main>
</div> </div>
</div> </div>
); )
} }

View File

@ -1,99 +1,29 @@
import { useEffect, useState, useRef } from "react";
import { useParams } from "react-router-dom";
import apiService from "@/services/apiService";
import MigrantForm from "@/components/admin/migrant/MigrationForm";
import Header from "../layout/Header";
import Sidebar from "../layout/Sidebar";
import type { Person } from "@/types/api";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import UpdateDialog from "./migrant/table/UpdateDialog"; import Header from "../layout/Header";
import Sidebar from "../layout/Sidebar";
import MigrationForm from "@/components/admin/migrant/MigrationForm";
export default function EditUserPage() { export default function EditMigrant() {
const { id } = useParams();
const [formData, setFormData] = useState<Person | null>(null);
const [open, setOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const formDataRef = useRef<any>(null);
useEffect(() => {
if (!id) return; // Avoid calling API with undefined/null id
const fetchData = async () => {
try {
const data = await apiService.getPersonById(id);
setFormData(data);
} catch (error) {
console.error("Failed to fetch migrant data:", error);
alert("Failed to load migrant data. Please try again.");
}
};
fetchData();
}, [id]);
const handleFormSubmit = async (data: any): Promise<void> => {
// Store the form data in ref for later use
formDataRef.current = data;
// Open the confirmation dialog
setOpen(true);
// Return a resolved promise to satisfy the form's onSubmit type
return Promise.resolve();
};
const handleUpdate = async () => {
if (!formDataRef.current || !id) return;
try {
setIsSubmitting(true);
// Prepare the form data, ensuring it's properly structured
const formattedData = { ...formDataRef.current };
// Send the update request
await apiService.updatePerson(id, formattedData);
setOpen(false);
alert("Migrant updated successfully!");
} catch (err: any) {
// More descriptive error message
const errorMsg = err.response?.data?.message || "An unexpected error occurred";
alert(`Failed to update migrant: ${errorMsg}`);
console.error("Update error:", err);
} finally {
setIsSubmitting(false);
}
};
if (!formData) return <p>Loading...</p>;
return ( return (
<div className="flex min-h-dvh bg-[#f8f5f2]"> <div className="flex min-h-dvh bg-gray-950">
<Sidebar /> <Sidebar />
<div className="flex-1 md:ml-16 lg:ml-64 w-full transition-all duration-300"> <div className="flex-1 md:ml-16 lg:ml-64 w-full transition-all duration-300">
<Header title="Edit Migrant" /> <Header title="Edit Migrant" />
<main className="p-6"> <main className="p-6">
<div className="flex items-center mb-6"> <div className="flex items-center mb-6">
<Link to="/admin/migrants"> <Link to="/admin/migrants">
<Button variant="ghost" size="sm" className="gap-1"> <Button variant="ghost" size="sm" className="gap-1 text-gray-300">
<ArrowLeft className="size-4" /> Back to Migrants <ArrowLeft className="size-4" /> Back to Migrants
</Button> </Button>
</Link> </Link>
<h1 className="text-3xl font-serif font-bold text-neutral-800 ml-4"> <h1 className="text-3xl font-serif font-bold text-white ml-4">
Edit Migrant Edit Migrant
</h1> </h1>
</div> </div>
<MigrantForm <MigrationForm/>
initialData={formData}
mode="edit"
onSubmit={handleFormSubmit}
/>
<UpdateDialog
open={open}
onOpenChange={setOpen}
onConfirm={handleUpdate}
isSubmitting={isSubmitting}
/>
</main> </main>
</div> </div>
</div> </div>

View File

@ -5,7 +5,7 @@ import type React from "react"
import { useState } from "react" import { useState } from "react"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { Eye, EyeOff, Lock, Mail } from "lucide-react" import { Eye, EyeOff, Lock, Mail } from "lucide-react"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@ -24,7 +24,6 @@ export default function LoginPage() {
setError("") setError("")
setIsLoading(true) setIsLoading(true)
// Simulate API call for authentication
try { try {
const response = await apiService.login({ const response = await apiService.login({
email, email,
@ -44,72 +43,56 @@ export default function LoginPage() {
setIsLoading(false) setIsLoading(false)
} }
} }
return (
<div className="min-h-dvh flex items-center justify-center bg-[url('/placeholder.svg?height=1080&width=1920')] bg-cover bg-center">
<div className="absolute inset-0 bg-gradient-to-br from-green-900/80 via-neutral-900/70 to-red-900/80 backdrop-blur-sm"></div>
<div className="w-full max-w-md px-4 relative z-10">
<Card className="border-0 py-0 shadow-2xl overflow-hidden">
{/* Italian flag stripe at the top */}
<div className="flex h-2">
<div className="w-1/3 bg-green-600"></div>
<div className="w-1/3 bg-white"></div>
<div className="w-1/3 bg-red-600"></div>
</div>
<CardHeader className="space-y-1 text-center border-b border-neutral-100 pb-6 bg-gradient-to-b from-neutral-50 to-white"> return (
<div className="flex justify-center mb-2"> <div className="min-h-screen flex items-center justify-center bg-gray-100 p-4">
<div className="size-16 rounded-full bg-gradient-to-r from-green-600 via-white to-red-600 flex items-center justify-center shadow-md"> <div className="w-full max-w-md">
<span className="text-2xl font-bold text-neutral-800">NT</span> <Card>
</div> <CardHeader>
</div> <CardTitle className="text-center text-xl font-semibold">Login</CardTitle>
<CardTitle className="text-2xl font-serif">Italian Migrants Database</CardTitle>
<CardDescription>Enter your credentials to access the admin panel</CardDescription>
</CardHeader> </CardHeader>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<CardContent className="space-y-4 pt-6 bg-white"> <CardContent className="space-y-4">
<div className="space-y-2"> <div>
<Label htmlFor="email">Email</Label> <Label htmlFor="email">Email</Label>
<div className="relative"> <div className="relative">
<Mail className="absolute left-3 top-2.5 size-5 text-neutral-500" /> <Mail className="absolute left-3 top-2.5 size-5 text-gray-400" />
<Input <Input
id="email" id="email"
type="email" type="email"
placeholder="admin@example.com" placeholder="admin@example.com"
className="pl-10 border-neutral-300 focus-visible:ring-green-600 shadow-sm" className="pl-10"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required required
/> />
</div> </div>
</div> </div>
<div className="space-y-2"> <div>
<Label htmlFor="password">Password</Label> <Label htmlFor="password">Password</Label>
<div className="relative"> <div className="relative">
<Lock className="absolute left-3 top-2.5 size-5 text-neutral-500" /> <Lock className="absolute left-3 top-2.5 size-5 text-gray-400" />
<Input <Input
id="password" id="password"
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
className="pl-10 pr-10 border-neutral-300 focus-visible:ring-green-600 shadow-sm" className="pl-10 pr-10"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
/> />
<button <button
type="button" type="button"
className="absolute right-3 top-2.5 text-neutral-500 hover:text-neutral-800" className="absolute right-3 top-2.5 text-gray-500 hover:text-gray-700"
onClick={() => setShowPassword(!showPassword)} onClick={() => setShowPassword(!showPassword)}
> >
{showPassword ? <EyeOff className="size-5" /> : <Eye className="size-5" />} {showPassword ? <EyeOff className="size-5" /> : <Eye className="size-5" />}
</button> </button>
</div> </div>
</div> </div>
{error && <p className="text-sm text-red-600">{error}</p>}
</CardContent> </CardContent>
<CardFooter className="bg-gradient-to-b mb-5 mt-8 from-white to-neutral-50"> <CardFooter>
<Button <Button type="submit" className="w-full mt-4" disabled={isLoading}>
type="submit"
className="w-full bg-gradient-to-r from-green-700 to-green-600 hover:from-green-800 hover:to-green-700 text-white shadow-md"
disabled={isLoading}
>
{isLoading ? "Authenticating..." : "Sign In"} {isLoading ? "Authenticating..." : "Sign In"}
</Button> </Button>
</CardFooter> </CardFooter>

View File

@ -1,105 +1,31 @@
"use client" "use client";
import { Link } from "react-router-dom";
import { useEffect, useState } from "react" import { PlusCircle } from "lucide-react";
import { Link } from "react-router-dom" import MigrantsTable from "@/components/admin/Table";
import { PlusCircle, Filter, Upload, Download } from "lucide-react" import Header from "@/components/layout/Header";
import Sidebar from "@/components/layout/Sidebar";
import Header from "@/components/layout/Header" import { Button } from "@/components/ui/button";
import Sidebar from "@/components/layout/Sidebar"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import MigrantTable from "@/components/admin/migrant/table/MigrantTable"
import apiService from "@/services/apiService"
import type { Person, Pagination } from "@/types/api"
export default function MigrantsPage() { export default function MigrantsPage() {
const [migrants, setMigrants] = useState<Person[]>([])
const [filter, setFilter] = useState("")
const [loading, setLoading] = useState(false)
const [pagination, setPagination] = useState<Pagination>({
current_page: 1,
per_page: 10,
total: 0,
next_page_url: null,
prev_page_url: null,
})
const fetchMigrants = async (url?: string) => {
setLoading(true)
try {
const res = url ? await apiService.getMigrantsByUrl(url) : await apiService.getMigrants(pagination.current_page)
const { data, current_page, per_page, total, next_page_url, prev_page_url } = res.data
setMigrants(data)
setPagination({ current_page, per_page, total, next_page_url, prev_page_url })
} catch (err) {
console.error("Error fetching migrants:", err)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchMigrants()
}, [])
const handlePageChange = (url?: string) => url && fetchMigrants(url)
return ( return (
<div className="flex min-h-dvh bg-[#f8f5f2]"> <div className="flex min-h-dvh bg-gray-950">
<Sidebar /> <Sidebar />
<div className="flex-1 md:ml-16 lg:ml-64"> <div className="flex-1 md:ml-16 lg:ml-64">
<Header title="Migrants Management" /> <Header title="Migrants Management" />
<main className="p-4 md:p-6"> <main className="p-4 md:p-6">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 gap-4"> <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 gap-4">
<h1 className="text-2xl md:text-3xl font-serif font-bold text-neutral-800">Migrants Database</h1> <h1 className="text-2xl text-white md:text-3xl font-serif font-bold text-neutral-800">
Migrants Database
</h1>
<Link to="/admin/migrants/add"> <Link to="/admin/migrants/add">
<Button className="bg-gradient-to-r from-green-700 to-green-600 hover:from-green-800 hover:to-green-700 shadow-md"> <Button className="bg-gradient-to-r from-green-700 to-green-600 hover:from-green-800 hover:to-green-700 shadow-md">
<PlusCircle className="mr-2 size-4" /> Add New Migrant <PlusCircle className="mr-2 size-4" /> Add New Migrant
</Button> </Button>
</Link> </Link>
</div> </div>
<MigrantsTable />
<Card className="mb-6 border-0 shadow-md bg-gradient-to-br from-neutral-50 to-neutral-100">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-green-600 via-white to-red-600" />
<CardHeader className="pb-3 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<CardTitle className="text-xl font-serif text-neutral-800">Search & Filter</CardTitle>
<Button variant="outline" size="sm" className="bg-white shadow-sm border-neutral-200">
<Filter className="mr-2 size-4" /> Advanced Filters
</Button>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-2">
<Input
placeholder="Search migrants..."
className="pl-3 border-neutral-300 bg-white shadow-sm"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<div className="flex gap-2">
<Button variant="outline" className="flex-1 bg-white shadow-sm border-neutral-200">
<Upload className="mr-2 size-4" /> Import
</Button>
<Button variant="outline" className="flex-1 bg-white shadow-sm border-neutral-200">
<Download className="mr-2 size-4" /> Export
</Button>
</div>
</div>
</CardContent>
</Card>
<MigrantTable
data={migrants}
globalFilter={filter}
loading={loading}
page={pagination.current_page}
meta={{ ...pagination, count: migrants.length, last_page: Math.ceil(pagination.total / pagination.per_page) }}
onNextPage={() => handlePageChange(pagination.next_page_url!)}
onPrevPage={() => handlePageChange(pagination.prev_page_url!)}
onRefresh={() => fetchMigrants()}
/>
</main> </main>
</div> </div>
</div> </div>
) );
} }

View File

@ -0,0 +1,124 @@
"use client"
import { useEffect, useState } from "react"
import ApiService from "@/services/apiService"
import type { ActivityLog } from "@/types/api"
import { motion } from "framer-motion"
import { PlusCircle, FileText, Users, BarChart2, Database } from "lucide-react"
export default function RecentActivity() {
const [logs, setLogs] = useState<ActivityLog[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchLogs = async () => {
try {
const data = await ApiService.getRecentActivityLogs()
setLogs(data)
} catch (err) {
console.error("Error fetching activity logs:", err)
} finally {
setLoading(false)
}
}
fetchLogs()
}, [])
const getType = (log: ActivityLog) => {
const action = log.description.toLowerCase()
if (action.includes("add")) return "add"
if (action.includes("update")) return "update"
if (action.includes("delete")) return "delete"
if (action.includes("report")) return "report"
return "import"
}
const getIcon = (type: string) => {
switch (type) {
case "add":
return <PlusCircle className="h-5 w-5" />
case "update":
return <FileText className="h-5 w-5" />
case "delete":
return <Users className="h-5 w-5" />
case "report":
return <BarChart2 className="h-5 w-5" />
default:
return <Database className="h-5 w-5" />
}
}
const getColorClass = (type: string) => {
switch (type) {
case "add":
return "bg-green-900/20 text-green-400 border border-green-800/30"
case "update":
return "bg-blue-900/20 text-blue-400 border border-blue-800/30"
case "delete":
return "bg-red-900/20 text-red-400 border border-red-800/30"
case "report":
return "bg-purple-900/20 text-purple-400 border border-purple-800/30"
default:
return "bg-[#9B2335]/20 text-[#9B2335] border border-[#9B2335]/30"
}
}
return (
<motion.div
className="bg-gray-900 rounded-lg shadow-2xlborder border-gray-800 overflow-hidden"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.4 }}
>
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-[#9B2335] to-[#9B2335]/60"></div>
<div className="px-6 py-2 border-b border-gray-800">
<h2 className="text-lg font-medium text-white">Recent Activity</h2>
</div>
<div className="p-6">
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#9B2335]"></div>
<p className="text-gray-400 ml-3">Loading activity...</p>
</div>
) : logs.length === 0 ? (
<div className="text-center py-8">
<Database className="mx-auto h-12 w-12 text-gray-600 mb-4" />
<p className="text-gray-400">No recent activity found.</p>
<p className="text-sm text-gray-500 mt-1">Activity will appear here when users interact with the system.</p>
</div>
) : (
<div className="space-y-6">
{logs.map((log, index) => {
const type = getType(log)
return (
<motion.div
key={index}
className="flex items-start group hover:bg-gray-800/30 p-3 rounded-lg transition-colors duration-200"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3, delay: 0.5 + index * 0.1 }}
>
<div className={`p-2 rounded-full mr-4 shadow-lg ${getColorClass(type)}`}>{getIcon(type)}</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-white group-hover:text-gray-100 transition-colors">
{log.description}
</p>
<div className="flex items-center mt-1 text-sm text-gray-400">
<span className="font-medium text-[#9B2335]">{log.causer_name}</span>
<span className="mx-2"></span>
<span>{new Date(log.created_at).toLocaleString()}</span>
</div>
</div>
<div className="ml-4 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="w-2 h-2 bg-[#9B2335] rounded-full"></div>
</div>
</motion.div>
)
})}
</div>
)}
</div>
</motion.div>
)
}

View File

@ -1,434 +1,72 @@
"use client" "use client"
import { useState } from "react" import { User } from "lucide-react"
import {
BarChart,
Download,
FileText,
PieChart,
RefreshCw,
User,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input" import { Tabs, TabsContent } from "@/components/ui/tabs"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { OccupationChart } from "../charts/OccupationChart"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import Header from "@/components/layout/Header"
import Sidebar from "@/components/layout/Sidebar"
import Header from "@/components/layout/Header" import { ResidenceChart } from "@/components/charts/ResidenceChart"
import Sidebar from "@/components/layout/Sidebar"
export default function ReportsPage() { export default function ReportsPage() {
const [dateRange, setDateRange] = useState<"all" | "year" | "decade" | "custom">("all")
const [reportType, setReportType] = useState<"demographics" | "migration" | "occupation">("demographics")
const [loading, setLoading] = useState(false)
const handleGenerateReport = () => {
setLoading(true)
}
const handleExportReport = () => {
}
return ( return (
<div className="flex min-h-dvh bg-[#f8f5f2]"> <div className="flex min-h-dvh bg-gray-950">
<Sidebar /> <Sidebar />
<div className="flex-1 md:ml-16 lg:ml-64 w-full transition-all duration-300"> <div className="flex-1 md:ml-16 lg:ml-64 w-full transition-all duration-300">
<Header title="Reports" /> <Header title="Reports" />
<main className="p-4 md:p-6"> <main className="p-4 md:p-6">
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl md:text-3xl font-serif font-bold text-neutral-800 mb-2">Data Reports</h1> <h1 className="text-2xl md:text-3xl font-serif font-bold text-white mb-2">Data Reports</h1>
<p className="text-neutral-600">Generate and analyze reports from the Italian Migrants Database</p> <p className="text-gray-400">Generate and analyze reports from the Italian Migrants Database</p>
</div> </div>
{/* Report Controls */}
<Card className="mb-6 md:mb-8 border-0 shadow-md bg-gradient-to-br from-neutral-50 to-neutral-100 overflow-hidden">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-green-600 via-white to-red-600"></div>
<CardHeader className="pb-3">
<CardTitle className="text-xl font-serif text-neutral-800">Report Generator</CardTitle>
<CardDescription>Configure and generate custom reports</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<label className="text-sm font-medium">Report Type</label>
<Select defaultValue={reportType} onValueChange={(value) => setReportType(value as any)}>
<SelectTrigger className="bg-white shadow-sm border-neutral-200">
<SelectValue placeholder="Select report type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="demographics">Demographics</SelectItem>
<SelectItem value="migration">Migration Patterns</SelectItem>
<SelectItem value="occupation">Occupations</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Date Range</label>
<Select defaultValue={dateRange} onValueChange={(value) => setDateRange(value as any)}>
<SelectTrigger className="bg-white shadow-sm border-neutral-200">
<SelectValue placeholder="Select date range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Time</SelectItem>
<SelectItem value="year">Past Year</SelectItem>
<SelectItem value="decade">By Decade</SelectItem>
<SelectItem value="custom">Custom Range</SelectItem>
</SelectContent>
</Select>
</div>
{dateRange === "custom" && (
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<label className="text-sm font-medium">Start Date</label>
<Input type="date" className="bg-white shadow-sm border-neutral-200" />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">End Date</label>
<Input type="date" className="bg-white shadow-sm border-neutral-200" />
</div>
</div>
)}
<div className="flex items-end">
<Button
onClick={handleGenerateReport}
className="bg-gradient-to-r from-green-700 to-green-600 hover:from-green-800 hover:to-green-700 shadow-md w-full"
disabled={loading}
>
{loading ? (
<>
<RefreshCw className="mr-2 size-4 animate-spin" />
Generating...
</>
) : (
<>
<FileText className="mr-2 size-4" />
Generate Report
</>
)}
</Button>
</div>
</div>
{dateRange === "decade" && (
<div className="mt-4 p-4 bg-white rounded-md shadow-inner">
<div className="flex flex-wrap gap-2">
{["1940s", "1950s", "1960s", "1970s", "1980s", "1990s"].map((decade) => (
<Button key={decade} variant="outline" className="bg-white border-neutral-200" size="sm">
{decade}
</Button>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Report Tabs */} {/* Report Tabs */}
<Tabs defaultValue="demographics" className="space-y-6"> <Tabs defaultValue="demographics" className="space-y-6">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-4 gap-3 sm:gap-0">
<TabsList className="bg-white shadow-sm border border-neutral-200">
<TabsTrigger
value="demographics"
className="data-[state=active]:bg-green-50 data-[state=active]:text-green-800"
>
Demographics
</TabsTrigger>
<TabsTrigger
value="occupation"
className="data-[state=active]:bg-green-50 data-[state=active]:text-green-800"
>
Occupation
</TabsTrigger>
</TabsList>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="bg-white shadow-sm border-neutral-200"
onClick={() => handleExportReport()}
>
<Download className="mr-2 size-4" /> PDF
</Button>
<Button
variant="outline"
size="sm"
className="bg-white shadow-sm border-neutral-200"
onClick={() => handleExportReport()}
>
<Download className="mr-2 size-4" /> CSV
</Button>
</div>
</div>
{/* Demographics Report */} {/* Demographics Report */}
<TabsContent value="demographics" className="space-y-6"> <TabsContent value="demographics" className="space-y-6">
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
<Card className="border-0 shadow-md overflow-hidden"> <ResidenceChart />
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-green-600 via-white to-red-600"></div> <OccupationChart />
<CardHeader>
<CardTitle className="text-xl font-serif">Age Distribution</CardTitle>
<CardDescription>Age breakdown of Italian migrants</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[300px] bg-white rounded-lg p-4 shadow-inner flex items-center justify-center">
<div className="w-full h-full flex items-end justify-between gap-2">
{[28, 45, 65, 42, 18, 10].map((height, i) => (
<div key={i} className="relative group flex flex-col items-center flex-1">
<div className="absolute -top-7 opacity-0 group-hover:opacity-100 transition-opacity bg-neutral-800 text-white px-2 py-1 rounded text-xs shadow-lg">
{["0-18", "19-30", "31-45", "46-60", "61-75", "76+"][i]}: {height}%
</div>
<div
className={`w-full rounded-t shadow-sm ${
i % 3 === 0
? "bg-gradient-to-t from-green-700 to-green-500"
: i % 3 === 1
? "bg-gradient-to-t from-neutral-400 to-white"
: "bg-gradient-to-t from-red-700 to-red-500"
}`}
style={{ height: `${height * 3}px` }}
></div>
<span className="text-xs mt-1 text-neutral-600 font-medium">
{["0-18", "19-30", "31-45", "46-60", "61-75", "76+"][i]}
</span>
</div>
))}
</div>
</div>
</CardContent>
</Card>
<Card className="border-0 shadow-md overflow-hidden">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-green-600 via-white to-red-600"></div>
<CardHeader>
<CardTitle className="text-xl font-serif">Gender Distribution</CardTitle>
<CardDescription>Gender breakdown of Italian migrants</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[300px] bg-white rounded-lg p-4 shadow-inner flex items-center justify-center">
<div className="relative w-64 h-64">
{/* Simple pie chart visualization */}
<div className="absolute inset-0 rounded-full overflow-hidden">
<div
className="absolute inset-0 bg-green-600"
style={{ clipPath: "polygon(50% 50%, 50% 0, 100% 0, 100% 100%, 0 100%, 0 0, 50% 0)" }}
></div>
<div
className="absolute inset-0 bg-red-600"
style={{ clipPath: "polygon(50% 50%, 100% 0, 100% 100%, 0 100%, 0 0, 50% 0)" }}
></div>
</div>
<div className="absolute inset-0 flex items-center justify-center">
<div className="bg-white rounded-full w-32 h-32 flex items-center justify-center shadow-inner">
<PieChart className="size-10 text-neutral-400" />
</div>
</div>
<div className="absolute bottom-0 left-0 right-0 flex justify-around mt-4">
<div className="flex items-center">
<div className="size-3 bg-green-600 rounded-full mr-2"></div>
<span className="text-sm">Male (62%)</span>
</div>
<div className="flex items-center">
<div className="size-3 bg-red-600 rounded-full mr-2"></div>
<span className="text-sm">Female (38%)</span>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div> </div>
<Card className="border-0 shadow-md overflow-hidden"> <Card className="border border-gray-800 bg-gray-900 shadow-2xl overflow-hidden">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-green-600 via-white to-red-600"></div> <div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-[#9B2335] to-[#9B2335]/60"></div>
<CardHeader> <CardHeader className="border-b border-gray-800">
<CardTitle className="text-xl font-serif">Family Status</CardTitle> <CardTitle className="text-xl font-serif text-white">Family Status</CardTitle>
<CardDescription>Family composition of Italian migrants</CardDescription> <CardDescription className="text-gray-400">Family composition of Italian migrants</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-white rounded-lg p-4 shadow-inner flex flex-col items-center justify-center"> <div className="bg-gray-800 rounded-lg p-4 border border-gray-700 flex flex-col items-center justify-center hover:bg-gray-750 transition-colors">
<div className="size-16 rounded-full bg-green-100 flex items-center justify-center mb-4"> <div className="size-16 rounded-full bg-[#9B2335]/20 border border-[#9B2335]/30 flex items-center justify-center mb-4">
<User className="size-8 text-green-600" /> <User className="size-8 text-[#9B2335]" />
</div> </div>
<h3 className="text-2xl font-bold">42%</h3> <h3 className="text-2xl font-bold text-white">42%</h3>
<p className="text-neutral-600">Single</p> <p className="text-gray-400">Single</p>
</div> </div>
<div className="bg-white rounded-lg p-4 shadow-inner flex flex-col items-center justify-center"> <div className="bg-gray-800 rounded-lg p-4 border border-gray-700 flex flex-col items-center justify-center hover:bg-gray-750 transition-colors">
<div className="size-16 rounded-full bg-neutral-100 flex items-center justify-center mb-4"> <div className="size-16 rounded-full bg-blue-500/20 border border-blue-500/30 flex items-center justify-center mb-4">
<div className="flex -space-x-2"> <div className="flex -space-x-2">
<User className="size-8 text-neutral-600" /> <User className="size-8 text-blue-400" />
<User className="size-8 text-neutral-400" /> <User className="size-8 text-blue-300" />
</div> </div>
</div> </div>
<h3 className="text-2xl font-bold">35%</h3> <h3 className="text-2xl font-bold text-white">35%</h3>
<p className="text-neutral-600">Married</p> <p className="text-gray-400">Married</p>
</div> </div>
<div className="bg-white rounded-lg p-4 shadow-inner flex flex-col items-center justify-center"> <div className="bg-gray-800 rounded-lg p-4 border border-gray-700 flex flex-col items-center justify-center hover:bg-gray-750 transition-colors">
<div className="size-16 rounded-full bg-red-100 flex items-center justify-center mb-4"> <div className="size-16 rounded-full bg-orange-500/20 border border-orange-500/30 flex items-center justify-center mb-4">
<div className="flex -space-x-4"> <div className="flex -space-x-4">
<User className="size-8 text-red-600" /> <User className="size-8 text-orange-400" />
<User className="size-6 text-red-400" /> <User className="size-6 text-orange-300" />
<User className="size-4 text-red-300" /> <User className="size-4 text-orange-200" />
</div> </div>
</div> </div>
<h3 className="text-2xl font-bold">23%</h3> <h3 className="text-2xl font-bold text-white">23%</h3>
<p className="text-neutral-600">Family</p> <p className="text-gray-400">Family</p>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Occupation Report */}
<TabsContent value="occupation" className="space-y-6">
<div className="grid gap-6 md:grid-cols-2">
<Card className="border-0 shadow-md overflow-hidden">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-green-600 via-white to-red-600"></div>
<CardHeader>
<CardTitle className="text-xl font-serif">Top Occupations</CardTitle>
<CardDescription>Most common professions among Italian migrants</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[300px] bg-white rounded-lg p-4 shadow-inner">
<div className="space-y-4">
{[
{ name: "Farmer", count: 287, percent: 23 },
{ name: "Carpenter", count: 215, percent: 17 },
{ name: "Seamstress", count: 189, percent: 15 },
{ name: "Mason", count: 156, percent: 12 },
{ name: "Cook", count: 124, percent: 10 },
{ name: "Fisherman", count: 98, percent: 8 },
{ name: "Merchant", count: 87, percent: 7 },
{ name: "Other", count: 102, percent: 8 },
].map((occupation, i) => (
<div key={i} className="flex items-center">
<span className="text-sm font-medium w-24 text-neutral-700">{occupation.name}</span>
<div className="flex-1 h-4 bg-neutral-100 rounded-full overflow-hidden shadow-inner">
<div
className={`h-full rounded-full ${
i % 3 === 0 ? "bg-green-600" : i % 3 === 1 ? "bg-neutral-400" : "bg-red-600"
}`}
style={{ width: `${occupation.percent}%` }}
></div>
</div>
<span className="text-xs text-neutral-600 ml-2 w-10 font-medium">{occupation.count}</span>
<span className="text-xs text-neutral-500 ml-1 w-8">({occupation.percent}%)</span>
</div>
))}
</div>
</div>
</CardContent>
</Card>
<Card className="border-0 shadow-md overflow-hidden">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-green-600 via-white to-red-600"></div>
<CardHeader>
<CardTitle className="text-xl font-serif">Occupation by Gender</CardTitle>
<CardDescription>Distribution of occupations by gender</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[300px] bg-white rounded-lg p-4 shadow-inner">
<div className="h-full flex items-end justify-between gap-4">
{[
{ name: "Farmer", male: 85, female: 15 },
{ name: "Carpenter", male: 98, female: 2 },
{ name: "Seamstress", male: 5, female: 95 },
{ name: "Mason", male: 92, female: 8 },
{ name: "Cook", male: 45, female: 55 },
].map((occupation, i) => (
<div key={i} className="flex flex-col items-center flex-1">
<div className="w-full flex flex-col h-[220px]">
<div className="w-full bg-green-600" style={{ height: `${occupation.male * 2}px` }}></div>
<div className="w-full bg-red-600" style={{ height: `${occupation.female * 2}px` }}></div>
</div>
<span className="text-xs mt-2 text-neutral-600 font-medium text-center">
{occupation.name}
</span>
</div>
))}
</div>
<div className="mt-4 flex justify-center gap-6">
<div className="flex items-center">
<div className="size-3 bg-green-600 rounded-full mr-2"></div>
<span className="text-xs">Male</span>
</div>
<div className="flex items-center">
<div className="size-3 bg-red-600 rounded-full mr-2"></div>
<span className="text-xs">Female</span>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
<Card className="border-0 shadow-md overflow-hidden">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-green-600 via-white to-red-600"></div>
<CardHeader>
<CardTitle className="text-xl font-serif">Occupation Trends</CardTitle>
<CardDescription>Changes in occupations over time</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[300px] bg-white rounded-lg p-4 shadow-inner flex items-center justify-center">
<div className="w-full h-full flex flex-col">
<div className="flex-1 flex items-end">
<div className="relative w-full h-full">
{/* Bar chart visualization */}
<div className="absolute inset-0 flex items-end justify-between">
{[1940, 1950, 1960, 1970, 1980, 1990].map((decade, i) => (
<div key={decade} className="h-full flex-1 flex flex-col justify-end px-1">
<div
className="w-full bg-green-600"
style={{ height: `${[30, 40, 35, 25, 15, 10][i]}%` }}
></div>
<div
className="w-full bg-neutral-400"
style={{ height: `${[20, 25, 30, 35, 40, 35][i]}%` }}
></div>
<div
className="w-full bg-red-600"
style={{ height: `${[10, 15, 20, 25, 30, 40][i]}%` }}
></div>
</div>
))}
</div>
<BarChart className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-neutral-200 size-32 opacity-10" />
</div>
</div>
<div className="h-6 flex justify-between mt-2">
{[1940, 1950, 1960, 1970, 1980, 1990].map((decade) => (
<div key={decade} className="text-xs text-neutral-600">
{decade}s
</div>
))}
</div>
</div>
</div>
<div className="mt-4 flex justify-center gap-6">
<div className="flex items-center">
<div className="size-3 bg-green-600 rounded-full mr-2"></div>
<span className="text-xs">Agricultural</span>
</div>
<div className="flex items-center">
<div className="size-3 bg-neutral-400 rounded-full mr-2"></div>
<span className="text-xs">Trade/Craft</span>
</div>
<div className="flex items-center">
<div className="size-3 bg-red-600 rounded-full mr-2"></div>
<span className="text-xs">Service</span>
</div> </div>
</div> </div>
</CardContent> </CardContent>

View File

@ -1,217 +1,205 @@
"use client" "use client"
import { useState } from "react" import type React from "react"
import { Key, Lock } from "lucide-react"
import { useState } from "react"
import { useNavigate } from "react-router-dom"
import { UserPlus, ArrowLeft } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch" import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { toast } from "react-hot-toast"
import { Textarea } from "@/components/ui/textarea" import apiService from "@/services/apiService"
import Header from "@/components/layout/Header"
import Sidebar from "@/components/layout/Sidebar"
import Header from "@/components/layout/Header" export default function UserCreate() {
import Sidebar from "@/components/layout/Sidebar" const navigate = useNavigate()
const [isSubmitting, setIsSubmitting] = useState(false)
const [formData, setFormData] = useState({
name: "",
email: "",
password: "",
password_confirmation: "",
})
export default function SettingsPage() { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const [loading, setLoading] = useState(false) const { name, value } = e.target
setFormData((prev) => ({
...prev,
[name]: value,
}))
}
const handleSaveSettings = () => { const handleSubmit = async (e: React.FormEvent) => {
setLoading(true) e.preventDefault()
setTimeout(() => {
setLoading(false) // Basic validation
}, 1000) if (!formData.name || !formData.email || !formData.password) {
toast.error("Please fill in all required fields")
return
}
if (formData.password !== formData.password_confirmation) {
toast.error("Passwords don't match")
return
}
try {
setIsSubmitting(true)
// This would need to be implemented in your apiService
await apiService.createUser(formData)
toast.success("User created successfully!")
navigate("/admin/settings") // Redirect to an appropriate page
} catch (error) {
console.error("Error creating user:", error)
toast.error("Failed to create user. Please try again.")
} finally {
setIsSubmitting(false)
}
} }
return ( return (
<div className="flex min-h-dvh bg-[#f8f5f2]"> <div className="flex min-h-dvh bg-gray-950">
<Sidebar /> <Sidebar />
<div className="flex-1 md:ml-16 lg:ml-64 w-full transition-all duration-300"> <div className="flex-1 md:ml-16 lg:ml-64 w-full transition-all duration-300">
<Header title="Settings" /> <Header title="Create User" />
<main className="p-4 md:p-6"> <main className="p-4 md:p-6">
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl md:text-3xl font-serif font-bold text-neutral-800 mb-2">User Settings</h1> <div className="flex items-center gap-2">
<p className="text-neutral-600">Manage your account preferences and settings</p> <Button
variant="ghost"
size="icon"
onClick={() => navigate("/admin/dashboard")}
className="hover:bg-gray-800 text-gray-400 hover:text-white">
<ArrowLeft className="size-5" />
</Button>
<h1 className="text-2xl md:text-3xl font-serif font-bold text-white">Account Settings</h1>
</div>
<p className="text-gray-400 mt-2 ml-10">Manage your profile and security preferences</p>
</div> </div>
<Tabs defaultValue="profile" className="space-y-6"> <div className="max-w-10xl mx-auto">
<TabsList className="bg-white shadow-sm border border-neutral-200"> <Card className="shadow-2xl border border-gray-800 bg-gray-900 overflow-hidden">
<TabsTrigger <div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-[#9B2335] to-[#9B2335]/60"></div>
value="profile" <CardHeader className="border-b border-gray-800">
className="data-[state=active]:bg-green-50 data-[state=active]:text-green-800" <CardTitle className="text-xl font-serif text-white">User Information</CardTitle>
> <CardDescription className="text-gray-400">Please fill in all required fields</CardDescription>
Profile </CardHeader>
</TabsTrigger> <form onSubmit={handleSubmit}>
<TabsTrigger <CardContent className="space-y-6 p-6">
value="account" <div className="space-y-6">
className="data-[state=active]:bg-green-50 data-[state=active]:text-green-800"
>
Account
</TabsTrigger>
</TabsList>
{/* Profile Settings */}
<TabsContent value="profile" className="space-y-6">
<Card className="border-0 shadow-md overflow-hidden">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-green-600 via-white to-red-600"></div>
<CardHeader>
<CardTitle className="text-xl font-serif">Personal Information</CardTitle>
<CardDescription>Update your personal details</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex flex-col md:flex-row gap-6">
<div className="flex-1 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="firstName">First Name</Label>
<Input id="firstName" defaultValue="Admin" className="border-neutral-300" />
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Last Name</Label>
<Input id="lastName" defaultValue="User" className="border-neutral-300" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
type="email"
defaultValue="admin@example.com"
className="border-neutral-300"
/>
</div>
<div className="space-y-2">
<Label htmlFor="phone">Phone Number</Label>
<Input id="phone" type="tel" defaultValue="+1 (555) 123-4567" className="border-neutral-300" />
</div>
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Input
id="role"
defaultValue="Database Administrator"
className="border-neutral-300"
readOnly
/>
</div>
</div>
<div className="md:w-1/3 flex flex-col items-center">
<div className="mb-4">
<div className="size-32 rounded-full bg-gradient-to-r from-green-600 via-white to-red-600 flex items-center justify-center shadow-md">
<span className="text-4xl font-bold text-neutral-800">A</span>
</div>
</div>
<Button className="bg-gradient-to-r from-green-700 to-green-600 hover:from-green-800 hover:to-green-700 shadow-md mb-2 w-full">
Upload New Photo
</Button>
<Button variant="outline" className="w-full">
Remove Photo
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="bio">Bio</Label>
<Textarea
id="bio"
className="min-h-[100px] border-neutral-300"
placeholder="Tell us about yourself"
defaultValue="Administrator for the Italian Migrants Database project. Responsible for data management and user access."
/>
</div>
<div className="flex justify-end">
<Button
onClick={handleSaveSettings}
className="bg-gradient-to-r from-green-700 to-green-600 hover:from-green-800 hover:to-green-700 shadow-md"
disabled={loading}
>
{loading ? "Saving..." : "Save Profile"}
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Account Settings */}
<TabsContent value="account" className="space-y-6">
<Card className="border-0 shadow-md overflow-hidden">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-green-600 via-white to-red-600"></div>
<CardHeader>
<CardTitle className="text-xl font-serif">Account Security</CardTitle>
<CardDescription>Manage your password and security settings</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="current-password">Current Password</Label> <Label htmlFor="name" className="text-gray-300">
<Input id="current-password" type="password" className="border-neutral-300" /> Full Name <span className="text-red-400">*</span>
</Label>
<Input
id="name"
name="name"
placeholder="Enter full name"
value={formData.name}
onChange={handleInputChange}
required
className="bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500"
/>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="space-y-2">
<div className="space-y-2"> <Label htmlFor="email" className="text-gray-300">
<Label htmlFor="new-password">New Password</Label> Email Address <span className="text-red-400">*</span>
<Input id="new-password" type="password" className="border-neutral-300" /> </Label>
</div> <Input
<div className="space-y-2"> id="email"
<Label htmlFor="confirm-password">Confirm New Password</Label> name="email"
<Input id="confirm-password" type="password" className="border-neutral-300" /> type="email"
</div> placeholder="Enter email address"
value={formData.email}
onChange={handleInputChange}
required
className="bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500"
/>
</div> </div>
<div className="flex justify-end"> <div className="border-t border-gray-800 pt-6">
<Button <h3 className="text-lg font-medium mb-4 text-white">Security Information</h3>
onClick={handleSaveSettings} <div className="space-y-4">
className="bg-gradient-to-r from-green-700 to-green-600 hover:from-green-800 hover:to-green-700 shadow-md" <div className="space-y-2">
disabled={loading} <Label htmlFor="current_password" className="text-gray-300">
> Current Password <span className="text-red-400">*</span>
<Key className="mr-2 size-4" /> </Label>
{loading ? "Updating..." : "Update Password"} <Input
</Button> id="current_password"
</div> name="current_password"
</div> type="password"
placeholder="Enter current password"
required
className="bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500"
/>
</div>
<div className="border-t border-neutral-200 pt-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<h3 className="text-lg font-medium mb-4">Two-Factor Authentication</h3> <div className="space-y-2">
<div className="flex items-center justify-between"> <Label htmlFor="password" className="text-gray-300">
<div> Password <span className="text-red-400">*</span>
<p className="font-medium">Protect your account with 2FA</p> </Label>
<p className="text-sm text-neutral-500">Add an extra layer of security to your account</p> <Input
</div> id="password"
<Switch defaultChecked /> name="password"
</div> type="password"
</div> placeholder="Enter password"
value={formData.password}
<div className="border-t border-neutral-200 pt-6"> onChange={handleInputChange}
<h3 className="text-lg font-medium mb-4">Login Sessions</h3> required
<div className="space-y-4"> className="bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500"
<div className="bg-neutral-50 p-4 rounded-md border border-neutral-200"> />
<div className="flex justify-between items-start">
<div>
<p className="font-medium">Current Session</p>
<p className="text-sm text-neutral-500">Windows 11 Chrome Sydney, Australia</p>
<p className="text-xs text-neutral-400 mt-1">Started 2 hours ago</p>
</div> </div>
<div className="bg-green-100 text-green-800 text-xs font-medium px-2 py-1 rounded">
Active Now <div className="space-y-2">
<Label htmlFor="password_confirmation" className="text-gray-300">
Confirm Password <span className="text-red-400">*</span>
</Label>
<Input
id="password_confirmation"
name="password_confirmation"
type="password"
placeholder="Confirm password"
value={formData.password_confirmation}
onChange={handleInputChange}
required
className="bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500"
/>
</div> </div>
</div> </div>
</div> </div>
<Button variant="outline" className="text-red-600 hover:text-red-700 hover:bg-red-50">
<Lock className="mr-2 size-4" />
Sign Out All Other Sessions
</Button>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card>
</TabsContent> <CardFooter className="flex justify-end gap-3 pt-2 pb-6 px-6 border-t border-gray-800">
</Tabs> <Button
type="button"
variant="outline"
onClick={() => navigate("/admin/settings")}
disabled={isSubmitting}
className="border-gray-700 text-gray-300 hover:bg-gray-800 hover:text-white bg-gray-900 shadow-lg"
>
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting}
className="bg-[#9B2335] hover:bg-[#9B2335]/90 text-white shadow-lg"
>
<UserPlus className="mr-2 size-4" />
{isSubmitting ? "Creating..." : "Create User"}
</Button>
</CardFooter>
</form>
</Card>
</div>
</main> </main>
</div> </div>
</div> </div>

View File

@ -0,0 +1,449 @@
"use client"
import { Pencil, Filter, Trash2, Search, Calendar, X } from "lucide-react"
import { useMigrants } from "@/hooks/useMigrants"
import { useState } from "react"
import DeleteDialog from "./migrant/Modal/DeleteDialog"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Checkbox } from "@/components/ui/checkbox"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogDescription,
} from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Label } from "@/components/ui/label"
import { useNavigate } from "react-router-dom"
import { formatDate } from "@/utils/date"
const MigrantsTable = () => {
const navigate = useNavigate()
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [deleteDialogIds, setDeleteDialogIds] = useState<string[]>([])
const [isBulkDelete, setIsBulkDelete] = useState(false)
const [isModalOpen, setIsModalOpen] = useState(false)
const applyAdvancedFilters = () => {
setIsModalOpen(false)
handleSearch() // Trigger the search with the current filters
}
const handleEdit = (id: number) => {
navigate(`/admin/migrants/edit/${id}?mode=edit`)
}
const [showAllActive, setShowAllActive] = useState(false)
const {
migrants,
loading,
currentPage,
totalPages,
searchFullName,
searchOccupation,
filters,
selectedMigrants,
setCurrentPage,
setSearchFullName,
setSearchOccupation,
setFilters,
handleSearch,
resetFilters,
toggleSelectMigrant,
toggleSelectAll,
isAllSelected,
handleBulkDelete,
refetchMigrants
} = useMigrants(showAllActive ? 1000 : 10)
return (
<div className="space-y-6">
{/* Filters */}
<Card className="border border-gray-800 bg-gray-900 shadow-2xl overflow-hidden">
<CardHeader className="pb-3 border-b border-gray-800">
<CardTitle className="text-md font-medium flex justify-between items-center text-white">
Search & Filters
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogTrigger asChild>
<Button
size="icon"
className="border-gray-700 text-gray-300 hover:bg-gray-900 hover:text-white bg-gray-800"
>
<Filter className="h-4 w-4" />
<span className="sr-only">Advanced filters</span>
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px] bg-gray-900 border-gray-800">
<DialogHeader>
<DialogTitle className="text-white">Advanced Filters</DialogTitle>
<DialogDescription className="text-gray-400">
Set sorting options and filters for the migrants list.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="sort-by" className="text-gray-300">
Sort By
</Label>
<Select
value={filters.sort_by}
onValueChange={(value) => setFilters({ ...filters, sort_by: value })}
>
<SelectTrigger id="sort-by" className="bg-gray-800 border-gray-700 text-white">
<SelectValue placeholder="Select field" />
</SelectTrigger>
<SelectContent className="bg-gray-800 border-gray-700">
<SelectItem value="full_name" className="text-white hover:bg-gray-700">
Full Name
</SelectItem>
<SelectItem value="occupation" className="text-white hover:bg-gray-700">
Occupation
</SelectItem>
<SelectItem value="arrival_date" className="text-white hover:bg-gray-700">
Arrival Date
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label className="text-gray-300">Alphabetical Order</Label>
<RadioGroup
value={filters.alphabetical_order}
onValueChange={(value) => setFilters({ ...filters, alphabetical_order: value })}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="asc" id="asc" className="border-gray-600 text-[#9B2335]" />
<Label htmlFor="asc" className="text-gray-300">
A-Z
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="desc" id="desc" className="border-gray-600 text-[#9B2335]" />
<Label htmlFor="desc" className="text-gray-300">
Z-A
</Label>
</div>
</RadioGroup>
</div>
{/* Arrival Date Sorting Options */}
{filters.sort_by === "arrival_date" && (
<div className="grid gap-2">
<Label className="text-gray-300">Arrival Date Order</Label>
<RadioGroup
value={filters.arrival_order === "desc" ? "newest" : "oldest"}
onValueChange={(value) =>
setFilters((prev) => ({
...prev,
sort_by: "arrival_date",
arrival_order: value === "newest" ? "desc" : "asc",
}))
}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="newest" id="newest" className="border-gray-600 text-[#9B2335]" />
<Label htmlFor="newest" className="text-gray-300">
Newest First
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="oldest" id="oldest" className="border-gray-600 text-[#9B2335]" />
<Label htmlFor="oldest" className="text-gray-300">
Oldest First
</Label>
</div>
</RadioGroup>
</div>
)}
<div className="grid gap-2">
<Label htmlFor="arrival-from" className="text-gray-300">
Arrival Date Range
</Label>
<div className="flex gap-2 items-center">
<div className="relative flex-1">
<Calendar className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500" />
<Input
id="arrival-from"
type="date"
placeholder="From"
value={filters.arrival_from}
onChange={(e) => setFilters({ ...filters, arrival_from: e.target.value })}
className="pl-8 bg-gray-800 border-gray-700 text-white focus:border-[#9B2335]"
/>
</div>
<span className="text-gray-400">to</span>
<div className="relative flex-1">
<Calendar className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500" />
<Input
type="date"
placeholder="To"
value={filters.arrival_to}
onChange={(e) => setFilters({ ...filters, arrival_to: e.target.value })}
className="pl-8 bg-gray-800 border-gray-700 text-white focus:border-[#9B2335]"
/>
</div>
</div>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setIsModalOpen(false)}
className="border-gray-700 text-gray-300 hover:bg-gray-800 bg-gray-800"
>
Cancel
</Button>
<Button onClick={applyAdvancedFilters} className="bg-[#9B2335] hover:bg-[#9B2335]/80">
Apply Filters
</Button>
</div>
</DialogContent>
</Dialog>
</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="flex flex-wrap gap-4">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500" />
<Input
type="text"
placeholder="Search Full Name"
value={searchFullName}
onChange={(e) => setSearchFullName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
className="pl-8 bg-gray-800 border-gray-700 text-white placeholder:text-gray-500 focus:border-[#9B2335]"
/>
</div>
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500" />
<Input
type="text"
placeholder="Search Occupation"
value={searchOccupation}
onChange={(e) => setSearchOccupation(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
onBlur={() => searchOccupation.trim() !== "" && handleSearch()}
className="pl-8 bg-gray-800 border-gray-700 text-white placeholder:text-gray-500 focus:border-[#9B2335]"
/>
</div>
<div className="relative flex-1 min-w-[200px]">
<Calendar className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500" />
<Input
type="date"
placeholder="Arrival From"
value={filters.arrival_from || ""}
onChange={(e) => setFilters({ ...filters, arrival_from: e.target.value })}
className="pl-8 bg-gray-800 border-gray-700 text-white focus:border-[#9B2335]"
/>
</div>
<div className="relative flex-1 min-w-[200px]">
<Calendar className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500" />
<Input
type="date"
placeholder="Arrival To"
value={filters.arrival_to || ""}
onChange={(e) => setFilters({ ...filters, arrival_to: e.target.value })}
className="pl-8 bg-gray-800 border-gray-700 text-white focus:border-[#9B2335]"
/>
</div>
<div className="flex gap-2">
<Button onClick={handleSearch} className="whitespace-nowrap bg-[#9B2335] hover:bg-[#9B2335]/90">
Apply Filters
</Button>
<Button
variant="outline"
onClick={resetFilters}
className="whitespace-nowrap border-gray-700 text-gray-300 hover:bg-gray-800 bg-gray-900"
>
<X className="mr-1 h-4 w-4" /> Reset
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Bulk Actions */}
{selectedMigrants.length > 0 && (
<div className="flex items-center gap-2 mb-4">
<span className="text-sm font-medium text-gray-400">{selectedMigrants.length} migrants selected</span>
<Button
variant="destructive"
size="sm"
className="ml-auto bg-red-600 hover:bg-red-700"
onClick={() => {
setIsBulkDelete(true)
setDeleteDialogIds(selectedMigrants.map((id) => id.toString()))
setDeleteDialogOpen(true)
}}
>
<Trash2 className="mr-1 h-4 w-4" /> Delete Selected
</Button>
</div>
)}
{/* Table */}
<Card className="border border-gray-800 bg-gray-900 shadow-2xl py-0">
<CardContent className="p-0">
{loading ? (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#9B2335]"></div>
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-gray-800/50 hover:bg-gray-800/50 border-b border-gray-800">
<TableHead className="w-[50px] text-center text-gray-300">
<Checkbox
checked={isAllSelected}
onCheckedChange={toggleSelectAll}
className="border-gray-600 data-[state=checked]:bg-[#9B2335] data-[state=checked]:border-[#9B2335]"
/>
</TableHead>
<TableHead className="text-gray-300">ID</TableHead>
<TableHead className="text-gray-300">Full Name</TableHead>
<TableHead className="text-gray-300">Date of Birth</TableHead>
<TableHead className="text-gray-300">Place of Birth</TableHead>
<TableHead className="text-gray-300">Occupation</TableHead>
<TableHead className="text-gray-300">Date of Arrival NT</TableHead>
<TableHead className="text-right text-gray-300">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{migrants.map((migrant) => {
const isSelected =
migrant.person_id !== undefined &&
typeof migrant.person_id === "number" &&
selectedMigrants.includes(migrant.person_id)
const hasValidId =
migrant.person_id !== undefined &&
typeof migrant.person_id === "number" &&
!isNaN(migrant.person_id)
return (
<TableRow
key={migrant.person_id}
className={`border-b border-gray-800 hover:bg-gray-800/30 ${
isSelected ? "bg-[#9B2335]/10" : ""
}`}
>
<TableCell className="text-center">
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleSelectMigrant(migrant.person_id)}
disabled={!hasValidId}
className="border-gray-600 data-[state=checked]:bg-[#9B2335] data-[state=checked]:border-[#9B2335]"
/>
</TableCell>
<TableCell className="font-medium text-white">{migrant.person_id ?? "—"}</TableCell>
<TableCell className="text-gray-300">{migrant.full_name ?? "—"}</TableCell>
<TableCell className="text-gray-300">{formatDate(migrant.date_of_birth ?? "—")}</TableCell>
<TableCell className="text-gray-300">{migrant.place_of_birth ?? "—"}</TableCell>
<TableCell className="text-gray-300">{migrant.occupation ?? "—"}</TableCell>
<TableCell className="text-gray-300">
{formatDate(migrant.migration?.date_of_arrival_nt ?? "—")}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-blue-400 hover:text-blue-300 hover:bg-blue-900/20"
onClick={() => handleEdit(migrant.person_id as number)}
>
<Pencil className="w-4 h-4 mr-1" /> Edit
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-red-400 hover:text-red-300 hover:bg-red-900/20"
onClick={() => {
if (migrant.person_id) {
setIsBulkDelete(false)
setDeleteDialogIds([migrant.person_id.toString()])
setDeleteDialogOpen(true)
}
}}
>
<Trash2 className="w-4 h-4 mr-1" /> Delete
</Button>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* Pagination */}
<div className="flex justify-center items-center gap-3 py-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
disabled={currentPage === 1 || showAllActive}
className="border-gray-700 text-gray-300 hover:bg-gray-800 hover:text-white disabled:opacity-50 bg-gray-900"
>
Previous
</Button>
<span className="text-sm font-medium text-white">
{showAllActive ? "Showing all records" : `Page ${currentPage} of ${totalPages}`}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.min(prev + 1, totalPages))}
disabled={currentPage === totalPages || showAllActive}
className="border-gray-700 text-gray-300 hover:bg-gray-800 hover:text-white disabled:opacity-50 bg-gray-900"
>
Next
</Button>
<Button
variant={showAllActive ? "default" : "outline"}
size="sm"
onClick={() => {
setShowAllActive(!showAllActive);
if (!showAllActive) {
// When activating show all
setCurrentPage(1);
}
// Trigger refetch with new page size
refetchMigrants();
}}
className={`${showAllActive ? "bg-[#9B2335] hover:bg-[#9B2335]/90" : "border-gray-700 bg-gray-900"} text-white hover:bg-gray-800 hover:text-white shadow-sm`}
>
{showAllActive ? "Show Paged" : "Show All"}
</Button>
</div>
{/* Delete Confirmation Dialog */}
<DeleteDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
bulkDelete={isBulkDelete}
selectedCount={deleteDialogIds.length}
ids={deleteDialogIds}
onDeleteSuccess={handleBulkDelete}
/>
</div>
)
}
export default MigrantsTable

View File

@ -1,41 +0,0 @@
// InterneeDetailsTab.jsx
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
export function InterneeDetailsTab() {
return (
<Card>
<CardHeader>
<CardTitle className="text-xl font-serif">Internee Details</CardTitle>
<CardDescription>Information about internment (if applicable)</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="internedIn">Interned In</Label>
<Input id="internedIn" placeholder="Location of internment" className="border-neutral-300" />
</div>
<div className="space-y-2">
<Label htmlFor="sentTo">Sent To</Label>
<Input id="sentTo" placeholder="Destination after internment" className="border-neutral-300" />
</div>
<div className="space-y-2">
<Label htmlFor="interneeOccupation">Internee Occupation</Label>
<Input id="interneeOccupation" placeholder="Occupation during internment" className="border-neutral-300" />
</div>
<div className="space-y-2">
<Label htmlFor="interneeAddress">Internee Address</Label>
<Textarea
id="interneeAddress"
placeholder="Address during internment"
className="min-h-[5rem] border-neutral-300"
/>
</div>
</CardContent>
</Card>
);
}

View File

@ -1,35 +0,0 @@
// LocationsTab.jsx
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
export function LocationsTab() {
return (
<Card>
<CardHeader>
<CardTitle className="text-xl font-serif">Location Information</CardTitle>
<CardDescription>Check all applicable locations</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center space-x-2">
<Checkbox id="darwin" />
<Label htmlFor="darwin">Darwin</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox id="katherine" />
<Label htmlFor="katherine">Katherine</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox id="tennantCreek" />
<Label htmlFor="tennantCreek">Tennant Creek</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox id="aliceSprings" />
<Label htmlFor="aliceSprings">Alice Springs</Label>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -1,123 +0,0 @@
// MigrationDetailsTab.jsx
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Calendar } from "lucide-react";
export function MigrationDetailsTab({ formData, handleInputChange }: {
formData: {
date_of_arrival_australia: string
date_of_arrival_nt: string
date_of_naturalisation: string
corps_issued: string
no_of_cert: string
issued_at: string
}
handleInputChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void
}) {
return (
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-xl font-serif">Migration Details</CardTitle>
<CardDescription>Information about the migration journey</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="date_of_arrival_australia">Date of Arrival in Australia</Label>
<div className="relative">
<Calendar className="absolute left-3 top-2.5 size-5 text-neutral-500" />
<Input
id="date_of_arrival_australia"
value={formData.date_of_arrival_australia}
type="date"
className="pl-10 border-neutral-300"
onChange={handleInputChange}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="date_of_arrival_nt">Date of Arrival in NT *</Label>
<div className="relative">
<Calendar className="absolute left-3 top-2.5 size-5 text-neutral-500" />
<Input
id="date_of_arrival_nt"
value={formData.date_of_arrival_nt}
type="date"
className="pl-10 border-neutral-300"
onChange={handleInputChange}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="date_of_naturalisation">Date of Naturalisation</Label>
<div className="relative">
<Calendar className="absolute left-3 top-2.5 size-5 text-neutral-500" />
<Input
id="date_of_naturalisation"
value={formData.date_of_naturalisation}
type="date"
className="pl-10 border-neutral-300"
onChange={handleInputChange}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="arrival_period">Arrival Period (auto-generated)</Label>
<Input
id="arrival_period"
placeholder="This will be auto-generated"
className="border-neutral-300 bg-neutral-100"
disabled
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-xl font-serif">Additional Migration Info</CardTitle>
<CardDescription>More details about migration history</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="corps_issued">Corps Issued</Label>
<Input
id="corps_issued"
value={formData.corps_issued}
placeholder="Corps Issued information"
className="border-neutral-300"
onChange={handleInputChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="no_of_cert">Number of Certificate</Label>
<Input
id="no_of_cert"
value={formData.no_of_cert}
placeholder="Certificate number"
className="border-neutral-300"
onChange={handleInputChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="issued_at">Issued At</Label>
<Input
id="issued_at"
value={formData.issued_at}
placeholder="Place of issuance"
className="border-neutral-300"
onChange={handleInputChange}
/>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,138 +1,783 @@
import { useState, forwardRef, useImperativeHandle } from "react"; "use client"
import { Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { PersonalInfoTab } from "./PersonalInfoTab"; import type React from "react"
import { MigrationDetailsTab } from "./MigrationDetailsTab";
import { LocationsTab } from "./LocationsTab";
import { InterneeDetailsTab } from "./InterneeDetailsTab";
import { PhotosTab } from "./PhotosTab";
import { NotesTab } from "./NotesTab";
type FormDataType = { import { useState, useEffect } from "react"
surname: string; import { useParams, useNavigate } from "react-router-dom"
christian_name: string; import { Button } from "@/components/ui/button"
full_name: string; import { Input } from "@/components/ui/input"
date_of_birth: string; import { Textarea } from "@/components/ui/textarea"
date_of_death: string; import { Progress } from "@/components/ui/progress"
place_of_birth: string; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
home_at_death: string; import { Label } from "@/components/ui/label"
occupation: string; import { Badge } from "@/components/ui/badge"
names_of_parents: string; import {
names_of_children: string; ChevronLeft,
data_source: string; ChevronRight,
reference: string; Save,
cav: string; Edit3,
id_card_no: string; User,
date_of_arrival_australia: string; MapPin,
date_of_arrival_nt: string; FileText,
date_of_naturalisation: string; Home,
corps_issued: string; Users,
no_of_cert: string; Shield,
issued_at: string; Camera,
}; } from "lucide-react"
import apiService from "@/services/apiService"
import { useTabsPaneFormSubmit } from "@/hooks/useTabsPaneFormSubmit"
import { showSuccessToast, showErrorToast, showUpdateItemToast } from "@/utils/toast"
type MigrantFormProps = { // Import confirmation modals
initialData?: Partial<FormDataType>; import AddDialog from "@/components/admin/migrant/Modal/AddDialog"
mode?: "add" | "edit"; import UpdateDialog from "@/components/admin/migrant/Modal/UpdateDialog"
onSubmit: (formData: FormDataType) => Promise<void>;
};
export type MigrantFormRef = { // Import step components
resetForm: () => void; import PersonDetailsStep from "@/components/admin/migrant/form-steps/PersonDetailsStep"
}; import MigrationInfoStep from "@/components/admin/migrant/form-steps/MigrationInfoStep"
import NaturalizationStep from "@/components/admin/migrant/form-steps/NaturalizationStep"
import ResidenceStep from "@/components/admin/migrant/form-steps/ResidenceStep"
import FamilyStep from "@/components/admin/migrant/form-steps/FamilyStep"
import InternmentStep from "@/components/admin/migrant/form-steps/InternmentStep"
import PhotosStep from "@/components/admin/migrant/form-steps/PhotosStep"
const MigrantForm = forwardRef<MigrantFormRef, MigrantFormProps>(function MigrantForm( const API_BASE_URL = "http://localhost:8000"
{
initialData = {}, const steps = ["Person Details", "Migration Info", "Naturalization", "Residence", "Family", "Internment", "Photos"]
mode = "add",
onSubmit, const stepDescriptions = [
}: MigrantFormProps, "Basic personal details and identification information",
ref "Immigration and arrival information in Australia",
) { "Citizenship and naturalization records",
const defaultFormData: FormDataType = { "Residential history and location details",
"Family members and relationship information",
"Internment and detention records",
"Upload photos and supporting documents",
]
const stepIcons = [User, MapPin, FileText, Home, Users, Shield, Camera]
// Type definitions for better type safety
interface PersonData {
surname: string
christian_name: string
date_of_birth: string
place_of_birth: string
date_of_death: string
occupation: string
additional_notes: string
reference: string
id_card_no: string
}
interface MigrationData {
date_of_arrival_aus: string
date_of_arrival_nt: string
arrival_period: string
data_source: string
}
interface NaturalizationData {
date_of_naturalisation: string
no_of_cert: string
issued_at: string
}
interface ResidenceData {
town_or_city: string
home_at_death: string
}
interface FamilyData {
names_of_parents: string
names_of_children: string
}
interface InternmentData {
corps_issued: string
interned_in: string
sent_to: string
internee_occupation: string
internee_address: string
cav: string
}
interface ExistingPhoto {
id: number
caption?: string
is_profile_photo?: boolean
filename?: string
url?: string
}
const StepperForm = () => {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const isEditMode = Boolean(id)
// Form state
const [currentStep, setCurrentStep] = useState(0)
const [loading, setLoading] = useState(false)
const [initialDataLoaded, setInitialDataLoaded] = useState(false)
// Modal states
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
// Initial state functions
const getInitialPersonState = (): PersonData => ({
surname: "", surname: "",
christian_name: "", christian_name: "",
full_name: "",
date_of_birth: "", date_of_birth: "",
date_of_death: "",
place_of_birth: "", place_of_birth: "",
home_at_death: "", date_of_death: "",
occupation: "", occupation: "",
names_of_parents: "", additional_notes: "",
names_of_children: "",
data_source: "",
reference: "", reference: "",
cav: "",
id_card_no: "", id_card_no: "",
date_of_arrival_australia: "", })
const getInitialMigrationState = (): MigrationData => ({
date_of_arrival_aus: "",
date_of_arrival_nt: "", date_of_arrival_nt: "",
arrival_period: "",
data_source: "",
})
const getInitialNaturalizationState = (): NaturalizationData => ({
date_of_naturalisation: "", date_of_naturalisation: "",
corps_issued: "",
no_of_cert: "", no_of_cert: "",
issued_at: "", issued_at: "",
...initialData, })
};
const [formData, setFormData] = useState<FormDataType>(defaultFormData); const getInitialResidenceState = (): ResidenceData => ({
town_or_city: "",
home_at_death: "",
})
useImperativeHandle(ref, () => ({ const getInitialFamilyState = (): FamilyData => ({
resetForm: () => setFormData(defaultFormData) names_of_parents: "",
})); names_of_children: "",
})
const getInitialInternmentState = (): InternmentData => ({
corps_issued: "",
interned_in: "",
sent_to: "",
internee_occupation: "",
internee_address: "",
cav: "",
})
// Form data state
const [person, setPerson] = useState<PersonData>(getInitialPersonState())
const [migration, setMigration] = useState<MigrationData>(getInitialMigrationState())
const [naturalization, setNaturalization] = useState<NaturalizationData>(getInitialNaturalizationState())
const [residence, setResidence] = useState<ResidenceData>(getInitialResidenceState())
const [family, setFamily] = useState<FamilyData>(getInitialFamilyState())
const [internment, setInternment] = useState<InternmentData>(getInitialInternmentState())
// Photo state
const [photos, setPhotos] = useState<File[]>([])
const [photoPreviews, setPhotoPreviews] = useState<string[]>([])
const [captions, setCaptions] = useState<string[]>([])
const [mainPhotoIndex, setMainPhotoIndex] = useState<number | null>(null)
const [existingPhotos, setExistingPhotos] = useState<ExistingPhoto[]>([])
// Load existing data when in edit mode
useEffect(() => {
if (isEditMode && id && !initialDataLoaded) {
loadExistingData()
}
}, [id, isEditMode, initialDataLoaded])
// Helper function to format date from API format to yyyy-MM-dd
const formatDate = (dateString: string | null | undefined): string => {
if (!dateString) return ""
try {
const date = new Date(dateString)
return date.toISOString().split("T")[0]
} catch (error) {
console.error("Error formatting date:", error)
return ""
}
}
const loadExistingData = async () => {
try {
setLoading(true)
const personId = Number.parseInt(id!, 10)
if (isNaN(personId)) {
throw new Error("Invalid person ID")
}
const migrantData = await apiService.getMigrantById(personId)
// Populate person data
setPerson({
surname: migrantData.surname || "",
christian_name: migrantData.christian_name || "",
date_of_birth: formatDate(migrantData.date_of_birth),
place_of_birth: migrantData.place_of_birth || "",
date_of_death: formatDate(migrantData.date_of_death),
occupation: migrantData.occupation || "",
additional_notes: migrantData.additional_notes || "",
reference: migrantData.reference || "",
id_card_no: migrantData.id_card_no || "",
})
// Populate migration data
if (migrantData.migration) {
setMigration({
date_of_arrival_aus: formatDate(migrantData.migration.date_of_arrival_aus),
date_of_arrival_nt: formatDate(migrantData.migration.date_of_arrival_nt),
arrival_period: migrantData.migration.arrival_period || "",
data_source: migrantData.migration.data_source || "",
})
}
// Populate naturalization data
if (migrantData.naturalization) {
setNaturalization({
date_of_naturalisation: formatDate(migrantData.naturalization.date_of_naturalisation),
no_of_cert: migrantData.naturalization.no_of_cert || "",
issued_at: migrantData.naturalization.issued_at || "",
})
}
// Populate residence data
if (migrantData.residence) {
setResidence({
town_or_city: migrantData.residence.town_or_city || "",
home_at_death: migrantData.residence.home_at_death || "",
})
}
// Populate family data
if (migrantData.family) {
setFamily({
names_of_parents: migrantData.family.names_of_parents || "",
names_of_children: migrantData.family.names_of_children || "",
})
}
// Populate internment data
if (migrantData.internment) {
setInternment({
corps_issued: migrantData.internment.corps_issued || "",
interned_in: migrantData.internment.interned_in || "",
sent_to: migrantData.internment.sent_to || "",
internee_occupation: migrantData.internment.internee_occupation || "",
internee_address: migrantData.internment.internee_address || "",
cav: migrantData.internment.cav || "",
})
}
// Handle existing photos with better logic
if (migrantData.photos && Array.isArray(migrantData.photos) && migrantData.photos.length > 0) {
const photoData = migrantData.photos as ExistingPhoto[]
setExistingPhotos(photoData)
// Set up captions array
const existingCaptions = photoData.map((photo: ExistingPhoto) => photo.caption || "")
setCaptions(existingCaptions)
// Find and set the main photo index
const mainPhotoIdx = photoData.findIndex((photo: ExistingPhoto) => photo.is_profile_photo)
if (mainPhotoIdx !== -1) {
setMainPhotoIndex(mainPhotoIdx)
}
}
setInitialDataLoaded(true)
} catch (error) {
console.error("Error loading migrant data:", error)
showErrorToast("Failed to load migrant data for editing.")
} finally {
setLoading(false)
}
}
const resetForm = () => {
setPerson(getInitialPersonState())
setMigration(getInitialMigrationState())
setNaturalization(getInitialNaturalizationState())
setResidence(getInitialResidenceState())
setFamily(getInitialFamilyState())
setInternment(getInitialInternmentState())
setPhotos([])
setPhotoPreviews([])
setCaptions([])
setMainPhotoIndex(null)
setExistingPhotos([])
setInitialDataLoaded(false)
}
const handlePhotoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const selectedFiles = Array.from(e.target.files)
// Add new photos
setPhotos((prev) => [...prev, ...selectedFiles])
// Create previews for new photos
const newPreviews = selectedFiles.map((file) => URL.createObjectURL(file))
setPhotoPreviews((prev) => [...prev, ...newPreviews])
// Add captions for new photos
setCaptions((prev) => {
const newCaptions = [...prev]
// Add empty captions for new photos
for (let i = 0; i < selectedFiles.length; i++) {
newCaptions.push("")
}
return newCaptions
})
// Set main photo index if none exists and we have photos
if (mainPhotoIndex === null && (existingPhotos.length > 0 || selectedFiles.length > 0)) {
setMainPhotoIndex(0)
}
}
// Reset the file input
e.target.value = ""
}
const removeExistingPhoto = (index: number) => {
const wasMainPhoto = mainPhotoIndex === index
// Remove the photo from existing photos
setExistingPhotos((prev) => prev.filter((_, i) => i !== index))
// Remove corresponding caption
setCaptions((prev) => {
const newCaptions = [...prev]
newCaptions.splice(index, 1)
return newCaptions
})
// Handle main photo index adjustment
if (wasMainPhoto) {
// If we're removing the main photo, try to set another photo as main
const totalRemainingPhotos = existingPhotos.length - 1 + photos.length
if (totalRemainingPhotos > 0) {
// Set the first remaining photo as main
setMainPhotoIndex(0)
} else {
setMainPhotoIndex(null)
}
} else if (mainPhotoIndex !== null && mainPhotoIndex > index) {
// Adjust main photo index if it's after the removed photo
setMainPhotoIndex(mainPhotoIndex - 1)
}
}
const removeNewPhoto = (photoIndex: number) => {
const totalIndex = existingPhotos.length + photoIndex
const wasMainPhoto = mainPhotoIndex === totalIndex
// Remove the photo and its preview
setPhotos((prev) => prev.filter((_, i) => i !== photoIndex))
setPhotoPreviews((prev) => prev.filter((_, i) => i !== photoIndex))
// Remove corresponding caption from the total captions array
setCaptions((prev) => {
const newCaptions = [...prev]
newCaptions.splice(totalIndex, 1)
return newCaptions
})
// Handle main photo index adjustment
if (wasMainPhoto) {
// If we're removing the main photo, try to set another photo as main
const totalRemainingPhotos = existingPhotos.length + photos.length - 1
if (totalRemainingPhotos > 0) {
// Set the first remaining photo as main
setMainPhotoIndex(0)
} else {
setMainPhotoIndex(null)
}
} else if (mainPhotoIndex !== null && mainPhotoIndex > totalIndex) {
// Adjust main photo index if it's after the removed photo
setMainPhotoIndex(mainPhotoIndex - 1)
}
}
const submitForm = async () => {
try {
const formData = new FormData()
// Add person data
console.log('Person data:', person);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { Object.entries(person).forEach(([key, value]) => {
const { id, value } = e.target; if (value) formData.append(key, value)
setFormData((prev) => ({ ...prev, [id]: value })); })
};
const handleSubmit = async (e: React.FormEvent) => { // Add migration data
e.preventDefault(); console.log('Migration data:', migration);
await onSubmit(formData); if (Object.values(migration).some(v => v)) {
}; Object.entries(migration).forEach(([key, value]) => {
if (value) formData.append(`migration[${key}]`, value)
})
}
// Add naturalization data
if (Object.values(naturalization).some(v => v)) {
Object.entries(naturalization).forEach(([key, value]) => {
if (value) formData.append(`naturalization[${key}]`, value)
})
}
// Add residence data
console.log('Residence data:', residence);
if (Object.values(residence).some(v => v)) {
Object.entries(residence).forEach(([key, value]) => {
if (value) formData.append(`residence[${key}]`, value)
})
}
// Add family data
console.log('Family data:', family);
if (Object.values(family).some(v => v)) {
Object.entries(family).forEach(([key, value]) => {
if (value) formData.append(`family[${key}]`, value)
})
}
// Add internment data
console.log('Internment data:', internment);
if (Object.values(internment).some(v => v)) {
Object.entries(internment).forEach(([key, value]) => {
if (value) formData.append(`internment[${key}]`, value)
})
}
// Add new photos
console.log('Photos:', photos);
photos.forEach((photo) => {
formData.append('photos[]', photo)
})
// Add captions
console.log('Captions:', captions);
captions.forEach((caption, index) => {
if (caption) {
formData.append(`captions[${index}]`, caption)
}
})
// Handle main photo logic
console.log('Main photo index:', mainPhotoIndex);
if (mainPhotoIndex !== null) {
formData.append('set_as_profile', 'true')
console.log('Existing photos:', existingPhotos);
if (mainPhotoIndex < existingPhotos.length) {
// Main photo is an existing photo
const existingPhoto = existingPhotos[mainPhotoIndex]
formData.append('profile_photo_id', existingPhoto.id.toString())
} else {
// Main photo is a new photo
const newPhotoIndex = mainPhotoIndex - existingPhotos.length
console.log('New photo index:', newPhotoIndex);
formData.append('profile_photo_index', newPhotoIndex.toString())
}
}
// Handle existing photo updates (captions)
console.log('Existing photos:', existingPhotos);
existingPhotos.forEach((photo, index) => {
formData.append(`existing_photos[${index}][id]`, photo.id.toString())
if (captions[index]) {
formData.append(`existing_photos[${index}][caption]`, captions[index])
}
})
let response
if (isEditMode && id) {
console.log('Updating migrant:', id);
response = await apiService.updateMigrant(parseInt(id), formData)
} else {
console.log('Creating new migrant');
response = await apiService.createMigrant(formData)
}
return response
} catch (error) {
console.error('Form submission error:', error)
throw error
}
}
const handleSubmit = () => {
if (!isSubmitting) {
if (isEditMode) {
setIsUpdateDialogOpen(true)
} else {
setIsAddDialogOpen(true)
}
}
}
const handleConfirmSubmit = async () => {
if (!isSubmitting) {
try {
setIsSubmitting(true)
const response = await submitForm()
if (isEditMode) {
showUpdateItemToast(`Migrant ${person.surname}, ${person.christian_name}`, () => {
navigate(`/admin/migrants`)
})
} else {
showSuccessToast(() => {
navigate(`/admin/migrants`)
})
resetForm()
}
return response
} catch (error) {
console.error("Form submission error:", error)
showErrorToast("There was a problem saving the migrant data. Please try again.")
} finally {
setIsSubmitting(false)
setIsAddDialogOpen(false)
setIsUpdateDialogOpen(false)
}
}
}
const renderFormField = (
label: string,
value: string,
onChange: (value: string) => void,
type = "text",
placeholder?: string,
required?: boolean,
) => (
<div className="space-y-2">
<Label htmlFor={label.toLowerCase().replace(/\s+/g, "-")} className="text-sm font-medium text-gray-300">
{label}
{required && <span className="text-red-400 ml-1">*</span>}
</Label>
{type === "textarea" ? (
<Textarea
id={label.toLowerCase().replace(/\s+/g, "-")}
placeholder={placeholder || label}
value={value}
onChange={(e) => onChange(e.target.value)}
className="min-h-[100px] resize-none bg-gray-800 border-gray-700 text-white placeholder:text-gray-500 focus:border-[#9B2335] focus:ring-[#9B2335]"
/>
) : (
<Input
id={label.toLowerCase().replace(/\s+/g, "-")}
type={type}
placeholder={placeholder || label}
value={value}
onChange={(e) => onChange(e.target.value)}
className="bg-gray-800 border-gray-700 text-white placeholder:text-gray-500 focus:border-[#9B2335] focus:ring-[#9B2335]"
/>
)}
</div>
)
const renderStepContent = () => {
switch (currentStep) {
case 0:
return <PersonDetailsStep person={person} setPerson={setPerson} renderFormField={renderFormField} />
case 1:
return <MigrationInfoStep migration={migration} setMigration={setMigration} renderFormField={renderFormField} />
case 2:
return (
<NaturalizationStep
naturalization={naturalization}
setNaturalization={setNaturalization}
renderFormField={renderFormField}
/>
)
case 3:
return <ResidenceStep residence={residence} setResidence={setResidence} renderFormField={renderFormField} />
case 4:
return <FamilyStep family={family} setFamily={setFamily} renderFormField={renderFormField} />
case 5:
return (
<InternmentStep internment={internment} setInternment={setInternment} renderFormField={renderFormField} />
)
case 6:
return (
<PhotosStep
photos={photos}
photoPreviews={photoPreviews}
captions={captions}
existingPhotos={existingPhotos}
mainPhotoIndex={mainPhotoIndex}
API_BASE_URL={API_BASE_URL}
handlePhotoChange={handlePhotoChange}
removeExistingPhoto={removeExistingPhoto}
removeNewPhoto={removeNewPhoto}
setCaptions={setCaptions}
setMainPhotoIndex={setMainPhotoIndex}
/>
)
default:
return null
}
}
if (loading && isEditMode && !initialDataLoaded) {
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
<Card className="w-full max-w-md bg-gray-900 border-gray-800">
<CardContent className="p-6 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#9B2335] mx-auto mb-4"></div>
<p className="text-gray-400">Loading form data...</p>
</CardContent>
</Card>
</div>
)
}
return ( return (
<form onSubmit={handleSubmit}> <div className="max-w-6xl mx-auto mt-8 mb-24 p-4">
<Tabs defaultValue="personal" className="mb-8"> <Card className="shadow-2xl border border-gray-800 bg-gray-900/50 overflow-hidden backdrop-blur-sm">
<TabsList className="bg-neutral-100 mb-6"> <CardHeader className="bg-gray-800 p-8 border-b border-gray-700">
<TabsTrigger value="personal">Personal Information</TabsTrigger> <div className="flex justify-between items-start mb-6">
<TabsTrigger value="migration">Migration Details</TabsTrigger> <div>
<TabsTrigger value="locations">Locations</TabsTrigger> <CardTitle className="text-xl md:text-2xl font-bold text-white">
<TabsTrigger value="internee">Internee Details</TabsTrigger> {isEditMode ? `Edit ${steps[currentStep]}` : steps[currentStep]}
<TabsTrigger value="photos">Photos & Documents</TabsTrigger> </CardTitle>
<TabsTrigger value="notes">Additional Notes</TabsTrigger> <CardDescription className="text-gray-400 mt-1">{stepDescriptions[currentStep]}</CardDescription>
</TabsList> </div>
<Badge variant="outline" className="text-sm border-gray-700 text-gray-300">
Step {currentStep + 1} of {steps.length}
</Badge>
</div>
<TabsContent value="personal"> <div className="mt-6">
<PersonalInfoTab formData={formData} handleInputChange={handleInputChange} /> <div className="flex justify-between text-sm text-gray-400 mb-2">
</TabsContent> <span>Progress</span>
<TabsContent value="migration"> <span>{Math.round((currentStep / (steps.length - 1)) * 100)}% Complete</span>
<MigrationDetailsTab formData={formData} handleInputChange={handleInputChange} /> </div>
</TabsContent> <Progress
<TabsContent value="locations"> value={(currentStep / (steps.length - 1)) * 100}
<LocationsTab /> className="h-2 bg-gray-800"
</TabsContent> style={
<TabsContent value="internee"> {
<InterneeDetailsTab /> "--progress-foreground": "#9B2335",
</TabsContent> } as React.CSSProperties
<TabsContent value="photos"> }
<PhotosTab /> />
</TabsContent> </div>
<TabsContent value="notes">
<NotesTab />
</TabsContent>
</Tabs>
<div className="flex justify-between items-center"> {/* Step indicators */}
<Button variant="outline" type="button">Save as Draft</Button> <div className="flex justify-between mt-4 overflow-x-auto">
<Button type="submit" className="bg-green-700 hover:bg-green-800"> {steps.map((step, index) => {
<Save className="mr-2 size-4" /> const StepIcon = stepIcons[index]
{mode === "edit" ? "Update Migrant Record" : "Save Migrant Record"} return (
</Button> <div key={index} className="flex flex-col items-center min-w-0 flex-1">
</div> <div
</form> className={`w-10 h-10 rounded-full flex items-center justify-center mb-2 transition-all duration-200 ${
); index <= currentStep
}); ? "bg-[#9B2335] text-white shadow-lg"
: "bg-gray-800 text-gray-500 border border-gray-700"
}`}
>
<StepIcon className="w-5 h-5" />
</div>
<span
className={`text-xs text-center px-1 transition-colors duration-200 ${
index <= currentStep ? "text-[#9B2335] font-medium" : "text-gray-500"
}`}
>
{step}
</span>
</div>
)
})}
</div>
</CardHeader>
export default MigrantForm; <CardContent className="p-8 bg-gray-900">{renderStepContent()}</CardContent>
<div className="bg-gray-800 px-8 py-6 border-t border-gray-700">
<div className="flex justify-between items-center">
<Button
variant="outline"
disabled={currentStep === 0}
onClick={() => setCurrentStep((prev) => prev - 1)}
className="flex items-center gap-2 border-gray-700 text-gray-300 hover:bg-gray-700 hover:text-white disabled:opacity-50"
>
<ChevronLeft className="w-4 h-4" />
Previous
</Button>
<div className="flex items-center gap-2 text-sm text-gray-400">
<span>
Step {currentStep + 1} of {steps.length}
</span>
</div>
{currentStep < steps.length - 1 ? (
<Button
onClick={() => setCurrentStep((prev) => prev + 1)}
className="flex items-center gap-2 bg-[#9B2335] hover:bg-[#9B2335]/90 shadow-lg"
>
Next
<ChevronRight className="w-4 h-4" />
</Button>
) : (
<Button
onClick={handleSubmit}
disabled={isSubmitting}
className="flex items-center gap-2 bg-green-600 hover:bg-green-700 shadow-lg"
>
{isSubmitting ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Saving...
</>
) : (
<>
<Save className="w-4 h-4" />
{isEditMode ? "Update Record" : "Submit Form"}
</>
)}
</Button>
)}
</div>
</div>
</Card>
{/* Confirmation Dialogs */}
<AddDialog
open={isAddDialogOpen}
onOpenChange={setIsAddDialogOpen}
onConfirm={handleConfirmSubmit}
isSubmitting={isSubmitting}
/>
<UpdateDialog
open={isUpdateDialogOpen}
onOpenChange={setIsUpdateDialogOpen}
onConfirm={handleConfirmSubmit}
isSubmitting={isSubmitting}
/>
</div>
)
}
export default StepperForm

View File

@ -10,7 +10,7 @@ import {
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { useState } from "react" import { useState } from "react"
import apiService from "@/services/apiService" import apiService from "@/services/apiService"
import { toast } from "sonner" import { showDeleteToast, showBulkDeleteToast, showErrorToast } from "@/utils/toast"
interface DeleteDialogProps { interface DeleteDialogProps {
open: boolean open: boolean
@ -32,38 +32,30 @@ export default function DeleteDialog({
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const handleDelete = async () => { const handleDelete = async () => {
// Validate that we have IDs to delete
if (!ids.length) { if (!ids.length) {
toast.error("No records to delete", {
description: "Could not find valid IDs for deletion."
})
onOpenChange(false) onOpenChange(false)
return return
} }
setLoading(true) setLoading(true)
try { try {
// Use Promise.all if bulk deleting await new Promise((resolve) => setTimeout(resolve, 1000)) // delay 1 second
if (bulkDelete) { if (bulkDelete) {
await Promise.all(ids.map(id => apiService.deletePerson(id))) await Promise.all(ids.map(id => apiService.deleteMigrant(id)))
toast.success(`${ids.length} records deleted`, { // Show bulk delete toast without undo functionality
description: "The selected migrant records have been removed." showBulkDeleteToast(selectedCount)
})
} else { } else {
await apiService.deletePerson(ids[0]) await apiService.deleteMigrant(ids[0])
toast.success("Record deleted", { // Show single delete toast without undo functionality
description: "The migrant record has been successfully removed." showDeleteToast("Migrant record")
})
} }
// Notify parent component about successful deletion
onDeleteSuccess?.() onDeleteSuccess?.()
onOpenChange(false) onOpenChange(false)
} catch (error: any) { } catch (error: any) {
console.error("Failed to delete record(s):", error) console.error("Failed to delete record(s):", error)
toast.error("Delete operation failed", { showErrorToast("Failed to delete record(s). Please try again.")
description: error.response?.data?.message || "An unexpected error occurred during deletion."
})
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@ -1,25 +0,0 @@
// NotesTab.jsx
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
export function NotesTab() {
return (
<Card>
<CardHeader>
<CardTitle className="text-xl font-serif">Additional Notes</CardTitle>
<CardDescription>Any other relevant information about this migrant</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="additionalNotes">Additional Notes</Label>
<Textarea
id="additionalNotes"
placeholder="Enter any additional information..."
className="min-h-[8rem] border-neutral-300"
/>
</div>
</CardContent>
</Card>
);
}

View File

@ -1,198 +0,0 @@
// PersonalInfoTab.jsx
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Calendar } from "lucide-react";
export function PersonalInfoTab({ formData, handleInputChange }: {
formData: {
surname: string
christian_name: string
full_name: string
date_of_birth: string
date_of_death: string
place_of_birth: string
home_at_death: string
occupation: string
names_of_parents: string
names_of_children: string
data_source: string
reference: string
cav: string
id_card_no: string
}
handleInputChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void
}) {
return (
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-xl font-serif">Basic Information</CardTitle>
<CardDescription>Enter the migrant's personal details</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="surname">Surname *</Label>
<Input
id="surname"
value={formData.surname}
placeholder="Surname"
className="border-neutral-300"
required
onChange={handleInputChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="christian_name">Christian Name *</Label>
<Input
id="christian_name"
value={formData.christian_name}
placeholder="Christian Name"
className="border-neutral-300"
required
onChange={handleInputChange}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="date_of_birth">Date of Birth</Label>
<div className="relative">
<Calendar className="absolute left-3 top-2.5 size-5 text-neutral-500" />
<Input
id="date_of_birth"
value={formData.date_of_birth}
type="date"
className="pl-10 border-neutral-300"
onChange={handleInputChange}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="date_of_death">Date of Death</Label>
<div className="relative">
<Calendar className="absolute left-3 top-2.5 size-5 text-neutral-500" />
<Input
id="date_of_death"
value={formData.date_of_death}
type="date"
className="pl-10 border-neutral-300"
onChange={handleInputChange}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="place_of_birth">Place of Birth</Label>
<Input
id="place_of_birth"
value={formData.place_of_birth}
placeholder="City, Region, Country"
className="border-neutral-300"
onChange={handleInputChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="home_at_death">Home at Death</Label>
<Input
id="home_at_death"
value={formData.home_at_death}
placeholder="Location at time of death"
className="border-neutral-300"
onChange={handleInputChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="occupation">Occupation</Label>
<Input
id="occupation"
value={formData.occupation}
placeholder="Primary occupation"
className="border-neutral-300"
onChange={handleInputChange}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-xl font-serif">Family Information</CardTitle>
<CardDescription>Information about family members</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="names_of_parents">Names of Parents</Label>
<Textarea
id="names_of_parents"
value={formData.names_of_parents}
placeholder="Enter parent names"
className="min-h-[5rem] border-neutral-300"
onChange={handleInputChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="names_of_children">Names of Children</Label>
<Textarea
id="names_of_children"
value={formData.names_of_children}
placeholder="Enter children names"
className="min-h-[5rem] border-neutral-300"
onChange={handleInputChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="data_source">Data Source</Label>
<Input
id="data_source"
value={formData.data_source}
placeholder="Source of information"
className="border-neutral-300"
onChange={handleInputChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="reference">Reference</Label>
<Input
id="reference"
value={formData.reference}
placeholder="Reference information"
className="border-neutral-300"
onChange={handleInputChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="cav">CAV</Label>
<Input
id="cav"
value={formData.cav}
placeholder="CAV information"
className="border-neutral-300"
onChange={handleInputChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="id_card_no">ID / Card No</Label>
<Input
id="id_card_no"
value={formData.id_card_no}
placeholder="ID or Card Number"
className="border-neutral-300"
onChange={handleInputChange}
/>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,107 +0,0 @@
// PhotosTab.jsx
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { ImageIcon, Upload, X } from "lucide-react";
import { useState } from "react";
export function PhotosTab() {
const [photos, setPhotos] = useState<string[]>([]);
const [mainPhotoIndex, setMainPhotoIndex] = useState<number | null>(null);
// Simulate photo upload
const handlePhotoUpload = () => {
// In a real app, this would handle file upload
const newPhoto = `/placeholder.svg?height=200&width=200`;
setPhotos([...photos, newPhoto]);
if (mainPhotoIndex === null) {
setMainPhotoIndex(photos.length);
}
};
const removePhoto = (index: number) => {
const newPhotos = [...photos];
newPhotos.splice(index, 1);
setPhotos(newPhotos);
if (mainPhotoIndex === index) {
setMainPhotoIndex(newPhotos.length > 0 ? 0 : null);
} else if (mainPhotoIndex !== null && mainPhotoIndex > index) {
setMainPhotoIndex(mainPhotoIndex - 1);
}
};
const setAsMainPhoto = (index: number) => {
setMainPhotoIndex(index);
};
return (
<Card>
<CardHeader>
<CardTitle className="text-xl font-serif">Photos & Documents</CardTitle>
<CardDescription>Upload photos and documents related to this migrant</CardDescription>
</CardHeader>
<CardContent>
<div className="mb-6">
<Button onClick={handlePhotoUpload} className="bg-green-700 hover:bg-green-800">
<Upload className="mr-2 size-4" /> Upload Photos
</Button>
</div>
{photos.length > 0 ? (
<div>
<Label className="mb-2 block">Photos ({photos.length})</Label>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{photos.map((photo, index) => (
<div
key={index}
className={`relative rounded-md border ${
mainPhotoIndex === index ? "border-green-600 ring-2 ring-green-200" : "border-neutral-200"
}`}
>
<img
src={photo || "/placeholder.svg"}
alt={`Migrant photo ${index + 1}`}
className="w-full h-40 object-cover rounded-t-md"
/>
<div className="p-2 bg-neutral-50 rounded-b-md">
<div className="flex justify-between items-center">
{mainPhotoIndex === index ? (
<span className="text-xs font-medium text-green-700">Main Photo</span>
) : (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() => setAsMainPhoto(index)}
>
<ImageIcon className="mr-1 size-3" /> Set as Main
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-7 text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => removePhoto(index)}
>
<X className="size-4" />
</Button>
</div>
</div>
</div>
))}
</div>
</div>
) : (
<div className="text-center py-12 border-2 border-dashed border-neutral-200 rounded-md">
<ImageIcon className="mx-auto size-12 text-neutral-400" />
<h3 className="mt-2 text-sm font-medium text-neutral-900">No photos uploaded</h3>
<p className="mt-1 text-sm text-neutral-500">
Upload photos of the migrant or related documents
</p>
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,49 @@
import React from "react"
interface FamilyStepProps {
family: {
names_of_parents: string
names_of_children: string
}
setFamily: React.Dispatch<
React.SetStateAction<{
names_of_parents: string
names_of_children: string
}>
>
renderFormField: (
label: string,
value: string,
onChange: (value: string) => void,
type?: string,
placeholder?: string,
required?: boolean
) => React.ReactNode
}
const FamilyStep: React.FC<FamilyStepProps> = ({
family,
setFamily,
renderFormField
}) => {
return (
<div className="space-y-6">
{renderFormField(
"Names of Parents",
family.names_of_parents,
(value) => setFamily({ ...family, names_of_parents: value }),
"textarea",
"Enter parent names (one per line or separated by commas)",
)}
{renderFormField(
"Names of Children",
family.names_of_children,
(value) => setFamily({ ...family, names_of_children: value }),
"textarea",
"Enter children names (one per line or separated by commas)",
)}
</div>
)
}
export default FamilyStep

View File

@ -0,0 +1,61 @@
import React from "react"
interface InternmentStepProps {
internment: {
corps_issued: string
interned_in: string
sent_to: string
internee_occupation: string
internee_address: string
cav: string
}
setInternment: React.Dispatch<
React.SetStateAction<{
corps_issued: string
interned_in: string
sent_to: string
internee_occupation: string
internee_address: string
cav: string
}>
>
renderFormField: (
label: string,
value: string,
onChange: (value: string) => void,
type?: string,
placeholder?: string,
required?: boolean
) => React.ReactNode
}
const InternmentStep: React.FC<InternmentStepProps> = ({
internment,
setInternment,
renderFormField
}) => {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{renderFormField("Corps Issued", internment.corps_issued, (value) =>
setInternment({ ...internment, corps_issued: value }),
)}
{renderFormField("Interned In", internment.interned_in, (value) =>
setInternment({ ...internment, interned_in: value }),
)}
{renderFormField("Sent To", internment.sent_to, (value) =>
setInternment({ ...internment, sent_to: value }),
)}
{renderFormField("Internee Occupation", internment.internee_occupation, (value) =>
setInternment({ ...internment, internee_occupation: value }),
)}
<div className="md:col-span-2">
{renderFormField("Internee Address", internment.internee_address, (value) =>
setInternment({ ...internment, internee_address: value }),
)}
</div>
{renderFormField("CAV", internment.cav, (value) => setInternment({ ...internment, cav: value }))}
</div>
)
}
export default InternmentStep

View File

@ -0,0 +1,57 @@
import React from "react"
interface MigrationInfoStepProps {
migration: {
date_of_arrival_aus: string
date_of_arrival_nt: string
arrival_period: string
data_source: string
}
setMigration: React.Dispatch<
React.SetStateAction<{
date_of_arrival_aus: string
date_of_arrival_nt: string
arrival_period: string
data_source: string
}>
>
renderFormField: (
label: string,
value: string,
onChange: (value: string) => void,
type?: string,
placeholder?: string,
required?: boolean
) => React.ReactNode
}
const MigrationInfoStep: React.FC<MigrationInfoStepProps> = ({
migration,
setMigration,
renderFormField
}) => {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{renderFormField(
"Date of Arrival in Australia",
migration.date_of_arrival_aus,
(value) => setMigration({ ...migration, date_of_arrival_aus: value }),
"date",
)}
{renderFormField(
"Date of Arrival in NT",
migration.date_of_arrival_nt,
(value) => setMigration({ ...migration, date_of_arrival_nt: value }),
"date",
)}
{renderFormField("Arrival Period", migration.arrival_period, (value) =>
setMigration({ ...migration, arrival_period: value }),
)}
{renderFormField("Data Source", migration.data_source, (value) =>
setMigration({ ...migration, data_source: value }),
)}
</div>
)
}
export default MigrationInfoStep

View File

@ -0,0 +1,51 @@
import React from "react"
interface NaturalizationStepProps {
naturalization: {
date_of_naturalisation: string
no_of_cert: string
issued_at: string
}
setNaturalization: React.Dispatch<
React.SetStateAction<{
date_of_naturalisation: string
no_of_cert: string
issued_at: string
}>
>
renderFormField: (
label: string,
value: string,
onChange: (value: string) => void,
type?: string,
placeholder?: string,
required?: boolean
) => React.ReactNode
}
const NaturalizationStep: React.FC<NaturalizationStepProps> = ({
naturalization,
setNaturalization,
renderFormField
}) => {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{renderFormField(
"Date of Naturalisation",
naturalization.date_of_naturalisation,
(value) => setNaturalization({ ...naturalization, date_of_naturalisation: value }),
"date",
)}
{renderFormField("Certificate Number", naturalization.no_of_cert, (value) =>
setNaturalization({ ...naturalization, no_of_cert: value }),
)}
<div className="md:col-span-2">
{renderFormField("Issued At", naturalization.issued_at, (value) =>
setNaturalization({ ...naturalization, issued_at: value }),
)}
</div>
</div>
)
}
export default NaturalizationStep

View File

@ -0,0 +1,94 @@
import React from "react"
interface PersonDetailsStepProps {
person: {
surname: string
christian_name: string
date_of_birth: string
place_of_birth: string
date_of_death: string
occupation: string
additional_notes: string
reference: string
id_card_no: string
}
setPerson: React.Dispatch<
React.SetStateAction<{
surname: string
christian_name: string
date_of_birth: string
place_of_birth: string
date_of_death: string
occupation: string
additional_notes: string
reference: string
id_card_no: string
}>
>
renderFormField: (
label: string,
value: string,
onChange: (value: string) => void,
type?: string,
placeholder?: string,
required?: boolean
) => React.ReactNode
}
const PersonDetailsStep: React.FC<PersonDetailsStepProps> = ({
person,
setPerson,
renderFormField
}) => {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{renderFormField(
"Surname",
person.surname,
(value) => setPerson({ ...person, surname: value }),
"text",
"Enter surname",
true,
)}
{renderFormField(
"Christian Name",
person.christian_name,
(value) => setPerson({ ...person, christian_name: value }),
"text",
"Enter first name",
true,
)}
{renderFormField(
"Date of Birth",
person.date_of_birth,
(value) => setPerson({ ...person, date_of_birth: value }),
"date",
)}
{renderFormField("Place of Birth", person.place_of_birth, (value) =>
setPerson({ ...person, place_of_birth: value }),
)}
{renderFormField(
"Date of Death",
person.date_of_death,
(value) => setPerson({ ...person, date_of_death: value }),
"date",
)}
{renderFormField("Occupation", person.occupation, (value) => setPerson({ ...person, occupation: value }))}
<div className="md:col-span-2">
{renderFormField(
"Additional Notes",
person.additional_notes,
(value) => setPerson({ ...person, additional_notes: value }),
"textarea",
"Enter any additional notes or comments",
)}
</div>
{renderFormField("Reference", person.reference, (value) => setPerson({ ...person, reference: value }))}
{renderFormField("ID Card Number", person.id_card_no, (value) =>
setPerson({ ...person, id_card_no: value }),
)}
</div>
)
}
export default PersonDetailsStep

View File

@ -0,0 +1,206 @@
import React from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Upload, X, ImageIcon } from "lucide-react"
interface PhotosStepProps {
photos: File[]
photoPreviews: string[]
captions: string[]
existingPhotos: any[]
mainPhotoIndex: number | null
API_BASE_URL: string
handlePhotoChange: (e: React.ChangeEvent<HTMLInputElement>) => void
removeExistingPhoto: (index: number) => void
removeNewPhoto: (index: number) => void
setCaptions: React.Dispatch<React.SetStateAction<string[]>>
setMainPhotoIndex: React.Dispatch<React.SetStateAction<number | null>>
}
const PhotosStep: React.FC<PhotosStepProps> = ({
photos,
photoPreviews,
captions,
existingPhotos,
mainPhotoIndex,
API_BASE_URL,
handlePhotoChange,
removeExistingPhoto,
removeNewPhoto,
setCaptions,
setMainPhotoIndex,
}) => {
return (
<div className="space-y-6">
{/* File Upload Section */}
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-gray-400 transition-colors">
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
<div className="space-y-2">
<Label htmlFor="photo-upload" className="text-lg font-medium text-gray-700 cursor-pointer">
Upload Photos & Documents
</Label>
<p className="text-sm text-gray-500">
Select multiple files to upload. Supported formats: JPG, PNG, PDF
</p>
<Input
id="photo-upload"
type="file"
multiple
accept="image/*"
onChange={handlePhotoChange}
className="hidden"
/>
<Button
variant="outline"
className="mt-4"
onClick={() => document.getElementById("photo-upload")?.click()}
>
<Upload className="w-4 h-4 mr-2" />
Choose Files
</Button>
</div>
</div>
{/* Existing Photos */}
{existingPhotos.length > 0 && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<ImageIcon className="w-5 h-5 text-gray-600" />
<h3 className="text-lg font-medium text-gray-900">Existing Photos</h3>
<Badge variant="secondary">{existingPhotos.length}</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{existingPhotos.map((photo, index) => (
<Card key={`existing-${photo.id}`} className="overflow-hidden">
<div className="relative">
<img
src={`${API_BASE_URL}${photo.file_path}`}
alt={`Existing ${index}`}
className="w-full h-48 object-cover"
/>
{mainPhotoIndex === index && (
<Badge className="absolute top-2 left-2 bg-green-600">Main Photo</Badge>
)}
<Button
variant="destructive"
size="sm"
className="absolute top-2 right-2"
onClick={() => removeExistingPhoto(index)}
>
<X className="w-4 h-4" />
</Button>
</div>
<CardContent className="p-4 space-y-3">
<div className="space-y-2">
<Label htmlFor={`caption-existing-${index}`} className="text-sm font-medium">
Caption
</Label>
<Input
id={`caption-existing-${index}`}
placeholder="Enter photo caption"
value={typeof captions[index] === "string" ? captions[index] : ""}
onChange={(e) =>
setCaptions((prev) => {
const copy = [...prev]
copy[index] = e.target.value
return copy
})
}
className="text-sm"
/>
</div>
<RadioGroup
value={mainPhotoIndex === index ? "main" : ""}
onValueChange={() => setMainPhotoIndex(index)}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="main" id={`main-existing-${index}`} />
<Label htmlFor={`main-existing-${index}`} className="text-sm">
Set as main photo
</Label>
</div>
</RadioGroup>
</CardContent>
</Card>
))}
</div>
</div>
)}
{/* New Photos */}
{photos.length > 0 && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Upload className="w-5 h-5 text-gray-600" />
<h3 className="text-lg font-medium text-gray-900">New Photos</h3>
<Badge variant="secondary">{photos.length}</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{photos.map((_, index) => {
const actualIndex = existingPhotos.length + index
return (
<Card key={`new-${index}`} className="overflow-hidden">
<div className="relative">
<img
src={photoPreviews[index] || "/placeholder.svg"}
alt={`Preview ${index}`}
className="w-full h-48 object-cover"
/>
{mainPhotoIndex === actualIndex && (
<Badge className="absolute top-2 left-2 bg-green-600">Main Photo</Badge>
)}
<Button
variant="destructive"
size="sm"
className="absolute top-2 right-2"
onClick={() => removeNewPhoto(actualIndex)}
>
<X className="w-4 h-4" />
</Button>
</div>
<CardContent className="p-4 space-y-3">
<div className="space-y-2">
<Label htmlFor={`caption-new-${index}`} className="text-sm font-medium">
Caption
</Label>
<Input
id={`caption-new-${index}`}
placeholder="Enter photo caption"
value={captions[actualIndex] ?? ""}
onChange={(e) =>
setCaptions((prev) => {
const copy = [...prev]
copy[actualIndex] = e.target.value
return copy
})
}
className="text-sm"
/>
</div>
<RadioGroup
value={mainPhotoIndex === actualIndex ? "main" : ""}
onValueChange={() => setMainPhotoIndex(actualIndex)}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="main" id={`main-new-${index}`} />
<Label htmlFor={`main-new-${index}`} className="text-sm">
Set as main photo
</Label>
</div>
</RadioGroup>
</CardContent>
</Card>
)
})}
</div>
</div>
)}
</div>
)
}
export default PhotosStep

View File

@ -0,0 +1,41 @@
import React from "react"
interface ResidenceStepProps {
residence: {
town_or_city: string
home_at_death: string
}
setResidence: React.Dispatch<
React.SetStateAction<{
town_or_city: string
home_at_death: string
}>
>
renderFormField: (
label: string,
value: string,
onChange: (value: string) => void,
type?: string,
placeholder?: string,
required?: boolean
) => React.ReactNode
}
const ResidenceStep: React.FC<ResidenceStepProps> = ({
residence,
setResidence,
renderFormField
}) => {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{renderFormField("Town or City", residence.town_or_city, (value) =>
setResidence({ ...residence, town_or_city: value }),
)}
{renderFormField("Home at Death", residence.home_at_death, (value) =>
setResidence({ ...residence, home_at_death: value }),
)}
</div>
)
}
export default ResidenceStep

View File

@ -1,164 +0,0 @@
"use client"
import { useState } from "react"
import {
useReactTable,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
type SortingState,
type VisibilityState,
} from "@tanstack/react-table"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { ChevronLeft, ChevronRight, Trash2 } from "lucide-react"
import { type Person, type PaginationMeta } from "@/types/api"
import TableView from "./TableView"
import useColumns from "./useColumnHooks"
import DeleteDialog from "./DeleteDialog"
interface MigrantsTableProps {
data: Person[]
globalFilter: string
loading: boolean
page?: number
meta: PaginationMeta
onNextPage: () => void
onPrevPage: () => void
onRefresh?: () => void
}
export default function MigrantTable({
data,
globalFilter,
loading,
meta,
onNextPage,
onPrevPage,
onRefresh,
}: MigrantsTableProps) {
const [sorting, setSorting] = useState<SortingState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({})
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [migrantToDelete, setMigrantToDelete] = useState<Person | null>(null)
const [bulkDelete, setBulkDelete] = useState(false)
const columns = useColumns({
onDeleteMigrant: (migrant) => {
setMigrantToDelete(migrant)
setBulkDelete(false)
setDeleteDialogOpen(true)
},
})
const table = useReactTable({
data,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
globalFilter,
},
onSortingChange: setSorting,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
})
const selectedRows = table.getFilteredSelectedRowModel().rows
const handleBulkDeleteClick = () => {
setBulkDelete(true)
setDeleteDialogOpen(true)
}
// This function is called when deletion is successful from the DeleteDialog
const handleDeleteMigrant = () => {
// Reset table selection if it was a bulk delete
if (bulkDelete) {
table.resetRowSelection()
}
// Reset the state
setDeleteDialogOpen(false)
setMigrantToDelete(null)
setBulkDelete(false)
// Refresh the data after deletion
if (onRefresh) {
onRefresh()
}
}
const pageRangeStart = (meta.current_page - 1) * meta.per_page + 1
const pageRangeEnd = Math.min(meta.current_page * meta.per_page, meta.total)
return (
<Card className="border-0 py-0 shadow-md overflow-hidden">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-green-600 via-white to-red-600" />
<CardContent className="p-0 bg-white">
<div className="flex justify-end p-4 py-0">
{selectedRows.length > 0 && (
<Button
variant="destructive"
size="sm"
className="shadow-sm"
onClick={handleBulkDeleteClick}
>
<Trash2 className="mr-2 size-4" />
Delete Selected ({selectedRows.length})
</Button>
)}
</div>
<TableView table={table} columns={columns} loading={loading} />
<div className="flex items-center justify-between p-4 border-t border-neutral-200">
<div className="text-sm text-neutral-600">
{data.length === 0
? "No migrants to display"
: `Showing ${pageRangeStart} to ${pageRangeEnd} of ${meta.total} migrants`}
</div>
<div className="flex items-center space-x-2">
<Button
onClick={onPrevPage}
disabled={loading || meta.current_page <= 1}
size="sm"
variant="outline"
>
<ChevronLeft className="mr-2 size-4" />
Previous
</Button>
<Button
onClick={onNextPage}
disabled={loading || meta.current_page >= meta.last_page}
size="sm"
variant="outline"
>
Next
<ChevronRight className="ml-2 size-4" />
</Button>
</div>
</div>
</CardContent>
<DeleteDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
bulkDelete={bulkDelete}
selectedCount={selectedRows.length}
ids={bulkDelete
? selectedRows.map(row => row.original.person_id || '').filter(id => id !== '') as string[]
: migrantToDelete && migrantToDelete.person_id ? [migrantToDelete.person_id]
: []}
onDeleteSuccess={handleDeleteMigrant}
/>
</Card>
)
}

View File

@ -1,70 +0,0 @@
import { type Table, flexRender } from "@tanstack/react-table"
import type { ColumnDef } from "@tanstack/react-table"
import {
Table as UITable,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import type { Person } from "@/types/api"
interface TableViewProps {
table: Table<Person>
columns: ColumnDef<Person>[]
loading: boolean
}
export default function TableView({ table, columns, loading }: TableViewProps) {
return (
<div className="overflow-x-auto">
<UITable>
<TableHeader className="bg-neutral-100">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={columns.length} className="text-center py-8 text-neutral-500">
Loading migrants data...
</TableCell>
</TableRow>
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
className="hover:bg-neutral-50"
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="text-center py-8 text-neutral-500">
No migrants found matching your search criteria.
</TableCell>
</TableRow>
)}
</TableBody>
</UITable>
</div>
)
}

View File

@ -1,178 +0,0 @@
import { useMemo } from "react"
import { type ColumnDef } from "@tanstack/react-table"
import { ArrowDown, ArrowUp, ArrowUpDown, MoreHorizontal } from "lucide-react"
import { Link } from "react-router-dom"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import type { Person } from "@/types/api"
interface UseColumnsProps {
onDeleteMigrant: (migrant: Person) => void
}
export default function useColumns({ onDeleteMigrant }: UseColumnsProps) {
const columns = useMemo<ColumnDef<Person>[]>(
() => [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "full_name",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="p-0 hover:bg-transparent flex items-center"
>
Name
{column.getIsSorted() === "asc" ? (
<ArrowUp className="ml-2 size-4" />
) : column.getIsSorted() === "desc" ? (
<ArrowDown className="ml-2 size-4" />
) : (
<ArrowUpDown className="ml-2 size-4" />
)}
</Button>
),
cell: ({ row }) => <div className="font-medium">{row.getValue("full_name")}</div>,
},
{
accessorKey: "date_of_birth",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="p-0 hover:bg-transparent flex items-center"
>
Birth Date
{column.getIsSorted() === "asc" ? (
<ArrowUp className="ml-2 size-4" />
) : column.getIsSorted() === "desc" ? (
<ArrowDown className="ml-2 size-4" />
) : (
<ArrowUpDown className="ml-2 size-4" />
)}
</Button>
),
cell: ({ row }) => {
const date = row.getValue("date_of_birth") as string
return <div>{date ? new Date(date).toLocaleDateString() : "-"}</div>
},
},
{
accessorKey: "place_of_birth",
header: "Birth Place",
cell: ({ row }) => <div>{row.getValue("place_of_birth") || "-"}</div>,
},
{
id: "date_of_arrival_aus",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="p-0 hover:bg-transparent flex items-center"
>
Arrival Date
{column.getIsSorted() === "asc" ? (
<ArrowUp className="ml-2 size-4" />
) : column.getIsSorted() === "desc" ? (
<ArrowDown className="ml-2 size-4" />
) : (
<ArrowUpDown className="ml-2 size-4" />
)}
</Button>
),
accessorFn: (row) => row.migration?.date_of_arrival_aus,
cell: ({ row }) =>
row.original.migration?.date_of_arrival_aus
? new Date(row.original.migration.date_of_arrival_aus).toLocaleDateString()
: "-",
},
{
accessorKey: "occupation",
header: "Occupation",
cell: ({ row }) => <div>{row.getValue("occupation") || "-"}</div>,
},
{
id: "has_photos",
header: "Photos",
accessorFn: (row) => row.has_photos,
cell: ({ row }) => {
const hasPhotos = row.original.has_photos
return hasPhotos ? (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Yes
</span>
) : (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-neutral-100 text-neutral-800">
No
</span>
)
},
},
{
id: "actions",
cell: ({ row }) => {
const migrant = row.original
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="size-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="z-50 w-40">
<DropdownMenuItem asChild>
<Link to={`/admin/migrants/edit/${migrant.person_id}`} className="w-full">
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
onDeleteMigrant(migrant)
}}
className="text-red-600"
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
},
},
],
[onDeleteMigrant]
)
return columns
}

View File

@ -0,0 +1,191 @@
"use client"
import type React from "react"
import { useState } from "react"
import { useNavigate } from "react-router-dom"
import { UserPlus, ArrowLeft } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { toast } from "react-hot-toast"
import apiService from "@/services/apiService"
import Header from "@/components/layout/Header"
import Sidebar from "@/components/layout/Sidebar"
export default function UserCreate() {
const navigate = useNavigate()
const [isSubmitting, setIsSubmitting] = useState(false)
const [formData, setFormData] = useState({
name: "",
email: "",
password: "",
password_confirmation: "",
})
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target
setFormData((prev) => ({
...prev,
[name]: value,
}))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// Basic validation
if (!formData.name || !formData.email || !formData.password) {
toast.error("Please fill in all required fields")
return
}
if (formData.password !== formData.password_confirmation) {
toast.error("Passwords don't match")
return
}
try {
setIsSubmitting(true)
// This would need to be implemented in your apiService
await apiService.createUser(formData)
toast.success("User created successfully!")
navigate("/admin/settings") // Redirect to an appropriate page
} catch (error) {
console.error("Error creating user:", error)
toast.error("Failed to create user. Please try again.")
} finally {
setIsSubmitting(false)
}
}
return (
<div className="flex min-h-dvh bg-gray-950">
<Sidebar />
<div className="flex-1 md:ml-16 lg:ml-64 w-full transition-all duration-300">
<Header title="Create User" />
<main className="p-4 md:p-6">
<div className="mb-6">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => navigate("/admin/settings")}
className="hover:bg-gray-800 text-gray-400 hover:text-white">
<ArrowLeft className="size-5" />
</Button>
<h1 className="text-2xl md:text-3xl font-serif font-bold text-white">Create New User</h1>
</div>
<p className="text-gray-400 mt-2 ml-10">Add a new administrator to the system</p>
</div>
<div className="max-w-10xl mx-auto">
<Card className="shadow-2xl border border-gray-800 bg-gray-900 overflow-hidden">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-[#9B2335] to-[#9B2335]/60"></div>
<CardHeader className="border-b border-gray-800">
<CardTitle className="text-xl font-serif text-white">User Information</CardTitle>
<CardDescription className="text-gray-400">Please fill in all required fields</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-6 p-6">
<div className="space-y-6">
<div className="space-y-2">
<Label htmlFor="name" className="text-gray-300">
Full Name <span className="text-red-400">*</span>
</Label>
<Input
id="name"
name="name"
placeholder="Enter full name"
value={formData.name}
onChange={handleInputChange}
required
className="bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email" className="text-gray-300">
Email Address <span className="text-red-400">*</span>
</Label>
<Input
id="email"
name="email"
type="email"
placeholder="Enter email address"
value={formData.email}
onChange={handleInputChange}
required
className="bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500"
/>
</div>
<div className="border-t border-gray-800 pt-6">
<h3 className="text-lg font-medium mb-4 text-white">Security Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="password" className="text-gray-300">
Password <span className="text-red-400">*</span>
</Label>
<Input
id="password"
name="password"
type="password"
placeholder="Enter password"
value={formData.password}
onChange={handleInputChange}
required
className="bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password_confirmation" className="text-gray-300">
Confirm Password <span className="text-red-400">*</span>
</Label>
<Input
id="password_confirmation"
name="password_confirmation"
type="password"
placeholder="Confirm password"
value={formData.password_confirmation}
onChange={handleInputChange}
required
className="bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500"
/>
</div>
</div>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-end gap-3 pt-2 pb-6 px-6 border-t border-gray-800">
<Button
type="button"
variant="outline"
onClick={() => navigate("/admin/settings")}
disabled={isSubmitting}
className="border-gray-700 text-gray-300 hover:bg-gray-800 hover:text-white bg-gray-900 shadow-lg"
>
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting}
className="bg-[#9B2335] hover:bg-[#9B2335]/90 text-white shadow-lg"
>
<UserPlus className="mr-2 size-4" />
{isSubmitting ? "Creating..." : "Create User"}
</Button>
</CardFooter>
</form>
</Card>
</div>
</main>
</div>
</div>
)
}

View File

@ -0,0 +1,81 @@
import { useMigrants } from "../../hooks/useGraph";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
CartesianGrid,
Label,
} from "recharts";
export const MigrationChart = () => {
const { migrationData, loading, error } = useMigrants();
if (loading) return <p>Loading...</p>;
if (error) return <p>{error}</p>;
if (!migrationData.length) return <p>No data available</p>;
return (
<div className="w-full h-full">
<h2 className="text-lg font-semibold text-gray-500 mb-2">
Italian Migration to Northern Territory (1900-1950)
</h2>
<ResponsiveContainer width="100%" height="90%">
<BarChart data={migrationData} barCategoryGap="30%">
<CartesianGrid strokeDasharray="3 3" stroke="#E5E7EB" />
<XAxis
dataKey="year"
tick={{ fontSize: 12, fill: "#6B7280" }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 12, fill: "#6B7280" }}
axisLine={false}
tickLine={false}
tickCount={5}
>
<Label
value="Migrants"
angle={-90}
position="insideLeft"
style={{ textAnchor: "middle", fill: "#374151", fontSize: 12 }}
offset={-10}
/>
</YAxis>
<Tooltip
cursor={{ fill: "rgba(203, 213, 225, 0.2)" }}
contentStyle={{
backgroundColor: "#1F2937",
borderRadius: "6px",
color: "#fff",
fontSize: "0.875rem",
}}
labelStyle={{ color: "#F43F5E" }}
formatter={(value: number) => [`${value} migrants`, ""]}
/>
<defs>
<linearGradient id="barGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#BE123C" />
<stop offset="100%" stopColor="#F43F5E" />
</linearGradient>
</defs>
<Bar
dataKey="count"
fill="url(#barGradient)"
radius={[4, 4, 0, 0]}
barSize={24}
/>
</BarChart>
</ResponsiveContainer>
</div>
);
};

View File

@ -0,0 +1,95 @@
import { useMigrants } from "@/hooks/useGraph"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend } from "recharts"
export const OccupationChart = () => {
const { occupationData, loading, error } = useMigrants()
if (loading) {
return (
<Card className="border border-gray-800 bg-gray-900 shadow-2xl overflow-hidden">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-[#9B2335] to-[#9B2335]/60"></div>
<CardContent className="p-6 flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#9B2335]"></div>
</CardContent>
</Card>
)
}
if (error) {
return (
<Card className="border border-gray-800 bg-gray-900 shadow-2xl overflow-hidden">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-red-500 to-red-600"></div>
<CardContent className="p-6 text-center">
<p className="text-red-400">{error}</p>
</CardContent>
</Card>
)
}
if (!occupationData.length) {
return (
<Card className="border border-gray-800 bg-gray-900 shadow-2xl overflow-hidden">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-gray-600 to-gray-700"></div>
<CardContent className="p-6 text-center">
<p className="text-gray-400">No data available</p>
</CardContent>
</Card>
)
}
// Sort by count descending and take top 10
const topOccupations = [...occupationData].sort((a, b) => b.value - a.value).slice(0, 10)
return (
<Card className="border border-gray-800 bg-gray-900 shadow-2xl overflow-hidden">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-[#9B2335] to-[#9B2335]/60"></div>
<CardHeader className="border-b border-gray-800">
<CardTitle className="text-xl font-serif text-white">Top 10 Occupations of Migrants</CardTitle>
<CardDescription className="text-gray-400">The most common occupations among Italian migrants</CardDescription>
</CardHeader>
<CardContent className="p-6">
<ResponsiveContainer width="100%" height={400}>
<BarChart
data={topOccupations}
layout="vertical"
margin={{ top: 20, right: 10, left: 10, bottom: 20 }}
barCategoryGap="20%"
>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" vertical={false} />
<XAxis type="number" tick={{ fontSize: 12, fill: "#9CA3AF" }} />
<YAxis type="category" dataKey="occupation" tick={{ fontSize: 12, fill: "#9CA3AF" }} width={140} />
<Tooltip
contentStyle={{
backgroundColor: "#1F2937",
borderRadius: "8px",
color: "#fff",
fontSize: "0.875rem",
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
border: "1px solid #374151",
}}
formatter={(value: number, name: string) => [`${value} migrants`, name]}
/>
<Legend
verticalAlign="top"
align="right"
iconType="circle"
wrapperStyle={{
fontSize: "0.875rem",
color: "#9CA3AF",
marginBottom: "1rem",
}}
/>
<Bar dataKey="value" fill="url(#colorUv)" radius={[6, 6, 6, 6]} barSize={18} />
<defs>
<linearGradient id="colorUv" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="#9B2335" stopOpacity={0.8} />
<stop offset="100%" stopColor="#DC2626" stopOpacity={0.8} />
</linearGradient>
</defs>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
)
}

View File

@ -0,0 +1,114 @@
"use client"
import { useMigrants } from "@/hooks/useGraph"
import { useRef } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from "recharts"
export const ResidenceChart = () => {
const { residenceData, loading, error } = useMigrants()
const chartRef = useRef<HTMLDivElement>(null)
if (loading) {
return (
<Card className="border border-gray-800 bg-gray-900 shadow-2xl overflow-hidden">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-[#9B2335] to-[#9B2335]/60"></div>
<CardContent className="p-6 flex items-center justify-center h-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#9B2335]"></div>
</CardContent>
</Card>
)
}
if (error) {
return (
<Card className="border border-gray-800 bg-gray-900 shadow-2xl overflow-hidden">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-red-500 to-red-600"></div>
<CardContent className="p-6 text-center">
<p className="text-red-400">{error}</p>
</CardContent>
</Card>
)
}
if (!residenceData.length) {
return (
<Card className="border border-gray-800 bg-gray-900 shadow-2xl overflow-hidden">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-gray-600 to-gray-700"></div>
<CardContent className="p-6 text-center">
<p className="text-gray-400">No data available</p>
</CardContent>
</Card>
)
}
const COLORS = [
"#9B2335",
"#DC2626",
"#EA580C",
"#D97706",
"#CA8A04",
"#65A30D",
"#16A34A",
"#059669",
"#0891B2",
"#0284C7",
]
return (
<Card ref={chartRef} className="border border-gray-800 bg-gray-900 shadow-2xl overflow-hidden">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-[#9B2335] to-[#9B2335]/60"></div>
<CardHeader className="border-b border-gray-800">
<CardTitle className="text-lg font-medium text-white">Italian Migration: Residence Distribution</CardTitle>
<CardDescription className="text-gray-400">
Distribution of Italian migrants across different towns or cities
</CardDescription>
</CardHeader>
<CardContent className="p-6">
<ResponsiveContainer width="100%" height={400}>
<PieChart>
<Pie
data={residenceData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={130}
innerRadius={70}
paddingAngle={3}
labelLine={false}
label={({ name, value }) => `${name} (${value})`}
>
{residenceData.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} className="rounded-full" />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: "#1F2937",
borderRadius: "8px",
color: "#fff",
fontSize: "0.875rem",
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.1)",
border: "1px solid #374151",
}}
formatter={(value: number, name: string) => [`${value} migrants`, name]}
/>
<Legend
verticalAlign="bottom"
iconType="circle"
align="center"
wrapperStyle={{
fontSize: "0.875rem",
color: "#9CA3AF",
marginTop: "1rem",
}}
/>
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
)
}

View File

@ -1,9 +0,0 @@
const LoadingSpinner = () => {
return (
<div className="flex justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary-color"></div>
</div>
)
}
export default LoadingSpinner

View File

@ -1,40 +0,0 @@
import { motion } from "framer-motion"
import { Link } from "react-router-dom"
import { PlusCircle, FileText, Database } from "lucide-react"
export default function QuickActions() {
return (
<motion.div
className="bg-white rounded-lg shadow mb-8"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-medium">Quick Actions</h2>
</div>
<div className="p-6 grid grid-cols-1 md:grid-cols-3 gap-4">
<Link
to="/admin/migrants"
className="flex items-center justify-center px-4 py-3 bg-[#9B2335] text-white rounded-md hover:bg-[#9B2335]/90"
>
<PlusCircle className="h-5 w-5 mr-2" />
Add New Migrant
</Link>
<button
className="flex items-center justify-center px-4 py-3 bg-[#01796F] text-white rounded-md hover:bg-[#01796F]/90"
>
<FileText className="h-5 w-5 mr-2" />
Generate Report
</button>
<button
className="flex items-center justify-center px-4 py-3 bg-[#1A2A57] text-white rounded-md hover:bg-[#1A2A57]/90"
>
<Database className="h-5 w-5 mr-2" />
Import Records
</button>
</div>
</motion.div>
)
}

View File

@ -1,111 +0,0 @@
"use client"
import { useEffect, useState } from 'react';
import ApiService from '@/services/apiService';
import type { ActivityLog } from '@/types/api';
import { motion } from 'framer-motion';
import { PlusCircle, FileText, Users, BarChart2, Database } from 'lucide-react';
export default function RecentActivity() {
const [logs, setLogs] = useState<ActivityLog[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchLogs = async () => {
try {
const data = await ApiService.getRecentActivityLogs();
setLogs(data);
} catch (err) {
console.error('Error fetching activity logs:', err);
} finally {
setLoading(false);
}
};
fetchLogs();
}, []);
const getType = (log: ActivityLog) => {
const action = log.description.toLowerCase()
if (action.includes("add")) return "add"
if (action.includes("update")) return "update"
if (action.includes("delete")) return "delete"
if (action.includes("report")) return "report"
return "import"
}
const getIcon = (type: string) => {
switch (type) {
case "add":
return <PlusCircle className="h-5 w-5" />
case "update":
return <FileText className="h-5 w-5" />
case "delete":
return <Users className="h-5 w-5" />
case "report":
return <BarChart2 className="h-5 w-5" />
default:
return <Database className="h-5 w-5" />
}
}
const getColorClass = (type: string) => {
switch (type) {
case "add":
return "bg-green-100 text-green-600"
case "update":
return "bg-blue-100 text-blue-600"
case "delete":
return "bg-red-100 text-red-600"
case "report":
return "bg-purple-100 text-purple-600"
default:
return "bg-yellow-100 text-yellow-600"
}
}
return (
<motion.div
className="bg-white rounded-lg shadow"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.4 }}
>
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-medium">Recent Activity</h2>
</div>
<div className="p-6">
{loading ? (
<p className="text-gray-500">Loading...</p>
) : logs.length === 0 ? (
<p className="text-gray-500">No recent activity found.</p>
) : (
<div className="space-y-6">
{logs.map((log, index) => {
const type = getType(log)
return (
<motion.div
key={index}
className="flex items-start"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3, delay: 0.5 + index * 0.1 }}
>
<div className={`p-2 rounded-full mr-4 ${getColorClass(type)}`}>
{getIcon(type)}
</div>
<div>
<p className="font-medium">{log.description}</p>
<p className="text-sm text-gray-500">
By {log.causer_name} {new Date(log.created_at).toLocaleString()}
</p>
</div>
</motion.div>
)
})}
</div>
)}
</div>
</motion.div>
)
}

View File

@ -1,25 +0,0 @@
import type { ReactNode } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
interface StatCardProps {
title: string
value: number
description: string
icon: ReactNode
}
export default function StatCard({ title, value, description, icon }: StatCardProps) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-neutral-600">{title}</CardTitle>
{icon}
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
<CardDescription>{description}</CardDescription>
</CardContent>
</Card>
)
}

View File

@ -1,71 +0,0 @@
import { Edit, FileText, Plus, Trash2, Upload } from "lucide-react"
// Sample activity data
const activities = [
{
id: 1,
action: "added",
subject: "Marco Rossi",
timestamp: "2 hours ago",
icon: Plus,
iconColor: "text-green-600",
user: "Admin",
},
{
id: 2,
action: "updated",
subject: "Sofia Bianchi",
timestamp: "Yesterday",
icon: Edit,
iconColor: "text-blue-600",
user: "Admin",
},
{
id: 3,
action: "uploaded",
subject: "5 photos for Antonio Esposito",
timestamp: "2 days ago",
icon: Upload,
iconColor: "text-purple-600",
user: "Admin",
},
{
id: 4,
action: "deleted",
subject: "Duplicate record",
timestamp: "3 days ago",
icon: Trash2,
iconColor: "text-red-600",
user: "Admin",
},
{
id: 5,
action: "added",
subject: "Document for Lucia Romano",
timestamp: "1 week ago",
icon: FileText,
iconColor: "text-amber-600",
user: "Admin",
},
]
export default function RecentActivityList() {
return (
<div className="divide-y divide-neutral-100">
{activities.map((activity) => (
<div key={activity.id} className="flex items-start gap-4 p-4">
<div className={`rounded-full p-2 ${activity.iconColor} bg-opacity-10`}>
<activity.icon className="size-4" />
</div>
<div className="flex-1 space-y-1">
<p className="text-sm font-medium">
<span className="font-semibold">{activity.user}</span> {activity.action}{" "}
<span className="font-medium text-neutral-900">{activity.subject}</span>
</p>
<p className="text-xs text-neutral-500">{activity.timestamp}</p>
</div>
</div>
))}
</div>
)
}

View File

@ -1,52 +1,364 @@
"use client";
import { motion } from "framer-motion";
import AnimatedImage from "@/components/ui/animated-image";
export default function HeroSection() { "use client"
import { Link } from "react-router-dom"
import { Search } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@/components/ui/carousel"
import { Card, CardContent } from "@/components/ui/card"
import SearchForm from "./SearchForm"
import { useEffect, useState } from "react"
import { useNavigate } from "react-router-dom"
import ApiService from "@/services/apiService";
import type { Person } from "@/types/api";
export default function Home() {
const [currentSlide, setCurrentSlide] = useState(0)
const navigate = useNavigate();
const [total, setTotal] = useState(0);
const [migrants, setMigrants] = useState<Person[]>([]);
const API_BASE_URL = "http://localhost:8000";
useEffect(() => {
async function fetchData() {
const response = await ApiService.getMigrants(1, 10);
setMigrants(response.data);
setTotal(response.total);
}
fetchData();
}, []);
const backgroundImages = [
{
src: "/slide1.avif",
alt: "Italian countryside landscape",
},
{
src: "/slide2.avif",
alt: "Vintage Italian architecture",
},
{
src: "/slide3.avif",
alt: "Italian coastal town",
},
{
src: "/slide4.avif",
alt: "Italian countryside with vineyards",
},
]
const galleryImages = [
{
src: "https://images.unsplash.com/photo-1523906834658-6e24ef2386f9?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=800&q=80",
alt: "Italian countryside village",
},
{
src: "https://images.unsplash.com/photo-1515542622106-78bda8ba0e5b?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=800&q=80",
alt: "Italian landscape",
},
{
src: "https://images.unsplash.com/photo-1552832230-c0197dd311b5?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=800&q=80",
alt: "Italian vineyards",
},
{
src: "https://images.unsplash.com/photo-1516483638261-f4dbaf036963?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=800&q=80",
alt: "Italian coastal town",
},
{
src: "https://images.unsplash.com/photo-1581833971358-2c8b550f87b3?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=800&q=80",
alt: "Italian traditional building",
},
]
useEffect(() => {
const timer = setInterval(() => {
setCurrentSlide((prev) => (prev + 1) % backgroundImages.length)
}, 5000)
return () => clearInterval(timer)
}, [backgroundImages.length])
return ( return (
<section className="relative w-full h-[70vh] md:h-[80vh] overflow-hidden"> <div className="flex flex-col min-h-screen">
<div className="absolute inset-0 bg-black/60 z-10" /> <header className="absolute top-0 left-0 right-0 z-50 border-b border-white/20 bg-black/20 backdrop-blur-sm">
<div className="relative h-full w-full"> <div className="container flex h-16 items-center justify-between px-4 md:px-6">
<AnimatedImage <Link to="/" className="flex items-center gap-2">
src="/hero.jpg" <span className="text-xl font-bold text-white">Italian Migrants NT</span>
alt="Historical image of Italian migrants in the Northern Territory" </Link>
fill <nav className="hidden md:flex gap-6">
/> <Link to="/" className="text-sm font-medium text-white hover:text-white/80 transition-colors">
</div> Home
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center text-center px-4"> </Link>
<motion.div <a href="#about" className="text-sm font-medium text-white hover:text-white/80 transition-colors">
initial={{ opacity: 0, y: 20 }} About
animate={{ opacity: 1, y: 0 }} </a>
transition={{ duration: 0.8, delay: 0.2 }} <a href="#stories" className="text-sm font-medium text-white hover:text-white/80 transition-colors">
className="max-w-4xl" Stories
> </a>
{/* Top 4 bars */} <Link to="/contact" className="text-sm font-medium text-white hover:text-white/80 transition-colors">
<div className="flex items-center justify-center mb-6 space-x-1"> Contact
<div className="h-1 w-12 bg-green-600" /> </Link>
<div className="h-1 w-12 bg-white" /> </nav>
<div className="h-1 w-12 bg-red-600" /> <Button variant="outline" size="icon" className="md:hidden border-white/20 text-white hover:bg-white/10">
<div className="h-1 w-12 bg-gray-400" /> <Search className="h-4 w-4" />
<span className="sr-only">Search</span>
</Button>
</div>
</header>
<main className="flex-1">
{/* Hero Section with Background Carousel and Search */}
<section className="relative w-full h-screen flex items-center justify-center overflow-hidden">
{/* Background Carousel */}
<div className="absolute inset-0">
{backgroundImages.map((image, index) => (
<div
key={index}
className={`absolute inset-0 transition-opacity duration-1000 ${index === currentSlide ? "opacity-100" : "opacity-0"
}`}
>
<img src={image.src || "/placeholder.svg"} alt={image.alt} className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/60" />
</div>
))}
</div> </div>
<h1 className="text-4xl md:text-6xl font-bold text-white mb-4 font-serif"> {/* Centered Content with Search */}
Italian Migration to the Northern Territory <div className="relative z-10 text-center text-white px-4 md:px-6 max-w-5xl mx-auto">
</h1> <h1 className="text-4xl md:text-6xl lg:text-7xl font-bold tracking-tighter font-serif mb-4">
<p className="text-xl md:text-2xl text-white max-w-3xl italic text-center mx-auto"> Find Your Italian Heritage
Exploring the rich history and cultural legacy of Italian immigrants </h1>
in Australia's Northern Territory <p className="text-lg md:text-xl lg:text-2xl mb-8 text-white/90 max-w-3xl mx-auto leading-relaxed">
</p> Search our comprehensive database of Italian migrants to the Northern Territory. Discover family
histories, personal stories, and cultural contributions spanning over a century.
</p>
{/* Bottom 4 bars */} {/* Main Search Form */}
<div className="flex items-center justify-center mt-6 space-x-1"> <div className="bg-white/95 backdrop-blur-sm rounded-2xl p-6 md:p-8 shadow-2xl mb-8 max-w-4xl mx-auto">
<div className="h-1 w-12 bg-green-600" /> <h2 className="text-2xl md:text-3xl font-bold text-[#9B2335] mb-6 font-serif">Search Migrant Database</h2>
<div className="h-1 w-12 bg-white" /> <SearchForm />
<div className="h-1 w-12 bg-red-600" /> </div>
<div className="h-1 w-12 bg-gray-400" />
<Button
size="lg"
variant="outline"
onClick={() => navigate("/search-results")}
className="border-white text-black hover:bg-white hover:text-black px-8 py-3 text-lg"
>
Browse All Records
</Button>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mt-12 text-center">
<div className="bg-white/10 backdrop-blur-sm rounded-lg p-4">
<div className="text-3xl font-bold text-white">{total.toLocaleString()}</div>
<div className="text-white/80">Migrant Records</div>
</div>
<div className="bg-white/10 backdrop-blur-sm rounded-lg p-4">
<div className="text-3xl font-bold text-white">1880-1980</div>
<div className="text-white/80">Years Covered</div>
</div>
<div className="bg-white/10 backdrop-blur-sm rounded-lg p-4">
<div className="text-3xl font-bold text-white">156</div>
<div className="text-white/80">Italian Regions</div>
</div>
</div>
</div> </div>
</motion.div>
</div> {/* Carousel Indicators */}
<div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-black to-transparent z-10" /> <div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 flex space-x-2">
</section> {backgroundImages.map((_, index) => (
); <button
key={index}
onClick={() => setCurrentSlide(index)}
className={`w-3 h-3 rounded-full transition-all ${index === currentSlide ? "bg-white" : "bg-white/50"}`}
/>
))}
</div>
</section>
{/* Quick Search Tips */}
<section className="w-full py-12 bg-[#E8DCCA]">
<div className="container px-4 md:px-6">
<div className="text-center mb-8">
<h2 className="text-2xl md:text-3xl font-bold text-[#9B2335] font-serif mb-4">Search Tips</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">
Get the most out of your search with these helpful tips
</p>
</div>
<div className="grid md:grid-cols-3 gap-6 max-w-4xl mx-auto">
<div className="text-center p-6 bg-white rounded-lg shadow-sm">
<div className="w-12 h-12 bg-[#01796F] rounded-full flex items-center justify-center mx-auto mb-4">
<Search className="h-6 w-6 text-white" />
</div>
<h3 className="font-semibold text-[#9B2335] mb-2">Name Variations</h3>
<p className="text-sm text-muted-foreground">
Try different spellings and shortened versions of names as they may have been anglicized upon arrival.
</p>
</div>
<div className="text-center p-6 bg-white rounded-lg shadow-sm">
<div className="w-12 h-12 bg-[#01796F] rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-white font-bold">📅</span>
</div>
<h3 className="font-semibold text-[#9B2335] mb-2">Date Ranges</h3>
<p className="text-sm text-muted-foreground">
Use broader date ranges as exact arrival dates may not always be recorded accurately.
</p>
</div>
<div className="text-center p-6 bg-white rounded-lg shadow-sm">
<div className="w-12 h-12 bg-[#01796F] rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-white font-bold">🗺</span>
</div>
<h3 className="font-semibold text-[#9B2335] mb-2">Regional Search</h3>
<p className="text-sm text-muted-foreground">
Search by Italian region or province if you know your family's origin to narrow results.
</p>
</div>
</div>
</div>
</section>
<section id="stories" className="w-full py-12 md:py-24 lg:py-32">
<div className="container px-4 md:px-6">
<div className="flex flex-col items-center justify-center space-y-4 text-center">
<div className="space-y-2">
<h2 className="text-3xl font-bold tracking-tighter sm:text-5xl font-serif text-[#1A2A57]">
Featured Stories
</h2>
<p className="max-w-[900px] text-muted-foreground md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed">
Discover some of the remarkable personal journeys found in our database. Each story represents
courage, determination, and the pursuit of a better life.
</p>
</div>
</div>
<div className="mx-auto max-w-5xl py-12">
<Carousel className="w-full">
<CarouselContent>
{migrants.map((person) => {
// Find the profile photo
const profilePhoto = person.photos?.find((photo) => photo.is_profile_photo);
return (
<CarouselItem key={person.person_id} className="md:basis-1/2 lg:basis-1/2">
<div className="p-2">
<Card className="h-full">
<CardContent className="p-6">
<div className="space-y-4">
<div className="aspect-video overflow-hidden rounded-lg">
<img
src={
profilePhoto
? `${API_BASE_URL}${profilePhoto.file_path}`
: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1000&q=80"
}
alt={`Portrait of ${person.full_name || person.surname || "Unnamed"}`}
className="object-cover w-full h-full"
/>
</div>
<div className="space-y-2">
<h3 className="text-xl font-bold font-serif text-[#9B2335]">
{person.full_name || person.surname || "Unnamed"}
</h3>
<p className="text-sm text-[#01796F] font-medium">
Arrived{" "}
{person.migration?.date_of_arrival_nt
? new Date(person.migration.date_of_arrival_nt).getFullYear()
: "Unknown"}
</p>
<p className="text-muted-foreground text-sm leading-relaxed">
{person.additional_notes || "No story available."}
</p>
<Button
size="sm"
variant="outline"
className="mt-3 border-[#9B2335] text-[#9B2335] hover:bg-[#9B2335] hover:text-white"
onClick={() => navigate(`/migrant-profile/${person.person_id}`)}
>
View Full Record
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
</CarouselItem>
);
})}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
</div>
</div>
</section>
<section id="about" className="w-full py-12 md:py-24 lg:py-32 bg-gray-50">
<div className="container px-4 md:px-6">
<div className="grid gap-6 lg:grid-cols-2 lg:gap-12 items-center">
<div className="space-y-4 text-center lg:text-left">
<h2 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl font-serif text-[#1A2A57]">
Preserving Our Heritage
</h2>
<p className="max-w-[600px] text-muted-foreground md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed mx-auto lg:mx-0">
This digital archive aims to preserve and celebrate the contributions of Italian migrants to the
Northern Territory. By documenting their stories, photographs, and historical records, we ensure that
their legacy continues for generations to come.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center lg:justify-start">
<Button className="bg-[#01796F] hover:bg-[#015a54] text-white">Contribute a Story</Button>
<Button
variant="outline"
className="border-[#9B2335] text-[#9B2335] hover:bg-[#9B2335] hover:text-white"
>
View Gallery
</Button>
</div>
</div>
<div className="w-full max-w-[500px] mx-auto">
<Carousel className="w-full">
<CarouselContent>
{galleryImages.map((image, index) => (
<CarouselItem key={index} className="md:basis-1/2">
<div className="p-1">
<div className="overflow-hidden rounded-xl aspect-square">
<img
src={image.src || "/placeholder.svg"}
alt={image.alt}
className="object-cover w-full h-full hover:scale-105 transition-transform duration-300"
/>
</div>
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
</div>
</div>
</div>
</section>
</main>
<footer className="border-t bg-[#1A2A57] text-white">
<div className="container flex flex-col gap-2 sm:flex-row py-6 w-full items-center px-4 md:px-6">
<p className="text-xs">&copy; {new Date().getFullYear()} Italian Migrants NT. All rights reserved.</p>
<nav className="sm:ml-auto flex gap-4 sm:gap-6">
<Link to="/terms" className="text-xs hover:underline underline-offset-4">
Terms of Service
</Link>
<Link to="/privacy" className="text-xs hover:underline underline-offset-4">
Privacy
</Link>
<Link to="/admin" className="text-xs hover:underline underline-offset-4">
Admin
</Link>
</nav>
</div>
</footer>
</div>
)
} }

View File

@ -1,113 +0,0 @@
import { Card, CardContent } from "@/components/ui/card";
interface HistoricalContextProps {
year: number;
}
const HistoricalContext = ({ year }: HistoricalContextProps) => {
// Function to get historical context based on the year
const getHistoricalContext = (year: number) => {
if (year < 1880) {
return {
period: "Early Settlement Period",
description:
"The earliest Italian migrants to the Northern Territory arrived during this period, primarily as individuals seeking opportunity in a new land. They often worked in mining, agriculture, or as merchants.",
events: [
"Early European settlement in the Northern Territory",
"Gold rushes in various parts of Australia",
"Establishment of Darwin (then called Palmerston)",
],
};
} else if (year < 1900) {
return {
period: "Late 19th Century",
description:
"This period saw increased Italian migration to Australia, with many arriving to work in agriculture, fishing, and construction. The Northern Territory's tropical climate was familiar to those from southern Italy.",
events: [
"Construction of the Overland Telegraph Line",
"Expansion of the pastoral industry",
"Growth of Darwin as a port city",
],
};
} else if (year < 1920) {
return {
period: "Federation and Early 20th Century",
description:
"Following Australian Federation in 1901, immigration policies became more restrictive, but Italians continued to arrive. Many came to join family members already established in the Territory.",
events: [
"Federation of Australia (1901)",
"World War I (1914-1918)",
"Development of the pearling industry",
],
};
} else if (year < 1940) {
return {
period: "Interwar Period",
description:
"The interwar period saw continued Italian migration, with many escaping economic hardship and the rise of fascism in Italy. Chain migration became common, with established migrants sponsoring relatives.",
events: [
"Great Depression (1929-1933)",
"Rise of fascism in Italy",
"Expansion of agriculture in the Northern Territory",
],
};
} else if (year < 1960) {
return {
period: "Post-World War II",
description:
"After World War II, Australia actively encouraged European migration. Many Italians came to Australia as part of assisted migration schemes, seeking to escape the devastation of post-war Italy.",
events: [
"World War II (1939-1945)",
"Beginning of Australia's 'Populate or Perish' policy",
"Bombing of Darwin (1942)",
"Reconstruction of Darwin",
],
};
} else if (year < 1980) {
return {
period: "Modern Migration",
description:
"This period saw continued Italian migration, though at a slower pace than previous decades. Many migrants were skilled workers or came to join established family networks.",
events: [
"End of the White Australia Policy",
"Cyclone Tracy devastates Darwin (1974)",
"Northern Territory granted self-government (1978)",
],
};
} else {
return {
period: "Contemporary Era",
description:
"Recent Italian migration to the Northern Territory has been limited, with most new arrivals being skilled professionals or family members of established migrants. The Italian community is now largely Australian-born descendants of earlier migrants.",
events: [
"Multiculturalism becomes official policy",
"Growth of tourism in the Northern Territory",
"Development of Darwin as a gateway to Asia",
],
};
}
};
const context = getHistoricalContext(year);
return (
<Card className="overflow-hidden border border-gray-200 shadow-none rounded-md bg-white">
<CardContent>
<h2 className="text-xl font-bold mb-2 font-serif text-gray-800">
Contesto Storico
</h2>
<div className="h-1 w-16 bg-gradient-to-r from-green-600 via-white to-red-600 mb-4" />
<h3 className="font-medium text-lg mb-2">{context.period}</h3>
<p className="text-sm mb-4">{context.description}</p>
<h4 className="font-medium text-sm mb-1">Key Events:</h4>
<ul className="text-sm list-disc pl-5 space-y-1">
{context.events.map((event, index) => (
<li key={index}>{event}</li>
))}
</ul>
</CardContent>
</Card>
);
};
export default HistoricalContext;

View File

@ -1,83 +0,0 @@
"use client";
import { motion } from "framer-motion";
import AnimatedImage from "@/components/ui/animated-image";
export default function IntroSection() {
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.2,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0, transition: { duration: 0.6 } },
};
return (
<section className="py-16 px-4 md:px-8 max-w-6xl mx-auto">
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-100px" }}
variants={containerVariants}
>
<motion.div variants={itemVariants} className="text-center mb-12">
<h2 className="text-3xl md:text-4xl font-bold mb-4 font-serif text-gray-800">
La Nostra Storia
</h2>
<div className="flex items-center justify-center">
<div className="h-1 w-12 bg-green-600" />
<div className="h-1 w-12 bg-white" />
<div className="h-1 w-12 bg-red-600" />
</div>
<p className="text-xl mt-4 text-gray-600 italic">Our History</p>
</motion.div>
<div className="grid md:grid-cols-2 gap-8 items-center">
<motion.div variants={itemVariants} className="space-y-4">
<p className="text-lg">
The history of Italian migration to the Northern Territory dates
back to the late 19th century, when the first Italian settlers
arrived seeking new opportunities and a better life.
</p>
<p className="text-lg">
These pioneers played a significant role in shaping the region's
development, contributing to industries such as agriculture,
fishing, construction, and mining.
</p>
<p className="text-lg">
Many Italian families established deep roots in the Territory,
creating vibrant communities that preserved their cultural
traditions while embracing their new Australian home.
</p>
<div className="pt-4 border-t border-gray-200">
<blockquote className="italic text-gray-700 pl-4 border-l-4 border-red-500">
"They brought with them not only their skills and work ethic,
but also their rich cultural heritage, culinary traditions, and
strong family values."
</blockquote>
</div>
</motion.div>
<motion.div variants={itemVariants}>
<div className="relative h-[300px] md:h-[400px] rounded-lg overflow-hidden shadow-lg border-8 border-white">
<AnimatedImage
src="https://www.lagazzettaitaliana.com/media/k2/items/cache/c62caf210f3cc558e679c3751b1975e6_XL.jpg"
alt="Italian family in the Northern Territory"
fill
/>
<div className="absolute bottom-0 left-0 right-0 bg-black/60 text-white p-3 text-sm">
Italian family settling in the Northern Territory, circa 1920s
</div>
</div>
</motion.div>
</div>
</motion.div>
</section>
);
}

View File

@ -1,228 +1,362 @@
"use client"; import { useEffect, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { MapPin, Calendar, User, Home, Ship, ImageIcon, FileText } from 'lucide-react';
import apiService from '@/services/apiService';
import type { Person } from '@/types/api';
import { formatDate } from '@/utils/date';
const API_BASE_URL = "http://localhost:8000";
import { motion } from "framer-motion"; export default function MigrantProfile() {
import { Link } from "react-router-dom"; const { id } = useParams<{ id: string }>();
import { ArrowLeft } from "lucide-react"; const [migrant, setMigrant] = useState<Person | null>(null);
import { Button } from "@/components/ui/button"; const [loading, setLoading] = useState(true);
import { Card, CardContent } from "@/components/ui/card"; const [error, setError] = useState<string | null>(null);
import PhotoGallery from "@/components/home/PhotoGallery"; const profilePhoto = migrant?.photos?.find((photo) => photo.is_profile_photo);
import RelatedMigrants from "@/components/home/RelatedMigrants";
import HistoricalContext from "@/components/home/HistoricalContext";
import AnimatedImage from "@/components/ui/animated-image";
import type { MigrantProfile as MigrantProfileType } from "@/types/migrant";
interface MigrantProfileProps { useEffect(() => {
migrant: MigrantProfileType; const fetchMigrant = async () => {
} try {
if (!id) {
setError('No ID provided in URL.');
setLoading(false);
return;
}
const data = await apiService.getMigrantById(id);
setMigrant(data);
} catch (err) {
setError('Failed to load migrant data.');
} finally {
setLoading(false);
}
};
fetchMigrant();
}, [id]);
if (loading) return <p className="text-gray-500 p-4">Loading...</p>;
if (error) return <p className="text-red-500 p-4">{error}</p>;
if (!migrant) return <p className="p-4">No data found.</p>;
export default function MigrantProfile({ migrant }: MigrantProfileProps) {
return ( return (
<main className="min-h-screen bg-gray-50 pb-16"> <div className="flex flex-col min-h-screen">
{/* Hero section with main photo */} <header className="border-b">
<div className="relative w-full h-[50vh] md:h-[60vh] overflow-hidden bg-gray-900"> <div className="container flex h-16 items-center justify-between px-4 md:px-6">
<AnimatedImage <Link to="/" className="flex items-center gap-2">
src={migrant.mainPhoto || "/placeholder.svg?height=1080&width=1920"} <span className="text-xl font-bold text-[#9B2335]">Italian Migrants NT</span>
alt={`${migrant.firstName} ${migrant.lastName}`} </Link>
fill <nav className="hidden md:flex gap-6">
className="opacity-80" <Link to="/" className="text-sm font-medium hover:underline underline-offset-4">
/> Home
<div className="absolute inset-0 bg-gradient-to-t from-black/80 to-transparent" />
<motion.div
className="absolute bottom-0 left-0 right-0 p-6 md:p-10 text-white"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.2 }}
>
<div className="flex space-x-2 mb-4">
<div className="h-8 w-3 bg-green-600" />
<div className="h-8 w-3 bg-white" />
<div className="h-8 w-3 bg-red-600" />
</div>
<h1 className="text-3xl md:text-5xl font-bold mb-2 font-serif">
{migrant.firstName} {migrant.lastName}
</h1>
<p className="text-xl md:text-2xl opacity-90">
{migrant.yearOfArrival} {migrant.regionOfOrigin}, Italy {" "}
{migrant.settlementLocation}, NT
</p>
</motion.div>
</div>
{/* Back button */}
<div className="max-w-6xl mx-auto px-4 md:px-8 mt-6">
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5 }}
>
<Button variant="ghost" asChild className="mb-6 hover:bg-gray-100">
<Link to="/" className="flex items-center gap-2">
<ArrowLeft size={16} />
Back to Search
</Link> </Link>
</Button> <Link to="/about" className="text-sm font-medium hover:underline underline-offset-4">
</motion.div> About
</Link>
<Link to="/search" className="text-sm font-medium hover:underline underline-offset-4">
Search
</Link>
<Link to="/stories" className="text-sm font-medium hover:underline underline-offset-4">
Stories
</Link>
<Link to="/contact" className="text-sm font-medium hover:underline underline-offset-4">
Contact
</Link>
</nav>
</div>
</header>
<main className="flex-1">
<section className="w-full py-12 md:py-16 lg:py-20 bg-[#E8DCCA]">
<div className="container px-4 md:px-6">
<div className="flex flex-col md:flex-row gap-8 items-start">
<div className="w-full md:w-1/3 lg:w-1/4">
<div className="sticky top-20 space-y-4">
<div className="overflow-hidden rounded-xl border-4 border-white shadow-lg">
<img
src={profilePhoto && profilePhoto.file_path
? profilePhoto.file_path.startsWith('http')
? profilePhoto.file_path
: `${API_BASE_URL}${profilePhoto.file_path}`
: '/placeholder.svg?height=600&width=450'}
alt={`${migrant.full_name || 'Migrant photo'}`}
className="aspect-[3/4] object-cover w-full"
onError={(e) => {
// Handle image loading errors by setting a fallback
e.currentTarget.src = '/placeholder.svg?height=600&width=450';
}}
/>
</div>
<div className="flex justify-between">
<Button variant="outline" size="sm" className="w-full">
<ImageIcon className="mr-2 h-4 w-4" />
View All Photos
</Button>
</div>
</div>
</div>
<div className="w-full md:w-2/3 lg:w-3/4 space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl font-serif text-[#9B2335]">
{migrant.full_name}
</h1>
<div className="flex flex-wrap gap-4 mt-4">
<div className="flex items-center text-sm text-muted-foreground">
<Calendar className="mr-1 h-4 w-4" />
<span>Arrived {formatDate(migrant.migration?.date_of_arrival_nt || migrant.migration?.date_of_arrival_aus || 'Unknown', 'short')}</span>
</div>
<div className="flex items-center text-sm text-muted-foreground">
<User className="mr-1 h-4 w-4" />
<span>{migrant.migration?.arrival_period ? `Age ${migrant.migration.arrival_period} at migration` : 'Age unknown at migration'}</span>
</div>
<div className="flex items-center text-sm text-muted-foreground">
<Home className="mr-1 h-4 w-4" />
<span>From {migrant.migration?.arrival_period || 'Unknown location'}</span>
</div>
<div className="flex items-center text-sm text-muted-foreground">
<MapPin className="mr-1 h-4 w-4" />
<span>Settled in {migrant.residence?.town_or_city || 'Northern Territory'}</span>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> <Tabs defaultValue="biography" className="w-full">
{/* Main content - 2/3 width on desktop */} <TabsList className="grid w-full grid-cols-4">
<div className="lg:col-span-2 space-y-8"> <TabsTrigger value="biography">Biography</TabsTrigger>
{/* Biographical information */} <TabsTrigger value="photos">Photos</TabsTrigger>
<motion.div <TabsTrigger value="documents">Documents</TabsTrigger>
initial={{ opacity: 0, y: 20 }} <TabsTrigger value="family">Family</TabsTrigger>
animate={{ opacity: 1, y: 0 }} </TabsList>
transition={{ duration: 0.6, delay: 0.1 }} <TabsContent value="biography" className="mt-6">
> <Card>
<Card className="overflow-hidden border border-gray-200 shadow-none rounded-md bg-white shadow-none rounded-md bg-white"> <CardContent className="pt-6">
<CardContent className=""> <div className="space-y-4">
<h2 className="text-2xl font-bold mb-4 font-serif text-gray-800"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
Biographical Information <div className="space-y-2">
</h2> <h3 className="font-medium text-[#01796F]">Personal Information</h3>
<div className="h-1 w-20 bg-gradient-to-r from-green-600 via-white to-red-600 mb-6" /> <div className="grid grid-cols-2 gap-2 text-sm">
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-4 gap-x-8"> <span className="font-medium">Birth Date:</span>
<div> <span>{formatDate(migrant.date_of_birth || 'Unknown', 'long')}</span>
<h3 className="text-sm font-medium text-gray-500"> <span className="font-medium">Birth Place:</span>
Full Name <span>{migrant.place_of_birth}</span>
</h3> <span className="font-medium">Occupation:</span>
<p className="text-lg"> <span>{migrant.occupation}</span>
{migrant.firstName}{" "} </div>
{migrant.middleName ? migrant.middleName + " " : ""} </div>
{migrant.lastName} <div className="space-y-2">
</p> <h3 className="font-medium text-[#01796F]">Migration Details</h3>
</div> <div className="grid grid-cols-2 gap-2 text-sm">
<div> <span className="font-medium">Year of Arrival:</span>
<h3 className="text-sm font-medium text-gray-500"> <span>{formatDate(migrant.migration?.date_of_arrival_nt || 'Unknown', 'long')}</span>
Birth Date <span className="font-medium">Age at Migration:</span>
</h3> <span>{migrant.migration?.arrival_period || 'Unknown'}</span>
<p className="text-lg"> <span className="font-medium">Region of Origin:</span>
{migrant.birthDate || "Unknown"} <span>{migrant.place_of_birth}</span>
</p> <span className="font-medium">Settlement:</span>
</div> <span>{migrant.residence?.town_or_city || 'Northern Territory'}</span>
<div> </div>
<h3 className="text-sm font-medium text-gray-500"> </div>
Birth Place </div>
</h3>
<p className="text-lg"> <div className="pt-4">
{migrant.birthPlace || "Unknown"} <h3 className="font-medium text-[#01796F] mb-2">Biography</h3>
</p> <div className=" max-w-none text-[#747474]">
</div> {migrant.additional_notes ? (
<div> migrant.additional_notes.split("\n\n").map((paragraph, index) => (
<h3 className="text-sm font-medium text-gray-500"> <p key={index} className="mb-4">
Age at Migration {paragraph}
</h3> </p>
<p className="text-lg">{migrant.ageAtMigration} years</p> ))
</div> ) : (
<div> <p className="mb-4">
<h3 className="text-sm font-medium text-gray-500"> {migrant.full_name} was an Italian migrant who made the journey to the Northern Territory of Australia.
Year of Arrival Born in {migrant.place_of_birth || 'Italy'}, {migrant.full_name} sought new opportunities and a better life abroad.
</h3> This migrant arrived in the Northern Territory in {formatDate(migrant.migration?.date_of_arrival_nt || 'Unknown', 'year') || 'the early 20th century'}.
<p className="text-lg">{migrant.yearOfArrival}</p> In Australia, {migrant.full_name} worked as {migrant.occupation || 'a laborer'} and contributed to the growing Italian community.
</div> {migrant.full_name}'s story represents the courage and determination of Italian migrants who helped shape the Northern Territory's rich multicultural heritage.
<div> </p>
<h3 className="text-sm font-medium text-gray-500"> )}
Region of Origin </div>
</h3> </div>
<p className="text-lg">{migrant.regionOfOrigin}, Italy</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500">
Settlement Location
</h3>
<p className="text-lg">
{migrant.settlementLocation}, NT
</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500">
Occupation
</h3>
<p className="text-lg">
{migrant.occupation || "Unknown"}
</p>
</div>
{migrant.deathDate && (
<>
<div>
<h3 className="text-sm font-medium text-gray-500">
Date of Death
</h3>
<p className="text-lg">{migrant.deathDate}</p>
</div> </div>
<div> </CardContent>
<h3 className="text-sm font-medium text-gray-500"> </Card>
Place of Death </TabsContent>
</h3> <TabsContent value="photos" className="mt-6">
<p className="text-lg"> <Card>
{migrant.deathPlace || "Unknown"} <CardContent className="pt-6">
</p> <h3 className="font-medium text-[#01796F] mb-4">Photos</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{migrant.photos?.map((photo) => (
<div key={photo.id} className="space-y-2">
<div className="overflow-hidden rounded-lg border">
<img
src={photo.file_path
? photo.file_path.startsWith('http')
? photo.file_path
: `${API_BASE_URL}${photo.file_path}`
: '/placeholder.svg?height=400&width=600'}
alt={photo.caption || "Migrant photo"}
className="aspect-video object-cover w-full"
onError={(e) => {
// Handle image loading errors
e.currentTarget.src = '/placeholder.svg?height=400&width=600';
}}
/>
</div>
<p className="text-sm text-muted-foreground">{photo.caption || 'No caption available'}</p>
</div>
))}
<p className="text-muted-foreground italic">No information available</p>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="documents" className="mt-6">
<Card>
<CardContent className="pt-6">
<h3 className="font-medium text-[#01796F] mb-4">Documents</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{migrant.naturalization ? (
<div className="flex items-center p-4 border rounded-lg">
<FileText className="h-8 w-8 text-[#9B2335] mr-4" />
<div>
<h4 className="font-medium">Naturalization Certificate</h4>
<p className="text-sm text-muted-foreground">
{formatDate(migrant.naturalization.date_of_naturalisation, 'long')}
</p>
<p className="text-sm text-muted-foreground">
Certificate No: {migrant.naturalization.no_of_cert || 'N/A'}
</p>
</div>
<Button variant="ghost" size="sm" className="ml-auto">
View
</Button>
</div>
) : (
<p className="text-muted-foreground italic">No information available</p>
)}
</div> </div>
</> </CardContent>
)}
</Card>
</TabsContent>
<TabsContent value="family" className="mt-6">
<Card>
<CardContent className="pt-6">
<h3 className="font-medium text-[#01796F] mb-4">Family Information</h3>
<div className="space-y-4">
{migrant.family ? (
<div className="grid grid-cols-1 gap-4">
{migrant.family.names_of_parents && (
<div className="p-4 border rounded-lg">
<h4 className="font-medium flex items-center">
<User className="h-5 w-5 text-[#9B2335] mr-2" />
Parents
</h4>
<p className="mt-2 text-sm text-gray-600">{migrant.family.names_of_parents}</p>
</div>
)}
{migrant.family.names_of_children && (
<div className="p-4 border rounded-lg">
<h4 className="font-medium flex items-center">
<User className="h-5 w-5 text-[#9B2335] mr-2" />
Children
</h4>
<p className="mt-2 text-sm text-gray-600">{migrant.family.names_of_children}</p>
</div>
)}
</div>
) : (
<p className="text-muted-foreground italic">No information available</p>
)}
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</div>
</div>
</section>
<section className="w-full py-12 md:py-24 lg:py-32">
<div className="container px-4 md:px-6">
<div className="flex flex-col items-center justify-center space-y-4 text-center">
<div className="space-y-2">
<h2 className="text-3xl font-bold tracking-tighter sm:text-4xl font-serif text-[#1A2A57]">
Historical Context
</h2>
<p className="max-w-[900px] text-muted-foreground md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed">
Understanding the historical context of {migrant.full_name}'s migration journey.
</p>
</div>
</div>
<div className="mt-12 grid grid-cols-1 md:grid-cols-3 gap-8">
<Card>
<CardContent className="pt-6">
<div className="text-center space-y-4">
<div className="inline-flex h-12 w-12 items-center justify-center rounded-full bg-[#E8DCCA]">
<Ship className="h-6 w-6 text-[#9B2335]" />
</div>
<h3 className="text-xl font-bold font-serif text-[#9B2335]">Italy in the 1920s</h3>
<p className="text-muted-foreground">
Post-World War I Italy faced economic hardship, political instability, and the rise of fascism
under Mussolini, prompting many Italians to seek opportunities abroad.
</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</motion.div> <Card>
<CardContent className="pt-6">
{/* Life story */} <div className="text-center space-y-4">
{migrant.biography && ( <div className="inline-flex h-12 w-12 items-center justify-center rounded-full bg-[#E8DCCA]">
<motion.div <Ship className="h-6 w-6 text-[#9B2335]" />
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
>
<Card className="overflow-hidden border border-gray-200 shadow-none rounded-md bg-white">
<CardContent className="">
<h2 className="text-2xl font-bold mb-4 font-serif text-gray-800">
La Storia di Vita
</h2>
<div className="h-1 w-20 bg-gradient-to-r from-green-600 via-white to-red-600 mb-6" />
<div className="prose max-w-none">
{migrant.biography.split("\n").map((paragraph, index) => (
<p key={index} className="mb-4">
{paragraph}
</p>
))}
</div> </div>
</CardContent> <h3 className="text-xl font-bold font-serif text-[#9B2335]">Migration Journey</h3>
</Card> <p className="text-muted-foreground">
</motion.div> The journey from Italy to Australia was long and arduous, typically involving multiple ships and
)} taking several weeks or even months to complete.
</p>
{/* Photo gallery - only show if there are additional photos */} </div>
{migrant.photos && migrant.photos.length > 0 && ( </CardContent>
<motion.div </Card>
initial={{ opacity: 0, y: 20 }} <Card>
animate={{ opacity: 1, y: 0 }} <CardContent className="pt-6">
transition={{ duration: 0.6, delay: 0.3 }} <div className="text-center space-y-4">
> <div className="inline-flex h-12 w-12 items-center justify-center rounded-full bg-[#E8DCCA]">
<PhotoGallery photos={migrant.photos} /> <Ship className="h-6 w-6 text-[#9B2335]" />
</motion.div> </div>
)} <h3 className="text-xl font-bold font-serif text-[#9B2335]">Northern Territory in the 1920s</h3>
</div> <p className="text-muted-foreground">
The Northern Territory in the 1920s was sparsely populated and developing, with opportunities in
{/* Sidebar - 1/3 width on desktop */} fishing, agriculture, and infrastructure projects attracting migrants.
<div className="space-y-8"> </p>
{/* Historical context */} </div>
<motion.div </CardContent>
initial={{ opacity: 0, y: 20 }} </Card>
animate={{ opacity: 1, y: 0 }} </div>
transition={{ duration: 0.6, delay: 0.4 }}
>
<HistoricalContext year={migrant.yearOfArrival} />
</motion.div>
{/* Related migrants */}
{migrant.relatedMigrants && migrant.relatedMigrants.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.5 }}
>
<RelatedMigrants migrants={migrant.relatedMigrants} />
</motion.div>
)}
</div> </div>
</section>
</main>
<footer className="border-t bg-[#1A2A57] text-white">
<div className="container flex flex-col gap-2 sm:flex-row py-6 w-full items-center px-4 md:px-6">
<p className="text-xs">&copy; {new Date().getFullYear()} Italian Migrants NT. All rights reserved.</p>
<nav className="sm:ml-auto flex gap-4 sm:gap-6">
<Link to="/terms" className="text-xs hover:underline underline-offset-4">
Terms of Service
</Link>
<Link to="/privacy" className="text-xs hover:underline underline-offset-4">
Privacy
</Link>
<Link to="/admin" className="text-xs hover:underline underline-offset-4">
Admin
</Link>
</nav>
</div> </div>
</div> </footer>
</main> </div>
); )
} }

View File

@ -1,197 +1,100 @@
import React, { useState } from 'react'; import { Search, ChevronDown, ChevronUp } from 'lucide-react';
import historicalSearchService, { type SearchParams, type SearchResponse } from '@/services/historicalService'; import { Input } from '../ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '../ui/button';
import { Search } from 'lucide-react'; import { Label } from '../ui/label';
import SearchResults from './SearchResults'; import { useSearch } from '@/hooks/useSearch';
interface SearchFormProps { const advancedSearchFields = [
initialQuery?: string; { id: 'firstName', label: 'First Name', placeholder: 'First Name' },
} { id: 'lastName', label: 'Last Name', placeholder: 'Last Name' },
{ id: 'dateOfBirth', label: 'Date of Birth', placeholder: 'YYYY-MM-DD' },
{ id: 'yearOfArrival', label: 'Year of Arrival', placeholder: 'YYYY' },
{ id: 'regionOfOrigin', label: 'Region of Origin', placeholder: 'Region/Place of Birth' },
{ id: 'settlementLocation', label: 'Settlement Location', placeholder: 'Town or City' }
] as const;
const SearchForm: React.FC<SearchFormProps> = ({ initialQuery = '' }) => { export default function SearchForm() {
// State for search const {
const [formData, setFormData] = useState<SearchParams>({ fields,
query: initialQuery, isAdvancedSearch,
page: 1, handleFieldChange,
per_page: 10 executeSearch,
}); clearAllFields,
const [searchResponse, setSearchResponse] = useState<SearchResponse | null>(null); toggleAdvancedSearch,
const [isLoading, setIsLoading] = useState<boolean>(false); } = useSearch();
const [error, setError] = useState<string | null>(null);
const [hasSearched, setHasSearched] = useState<boolean>(false);
// Form fields configuration const handleSubmit = (e: React.FormEvent) => {
const textFields = [
{ id: 'name', label: 'Name', type: 'text' },
{ id: 'birth_year', label: 'Birth Year', type: 'number', min: 1800, max: 2000 },
{ id: 'place_of_birth', label: 'Place of Birth', type: 'text' },
{ id: 'arrival_year', label: 'Arrival Year', type: 'number', min: 1800, max: 2000 },
{ id: 'occupation', label: 'Occupation', type: 'text' },
{ id: 'residence', label: 'Place of Residence', type: 'text' }
];
// Handle form input changes
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
// Handle search form submission
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setIsLoading(true); executeSearch();
setError(null);
setHasSearched(true);
try {
const response = await historicalSearchService.searchRecords(formData);
setSearchResponse(response);
} catch (err) {
setError(err instanceof Error ? err.message : 'An unknown error occurred');
} finally {
setIsLoading(false);
}
}; };
// Handle form reset const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const handleReset = () => { const { id, value } = e.target;
setFormData({ page: 1, per_page: 10 }); handleFieldChange(id as keyof typeof fields, value);
setSearchResponse(null);
setHasSearched(false);
};
// Handle pagination
const handlePageChange = async (newPage: number) => {
setIsLoading(true);
try {
const response = await historicalSearchService.searchRecords({ ...formData, page: newPage });
setSearchResponse(response);
} catch (err) {
setError(err instanceof Error ? err.message : 'An unknown error occurred');
} finally {
setIsLoading(false);
}
}; };
return ( return (
<div className="search-container"> <div className="w-full max-w-6xl mx-auto p-4 space-y-6">
<h2 className="text-2xl font-bold mb-6">Historical Records Search</h2>
{/* Search Form */} <form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} className="card p-6 mb-8 bg-white shadow-sm rounded-lg border border-gray-100"> <div className="flex flex-col sm:flex-row gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <Input
{/* Text and number input fields */} className='text-[#000] font-bold'
{textFields.map(({ id, label, type, min, max }) => ( id="searchTerm"
<div key={id} className="space-y-2"> placeholder="Search by name..."
<label htmlFor={id} className="block text-sm font-medium"> value={fields.searchTerm}
{label} onChange={handleInputChange}
</label> />
<input <Button type="submit" className="bg-[#9B2335] hover:bg-[#7a1c2a] text-white">
id={id} <Search className="mr-2 h-4 w-4" />
name={id} Search
type={type} </Button>
value={formData[id as keyof SearchParams] || ""}
onChange={handleChange}
placeholder={`Enter ${label.toLowerCase()}`}
min={min}
max={max}
className="w-full p-2 border border-gray-300 rounded-md"
/>
</div>
))}
</div> </div>
<div className="flex flex-wrap gap-4 mt-8 justify-end"> <div className="flex items-center justify-center">
<Button <Button
type="button" type="button"
variant="outline" variant="ghost"
onClick={handleReset} className="text-[#01796F]"
className="border-gray-300 text-gray-700 hover:bg-gray-50" onClick={toggleAdvancedSearch}
> >
Reset {isAdvancedSearch ? (
</Button> <>
<Button <ChevronUp className="mr-2 h-4 w-4" />
type="submit" Simple Search
className="bg-gradient-to-r from-green-600 via-white to-red-600 text-gray-800 hover:from-green-700 hover:via-gray-100 hover:to-red-700 font-medium" </>
disabled={isLoading} ) : (
> <>
<Search className="mr-2 h-4 w-4" /> Search Records <ChevronDown className="mr-2 h-4 w-4" />
Advanced Search
</>
)}
</Button> </Button>
</div> </div>
{isAdvancedSearch && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4">
{advancedSearchFields.map(({ id, label, placeholder }) => (
<div key={id} className="space-y-2">
<Label htmlFor={id}>{label}</Label>
<Input
id={id}
placeholder={placeholder}
value={fields[id as keyof typeof fields]}
onChange={handleInputChange}
/>
</div>
))}
<div className="space-y-2 md:col-span-2">
<Label>Actions</Label>
<Button type="button" variant="ghost" className="w-full" onClick={clearAllFields}>
Clear All Filters
</Button>
</div>
</div>
)}
</form> </form>
{/* Loading State */}
{isLoading && (
<div className="flex justify-center my-8">
<div className="loading-spinner animate-spin h-10 w-10 border-4 border-gray-300 border-t-blue-600 rounded-full"></div>
</div>
)}
{/* Error State */}
{error && (
<div className="error-message bg-red-50 p-4 rounded border border-red-200 text-red-700 mb-6">
Error: {error}
</div>
)}
{/* Results Summary */}
{hasSearched && searchResponse && searchResponse.pagination && !isLoading && (
<div className="results-summary mb-4">
Found {searchResponse.pagination.total} results
</div>
)}
{/* Display search results using the SearchResults component */}
{hasSearched && searchResponse && !isLoading && (
<SearchResults
results={searchResponse.data}
isLoading={isLoading}
hasSearched={hasSearched}
/>
)}
{/* Pagination Controls */}
{hasSearched && searchResponse?.pagination && searchResponse.pagination.totalPages > 1 && (
<div className="pagination-controls mt-6 flex justify-center gap-2">
<button
onClick={() => handlePageChange(1)}
disabled={searchResponse.pagination.currentPage === 1}
className="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded disabled:opacity-50"
>
First
</button>
<button
onClick={() => handlePageChange(searchResponse.pagination.currentPage - 1)}
disabled={searchResponse.pagination.currentPage === 1}
className="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded disabled:opacity-50"
>
Previous
</button>
<span className="px-3 py-1">
Page {searchResponse.pagination.currentPage} of {searchResponse.pagination.totalPages}
</span>
<button
onClick={() => handlePageChange(searchResponse.pagination.currentPage + 1)}
disabled={searchResponse.pagination.currentPage === searchResponse.pagination.totalPages}
className="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded disabled:opacity-50"
>
Next
</button>
<button
onClick={() => handlePageChange(searchResponse.pagination.totalPages)}
disabled={searchResponse.pagination.currentPage === searchResponse.pagination.totalPages}
className="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded disabled:opacity-50"
>
Last
</button>
</div>
)}
</div> </div>
); );
}; }
export default SearchForm;

View File

@ -1,158 +1,161 @@
"use client"; import { Link } from "react-router-dom";
import {
Card, CardContent, CardFooter, CardHeader, CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { useMigrantsSearch } from "@/hooks/useMigrantsSearch";
import { useNavigate } from "react-router-dom"; export default function SearchResults() {
import { motion, AnimatePresence } from "framer-motion"; const {
import type { SearchResult } from "@/types/search"; migrants,
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; loading,
import { Skeleton } from "@/components/ui/skeleton"; error,
import AnimatedImage from "@/components/ui/animated-image"; pagination,
hasActiveFilters,
interface SearchResultsProps { handleNextPage,
results: SearchResult[]; handlePrevPage,
isLoading: boolean; } = useMigrantsSearch(10);
hasSearched?: boolean; const API_BASE_URL = import.meta.env.VITE_API_URL;
}
export default function SearchResults({
results,
isLoading,
hasSearched = false,
}: SearchResultsProps) {
const navigate = useNavigate();
// Define animation variants
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
};
const item = {
hidden: { opacity: 0, y: 20 },
show: { opacity: 1, y: 0, transition: { duration: 0.5 } },
};
if (isLoading) {
return (
<div>
<h3 className="text-2xl font-semibold mb-6 font-serif">
Search Results
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => (
<Card
key={i}
className="overflow-hidden border border-gray-200 shadow-none rounded-md bg-white"
>
<div className="relative h-48 w-full">
<Skeleton className="h-full w-full" />
</div>
<CardHeader>
<Skeleton className="h-6 w-3/4 mb-2" />
<Skeleton className="h-4 w-1/2" />
</CardHeader>
<CardContent>
<Skeleton className="h-4 w-full mb-2" />
<Skeleton className="h-4 w-full mb-2" />
<Skeleton className="h-4 w-3/4" />
</CardContent>
</Card>
))}
</div>
</div>
);
}
if (results.length === 0 && hasSearched) {
return (
<motion.div
className="text-center py-12 bg-gray-50 rounded-lg border border-gray-200"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<h3 className="text-2xl font-semibold mb-4 font-serif">
No Results Found
</h3>
<p className="text-gray-500">
Try adjusting your search criteria to find more records.
</p>
</motion.div>
);
}
if (results.length === 0) {
return null;
}
return ( return (
<div> <div className="flex flex-col min-h-screen">
<h3 className="text-2xl font-semibold mb-6 font-serif"> {/* Header */}
Search Results ({results.length}) <header className="border-b">
</h3> <div className="container flex h-16 items-center justify-between px-4 md:px-6">
<AnimatePresence> <Link to="/" className="flex items-center gap-2">
<motion.div <span className="text-xl font-bold text-[#9B2335]">Italian Migrants NT</span>
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" </Link>
variants={container} <nav className="hidden md:flex gap-6">
initial="hidden" {["home", "about", "search", "stories", "contact"].map((path) => (
animate="show" <Link
> key={path}
{results.map((person) => ( to={`/${path}`}
<motion.div key={person.person_id || person.id_card_no} variants={item} onClick={() => navigate(`/migrants/${person.person_id}`)}> className="text-sm font-medium hover:underline underline-offset-4 capitalize"
<div className="block h-full cursor-pointer"> >
<Card className="overflow-hidden hover:shadow-lg transition-shadow h-full border border-gray-200 group"> {path}
<div className="relative h-48 w-full overflow-hidden"> </Link>
<AnimatedImage ))}
src={ </nav>
"/placeholder.svg?height=300&width=300" </div>
} </header>
alt=""
fill {/* Hero Section */}
/> <main className="flex-1">
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" /> <section className="w-full py-12 md:py-16 lg:py-20 bg-[#E8DCCA]">
<div className="absolute bottom-0 left-0 right-0 p-3 transform translate-y-full group-hover:translate-y-0 transition-transform duration-300"> <div className="container px-4 md:px-6 text-center space-y-4">
<div className="flex space-x-1"> <h1 className="text-3xl sm:text-4xl md:text-5xl font-serif font-bold tracking-tighter text-[#9B2335]">
<div className="h-6 w-2 bg-green-600" /> Search Results
<div className="h-6 w-2 bg-white" /> </h1>
<div className="h-6 w-2 bg-red-600" /> <p className="max-w-[700px] mx-auto text-muted-foreground md:text-xl/relaxed">
</div> Displaying results based on your search criteria.
</div> </p>
</div> </div>
<CardHeader> </section>
<CardTitle className="font-serif">
{person.full_name} {/* Results Section */}
</CardTitle> <section className="w-full py-8 md:py-12">
<p className="text-sm text-gray-500"> <div className="container px-4 md:px-6">
{person.migration?.date_of_arrival_nt ? {error && (
`Arrived ${new Date(person.migration.date_of_arrival_nt).getFullYear()}` : 'Date unknown'} <div className="p-3 bg-red-100 text-red-700 rounded mb-6">
</p> {error}
</CardHeader>
<CardContent>
<div className="space-y-2">
<p>
<span className="font-medium">From:</span>{" "}
{person.place_of_birth || 'Unknown'}, Italy
</p>
<p>
<span className="font-medium">Settled in:</span>{" "}
{person.residence?.town_or_city || 'Unknown'}, NT
</p>
<p>
<span className="font-medium">Occupation:</span>{" "}
{person.occupation || 'Unknown'}
</p>
</div>
</CardContent>
</Card>
</div> </div>
</motion.div> )}
))}
</motion.div> {loading ? (
</AnimatePresence> <div className="flex justify-center my-8">
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-blue-500"></div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{migrants.length > 0 ? (
migrants.map((migrant) => (
<Card key={migrant.person_id} className="overflow-hidden pt-0">
<div className="aspect-square overflow-hidden">
<img
src={
migrant.profilePhoto
? `${API_BASE_URL}${migrant.profilePhoto.file_path}`
: "/placeholder.svg?height=300&width=300"
}
alt={migrant.full_name || "Unknown"}
className="w-full h-full object-cover object-center transition-transform hover:scale-105"
/>
</div>
<CardHeader>
<CardTitle className="font-serif text-[#9B2335]">
{migrant.full_name || "Unknown"}
</CardTitle>
</CardHeader>
<CardContent className="text-sm space-y-2">
<div className="flex justify-between">
<span className="font-medium">ID:</span>
<span>{migrant.person_id}</span>
</div>
<div className="flex justify-between">
<span className="font-medium">Date of Birth:</span>
<span>{migrant.date_of_birth || "Unknown"}</span>
</div>
{migrant.place_of_birth && (
<div className="flex justify-between">
<span className="font-medium">Place of Birth:</span>
<span>{migrant.place_of_birth}</span>
</div>
)}
</CardContent>
<CardFooter>
<Button asChild className="w-full bg-[#01796F] hover:bg-[#015a54] text-white">
<Link to={`/migrant-profile/${migrant.person_id}`}>View Profile</Link>
</Button>
</CardFooter>
</Card>
))
) : (
<div className="col-span-3 text-center p-8 bg-gray-50 rounded-lg">
<p className="text-gray-500">
{hasActiveFilters
? "No migrants found matching your search criteria."
: "No migrants found."}
</p>
</div>
)}
</div>
)}
{/* Pagination */}
{migrants.length > 0 && (
<div className="flex justify-between items-center mt-8">
<div className="text-sm text-gray-700">
Showing <span className="font-medium">{migrants.length}</span> of{" "}
<span className="font-medium">{pagination.totalItems}</span> results
{hasActiveFilters && (
<span> Page {pagination.currentPage} of {pagination.totalPages}</span>
)}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handlePrevPage} disabled={pagination.currentPage === 1}>
Previous
</Button>
<Button variant="outline" onClick={handleNextPage}>
Next
</Button>
</div>
</div>
)}
</div>
</section>
</main>
{/* Footer */}
<footer className="border-t bg-[#1A2A57] text-white">
<div className="container flex flex-col sm:flex-row items-center justify-between px-4 md:px-6 py-4 gap-2">
<p className="text-xs">&copy; {new Date().getFullYear()} Italian Migrants NT. All rights reserved.</p>
<nav className="flex gap-4 sm:gap-6 text-xs">
<Link to="/terms" className="hover:underline underline-offset-4">Terms</Link>
<Link to="/privacy" className="hover:underline underline-offset-4">Privacy</Link>
<Link to="/admin" className="hover:underline underline-offset-4">Admin</Link>
</nav>
</div>
</footer>
</div> </div>
); );
} }

View File

@ -1,74 +0,0 @@
"use client";
import { useState } from "react";
import SearchForm from "./SearchForm";
import SearchResults from "./SearchResults";
import type { SearchParams, SearchResult } from "@/types/search";
import apiService from "@/services/apiService";
import { Loader2 } from "lucide-react";
const SearchSection = () => {
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [hasSearched, setHasSearched] = useState(false);
const [noResultsFound, setNoResultsFound] = useState(false);
const handleSearch = async (params: SearchParams) => {
// Check if at least one search parameter is provided (not empty and not 'all')
const hasSearchCriteria = Object.entries(params).some(([_, value]) =>
value && value !== "" && value !== "all"
);
// If no search criteria provided, don't perform search
if (!hasSearchCriteria) {
setSearchResults([]);
setHasSearched(false);
setNoResultsFound(false);
return;
}
// Show loading indicator
setIsSearching(true);
setSearchResults([]);
}
return (
<section className="py-16 px-4 md:px-8 bg-gray-50">
<div className="max-w-6xl mx-auto">
<h2 className="text-3xl md:text-4xl font-bold mb-8 text-center">
Search Historical Records
</h2>
<SearchForm />
{isSearching && (
<div className="flex justify-center my-8">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
<span className="sr-only">Loading...</span>
</div>
)}
{hasSearched && !isSearching && noResultsFound && (
<div className="text-center py-12 my-8 bg-gray-100 rounded-lg border border-gray-200">
<h3 className="text-2xl font-semibold mb-4 font-serif">No Results Found</h3>
<p className="text-gray-500 mb-4">
We couldn't find any records matching your search criteria.
</p>
<p className="text-gray-600">
Try adjusting your search filters or using fewer criteria to broaden your results.
</p>
</div>
)}
{(hasSearched || isSearching) && (
<SearchResults
results={searchResults}
isLoading={isSearching}
hasSearched={hasSearched && !noResultsFound}
/>
)}
</div>
</section>
);
};
export default SearchSection;

View File

@ -1,40 +1,72 @@
import { Bell, HelpCircle, User } from "lucide-react" import { User } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import apiService from "@/services/apiService"
import { useState } from "react"
import LogoutDialog from "../admin/migrant/Modal/LogoutDialog"
interface HeaderProps { interface HeaderProps {
title: string title: string
} }
export default function Header({ title }: HeaderProps) { export default function Header({ title }: HeaderProps) {
const [logoutDialogOpen, setLogoutDialogOpen] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const handleLogout = async () => {
try {
setIsSubmitting(true)
await apiService.logout()
alert("Logged out successfully")
setTimeout(() => {
window.location.href = "/login"
}, 1000) // Delay so the alert shows
} catch (err) {
alert("Logout failed. Please try again.")
} finally {
setIsSubmitting(false)
setLogoutDialogOpen(false)
}
}
return ( return (
<header className="h-16 border-b border-neutral-200 bg-white flex items-center justify-between px-6">
<h2 className="text-xl font-medium text-neutral-800">{title}</h2> <header className="h-16 border-b border-gray-800 bg-gray-900 flex items-center justify-between px-6 shadow-lg relative">
<div className="h-1 bg-gradient-to-r from-[#9B2335] to-[#9B2335]/60 absolute top-0 left-0 w-full"></div>
<h2 className="text-xl font-medium text-white">{title}</h2>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<Button variant="ghost" size="icon" className="text-neutral-600">
<HelpCircle className="size-5" />
</Button>
<Button variant="ghost" size="icon" className="text-neutral-600 relative">
<Bell className="size-5" />
<span className="absolute top-1 right-1 size-2 bg-red-500 rounded-full"></span>
</Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="rounded-full"> <Button
<User className="size-5 text-neutral-600" /> variant="ghost"
size="icon"
className="rounded-full text-gray-300 hover:text-white hover:bg-gray-800"
>
<User className="size-5" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end" className="bg-gray-800 border-gray-700">
<DropdownMenuItem>Profile</DropdownMenuItem> <DropdownMenuItem className="text-gray-300 hover:text-white hover:bg-gray-700 focus:bg-gray-700 focus:text-white">
<DropdownMenuItem>Settings</DropdownMenuItem> Profile
<DropdownMenuItem>Logout</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem className="text-gray-300 hover:text-white hover:bg-gray-700 focus:bg-gray-700 focus:text-white">
Settings
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setLogoutDialogOpen(true)} className="text-red-400 hover:text-red-300 hover:bg-red-900/20 focus:bg-red-900/20 focus:text-red-300">
Logout
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
</header>
<LogoutDialog
open={logoutDialogOpen}
onOpenChange={setLogoutDialogOpen}
onConfirm={handleLogout}
isSubmitting={isSubmitting}
/>
</header>
) )
} }

View File

@ -1,53 +1,57 @@
"use client"
import { useState } from "react" import { useState } from "react"
import { BarChart3, Home, LogOut, Settings, Users, Menu, X } from "lucide-react" import { BarChart3, Home, LogOut, Settings, Users, Menu, X, UserPlus, Shield } from "lucide-react"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import apiService from "@/services/apiService" import apiService from "@/services/apiService"
import LogoutDialog from "@/components/admin/migrant/table/LogoutDialog" import LogoutDialog from "@/components/admin/migrant/Modal/LogoutDialog"
export default function Sidebar() { export default function Sidebar() {
const [collapsed, setCollapsed] = useState(false) const [collapsed, setCollapsed] = useState(false)
const [mobileOpen, setMobileOpen] = useState(false) const [mobileOpen, setMobileOpen] = useState(false)
const [logoutDialogOpen, setLogoutDialogOpen] = useState(false) const [logoutDialogOpen, setLogoutDialogOpen] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false)
const handleLogout = async () => { const handleLogout = async () => {
try { try {
setIsSubmitting(true); setIsSubmitting(true)
await apiService.logout(); await apiService.logout()
alert("Logged out successfully"); alert("Logged out successfully")
setTimeout(() => { setTimeout(() => {
window.location.href = "/login"; window.location.href = "/login"
}, 1000); // Delay so the alert shows }, 1000) // Delay so the alert shows
} catch (err) { } catch (err) {
alert("Logout failed. Please try again."); alert("Logout failed. Please try again.")
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false)
setLogoutDialogOpen(false); setLogoutDialogOpen(false)
} }
}; }
const isActive = (path : string) => { const isActive = (path: string) => {
// For dashboard, match exactly /admin or /admin/ // For dashboard, match exactly /admin or /admin/
if (path === '/admin/') { if (path === "/admin/") {
return location.pathname === '/admin' || location.pathname === '/admin/'; return location.pathname === "/admin" || location.pathname === "/admin/"
} }
// For all other routes, use exact matching // For all other routes, use exact matching
return location.pathname === path; return location.pathname === path
}; }
return ( return (
<> <>
<LogoutDialog <LogoutDialog
open={logoutDialogOpen} open={logoutDialogOpen}
onOpenChange={setLogoutDialogOpen} onOpenChange={setLogoutDialogOpen}
onConfirm={handleLogout} onConfirm={handleLogout}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
/> />
{/* Mobile menu button - only visible on small screens */} {/* Mobile menu button - only visible on small screens */}
<button <button
className="md:hidden fixed top-4 left-4 z-50 bg-white rounded-md p-2 shadow-md border border-neutral-200" className="md:hidden fixed top-4 left-4 z-50 bg-gray-800 text-white rounded-md p-2 shadow-lg border border-gray-700 hover:bg-gray-700 transition-colors"
onClick={() => setMobileOpen(!mobileOpen)} onClick={() => setMobileOpen(!mobileOpen)}
aria-label="Toggle menu" aria-label="Toggle menu"
> >
@ -56,126 +60,135 @@ export default function Sidebar() {
{/* Sidebar backdrop for mobile */} {/* Sidebar backdrop for mobile */}
{mobileOpen && ( {mobileOpen && (
<div className="md:hidden fixed inset-0 bg-black/50 z-40" onClick={() => setMobileOpen(false)}></div> <div className="md:hidden fixed inset-0 bg-black/70 z-40" onClick={() => setMobileOpen(false)}></div>
)} )}
{/* Sidebar */} {/* Sidebar */}
<aside <aside
className={`bg-gradient-to-b from-neutral-50 to-white border-r border-neutral-200 shadow-md fixed h-full z-50 flex flex-col className={`bg-gray-900 border-r border-gray-800 shadow-2xl fixed h-full z-50 flex flex-col
${collapsed ? "w-16" : "w-64"} ${collapsed ? "w-16" : "w-64"}
${mobileOpen ? "left-0" : "-left-full md:left-0"} ${mobileOpen ? "left-0" : "-left-full md:left-0"}
transition-all duration-300 transition-all duration-300
`} `}
> >
{/* Italian flag stripe at the top */} {/* Accent stripe at the top */}
<div className="flex h-1.5 flex-shrink-0"> <div className="h-1.5 bg-gradient-to-r from-[#9B2335] to-[#9B2335]/60 mx-2 mt-2 rounded-full shadow-lg flex-shrink-0"></div>
<div className="w-1/3 bg-green-600"></div>
<div className="w-1/3 bg-white"></div>
<div className="w-1/3 bg-red-600"></div>
</div>
{/* Sidebar header */} {/* Sidebar header */}
<div className="p-4 flex items-center justify-center border-b border-neutral-200 flex-shrink-0"> <div className="p-4 flex items-center justify-center border-b border-gray-800 flex-shrink-0">
{!collapsed && ( {!collapsed && (
<div className="flex items-center"> <div className="flex items-center">
<div className="size-8 rounded-full bg-gradient-to-r from-green-600 via-white to-red-600 flex items-center justify-center mr-2 shadow-md"> <div className="size-10 rounded-xl bg-[#9B2335] flex items-center justify-center mr-3 shadow-xl border border-gray-700">
<span className="text-sm font-bold text-neutral-800">NT</span> <Shield className="size-5 text-white" />
</div>
<div className="flex flex-col">
<h1 className="text-lg font-bold text-white">Italian Migrants</h1>
<p className="text-xs text-[#9B2335]">Admin Portal</p>
</div> </div>
<h1 className="text-lg font-serif font-bold text-neutral-800">Italian Migrants</h1>
</div> </div>
)} )}
{collapsed && ( {collapsed && (
<div className="size-8 rounded-full bg-gradient-to-r from-green-600 via-white to-red-600 flex items-center justify-center shadow-md"> <div className="size-10 rounded-xl bg-[#9B2335] flex items-center justify-center shadow-xl border border-gray-700">
<span className="text-sm font-bold text-neutral-800">NT</span> <Shield className="size-5 text-white" />
</div> </div>
)} )}
</div> </div>
{/* Scrollable navigation area */} {/* Scrollable navigation area */}
<nav className="flex-1 overflow-y-auto py-4 scrollbar-thin scrollbar-thumb-neutral-300 scrollbar-track-transparent"> <nav className="flex-1 py-4 scrollbar-thin scrollbar-thumb-gray-600 scrollbar-track-transparent">
<ul className="space-y-1 px-2"> <ul className="space-y-2 px-3">
<li> <li>
<Link to="/admin/"> <Link to="/admin/">
<Button <Button
variant="ghost" variant="ghost"
className={`w-full justify-start ${ className={`w-full justify-start rounded-lg transition-all duration-300 ${
isActive("/admin/") isActive("/admin/")
? "bg-green-50 text-green-700 hover:bg-green-100 hover:text-green-800 shadow-sm" ? "bg-[#9B2335] text-white shadow-lg hover:bg-[#9B2335]/90"
: "text-neutral-700" : "text-gray-300 hover:text-white hover:bg-gray-800"
}`} }`}
> >
<Home className={`${collapsed ? "mr-0" : "mr-2"} size-5`} /> <Home className={`${collapsed ? "mr-0" : "mr-3"} size-5`} />
{!collapsed && <span>Dashboard</span>} {!collapsed && <span className="font-medium">Dashboard</span>}
</Button> </Button>
</Link> </Link>
</li> </li>
<li> <li>
<Link to="/admin/migrants"> <Link to="/admin/migrants">
<Button <Button
variant="ghost" variant="ghost"
className={`w-full justify-start ${ className={`w-full justify-start rounded-lg transition-all duration-300 ${
isActive("/admin/migrants") isActive("/admin/migrants")
? "bg-green-50 text-green-700 hover:bg-green-100 hover:text-green-800 shadow-sm" ? "bg-[#9B2335] text-white shadow-lg hover:bg-[#9B2335]/90"
: "text-neutral-700" : "text-gray-300 hover:text-white hover:bg-gray-800"
}`} }`}
> >
<Users className={`${collapsed ? "mr-0" : "mr-2"} size-5`} /> <Users className={`${collapsed ? "mr-0" : "mr-3"} size-5`} />
{!collapsed && <span>Migrants</span>} {!collapsed && <span className="font-medium">Migrants</span>}
</Button> </Button>
</Link> </Link>
</li> </li>
<li> <li>
<Link to="/admin/reports"> <Link to="/admin/reports">
<Button <Button
variant="ghost" variant="ghost"
className={`w-full justify-start ${ className={`w-full justify-start rounded-lg transition-all duration-300 ${
isActive("/admin/reports") isActive("/admin/reports")
? "bg-green-50 text-green-700 hover:bg-green-100 hover:text-green-800 shadow-sm" ? "bg-[#9B2335] text-white shadow-lg hover:bg-[#9B2335]/90"
: "text-neutral-700" : "text-gray-300 hover:text-white hover:bg-gray-800"
}`} }`}
> >
<BarChart3 className={`${collapsed ? "mr-0" : "mr-2"} size-5`} /> <BarChart3 className={`${collapsed ? "mr-0" : "mr-3"} size-5`} />
{!collapsed && <span>Reports</span>} {!collapsed && <span className="font-medium">Reports</span>}
</Button> </Button>
</Link> </Link>
</li> </li>
</ul> </ul>
<Separator className="my-4" />
<ul className="space-y-1 px-2"> <Separator className="my-6 bg-gray-800 mx-3" />
<li>
<Link to="/admin/settings"> <ul className="space-y-2 px-3">
<Button <li>
variant="ghost" <Link to="/admin/users/create">
className={`w-full justify-start ${ <Button
isActive("/admin/settings") variant="ghost"
? "bg-green-50 text-green-700 hover:bg-green-100 hover:text-green-800 shadow-sm" className={`w-full justify-start rounded-lg transition-all duration-300 ${
: "text-neutral-700" isActive("/admin/users/create")
}`} ? "bg-[#9B2335] text-white shadow-lg hover:bg-[#9B2335]/90"
> : "text-gray-300 hover:text-white hover:bg-gray-800"
<Settings className={`${collapsed ? "mr-0" : "mr-2"} size-5`} /> }`}
{!collapsed && <span>Settings</span>} >
</Button> <UserPlus className={`${collapsed ? "mr-0" : "mr-3"} size-5`} />
</Link> {!collapsed && <span className="font-medium">Create User</span>}
</li> </Button>
</ul> </Link>
</nav> </li>
<li>
<Link to="/admin/settings">
<Button
variant="ghost"
className={`w-full justify-start rounded-lg transition-all duration-300 ${
isActive("/admin/settings")
? "bg-[#9B2335] text-white shadow-lg hover:bg-[#9B2335]/90"
: "text-gray-300 hover:text-white hover:bg-gray-800"
}`}
>
<Settings className={`${collapsed ? "mr-0" : "mr-3"} size-5`} />
{!collapsed && <span className="font-medium">Settings</span>}
</Button>
</Link>
</li>
</ul>
</nav>
{/* Sidebar footer */} {/* Sidebar footer */}
<div className="p-4 border-t border-neutral-200 flex-shrink-0"> <div className="p-4 border-t border-gray-800 flex-shrink-0">
<Button <Button
variant="ghost" variant="ghost"
className="w-full justify-start text-neutral-700 hover:bg-red-50 hover:text-red-700" className="w-full justify-start text-red-400 hover:bg-red-900/20 hover:text-red-300 rounded-lg transition-all duration-300"
onClick={() => setLogoutDialogOpen(true)} onClick={() => setLogoutDialogOpen(true)}
> >
<LogOut className={`${collapsed ? "mr-0" : "mr-2"} size-5`} /> <LogOut className={`${collapsed ? "mr-0" : "mr-3"} size-5`} />
{!collapsed && <span>Logout</span>} {!collapsed && <span className="font-medium">Logout</span>}
</Button>
</div>
{/* Only show collapse button on desktop */}
<div className="p-2 border-t border-neutral-200 hidden md:block flex-shrink-0">
<Button variant="ghost" size="sm" className="w-full" onClick={() => setCollapsed(!collapsed)}>
{collapsed ? ">>" : "<<"}
</Button> </Button>
</div> </div>
</aside> </aside>

View File

@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
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",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
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",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,239 @@
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

View File

@ -4,36 +4,45 @@ import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Dialog({ const Dialog = React.forwardRef<
...props React.ElementRef<typeof DialogPrimitive.Root>,
}: React.ComponentProps<typeof DialogPrimitive.Root>) { React.ComponentPropsWithoutRef<typeof DialogPrimitive.Root>
return <DialogPrimitive.Root data-slot="dialog" {...props} /> >((props, ref) => {
} return <DialogPrimitive.Root data-slot="dialog" ref={ref} {...props} />
})
Dialog.displayName = "Dialog"
function DialogTrigger({ const DialogTrigger = React.forwardRef<
...props React.ElementRef<typeof DialogPrimitive.Trigger>,
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) { React.ComponentPropsWithoutRef<typeof DialogPrimitive.Trigger>
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> >((props, ref) => {
} return <DialogPrimitive.Trigger data-slot="dialog-trigger" ref={ref} {...props} />
})
DialogTrigger.displayName = "DialogTrigger"
function DialogPortal({ const DialogPortal = React.forwardRef<
...props React.ElementRef<typeof DialogPrimitive.Portal>,
}: React.ComponentProps<typeof DialogPrimitive.Portal>) { React.ComponentPropsWithoutRef<typeof DialogPrimitive.Portal>
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> >((props, ref) => {
} return <DialogPrimitive.Portal data-slot="dialog-portal" ref={ref} {...props} />
})
DialogPortal.displayName = "DialogPortal"
function DialogClose({ const DialogClose = React.forwardRef<
...props React.ElementRef<typeof DialogPrimitive.Close>,
}: React.ComponentProps<typeof DialogPrimitive.Close>) { React.ComponentPropsWithoutRef<typeof DialogPrimitive.Close>
return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> >((props, ref) => {
} return <DialogPrimitive.Close data-slot="dialog-close" ref={ref} {...props} />
})
DialogClose.displayName = "DialogClose"
function DialogOverlay({ const DialogOverlay = React.forwardRef<
className, React.ElementRef<typeof DialogPrimitive.Overlay>,
...props React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) { >(({ className, ...props }, ref) => {
return ( return (
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
ref={ref}
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
@ -42,17 +51,18 @@ function DialogOverlay({
{...props} {...props}
/> />
) )
} })
DialogOverlay.displayName = "DialogOverlay"
function DialogContent({ const DialogContent = React.forwardRef<
className, React.ElementRef<typeof DialogPrimitive.Content>,
children, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
...props >(({ className, children, ...props }, ref) => {
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return ( return (
<DialogPortal data-slot="dialog-portal"> <DialogPortal>
<DialogOverlay /> <DialogOverlay />
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref}
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
@ -68,56 +78,63 @@ function DialogContent({
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
) )
} })
DialogContent.displayName = "DialogContent"
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { const DialogHeader = React.forwardRef<
return ( HTMLDivElement,
<div React.HTMLAttributes<HTMLDivElement>
data-slot="dialog-header" >(({ className, ...props }, ref) => (
className={cn("flex flex-col gap-2 text-center sm:text-left", className)} <div
{...props} ref={ref}
/> data-slot="dialog-header"
) className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
} {...props}
/>
))
DialogHeader.displayName = "DialogHeader"
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { const DialogFooter = React.forwardRef<
return ( HTMLDivElement,
<div React.HTMLAttributes<HTMLDivElement>
data-slot="dialog-footer" >(({ className, ...props }, ref) => (
className={cn( <div
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", ref={ref}
className data-slot="dialog-footer"
)} className={cn(
{...props} "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
/> className
) )}
} {...props}
/>
))
DialogFooter.displayName = "DialogFooter"
function DialogTitle({ const DialogTitle = React.forwardRef<
className, React.ElementRef<typeof DialogPrimitive.Title>,
...props React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
}: React.ComponentProps<typeof DialogPrimitive.Title>) { >(({ className, ...props }, ref) => (
return ( <DialogPrimitive.Title
<DialogPrimitive.Title ref={ref}
data-slot="dialog-title" data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)} className={cn("text-lg leading-none font-semibold", className)}
{...props} {...props}
/> />
) ))
} DialogTitle.displayName = "DialogTitle"
function DialogDescription({ const DialogDescription = React.forwardRef<
className, React.ElementRef<typeof DialogPrimitive.Description>,
...props React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
}: React.ComponentProps<typeof DialogPrimitive.Description>) { >(({ className, ...props }, ref) => (
return ( <DialogPrimitive.Description
<DialogPrimitive.Description ref={ref}
data-slot="dialog-description" data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) ))
} DialogDescription.displayName = "DialogDescription"
export { export {
Dialog, Dialog,

View File

@ -0,0 +1,29 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

137
src/components/ui/sheet.tsx Normal file
View File

@ -0,0 +1,137 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@ -0,0 +1,724 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva } from "class-variance-authority"
// Polyfill for VariantProps if not available
type VariantProps<T extends (...args: any) => any> = Omit<Parameters<T>[0], "className" | "as" | "asChild">
import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@ -0,0 +1,35 @@
"use client"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
import { useToast } from "@/hooks/useToast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 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 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -0,0 +1,108 @@
"use client"
import { useEffect, useRef } from "react"
export default function YearlyMigrationChart() {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext("2d")
if (!ctx) return
// Set canvas dimensions
canvas.width = canvas.offsetWidth
canvas.height = canvas.offsetHeight
// Data for yearly migration
const years = [1900, 1905, 1910, 1915, 1920, 1925, 1930, 1935, 1940, 1945, 1950]
const migrationData = [12, 18, 25, 15, 30, 85, 120, 95, 40, 25, 35]
const maxValue = Math.max(...migrationData)
const padding = 50
const chartWidth = canvas.width - padding * 2
const chartHeight = canvas.height - padding * 2
const barWidth = (chartWidth / years.length) * 0.6
const barSpacing = (chartWidth / years.length) * 0.4
// Draw chart
ctx.clearRect(0, 0, canvas.width, canvas.height)
// Draw axes
ctx.beginPath()
ctx.moveTo(padding, padding)
ctx.lineTo(padding, canvas.height - padding)
ctx.lineTo(canvas.width - padding, canvas.height - padding)
ctx.strokeStyle = "#d1d5db"
ctx.stroke()
// Draw grid lines
const gridLines = 5
for (let i = 0; i <= gridLines; i++) {
const y = padding + (chartHeight / gridLines) * i
const value = Math.round(maxValue - (maxValue / gridLines) * i)
ctx.beginPath()
ctx.moveTo(padding, y)
ctx.lineTo(canvas.width - padding, y)
ctx.strokeStyle = "#f3f4f6"
ctx.stroke()
// Y-axis labels
ctx.fillStyle = "#6b7280"
ctx.font = "10px sans-serif"
ctx.textAlign = "right"
ctx.textBaseline = "middle"
ctx.fillText(value.toString(), padding - 10, y)
}
// Draw bars and x-axis labels
years.forEach((year, index) => {
const x = padding + (chartWidth / years.length) * index + barSpacing / 2
const barHeight = (migrationData[index] / maxValue) * chartHeight
const y = canvas.height - padding - barHeight
// Create gradient for bars
const gradient = ctx.createLinearGradient(x, y, x, canvas.height - padding)
gradient.addColorStop(0, "#9B2335")
gradient.addColorStop(1, "rgba(155, 35, 53, 0.7)")
ctx.fillStyle = gradient
ctx.fillRect(x, y, barWidth, barHeight)
// X-axis label
ctx.fillStyle = "#6b7280"
ctx.font = "10px sans-serif"
ctx.textAlign = "center"
ctx.textBaseline = "top"
ctx.fillText(year.toString(), x + barWidth / 2, canvas.height - padding + 10)
})
// Draw chart title
ctx.fillStyle = "#1f2937"
ctx.font = "bold 14px sans-serif"
ctx.textAlign = "center"
ctx.textBaseline = "top"
ctx.fillText("Italian Migration to Northern Territory (1900-1950)", canvas.width / 2, 15)
// Draw y-axis title
ctx.save()
ctx.translate(15, canvas.height / 2)
ctx.rotate(-Math.PI / 2)
ctx.fillStyle = "#6b7280"
ctx.font = "12px sans-serif"
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillText("Number of Migrants", 0, 0)
ctx.restore()
}, [])
return (
<div className="w-full h-[250px]">
<canvas ref={canvasRef} className="w-full h-full"></canvas>
</div>
)
}

19
src/hooks/use-mobile.ts Normal file
View File

@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

37
src/hooks/useGraph.ts Normal file
View File

@ -0,0 +1,37 @@
import { useEffect, useState } from "react";
import ApiService from "@/services/apiService";
import { getMigrationCounts, getResidenceCounts, getOccupationCounts } from "@/utils/dataProcessing";
import type { Person } from "@/types/api";
export const useMigrants = () => {
const [migrationData, setMigrationData] = useState<{ year: number; count: number }[]>([]);
const [residenceData, setResidenceData] = useState<{ name: string; value: number }[]>([]);
const [occupationData, setOccupationData] = useState<{ occupation: string; value: number }[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<null | string>(null);
useEffect(() => {
const fetchData = async () => {
try {
const response = await ApiService.getMigrants(1, 1000);
const people: Person[] = response.data;
const migrationCounts = getMigrationCounts(people);
const residenceCounts = getResidenceCounts(people);
const occupationCounts = getOccupationCounts(people);
setMigrationData(migrationCounts);
setResidenceData(residenceCounts);
setOccupationData(occupationCounts);
} catch (err) {
setError("Error fetching data");
} finally {
setLoading(false);
}
};
fetchData();
}, []);
return { migrationData, residenceData, occupationData, loading, error };
};

View File

@ -1,28 +0,0 @@
"use client"
import { useState, useEffect } from "react"
export const useImageLoader = (src: string) => {
const [isLoaded, setIsLoaded] = useState(false)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
const img = new Image()
img.src = src
img.onload = () => {
setIsLoaded(true)
}
img.onerror = (e) => {
setError(e as unknown as Error)
}
return () => {
img.onload = null
img.onerror = null
}
}, [src])
return { isLoaded, error }
}

217
src/hooks/useMigrants.ts Normal file
View File

@ -0,0 +1,217 @@
import { useEffect, useState } from "react";
import ApiService from "@/services/apiService";
import type { Person } from "@/types/api";
interface UseMigrantsReturn {
migrants: Person[];
loading: boolean;
currentPage: number;
totalPages: number;
searchFullName: string;
searchOccupation: string;
filters: Record<string, string>;
selectedMigrants: number[];
setCurrentPage: (page: number | ((prev: number) => number)) => void;
setSearchFullName: (name: string) => void;
setSearchOccupation: (occupation: string) => void;
setFilters: (filters: Record<string, string> | ((prev: Record<string, string>) => Record<string, string>)) => void;
handleSearch: () => void;
resetFilters: () => void;
refetchMigrants: () => void;
toggleSelectMigrant: (migrantId: number | string | undefined) => void;
toggleSelectAll: () => void;
isAllSelected: boolean;
clearSelection: () => void;
handleBulkDelete: () => void;
}
export const useMigrants = (perPage: number = 10): UseMigrantsReturn => {
const [migrants, setMigrants] = useState<Person[]>([]);
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [searchFullName, setSearchFullName] = useState("");
const [searchOccupation, setSearchOccupation] = useState("");
const [filters, setFilters] = useState<Record<string, string>>({
// Initialize with default values
arrival_order: "desc" // default to newest first
});
const [triggerFetch, setTriggerFetch] = useState(false);
const [selectedMigrants, setSelectedMigrants] = useState<number[]>([]);
const fetchMigrants = async () => {
setLoading(true);
try {
const activeFilters: Record<string, string> = {};
// Add search filters with improved validation
if (searchFullName && searchFullName.trim() !== "") activeFilters.full_name = searchFullName.trim();
// Enhanced logging for occupation search to debug the issue
console.log("Search Occupation value:", searchOccupation);
if (searchOccupation && searchOccupation.trim() !== "") {
activeFilters.occupation = searchOccupation.trim();
console.log("Added occupation filter:", searchOccupation.trim());
}
if (filters.arrival_from) activeFilters.arrival_from = filters.arrival_from;
if (filters.arrival_to) activeFilters.arrival_to = filters.arrival_to;
if (filters.sort_by === "arrival_date") {
// Always set the sort_by field to the actual DB column name
activeFilters.sort_by = "date_of_arrival_nt";
// Use arrival_order if available, default to 'desc' (newest first) if not set
activeFilters.sort_order = filters.arrival_order || "desc";
if (filters.alphabetical_order) {
activeFilters.secondary_sort_by = "full_name";
activeFilters.secondary_sort_order = filters.alphabetical_order;
}
} else if (filters.alphabetical_order) {
activeFilters.sort_by = "full_name";
activeFilters.sort_order = filters.alphabetical_order;
}
console.log("Sending filters:", activeFilters);
const res = await ApiService.getMigrants(currentPage, perPage, activeFilters);
setMigrants(res.data);
setTotalPages(res.last_page);
setCurrentPage(res.current_page);
} catch (err) {
console.error("Failed to fetch migrants", err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchMigrants();
}, [currentPage]);
useEffect(() => {
if (triggerFetch) {
fetchMigrants();
setTriggerFetch(false);
}
}, [triggerFetch]);
useEffect(() => {
setSelectedMigrants([]);
}, [currentPage, triggerFetch]);
const handleSearch = () => {
console.log("handleSearch called with searchOccupation:", searchOccupation);
const hasInput =
searchFullName.trim() !== "" ||
searchOccupation.trim() !== "" ||
filters.arrival_from?.trim() ||
filters.arrival_to?.trim() ||
filters.alphabetical_order?.trim() ||
filters.sort_by?.trim() ||
filters.arrival_order?.trim();
// Force trigger search when occupation is provided
if (searchOccupation.trim() !== "") {
console.log("Occupation search triggered with:", searchOccupation);
setCurrentPage(1);
setTriggerFetch(true);
return;
}
// Always trigger the search if sorting options are selected
if (filters.sort_by === "arrival_date") {
setCurrentPage(1);
setTriggerFetch(true);
} else if (!hasInput) {
resetFilters();
} else {
setCurrentPage(1);
setTriggerFetch(true);
}
};
const resetFilters = () => {
setSearchFullName("");
setSearchOccupation("");
// Preserve default arrival_order value when resetting
setFilters({
arrival_order: "desc" // keep default sorting (newest first)
});
setCurrentPage(1);
setTriggerFetch(true);
};
const refetchMigrants = () => {
setTriggerFetch(true);
};
const toggleSelectMigrant = (migrantId: number | string | undefined) => {
// Convert to number and validate
const id = typeof migrantId === 'number' ? migrantId :
typeof migrantId === 'string' ? parseInt(migrantId) : null;
if (id === null || isNaN(id)) return;
setSelectedMigrants(prev =>
prev.includes(id)
? prev.filter(selectedId => selectedId !== id)
: [...prev, id]
);
};
const toggleSelectAll = () => {
if (selectedMigrants.length === migrants.length) {
setSelectedMigrants([]);
} else {
setSelectedMigrants(
migrants
.map(migrant => migrant.person_id)
.filter((id): id is number => typeof id === 'number')
);
}
};
const isAllSelected = migrants.length > 0 && selectedMigrants.length === migrants.filter(migrant =>
typeof migrant.person_id === 'number' ||
(typeof migrant.person_id === 'string' && !isNaN(parseInt(migrant.person_id)))
).length;
const clearSelection = () => {
setSelectedMigrants([]);
};
const handleBulkDelete = () => {
// After successful deletion (called by DeleteDialog's onDeleteSuccess)
setSelectedMigrants([]);
setTriggerFetch(true);
};
return {
migrants,
loading,
currentPage,
totalPages,
searchFullName,
searchOccupation,
filters,
selectedMigrants,
setCurrentPage,
setSearchFullName,
setSearchOccupation,
setFilters,
handleSearch,
resetFilters,
refetchMigrants,
toggleSelectMigrant,
toggleSelectAll,
isAllSelected,
clearSelection,
handleBulkDelete,
};
};

View File

@ -0,0 +1,97 @@
import { useEffect, useState, useCallback } from "react";
import { useSearchParams } from "react-router-dom";
import apiService from "@/services/apiService";
import type { Person, Photo } from "@/types/api";
const getSearchParamsObject = (params: URLSearchParams): Record<string, string> => {
const result: Record<string, string> = {};
params.forEach((value, key) => {
result[key] = value;
});
return result;
};
export function useMigrantsSearch(perPage = 10) {
const [searchParams] = useSearchParams();
const [migrants, setMigrants] = useState<Person[]>([]);
const [photosById, setPhotosById] = useState<Record<number, Photo[]>>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState<number>(1);
const [pagination, setPagination] = useState({
currentPage: 1,
totalPages: 1,
totalItems: 0,
});
const hasActiveFilters = searchParams.toString().length > 0;
const fetchMigrants = useCallback(async () => {
setLoading(true);
setError(null);
try {
const filters = getSearchParamsObject(searchParams);
const response = await apiService.getMigrants(page, perPage, filters);
const fetchedMigrants = response.data;
setMigrants(fetchedMigrants);
setPagination({
currentPage: response.current_page,
totalPages: response.last_page,
totalItems: response.total,
});
// Fetch photos for each migrant
const photosMap: Record<number, Photo[]> = {};
await Promise.all(
fetchedMigrants.map(async (migrant: Person) => {
try {
const photos = await apiService.getPhotos(migrant.person_id);
migrant.photos = photos; // Store all photos
migrant.profilePhoto = photos.find((photo) => photo.is_profile_photo) || null;
} catch (err) {
console.error(`Failed to fetch photos for person ${migrant.person_id}`, err);
migrant.photos = [];
migrant.profilePhoto = null;
}
})
);
setPhotosById(photosMap);
} catch (err) {
setError("Failed to fetch migrants data.");
} finally {
setLoading(false);
}
}, [searchParams, page, perPage]);
useEffect(() => {
fetchMigrants();
}, [fetchMigrants]);
const handleNextPage = () => {
if (pagination.currentPage < pagination.totalPages) {
setPage((prev) => prev + 1);
}
};
const handlePrevPage = () => {
if (pagination.currentPage > 1) {
setPage((prev) => prev - 1);
}
};
return {
migrants,
photosById, // expose photos by person_id
loading,
error,
pagination,
hasActiveFilters,
handleNextPage,
handlePrevPage,
};
}

104
src/hooks/useSearch.ts Normal file
View File

@ -0,0 +1,104 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
export interface SearchFields {
searchTerm: string;
firstName: string;
lastName: string;
dateOfBirth: string;
yearOfArrival: string;
regionOfOrigin: string;
settlementLocation: string;
}
const initialFields: SearchFields = {
searchTerm: '',
firstName: '',
lastName: '',
dateOfBirth: '',
yearOfArrival: '',
regionOfOrigin: '',
settlementLocation: '',
};
const fieldMapping: Record<keyof SearchFields, string> = {
searchTerm: 'full_name',
firstName: 'christian_name',
lastName: 'surname',
dateOfBirth: 'date_of_birth',
yearOfArrival: 'date_of_arrival_nt',
regionOfOrigin: 'place_of_birth',
settlementLocation: 'town_or_city',
};
export function useSearch() {
const navigate = useNavigate();
const [fields, setFields] = useState<SearchFields>(initialFields);
const [isAdvancedSearch, setIsAdvancedSearch] = useState(false);
const handleFieldChange = (fieldId: keyof SearchFields, value: string) => {
setFields(prev => ({ ...prev, [fieldId]: value }));
};
const buildSearchParams = (): URLSearchParams => {
const params = new URLSearchParams();
Object.entries(fields).forEach(([key, value]) => {
if (value.trim()) {
const mappedKey = fieldMapping[key as keyof SearchFields];
params.append(mappedKey, value.trim());
}
});
return params;
};
const executeSearch = () => {
const params = buildSearchParams();
navigate(`/search-results?${params.toString()}`);
};
const clearAllFields = () => {
setFields(initialFields);
};
const toggleAdvancedSearch = () => {
setIsAdvancedSearch(prev => !prev);
};
const hasActiveFilters = Object.values(fields).some(Boolean);
const getActiveFilters = () => {
return Object.entries(fields)
.filter(([_, value]) => Boolean(value))
.map(([key, value]) => ({
key: key as keyof SearchFields,
value,
label: getFieldLabel(key as keyof SearchFields)
}));
};
const getFieldLabel = (key: keyof SearchFields): string => {
const labels: Record<keyof SearchFields, string> = {
searchTerm: 'Name',
firstName: 'First',
lastName: 'Last',
dateOfBirth: 'Date of Birth',
yearOfArrival: 'Year of Arrival',
regionOfOrigin: 'Region',
settlementLocation: 'Settlement',
};
return labels[key];
};
return {
fields,
isAdvancedSearch,
hasActiveFilters,
handleFieldChange,
executeSearch,
clearAllFields,
toggleAdvancedSearch,
getActiveFilters,
};
}

View File

@ -0,0 +1,215 @@
import { useState } from "react"
import { z } from "zod"
import apiService from "@/services/apiService"
// Schema types
export interface PersonData {
surname: string
christian_name: string
date_of_birth: string
place_of_birth: string
date_of_death: string
occupation: string
additional_notes: string
reference: string
id_card_no: string
}
export interface MigrationData {
date_of_arrival_aus: string
date_of_arrival_nt: string
arrival_period: string
data_source: string
}
export interface NaturalizationData {
date_of_naturalisation: string
no_of_cert: string
issued_at: string
}
export interface ResidenceData {
town_or_city: string
home_at_death: string
}
export interface FamilyData {
names_of_parents: string
names_of_children: string
}
export interface InternmentData {
corps_issued: string
interned_in: string
sent_to: string
internee_occupation: string
internee_address: string
cav: string
}
// Zod schemas for validation
export const personSchema = z.object({
surname: z.string().min(1, "Surname is required"),
christian_name: z.string().min(1, "Christian Name is required"),
date_of_birth: z.string().min(1, "Date of Birth is required"),
place_of_birth: z.string().min(1, "Place of Birth is required"),
date_of_death: z.string().min(1, "Date of Death is required"),
occupation: z.string().min(1, "Occupation is required"),
additional_notes: z.string().min(1, "Notes are required"),
reference: z.string().min(1, "Reference is required"),
id_card_no: z.string().min(1, "ID Card No is required"),
})
export const migrationSchema = z.object({
date_of_arrival_aus: z.string().min(1, "Date of Arrival in Australia is required"),
date_of_arrival_nt: z.string().min(1, "Date of Arrival in NT is required"),
arrival_period: z.string().min(1, "Arrival Period is required"),
data_source: z.string().min(1, "Data Source is required"),
})
export const naturalizationSchema = z.object({
date_of_naturalisation: z.string().min(1, "Date of Naturalisation is required"),
no_of_cert: z.string().min(1, "Number of Certificate is required"),
issued_at: z.string().min(1, "Issued At is required"),
})
export const residenceSchema = z.object({
town_or_city: z.string().min(1, "Town or City is required"),
home_at_death: z.string().min(1, "Home at Death is required"),
})
export const familySchema = z.object({
names_of_parents: z.string().min(1, "Names of Parents are required"),
names_of_children: z.string().min(1, "Names of Children are required"),
})
export const internmentSchema = z.object({
corps_issued: z.string().min(1, "Corps Issued is required"),
interned_in: z.string().min(1, "Interned In is required"),
sent_to: z.string().min(1, "Sent To is required"),
internee_occupation: z.string().min(1, "Internee Occupation is required"),
internee_address: z.string().min(1, "Internee Address is required"),
cav: z.string().min(1, "CAV is required"),
})
interface UseTabsPaneFormSubmitParams {
person: PersonData
migration: MigrationData
naturalization: NaturalizationData
residence: ResidenceData
family: FamilyData
internment: InternmentData
photos: File[]
captions: string[]
existingPhotos: any[]
mainPhotoIndex: number | null
isEditMode: boolean
id?: string
onReset: () => void
}
export const useTabsPaneFormSubmit = ({
person,
migration,
naturalization,
residence,
family,
internment,
photos,
captions,
existingPhotos,
mainPhotoIndex,
isEditMode,
id,
onReset,
}: UseTabsPaneFormSubmitParams) => {
const [loading, setLoading] = useState(false)
const handleSubmit = async () => {
try {
setLoading(true)
// Validate all form data using zod schemas
personSchema.parse(person)
migrationSchema.parse(migration)
naturalizationSchema.parse(naturalization)
residenceSchema.parse(residence)
familySchema.parse(family)
internmentSchema.parse(internment)
const formData = new FormData()
// Add person data
Object.entries(person).forEach(([key, value]) => formData.append(key, value))
// Add section data
Object.entries(migration).forEach(([key, value]) => formData.append(`migration[${key}]`, value))
Object.entries(naturalization).forEach(([key, value]) => formData.append(`naturalization[${key}]`, value))
Object.entries(residence).forEach(([key, value]) => formData.append(`residence[${key}]`, value))
Object.entries(family).forEach(([key, value]) => formData.append(`family[${key}]`, value))
Object.entries(internment).forEach(([key, value]) => formData.append(`internment[${key}]`, value))
// Add new photos
photos.forEach((file) => formData.append("photos[]", file))
// Add all captions (existing + new)
captions.forEach((caption, index) => formData.append(`captions[${index}]`, caption))
// Add main photo index and profile photo info
if (mainPhotoIndex !== null) {
formData.append("main_photo_index", mainPhotoIndex.toString())
formData.append("set_as_profile", "true")
// Determine if it's an existing photo or new photo
if (mainPhotoIndex < existingPhotos.length) {
// It's an existing photo
formData.append("profile_photo_id", existingPhotos[mainPhotoIndex].id.toString())
} else {
// It's a new photo
formData.append("profile_photo_index", (mainPhotoIndex - existingPhotos.length).toString())
}
}
// Add existing photo IDs that should be kept
existingPhotos.forEach((photo, index) => {
formData.append(`existing_photos[${index}]`, photo.id.toString())
})
let response
if (isEditMode && id) {
// Convert string ID to number for the API call
const personId = Number.parseInt(id, 10)
if (isNaN(personId)) {
throw new Error("Invalid person ID")
}
// Update existing migrant
response = await apiService.updateMigrant(personId, formData)
alert("Person updated successfully!")
} else {
// Create new migrant
response = await apiService.createMigrant(formData)
alert("Person created successfully!")
onReset()
}
console.log("Success:", response)
return response
} catch (err: any) {
if (err instanceof z.ZodError) {
alert("Validation error: " + err.errors.map((e) => e.message).join(", "))
} else {
console.error("Error:", err)
alert(`Failed to ${isEditMode ? "update" : "create"} person.`)
}
throw err
} finally {
setLoading(false)
}
}
return {
loading,
handleSubmit,
}
}

View File

@ -118,3 +118,4 @@
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }
.container{width:100%;margin-right:auto;margin-left:auto;padding-right:2rem;padding-left:2rem}

View File

@ -1,13 +1,11 @@
import HeroSection from "../components/home/HeroSection"; import HeroSection from "../components/home/HeroSection";
import IntroSection from "../components/home/IntroSection";
import SearchSection from "../components/home/SearchSection";
const HomePage = () => { const HomePage = () => {
return ( return (
<main> <main>
<HeroSection /> <HeroSection />
<SearchSection />
<IntroSection />
</main> </main>
); );
}; };

View File

@ -1,155 +1,41 @@
"use client"; import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import type { Person } from '@/types/api';
import apiService from '@/services/apiService';
import MigrantProfileComponent from '@/components/home/MigrantProfileComponent'; // Adjust path
import { useEffect, useState } from "react"; const MigrantProfilePage: React.FC = () => {
import { useParams, useNavigate } from "react-router-dom";
import apiService from "@/services/apiService";
import type { MigrantProfile } from "@/types/migrant";
import MigrantProfileComponent from "@/components/home/MigrantProfileComponent";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import { AlertCircle, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { calculateAgeAtMigration } from "@/utils/date";
const MigrantProfilePage = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const [migrant, setMigrant] = useState<Person | null>(null);
const [migrant, setMigrant] = useState<MigrantProfile | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [retryCount, setRetryCount] = useState(0);
// Fetch migrant data when component mounts or ID changes
useEffect(() => { useEffect(() => {
const fetchMigrantData = async () => { if (!id) {
// Reset state when ID changes setError('No ID provided in the URL.');
setLoading(true); setLoading(false);
setMigrant(null); return;
setError(null); }
if (!id) {
setError('Missing migrant ID');
setLoading(false);
return;
}
const fetchMigrant = async () => {
try { try {
// Fetch migrant data from the backend using apiService const data = await apiService.getMigrantById(id);
const data = await apiService.getRecordById(id); setMigrant(data);
} catch (err) {
if (data) { setError('Failed to load migrant data.');
// Data successfully retrieved - convert API data to MigrantProfile
const migrantProfile: MigrantProfile = {
id: data.person_id || id,
firstName: data.christian_name || '',
lastName: data.surname || '',
middleName: '',
birthDate: data.date_of_birth || '',
birthPlace: data.place_of_birth || '',
ageAtMigration: calculateAgeAtMigration(data.date_of_birth, data.migration?.date_of_arrival_nt),
yearOfArrival: data.migration?.date_of_arrival_nt ?
new Date(data.migration.date_of_arrival_nt).getFullYear() : 0,
regionOfOrigin: data.place_of_birth || 'Unknown',
settlementLocation: data.residence?.town_or_city || 'Unknown',
occupation: data.occupation || 'Unknown',
deathDate: data.date_of_death || '',
deathPlace: data.residence?.home_at_death || '',
mainPhoto: '', // No photo URL in the API response
biography: data.additional_notes || '',
photos: [],
relatedMigrants: []
};
setMigrant(migrantProfile);
setError(null);
} else {
// No data found for this ID
setMigrant(null);
setError(`No migrant found with ID: ${id}`);
}
} catch (error: any) {
// Handle API errors
const errorMessage =
error.response?.data?.message ||
error.message ||
'An unexpected error occurred';
console.error('Error fetching migrant:', errorMessage);
setError(`Failed to load migrant data: ${errorMessage}`);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
fetchMigrantData(); fetchMigrant();
}, [id, retryCount]); // retryCount allows manual retries }, [id]);
// Handle retry button click if (loading) return <p className="text-gray-500">Loading...</p>;
const handleRetry = () => { if (error) return <p className="text-red-500">{error}</p>;
setRetryCount(prev => prev + 1); if (!migrant) return <p>No data found.</p>;
};
// Loading state return <MigrantProfileComponent/>;
if (loading) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] p-8">
<Loader2 className="h-12 w-12 animate-spin text-primary mb-4" />
<h2 className="text-xl font-medium mb-2">Loading migrant profile...</h2>
<p className="text-gray-500">Retrieving data from the database</p>
</div>
);
}
// Error state
if (error) {
// Check if the error is specifically about not finding a migrant
const isNotFoundError = error.includes('No migrant found');
return (
<div className="max-w-3xl mx-auto p-8">
<Alert variant={isNotFoundError ? "default" : "destructive"} className={`mb-6 ${isNotFoundError ? 'border-amber-500' : ''}`}>
<AlertCircle className={`h-5 w-5 ${isNotFoundError ? 'text-amber-500' : ''}`} />
<AlertTitle>{isNotFoundError ? "Migrant Not Found" : "Error"}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
<div className="flex flex-col items-center mt-8 mb-4">
{isNotFoundError && (
<div className="text-center mb-6">
<p className="text-lg mb-4">The migrant profile you're looking for could not be found in our database.</p>
<p className="text-gray-600">This might be because:</p>
<ul className="list-disc list-inside text-gray-600 mt-2 mb-4">
<li>The ID provided is incorrect</li>
<li>The record has been removed from the database</li>
<li>The record hasn't been added to the database yet</li>
</ul>
</div>
)}
<div className="flex gap-4 justify-center">
<Button onClick={() => navigate(-1)}>Go Back</Button>
{!isNotFoundError && (
<Button variant="outline" onClick={handleRetry}>Retry</Button>
)}
<Button variant="outline" onClick={() => window.location.href = '/search-test'}>Search Again</Button>
</div>
</div>
</div>
);
}
// No data state (should be caught by error state, but just in case)
if (!migrant) {
return (
<div className="max-w-3xl mx-auto p-8 text-center">
<h2 className="text-2xl font-bold mb-4">Migrant Profile Not Found</h2>
<p className="mb-6">The migrant profile you're looking for could not be found.</p>
<Button onClick={() => navigate(-1)}>Return to Previous Page</Button>
</div>
);
}
// Render migrant profile when data is loaded successfully
return <MigrantProfileComponent migrant={migrant} />;
}; };
export default MigrantProfilePage; export default MigrantProfilePage;

View File

@ -1,36 +1,42 @@
import axios from "axios"; import axios, { type AxiosInstance } from "axios";
import type { DashboardResponse, Person } from "@/types/api"; import type { DashboardResponse, Person, Photo, User } from "@/types/api";
import type { SearchParams, SearchResult } from "@/types/search";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
const api = axios.create({
baseURL: API_BASE_URL,
headers: { "Content-Type": "application/json" },
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem("token");
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem("token");
localStorage.removeItem("user");
window.location.href = "/login";
}
return Promise.reject(err);
}
);
class ApiService { class ApiService {
private api: AxiosInstance;
constructor() {
this.api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
headers: { "Content-Type": "application/json" },
});
// Request Interceptor
this.api.interceptors.request.use((config) => {
const token = localStorage.getItem("token");
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// Response Interceptor
this.api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem("token");
localStorage.removeItem("user");
window.location.href = "/login";
}
return Promise.reject(err);
}
);
}
get baseURL(): string {
return this.api.defaults.baseURL || "";
}
// --- AUTH --- // --- AUTH ---
async login(params: { email: string; password: string }) { async login(params: { email: string; password: string }) {
return api.post("/api/login", params).then((res) => { return this.api.post("/api/login", params).then((res) => {
localStorage.setItem("token", res.data.token); localStorage.setItem("token", res.data.token);
localStorage.setItem("user", JSON.stringify(res.data.user)); localStorage.setItem("user", JSON.stringify(res.data.user));
return res.data; return res.data;
@ -38,11 +44,15 @@ class ApiService {
} }
async register(params: { name: string; email: string; password: string }) { async register(params: { name: string; email: string; password: string }) {
return api.post("/api/register", params).then((res) => res.data); return this.api.post("/api/register", params).then((res) => res.data);
}
async createUser(user: User) {
return this.api.post("/api/register", user).then((res) => res.data);
} }
async logout() { async logout() {
return api.post("/api/logout").then((res) => { return this.api.post("/api/logout").then((res) => {
localStorage.removeItem("token"); localStorage.removeItem("token");
localStorage.removeItem("user"); localStorage.removeItem("user");
return res.data; return res.data;
@ -50,65 +60,59 @@ class ApiService {
} }
// --- MIGRANTS --- // --- MIGRANTS ---
async getMigrants(page = 1, perPage = 10) { async getMigrants(page = 1, perPage = 10, filters = {}): Promise<Person> {
return api.get("/api/persons", { return this.api.get("/api/migrants", {
params: { page, per_page: perPage }, params: { page, per_page: perPage, ...filters },
}).then((res) => res.data); }).then((res) => res.data.data);
} }
async getMigrantsByUrl(url: string) { async getMigrantsByUrl(url: string) {
return api.get(url).then((res) => res.data); return this.api.get(url).then((res) => res.data);
} }
async getPersonById(id: string | number): Promise<Person> { async getMigrantById(id: string | number): Promise<Person> {
return api.get(`/api/persons/${id}`).then((res) => res.data.data); return this.api.get(`/api/migrants/${id}`).then((res) => res.data.data);
} }
async createPerson(formData: any) { async createMigrant(formData: FormData): Promise<any> {
return api.post("/api/persons", formData).then((res) => res.data); return this.api.post("/api/migrants", formData, {
headers: { "Content-Type": "multipart/form-data" },
}).then((res) => res.data.data);
} }
async updatePerson(id: string | number, formData: any) { async updateMigrant(personId: number, formData: FormData): Promise<any> {
return api.put(`/api/persons/${id}`, formData).then((res) => res.data); // Use POST with method spoofing instead of PUT for better FormData compatibility
formData.append('_method', 'PUT');
return this.api.post(`/api/migrants/${personId}`, formData, {
headers: { "Content-Type": "multipart/form-data" },
}).then((res) => {
return res.data.data;
}).catch(error => {
throw error;
});
} }
async deletePerson(id: string | number) { async deleteMigrant(id: string | number) {
return api.delete(`/api/persons/${id}`).then((res) => res.data); return this.api.delete(`/api/migrants/${id}`).then((res) => res.data);
} }
// --- SEARCH --- async getPhotos(id: number): Promise<Photo[]> {
async getRecordById(id: string): Promise<SearchResult> { return this.api.get(`/api/migrants/${id}/photos`).then((res) => {
return api.get(`/api/persons/${id}`).then((res) => res.data.data); const photosData = res.data.data.photos;
return Array.isArray(photosData) ? photosData : photosData ? [photosData] : [];
});
} }
async searchPeople(params: SearchParams): Promise<SearchResult[]> { // --- DASHBOARD ---
const filteredParams = Object.fromEntries(
Object.entries(params).filter(([_, v]) => v && v !== "all")
);
if (Object.keys(filteredParams).length === 0) return [];
return api.get("/api/persons/search", {
params: { ...filteredParams, exactMatch: true },
}).then((res) =>
res.data.success && res.data.data ? res.data.data.data : []
);
}
//DASHBOARD
async getDashboardStats(): Promise<DashboardResponse> { async getDashboardStats(): Promise<DashboardResponse> {
const response = await api.get<DashboardResponse>('/api/dashboard/stats'); const response = await this.api.get<DashboardResponse>("/api/dashboard/stats");
return response.data; return response.data;
} }
async getRecentActivityLogs() {
return api.get("/api/activity-logs").then((res) => res.data.data);
}
async searchByText(query: string): Promise<Person[]> {
if (!query.trim()) return [];
const res = await api.get("/api/historical/search", { params: { query } });
return res.data?.data ?? [];
}
async getRecentActivityLogs() {
return this.api.get("/api/activity-logs").then((res) => res.data.data);
}
} }
export default new ApiService(); export default new ApiService();

View File

@ -5,6 +5,18 @@ export interface SearchParams {
query?: string; query?: string;
page?: number; page?: number;
per_page?: number; per_page?: number;
// Person details
surname?: string;
christian_name?: string;
date_of_birth?: string;
place_of_birth?: string;
// Migration details
date_of_arrival_nt?: string;
year_of_arrival?: string;
region_of_origin?: string;
settlement_location?: string;
// Computed fields
age_at_migration?: number;
} }
// Pagination metadata structure // Pagination metadata structure
@ -21,6 +33,8 @@ export interface Migration {
date_of_arrival_nt?: string; date_of_arrival_nt?: string;
arrival_period?: string; arrival_period?: string;
data_source?: string; data_source?: string;
region_of_origin?: string; // Region of Origin in Italy
settlement_location?: string; // Settlement Location in NT
} }
// Naturalization details for a person // Naturalization details for a person
@ -98,7 +112,19 @@ class HistoricalSearchService {
params: { params: {
query: params.query || '', query: params.query || '',
page: params.page || 1, page: params.page || 1,
per_page: params.per_page || 10 per_page: params.per_page || 10,
// Person details
surname: params.surname,
christian_name: params.christian_name,
date_of_birth: params.date_of_birth,
place_of_birth: params.place_of_birth,
// Migration details
date_of_arrival_nt: params.date_of_arrival_nt,
year_of_arrival: params.year_of_arrival,
region_of_origin: params.region_of_origin,
settlement_location: params.settlement_location,
// Computed fields
age_at_migration: params.age_at_migration
} }
}); });
@ -132,7 +158,7 @@ class HistoricalSearchService {
* @param personId The ID of the person to retrieve * @param personId The ID of the person to retrieve
* @returns Promise with SearchResult * @returns Promise with SearchResult
*/ */
async getPersonById(personId: string): Promise<SearchResult> { async getMigrantById(personId: string): Promise<SearchResult> {
try { try {
const response = await axios.get(`${API_BASE_URL}/person/${personId}`); const response = await axios.get(`${API_BASE_URL}/person/${personId}`);
return response.data.data; return response.data.data;

View File

@ -1,137 +0,0 @@
import axios from "axios";
import type { SearchParams, SearchResult } from "@/types/search";
import type { MigrantProfile } from "@/types/migrant";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
class ApiService {
async getRecords(): Promise<SearchResult[]> {
return api.get('/api/persons/search').then((response) => response.data);
}
}
export const apiService = new ApiService();
export const searchPeople = async (params: SearchParams): Promise<SearchResult[]> => {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value && value !== "all") {
query.append(key, value.toString());
}
});
try {
const response = await api.get(
`/api/persons/search?${query.toString()}`
);
const responseData = response.data;
if (responseData.success && responseData.data) {
return responseData.data.data.map((person: any) => ({
id: person.person_id || person.id,
firstName: person.christian_name,
lastName: person.surname,
yearOfArrival: person.migration?.date_of_arrival_nt ?
new Date(person.migration.date_of_arrival_nt).getFullYear() : 'Unknown',
ageAtMigration: person.age_at_migration || 'Unknown',
regionOfOrigin: person.place_of_birth || 'Unknown',
settlementLocation: person.residence?.suburb || 'Unknown',
occupation: person.occupation || 'Unknown',
photoUrl: person.photo_url || null
}));
}
return [];
} catch (error) {
console.error('Error searching people:', error);
throw error;
}
};
/**
* Retrieves detailed profile information for a specific migrant by ID
* @param id - The migrant's unique identifier
* @returns Promise containing the migrant's complete profile or null if not found
*/
export const getPersonById = async (id: string): Promise<MigrantProfile | null> => {
try {
console.log(`Making API request to: ${API_BASE_URL}/api/migrants/${id}`);
const response = await axios.get(
`${API_BASE_URL}/api/migrants/${id}`
);
console.log('API Response:', response.status, response.statusText);
console.log('Response data:', response.data);
const responseData = response.data;
if (responseData.success && responseData.data) {
console.log('Successfully parsed response data');
const person = responseData.data;
return {
id: person.person_id || person.id,
firstName: person.christian_name,
lastName: person.surname,
middleName: person.middle_name,
birthDate: person.date_of_birth
? new Date(person.date_of_birth.split('.')[0] + 'Z').toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
: 'Unknown',
birthPlace: person.place_of_birth,
ageAtMigration: person.age_at_migration ? Number(person.age_at_migration) : 0,
yearOfArrival: person.migration?.date_of_arrival_nt ?
new Date(person.migration.date_of_arrival_nt).getFullYear() : 0,
regionOfOrigin: person.place_of_birth || 'Unknown',
settlementLocation: person.residence?.suburb || 'Unknown',
occupation: person.occupation || 'Unknown',
deathDate: person.date_of_death
? new Date(person.date_of_death.split('.')[0] + 'Z').toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
: 'Unknown',
deathPlace: person.residence?.home_at_death || 'Unknown',
mainPhoto: person.photo_url || null,
biography: person.biography || '',
photos: person.photos || [],
relatedMigrants: person.related_persons || []
};
}
return null;
} catch (error: any) {
if (axios.isAxiosError(error)) {
console.error('Axios error in getPersonById:', {
status: error.response?.status,
statusText: error.response?.statusText,
url: error.config?.url,
data: error.response?.data
});
// If the error is a 404, we can return null to indicate no migrant found
if (error.response?.status === 404) {
console.log('Migrant not found (404 response)');
return null;
}
} else {
console.error('Non-Axios error fetching migrant profile:', error);
}
throw error;
}
};

View File

@ -1,9 +1,10 @@
export interface User { export interface User {
id: string;
name: string; name: string;
email: string; email: string;
password: string;
password_confirmation: string;
} }
// types/person.ts
export interface Migration { export interface Migration {
migration_id?: number; migration_id?: number;
@ -38,9 +39,8 @@ export interface Residence {
export interface Family { export interface Family {
family_id?: number; family_id?: number;
person_id?: number; person_id?: number;
name?: string; names_of_parents?: string;
relationship?: string; names_of_children?: string;
notes?: string;
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
} }
@ -58,8 +58,24 @@ export interface Internment {
updated_at?: string; updated_at?: string;
} }
export interface Photo {
id?: number;
person_id?: number;
filename: string;
original_filename?: string;
file_path: string;
mime_type?: string;
file_size?: number;
is_profile_photo: boolean;
caption?: string | null;
created_at?: string;
updated_at?: string;
deleted_at?: string | null;
}
export interface Person { export interface Person {
person_id?: string; person_id: number;
surname?: string; surname?: string;
christian_name?: string; christian_name?: string;
full_name?: string; full_name?: string;
@ -73,14 +89,28 @@ export interface Person {
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
photos?: Photo[];
profilePhoto?: Photo | null;
migration?: Migration | null; migration?: Migration | null;
naturalization?: Naturalization | null; naturalization?: Naturalization | null;
residence?: Residence | null; residence?: Residence | null;
family?: Family | null; family?: Family | null;
internment?: Internment | null; internment?: Internment | null;
has_photos?: boolean; data: Person[];
total: number;
} }
export interface PaginatedResponse<T> {
data: T[];
current_page: number;
last_page: number;
total: number;
per_page: number;
}
export interface createMigrantPayload { export interface createMigrantPayload {
surname: string; surname: string;
christian_name: string; christian_name: string;
@ -117,12 +147,6 @@ export interface Pagination {
prev_page_url: string | null; prev_page_url: string | null;
} }
export interface ApiResponse<T> {
success: boolean;
message: string;
data: T[];
pagination: Pagination;
}
//DASHBOARD STATS //DASHBOARD STATS
export interface DashboardStats { export interface DashboardStats {
total_migrants: number; total_migrants: number;
@ -130,6 +154,14 @@ export interface DashboardStats {
recent_additions: number; recent_additions: number;
pending_reviews: number; pending_reviews: number;
incomplete_records: number; incomplete_records: number;
peak_migration_year:{
year: number;
count: number;
}
most_common_origin: {
place: string;
count: number;
}
} }
export interface DashboardResponse { export interface DashboardResponse {

View File

@ -1,33 +0,0 @@
export interface Photo {
url: string
caption?: string
year?: number
}
export interface RelatedMigrant {
id: string
firstName: string
lastName: string
relationship: string
photoUrl?: string
}
export interface MigrantProfile {
id: string
firstName: string
lastName: string
middleName?: string
birthDate?: string
birthPlace?: string
ageAtMigration: number
yearOfArrival: number
regionOfOrigin: string
settlementLocation: string
occupation?: string
deathDate?: string
deathPlace?: string
mainPhoto?: string
biography?: string
photos?: Photo[]
relatedMigrants?: RelatedMigrant[]
}

View File

@ -1,84 +1,84 @@
import axios from 'axios'; // import axios from 'axios';
// Interfaces // // Interfaces
export interface SearchParams { // export interface SearchParams {
query?: string; // query?: string;
page?: number; // page?: number;
per_page?: number; // per_page?: number;
} // }
// Pagination metadata structure // // Pagination metadata structure
export interface Pagination { // export interface Pagination {
total: number; // total: number;
currentPage: number; // currentPage: number;
totalPages: number; // totalPages: number;
perPage: number; // perPage: number;
} // }
// Migration details for a person // // Migration details for a person
export interface Migration { // export interface Migration {
date_of_arrival_aus?: string; // date_of_arrival_aus?: string;
date_of_arrival_nt?: string; // date_of_arrival_nt?: string;
arrival_period?: string; // arrival_period?: string;
data_source?: string; // data_source?: string;
} // }
// Naturalization details for a person // // Naturalization details for a person
export interface Naturalization { // export interface Naturalization {
certificate_number?: string; // certificate_number?: string;
date_of_naturalization?: string; // date_of_naturalization?: string;
previous_nationality?: string; // previous_nationality?: string;
place_of_naturalization?: string; // place_of_naturalization?: string;
} // }
// Residence details for a person // // Residence details for a person
export interface Residence { // export interface Residence {
town_or_city?: string; // town_or_city?: string;
home_at_death?: string; // home_at_death?: string;
} // }
// Family details for a person // // Family details for a person
export interface Family { // export interface Family {
spouse_name?: string; // spouse_name?: string;
spouse_origin?: string; // spouse_origin?: string;
number_of_children?: number; // number_of_children?: number;
names_of_children?: string; // names_of_children?: string;
additional_notes?: string; // additional_notes?: string;
} // }
// Internment details for a person // // Internment details for a person
export interface Internment { // export interface Internment {
was_interned: boolean; // was_interned: boolean;
camp_name?: string; // camp_name?: string;
date_of_internment?: string; // date_of_internment?: string;
date_of_release?: string; // date_of_release?: string;
additional_notes?: string; // additional_notes?: string;
} // }
// A single search result representing a person record // // A single search result representing a person record
export interface SearchResult { // export interface SearchResult {
person_id: string; // person_id: string;
surname: string; // surname: string;
christian_name: string; // christian_name: string;
full_name: string; // full_name: string;
date_of_birth?: string; // date_of_birth?: string;
place_of_birth?: string; // place_of_birth?: string;
date_of_death?: string; // date_of_death?: string;
occupation?: string; // occupation?: string;
additional_notes?: string; // additional_notes?: string;
reference?: string; // reference?: string;
id_card_no?: string; // id_card_no?: string;
migration: Migration; // migration: Migration;
naturalization: Naturalization; // naturalization: Naturalization;
residence: Residence; // residence: Residence;
family: Family; // family: Family;
internment: Internment; // internment: Internment;
} // }
// The overall search response from the API // // The overall search response from the API
export interface SearchResponse { // export interface SearchResponse {
data: SearchResult[]; // data: SearchResult[];
pagination: Pagination; // pagination: Pagination;
success: boolean; // success: boolean;
message: string; // message: string;
} // }

View File

@ -0,0 +1,49 @@
import type { Person } from "@/types/api";
export const getMigrationCounts = (people: Person[]) => {
const counts: Record<string, number> = {};
for (const person of people) {
const date = person.migration?.date_of_arrival_nt;
if (date) {
const year = date.slice(0, 4); // Extract year string like "2015"
counts[year] = (counts[year] || 0) + 1;
}
}
return Object.entries(counts)
.map(([year, count]) => ({
year: parseInt(year, 10),
count,
}))
.sort((a, b) => a.year - b.year);
};
export const getResidenceCounts = (people: Person[]) => {
const counts: Record<string, number> = {};
for (const person of people) {
const city = person.residence?.town_or_city || "Unknown";
counts[city] = (counts[city] || 0) + 1;
}
return Object.entries(counts).map(([name, value]) => ({
name,
value,
}));
};
// ✅ New function: getOccupationCounts
export const getOccupationCounts = (people: Person[]) => {
const counts: Record<string, number> = {};
for (const person of people) {
const occupation = person.occupation || "Unknown";
counts[occupation] = (counts[occupation] || 0) + 1;
}
return Object.entries(counts).map(([occupation, value]) => ({
occupation,
value,
}));
};

View File

@ -1,19 +1,35 @@
export const calculateAgeAtMigration = (birthDate: string | null | undefined, migrationDate: string | null | undefined): number => { /**
if (!birthDate || !migrationDate) return 0; * Formats a date for display
* @param dateString Date as string
* @param format Format type (default: 'medium')
* @returns Formatted date string or empty string if invalid
*/
export const formatDate = (
dateString: string | null | undefined,
format: 'short' | 'medium' | 'long' | 'year' = 'medium'
): string => {
if (!dateString) return '';
try { try {
const normalizedBirthDate = birthDate.includes('.') ? birthDate.split('.')[0] + 'Z' : birthDate; const normalizedDate = dateString.includes('.') ? dateString.split('.')[0] + 'Z' : dateString;
const normalizedMigrationDate = migrationDate.includes('.') ? migrationDate.split('.')[0] + 'Z' : migrationDate; const date = new Date(normalizedDate);
const birthYear = new Date(normalizedBirthDate).getFullYear(); if (isNaN(date.getTime())) return '';
const migrationYear = new Date(normalizedMigrationDate).getFullYear();
// Simple year difference calculation switch (format) {
const age = migrationYear - birthYear; case 'short':
return date.toLocaleDateString('en-AU', { year: 'numeric' });
return age >= 0 ? age : 0; case 'medium':
} catch (error) { return date.toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' });
console.error(`Error calculating age: birth=${birthDate}, migration=${migrationDate}`, error); case 'long':
return 0; return date.toLocaleDateString('en-AU', { day: 'numeric', month: 'long', year: 'numeric' });
case 'year':
return date.getFullYear().toString();
default:
return date.toLocaleDateString();
} }
}; } catch (error) {
console.error(`Error formatting date: ${dateString}`, error);
return '';
}
};

225
src/utils/toast.tsx Normal file
View File

@ -0,0 +1,225 @@
import toast from 'react-hot-toast';
import { PlusCircle, X, XCircle, Trash2, Clock, Edit } from 'lucide-react';
// React import is needed for JSX rendering
/**
* Show a custom toast notification for successfully creating a new migrant
* @param onViewMigrants - Optional callback for when the user clicks 'View Migrants'
* @param onAddAnother - Optional callback for when the user clicks 'Add Another'
*/
export const showSuccessToast = (onViewMigrants?: () => void) => {
toast(
(t) => (
<div className="flex items-start">
<div className="flex-shrink-0 bg-green-50 rounded-full p-2">
<PlusCircle className="h-5 w-5 text-green-600" />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-slate-900">Migrant Created</h3>
<div className="mt-1 text-sm text-slate-500">
New migrant record has been successfully added to the database.
</div>
<div className="mt-2 flex space-x-3">
<button
onClick={() => {
toast.dismiss(t.id);
if (onViewMigrants) onViewMigrants();
}}
className="inline-flex text-xs font-medium text-green-700 hover:text-green-800"
>
View Migrants
</button>
</div>
</div>
<div className="ml-auto pl-3">
<div className="-mx-1.5 -my-1.5">
<button
onClick={() => toast.dismiss(t.id)}
className="inline-flex rounded-md p-1.5 text-slate-500 hover:bg-slate-100 hover:text-slate-700"
>
<span className="sr-only">Dismiss</span>
<X className="h-4 w-4" />
</button>
</div>
</div>
</div>
),
{
duration: 5000,
style: {
background: "#ffffff",
padding: "16px",
borderRadius: "8px",
boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)",
border: "1px solid #e2e8f0",
maxWidth: "400px",
},
}
);
};
/**
* Show an error toast notification with red styling
*/
export const showErrorToast = (message: string) => {
return toast.error(message, {
style: {
background: '#EF4444',
color: '#fff',
padding: '16px',
borderRadius: '10px',
},
icon: <XCircle className="text-white" />,
duration: 3000,
position: 'top-right',
});
};
/**
* Show a delete toast notification for a single item
* @param itemName - Name of the item that was deleted
*/
export const showDeleteToast = (itemName: string) => {
toast(
(t) => (
<div className="flex items-start">
<div className="flex-shrink-0 bg-red-50 rounded-full p-2">
<Trash2 className="h-5 w-5 text-red-600" />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-slate-900">Item Deleted</h3>
<div className="mt-1 text-sm text-slate-500">
<span className="font-medium">{itemName}</span> has been deleted.
</div>
</div>
<div className="ml-auto pl-3">
<div className="-mx-1.5 -my-1.5">
<button
onClick={() => toast.dismiss(t.id)}
className="inline-flex rounded-md p-1.5 text-slate-500 hover:bg-slate-100 hover:text-slate-700"
>
<span className="sr-only">Dismiss</span>
<X className="h-4 w-4" />
</button>
</div>
</div>
</div>
),
{
duration: 5000,
style: {
background: "#ffffff",
padding: "16px",
borderRadius: "8px",
boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)",
border: "1px solid #e2e8f0",
maxWidth: "400px",
},
}
);
};
/**
* Show a bulk delete toast notification for multiple items
* @param count - Number of items that were deleted
*/
export const showBulkDeleteToast = (count: number) => {
toast(
(t) => (
<div className="flex items-start">
<div className="flex-shrink-0 bg-red-50 rounded-full p-2">
<Trash2 className="h-5 w-5 text-red-600" />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-slate-900">Items Deleted</h3>
<div className="mt-1 text-sm text-slate-500">
<span className="font-medium">{count} items</span> have been deleted from your workspace.
</div>
<div className="mt-2 flex">
<button
onClick={() => toast.dismiss(t.id)}
className="inline-flex text-xs font-medium text-slate-500 hover:text-slate-700"
>
Dismiss
</button>
</div>
</div>
</div>
),
{
duration: 5000,
style: {
background: "#ffffff",
padding: "16px",
borderRadius: "8px",
boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)",
border: "1px solid #e2e8f0",
maxWidth: "400px",
},
}
);
};
/**
* Show an update toast notification with details about the updated item
* @param itemName - Name of the item that was updated
* @param onViewChanges - Optional callback for when the user clicks 'View Changes'
*/
export const showUpdateItemToast = (itemName: string, onViewChanges?: () => void) => {
toast(
(t) => (
<div className="flex items-start">
<div className="flex-shrink-0 bg-blue-50 rounded-full p-2">
<Edit className="h-5 w-5 text-blue-600" />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-slate-900">Item Updated</h3>
<div className="mt-1 text-sm text-slate-500">
<span className="font-medium">{itemName}</span> has been updated successfully.
</div>
<div className="mt-2 flex items-center space-x-4">
<div className="flex items-center">
<div className="text-xs text-slate-500 flex items-center">
<Clock className="inline-block h-3 w-3 mr-1" />
Just now
</div>
</div>
{onViewChanges && (
<button
onClick={() => {
toast.dismiss(t.id);
if (onViewChanges) onViewChanges();
}}
className="inline-flex text-xs font-medium text-blue-700 hover:text-blue-800"
>
View Changes
</button>
)}
</div>
</div>
<div className="ml-auto pl-3">
<div className="-mx-1.5 -my-1.5">
<button
onClick={() => toast.dismiss(t.id)}
className="inline-flex rounded-md p-1.5 text-slate-500 hover:bg-slate-100 hover:text-slate-700"
>
<span className="sr-only">Dismiss</span>
<X className="h-4 w-4" />
</button>
</div>
</div>
</div>
),
{
duration: 5000,
style: {
background: "#ffffff",
padding: "16px",
borderRadius: "8px",
boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)",
border: "1px solid #e2e8f0",
maxWidth: "400px",
},
}
);
};