initial commit
This commit is contained in:
parent
1cda1cbf72
commit
5d1c3576cf
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
|
|
@ -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",
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,2 +1,6 @@
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
22
src/App.tsx
22
src/App.tsx
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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="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="Dashboard" />
|
||||||
|
<main className="p-6">
|
||||||
<div className="grid gap-6 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">
|
||||||
{[...Array(4)].map((_, index) => (
|
{[...Array(4)].map((_, index) => (
|
||||||
<div key={index} className="bg-white rounded-lg shadow p-6 animate-pulse">
|
<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-200 rounded w-1/2 mb-4"></div>
|
<div className="h-4 bg-gray-700 rounded w-1/2 mb-4"></div>
|
||||||
<div className="h-8 bg-gray-200 rounded w-1/4 mb-4"></div>
|
<div className="h-8 bg-gray-700 rounded w-1/4 mb-4"></div>
|
||||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
<div className="h-4 bg-gray-700 rounded w-3/4"></div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
</main>
|
||||||
|
</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>
|
||||||
|
<div className="h-12 w-12 rounded-full bg-[#9B2335]/20 border border-[#9B2335]/30 flex items-center justify-center">
|
||||||
<div className="grid gap-6 md:grid-cols-2 mb-8">
|
<TrendingUp className="h-6 w-6 text-[#9B2335]" />
|
||||||
<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>
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
</form>
|
<div className="mt-4">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
{/* Results */}
|
<span className="text-gray-400">Growth Rate</span>
|
||||||
<div className="mt-6 space-y-2">
|
<span className="font-medium text-[#9B2335]">+24% YoY</span>
|
||||||
{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="w-full bg-gray-800 rounded-full h-1.5 mt-1">
|
||||||
results.map((person) => (
|
<div className="bg-[#9B2335] h-1.5 rounded-full" style={{ width: "24%" }}></div>
|
||||||
<div
|
</div>
|
||||||
key={person.person_id}
|
</div>
|
||||||
onClick={() => navigate(`/migrants/${person.person_id}`)}
|
</CardContent>
|
||||||
className="cursor-pointer border rounded px-4 py-3 hover:bg-neutral-100 transition"
|
</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">Recent Addition</p>
|
||||||
|
<h3 className="text-2xl font-bold mt-1 text-white">{stats?.recent_additions || 0}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="h-12 w-12 rounded-full bg-blue-500/20 border border-blue-500/30 flex items-center justify-center">
|
||||||
|
<TrendingDown className="h-6 w-6 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-gray-400">Change</span>
|
||||||
|
<span className="font-medium text-blue-400">-1.2 years</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-800 rounded-full h-1.5 mt-1">
|
||||||
|
<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"
|
||||||
>
|
>
|
||||||
<div className="font-medium">{person.full_name}</div>
|
<path
|
||||||
{person.migration?.date_of_arrival_nt && (
|
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"
|
||||||
<div className="text-sm text-gray-600">
|
stroke="currentColor"
|
||||||
Date of Arrival: {person.migration.date_of_arrival_nt}
|
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>
|
</div>
|
||||||
))
|
<div className="mt-4">
|
||||||
) : (
|
<div className="flex items-center justify-between text-xs">
|
||||||
searchQuery.trim() && (
|
<span className="text-gray-400">Percentage</span>
|
||||||
<p className="text-sm text-gray-500 mt-4">No results found.</p>
|
<span className="font-medium text-purple-400">35%</span>
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
</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>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<ResidenceChart />
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-xl font-serif">Migration Trends</CardTitle>
|
|
||||||
<CardDescription>Yearly migration patterns</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-4">
|
|
||||||
<div className="h-[180px] flex items-end justify-between gap-2">
|
|
||||||
{[35, 45, 20, 30, 75, 60, 40, 80, 90, 50].map((height, i) => (
|
|
||||||
<div key={i} className="relative group flex flex-col items-center">
|
|
||||||
<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">
|
|
||||||
{1950 + i * 5}: {height} migrants
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="w-7 bg-gradient-to-t from-green-600 to-green-400 rounded-t"
|
|
||||||
style={{ height: `${height * 1.8}px` }}
|
|
||||||
></div>
|
|
||||||
<span className="text-xs mt-1 text-neutral-500">{1950 + i * 5}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 Header from "@/components/layout/Header"
|
||||||
import Sidebar from "@/components/layout/Sidebar"
|
import Sidebar from "@/components/layout/Sidebar"
|
||||||
|
import { ResidenceChart } from "@/components/charts/ResidenceChart"
|
||||||
|
|
||||||
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>
|
||||||
|
|
|
||||||
|
|
@ -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 Header from "@/components/layout/Header"
|
||||||
import Sidebar from "@/components/layout/Sidebar"
|
import Sidebar from "@/components/layout/Sidebar"
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function UserCreate() {
|
||||||
const [loading, setLoading] = useState(false)
|
const navigate = useNavigate()
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
password_confirmation: "",
|
||||||
|
})
|
||||||
|
|
||||||
const handleSaveSettings = () => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setLoading(true)
|
const { name, value } = e.target
|
||||||
setTimeout(() => {
|
setFormData((prev) => ({
|
||||||
setLoading(false)
|
...prev,
|
||||||
}, 1000)
|
[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 (
|
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
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="account"
|
|
||||||
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>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="flex flex-col md:flex-row gap-6">
|
<CardContent className="space-y-6 p-6">
|
||||||
<div className="flex-1 space-y-4">
|
<div className="space-y-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="firstName">First Name</Label>
|
<Label htmlFor="name" className="text-gray-300">
|
||||||
<Input id="firstName" defaultValue="Admin" className="border-neutral-300" />
|
Full Name <span className="text-red-400">*</span>
|
||||||
</div>
|
</Label>
|
||||||
<div className="space-y-2">
|
<Input
|
||||||
<Label htmlFor="lastName">Last Name</Label>
|
id="name"
|
||||||
<Input id="lastName" defaultValue="User" className="border-neutral-300" />
|
name="name"
|
||||||
</div>
|
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="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">Email Address</Label>
|
<Label htmlFor="email" className="text-gray-300">
|
||||||
|
Email Address <span className="text-red-400">*</span>
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
defaultValue="admin@example.com"
|
placeholder="Enter email address"
|
||||||
className="border-neutral-300"
|
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="space-y-2">
|
<div className="border-t border-gray-800 pt-6">
|
||||||
<Label htmlFor="phone">Phone Number</Label>
|
<h3 className="text-lg font-medium mb-4 text-white">Security Information</h3>
|
||||||
<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-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="current-password">Current Password</Label>
|
<Label htmlFor="current_password" className="text-gray-300">
|
||||||
<Input id="current-password" type="password" className="border-neutral-300" />
|
Current Password <span className="text-red-400">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="current_password"
|
||||||
|
name="current_password"
|
||||||
|
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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="new-password">New Password</Label>
|
<Label htmlFor="password" className="text-gray-300">
|
||||||
<Input id="new-password" type="password" className="border-neutral-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>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="confirm-password">Confirm New Password</Label>
|
<Label htmlFor="password_confirmation" className="text-gray-300">
|
||||||
<Input id="confirm-password" type="password" className="border-neutral-300" />
|
Confirm Password <span className="text-red-400">*</span>
|
||||||
</div>
|
</Label>
|
||||||
</div>
|
<Input
|
||||||
|
id="password_confirmation"
|
||||||
<div className="flex justify-end">
|
name="password_confirmation"
|
||||||
<Button
|
type="password"
|
||||||
onClick={handleSaveSettings}
|
placeholder="Confirm password"
|
||||||
className="bg-gradient-to-r from-green-700 to-green-600 hover:from-green-800 hover:to-green-700 shadow-md"
|
value={formData.password_confirmation}
|
||||||
disabled={loading}
|
onChange={handleInputChange}
|
||||||
>
|
required
|
||||||
<Key className="mr-2 size-4" />
|
className="bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500"
|
||||||
{loading ? "Updating..." : "Update Password"}
|
/>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-neutral-200 pt-6">
|
|
||||||
<h3 className="text-lg font-medium mb-4">Two-Factor Authentication</h3>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">Protect your account with 2FA</p>
|
|
||||||
<p className="text-sm text-neutral-500">Add an extra layer of security to your account</p>
|
|
||||||
</div>
|
|
||||||
<Switch defaultChecked />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-neutral-200 pt-6">
|
|
||||||
<h3 className="text-lg font-medium mb-4">Login Sessions</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<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 className="bg-green-100 text-green-800 text-xs font-medium px-2 py-1 rounded">
|
|
||||||
Active Now
|
|
||||||
</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>
|
||||||
|
|
||||||
|
<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>
|
</Card>
|
||||||
</TabsContent>
|
</div>
|
||||||
</Tabs>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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">
|
||||||
<TabsContent value="personal">
|
Step {currentStep + 1} of {steps.length}
|
||||||
<PersonalInfoTab formData={formData} handleInputChange={handleInputChange} />
|
</Badge>
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="migration">
|
|
||||||
<MigrationDetailsTab formData={formData} handleInputChange={handleInputChange} />
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="locations">
|
|
||||||
<LocationsTab />
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="internee">
|
|
||||||
<InterneeDetailsTab />
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="photos">
|
|
||||||
<PhotosTab />
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="notes">
|
|
||||||
<NotesTab />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<Button variant="outline" type="button">Save as Draft</Button>
|
|
||||||
<Button type="submit" className="bg-green-700 hover:bg-green-800">
|
|
||||||
<Save className="mr-2 size-4" />
|
|
||||||
{mode === "edit" ? "Update Migrant Record" : "Save Migrant Record"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default MigrantForm;
|
<div className="mt-6">
|
||||||
|
<div className="flex justify-between text-sm text-gray-400 mb-2">
|
||||||
|
<span>Progress</span>
|
||||||
|
<span>{Math.round((currentStep / (steps.length - 1)) * 100)}% Complete</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={(currentStep / (steps.length - 1)) * 100}
|
||||||
|
className="h-2 bg-gray-800"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--progress-foreground": "#9B2335",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step indicators */}
|
||||||
|
<div className="flex justify-between mt-4 overflow-x-auto">
|
||||||
|
{steps.map((step, index) => {
|
||||||
|
const StepIcon = stepIcons[index]
|
||||||
|
return (
|
||||||
|
<div key={index} className="flex flex-col items-center min-w-0 flex-1">
|
||||||
|
<div
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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">
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
<a href="#about" className="text-sm font-medium text-white hover:text-white/80 transition-colors">
|
||||||
|
About
|
||||||
|
</a>
|
||||||
|
<a href="#stories" className="text-sm font-medium text-white hover:text-white/80 transition-colors">
|
||||||
|
Stories
|
||||||
|
</a>
|
||||||
|
<Link to="/contact" className="text-sm font-medium text-white hover:text-white/80 transition-colors">
|
||||||
|
Contact
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
<Button variant="outline" size="icon" className="md:hidden border-white/20 text-white hover:bg-white/10">
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Search</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center text-center px-4">
|
</header>
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
<main className="flex-1">
|
||||||
animate={{ opacity: 1, y: 0 }}
|
{/* Hero Section with Background Carousel and Search */}
|
||||||
transition={{ duration: 0.8, delay: 0.2 }}
|
<section className="relative w-full h-screen flex items-center justify-center overflow-hidden">
|
||||||
className="max-w-4xl"
|
{/* 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"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{/* Top 4 bars */}
|
<img src={image.src || "/placeholder.svg"} alt={image.alt} className="w-full h-full object-cover" />
|
||||||
<div className="flex items-center justify-center mb-6 space-x-1">
|
<div className="absolute inset-0 bg-black/60" />
|
||||||
<div className="h-1 w-12 bg-green-600" />
|
</div>
|
||||||
<div className="h-1 w-12 bg-white" />
|
))}
|
||||||
<div className="h-1 w-12 bg-red-600" />
|
|
||||||
<div className="h-1 w-12 bg-gray-400" />
|
|
||||||
</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 className="text-4xl md:text-6xl lg:text-7xl font-bold tracking-tighter font-serif mb-4">
|
||||||
|
Find Your Italian Heritage
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl md:text-2xl text-white max-w-3xl italic text-center mx-auto">
|
<p className="text-lg md:text-xl lg:text-2xl mb-8 text-white/90 max-w-3xl mx-auto leading-relaxed">
|
||||||
Exploring the rich history and cultural legacy of Italian immigrants
|
Search our comprehensive database of Italian migrants to the Northern Territory. Discover family
|
||||||
in Australia's Northern Territory
|
histories, personal stories, and cultural contributions spanning over a century.
|
||||||
</p>
|
</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 className="h-1 w-12 bg-gray-400" />
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Carousel Indicators */}
|
||||||
|
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 flex space-x-2">
|
||||||
|
{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>
|
</div>
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-black to-transparent z-10" />
|
|
||||||
</section>
|
</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">© {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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
src={migrant.mainPhoto || "/placeholder.svg?height=1080&width=1920"}
|
|
||||||
alt={`${migrant.firstName} ${migrant.lastName}`}
|
|
||||||
fill
|
|
||||||
className="opacity-80"
|
|
||||||
/>
|
|
||||||
<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">
|
<Link to="/" className="flex items-center gap-2">
|
||||||
<ArrowLeft size={16} />
|
<span className="text-xl font-bold text-[#9B2335]">Italian Migrants NT</span>
|
||||||
Back to Search
|
|
||||||
</Link>
|
</Link>
|
||||||
|
<nav className="hidden md:flex gap-6">
|
||||||
|
<Link to="/" className="text-sm font-medium hover:underline underline-offset-4">
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
<Link to="/about" className="text-sm font-medium hover:underline underline-offset-4">
|
||||||
|
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>
|
</Button>
|
||||||
</motion.div>
|
</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}{" "}
|
|
||||||
{migrant.middleName ? migrant.middleName + " " : ""}
|
|
||||||
{migrant.lastName}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-gray-500">
|
|
||||||
Birth Date
|
|
||||||
</h3>
|
|
||||||
<p className="text-lg">
|
|
||||||
{migrant.birthDate || "Unknown"}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<h3 className="text-sm font-medium text-gray-500">
|
<h3 className="font-medium text-[#01796F]">Migration Details</h3>
|
||||||
Birth Place
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
</h3>
|
<span className="font-medium">Year of Arrival:</span>
|
||||||
<p className="text-lg">
|
<span>{formatDate(migrant.migration?.date_of_arrival_nt || 'Unknown', 'long')}</span>
|
||||||
{migrant.birthPlace || "Unknown"}
|
<span className="font-medium">Age at Migration:</span>
|
||||||
</p>
|
<span>{migrant.migration?.arrival_period || 'Unknown'}</span>
|
||||||
|
<span className="font-medium">Region of Origin:</span>
|
||||||
|
<span>{migrant.place_of_birth}</span>
|
||||||
|
<span className="font-medium">Settlement:</span>
|
||||||
|
<span>{migrant.residence?.town_or_city || 'Northern Territory'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-gray-500">
|
|
||||||
Age at Migration
|
|
||||||
</h3>
|
|
||||||
<p className="text-lg">{migrant.ageAtMigration} years</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-gray-500">
|
|
||||||
Year of Arrival
|
|
||||||
</h3>
|
|
||||||
<p className="text-lg">{migrant.yearOfArrival}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-gray-500">
|
|
||||||
Region of Origin
|
|
||||||
</h3>
|
|
||||||
<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>
|
|
||||||
<h3 className="text-sm font-medium text-gray-500">
|
|
||||||
Place of Death
|
|
||||||
</h3>
|
|
||||||
<p className="text-lg">
|
|
||||||
{migrant.deathPlace || "Unknown"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Life story */}
|
<div className="pt-4">
|
||||||
{migrant.biography && (
|
<h3 className="font-medium text-[#01796F] mb-2">Biography</h3>
|
||||||
<motion.div
|
<div className=" max-w-none text-[#747474]">
|
||||||
initial={{ opacity: 0, y: 20 }}
|
{migrant.additional_notes ? (
|
||||||
animate={{ opacity: 1, y: 0 }}
|
migrant.additional_notes.split("\n\n").map((paragraph, index) => (
|
||||||
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">
|
<p key={index} className="mb-4">
|
||||||
{paragraph}
|
{paragraph}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))
|
||||||
|
) : (
|
||||||
|
<p className="mb-4">
|
||||||
|
{migrant.full_name} was an Italian migrant who made the journey to the Northern Territory of Australia.
|
||||||
|
Born in {migrant.place_of_birth || 'Italy'}, {migrant.full_name} sought new opportunities and a better life abroad.
|
||||||
|
This migrant arrived in the Northern Territory in {formatDate(migrant.migration?.date_of_arrival_nt || 'Unknown', 'year') || 'the early 20th century'}.
|
||||||
|
In Australia, {migrant.full_name} worked as {migrant.occupation || 'a laborer'} and contributed to the growing Italian community.
|
||||||
|
{migrant.full_name}'s story represents the courage and determination of Italian migrants who helped shape the Northern Territory's rich multicultural heritage.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</motion.div>
|
</TabsContent>
|
||||||
)}
|
<TabsContent value="photos" className="mt-6">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<h3 className="font-medium text-[#01796F] mb-4">Photos</h3>
|
||||||
|
|
||||||
{/* Photo gallery - only show if there are additional photos */}
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{migrant.photos && migrant.photos.length > 0 && (
|
{migrant.photos?.map((photo) => (
|
||||||
<motion.div
|
<div key={photo.id} className="space-y-2">
|
||||||
initial={{ opacity: 0, y: 20 }}
|
<div className="overflow-hidden rounded-lg border">
|
||||||
animate={{ opacity: 1, y: 0 }}
|
<img
|
||||||
transition={{ duration: 0.6, delay: 0.3 }}
|
src={photo.file_path
|
||||||
>
|
? photo.file_path.startsWith('http')
|
||||||
<PhotoGallery photos={migrant.photos} />
|
? photo.file_path
|
||||||
</motion.div>
|
: `${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>
|
||||||
|
|
||||||
{/* Sidebar - 1/3 width on desktop */}
|
</Card>
|
||||||
<div className="space-y-8">
|
</TabsContent>
|
||||||
{/* Historical context */}
|
<TabsContent value="family" className="mt-6">
|
||||||
<motion.div
|
<Card>
|
||||||
initial={{ opacity: 0, y: 20 }}
|
<CardContent className="pt-6">
|
||||||
animate={{ opacity: 1, y: 0 }}
|
<h3 className="font-medium text-[#01796F] mb-4">Family Information</h3>
|
||||||
transition={{ duration: 0.6, delay: 0.4 }}
|
<div className="space-y-4">
|
||||||
>
|
{migrant.family ? (
|
||||||
<HistoricalContext year={migrant.yearOfArrival} />
|
<div className="grid grid-cols-1 gap-4">
|
||||||
</motion.div>
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Related migrants */}
|
{migrant.family.names_of_children && (
|
||||||
{migrant.relatedMigrants && migrant.relatedMigrants.length > 0 && (
|
<div className="p-4 border rounded-lg">
|
||||||
<motion.div
|
<h4 className="font-medium flex items-center">
|
||||||
initial={{ opacity: 0, y: 20 }}
|
<User className="h-5 w-5 text-[#9B2335] mr-2" />
|
||||||
animate={{ opacity: 1, y: 0 }}
|
Children
|
||||||
transition={{ duration: 0.6, delay: 0.5 }}
|
</h4>
|
||||||
>
|
<p className="mt-2 text-sm text-gray-600">{migrant.family.names_of_children}</p>
|
||||||
<RelatedMigrants migrants={migrant.relatedMigrants} />
|
</div>
|
||||||
</motion.div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground italic">No information available</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<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]">Migration Journey</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
The journey from Italy to Australia was long and arduous, typically involving multiple ships and
|
||||||
|
taking several weeks or even months to complete.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<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]">Northern Territory in the 1920s</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
The Northern Territory in the 1920s was sparsely populated and developing, with opportunities in
|
||||||
|
fishing, agriculture, and infrastructure projects attracting migrants.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</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">© {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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
placeholder="Search by name..."
|
||||||
|
value={fields.searchTerm}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
<Button type="submit" className="bg-[#9B2335] hover:bg-[#7a1c2a] text-white">
|
||||||
|
<Search className="mr-2 h-4 w-4" />
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-[#01796F]"
|
||||||
|
onClick={toggleAdvancedSearch}
|
||||||
|
>
|
||||||
|
{isAdvancedSearch ? (
|
||||||
|
<>
|
||||||
|
<ChevronUp className="mr-2 h-4 w-4" />
|
||||||
|
Simple Search
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronDown className="mr-2 h-4 w-4" />
|
||||||
|
Advanced Search
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</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">
|
<div key={id} className="space-y-2">
|
||||||
<label htmlFor={id} className="block text-sm font-medium">
|
<Label htmlFor={id}>{label}</Label>
|
||||||
{label}
|
<Input
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id={id}
|
id={id}
|
||||||
name={id}
|
placeholder={placeholder}
|
||||||
type={type}
|
value={fields[id as keyof typeof fields]}
|
||||||
value={formData[id as keyof SearchParams] || ""}
|
onChange={handleInputChange}
|
||||||
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="space-y-2 md:col-span-2">
|
||||||
|
<Label>Actions</Label>
|
||||||
<div className="flex flex-wrap gap-4 mt-8 justify-end">
|
<Button type="button" variant="ghost" className="w-full" onClick={clearAllFields}>
|
||||||
<Button
|
Clear All Filters
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleReset}
|
|
||||||
className="border-gray-300 text-gray-700 hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
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
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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;
|
|
||||||
|
|
@ -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,
|
||||||
|
handleNextPage,
|
||||||
|
handlePrevPage,
|
||||||
|
} = useMigrantsSearch(10);
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
interface SearchResultsProps {
|
|
||||||
results: SearchResult[];
|
|
||||||
isLoading: boolean;
|
|
||||||
hasSearched?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div>
|
<div className="flex flex-col min-h-screen">
|
||||||
<h3 className="text-2xl font-semibold mb-6 font-serif">
|
{/* Header */}
|
||||||
|
<header className="border-b">
|
||||||
|
<div className="container flex h-16 items-center justify-between px-4 md:px-6">
|
||||||
|
<Link to="/" className="flex items-center gap-2">
|
||||||
|
<span className="text-xl font-bold text-[#9B2335]">Italian Migrants NT</span>
|
||||||
|
</Link>
|
||||||
|
<nav className="hidden md:flex gap-6">
|
||||||
|
{["home", "about", "search", "stories", "contact"].map((path) => (
|
||||||
|
<Link
|
||||||
|
key={path}
|
||||||
|
to={`/${path}`}
|
||||||
|
className="text-sm font-medium hover:underline underline-offset-4 capitalize"
|
||||||
|
>
|
||||||
|
{path}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Hero Section */}
|
||||||
|
<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 text-center space-y-4">
|
||||||
|
<h1 className="text-3xl sm:text-4xl md:text-5xl font-serif font-bold tracking-tighter text-[#9B2335]">
|
||||||
Search Results
|
Search Results
|
||||||
</h3>
|
</h1>
|
||||||
|
<p className="max-w-[700px] mx-auto text-muted-foreground md:text-xl/relaxed">
|
||||||
|
Displaying results based on your search criteria.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Results Section */}
|
||||||
|
<section className="w-full py-8 md:py-12">
|
||||||
|
<div className="container px-4 md:px-6">
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-100 text-red-700 rounded mb-6">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<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">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{[...Array(6)].map((_, i) => (
|
{migrants.length > 0 ? (
|
||||||
<Card
|
migrants.map((migrant) => (
|
||||||
key={i}
|
<Card key={migrant.person_id} className="overflow-hidden pt-0">
|
||||||
className="overflow-hidden border border-gray-200 shadow-none rounded-md bg-white"
|
<div className="aspect-square overflow-hidden">
|
||||||
>
|
<img
|
||||||
<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 (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-2xl font-semibold mb-6 font-serif">
|
|
||||||
Search Results ({results.length})
|
|
||||||
</h3>
|
|
||||||
<AnimatePresence>
|
|
||||||
<motion.div
|
|
||||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
|
|
||||||
variants={container}
|
|
||||||
initial="hidden"
|
|
||||||
animate="show"
|
|
||||||
>
|
|
||||||
{results.map((person) => (
|
|
||||||
<motion.div key={person.person_id || person.id_card_no} variants={item} onClick={() => navigate(`/migrants/${person.person_id}`)}>
|
|
||||||
<div className="block h-full cursor-pointer">
|
|
||||||
<Card className="overflow-hidden hover:shadow-lg transition-shadow h-full border border-gray-200 group">
|
|
||||||
<div className="relative h-48 w-full overflow-hidden">
|
|
||||||
<AnimatedImage
|
|
||||||
src={
|
src={
|
||||||
"/placeholder.svg?height=300&width=300"
|
migrant.profilePhoto
|
||||||
|
? `${API_BASE_URL}${migrant.profilePhoto.file_path}`
|
||||||
|
: "/placeholder.svg?height=300&width=300"
|
||||||
}
|
}
|
||||||
alt=""
|
alt={migrant.full_name || "Unknown"}
|
||||||
fill
|
className="w-full h-full object-cover object-center transition-transform hover:scale-105"
|
||||||
/>
|
/>
|
||||||
<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" />
|
|
||||||
<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="flex space-x-1">
|
|
||||||
<div className="h-6 w-2 bg-green-600" />
|
|
||||||
<div className="h-6 w-2 bg-white" />
|
|
||||||
<div className="h-6 w-2 bg-red-600" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="font-serif">
|
<CardTitle className="font-serif text-[#9B2335]">
|
||||||
{person.full_name}
|
{migrant.full_name || "Unknown"}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
{person.migration?.date_of_arrival_nt ?
|
|
||||||
`Arrived ${new Date(person.migration.date_of_arrival_nt).getFullYear()}` : 'Date unknown'}
|
|
||||||
</p>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="text-sm space-y-2">
|
||||||
<div className="space-y-2">
|
<div className="flex justify-between">
|
||||||
<p>
|
<span className="font-medium">ID:</span>
|
||||||
<span className="font-medium">From:</span>{" "}
|
<span>{migrant.person_id}</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>
|
</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>
|
</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>
|
</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>
|
||||||
</motion.div>
|
)}
|
||||||
))}
|
</div>
|
||||||
</motion.div>
|
)}
|
||||||
</AnimatePresence>
|
|
||||||
|
{/* 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">© {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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
<LogoutDialog
|
||||||
|
open={logoutDialogOpen}
|
||||||
|
onOpenChange={setLogoutDialogOpen}
|
||||||
|
onConfirm={handleLogout}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
/>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,44 @@
|
||||||
|
"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 (
|
||||||
<>
|
<>
|
||||||
|
|
@ -45,9 +48,10 @@ export default function Sidebar() {
|
||||||
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,56 +60,55 @@ 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>
|
||||||
|
|
@ -113,14 +116,14 @@ export default function Sidebar() {
|
||||||
<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>
|
||||||
|
|
@ -128,32 +131,49 @@ export default function Sidebar() {
|
||||||
<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" />
|
||||||
|
|
||||||
|
<ul className="space-y-2 px-3">
|
||||||
|
<li>
|
||||||
|
<Link to="/admin/users/create">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={`w-full justify-start rounded-lg transition-all duration-300 ${
|
||||||
|
isActive("/admin/users/create")
|
||||||
|
? "bg-[#9B2335] text-white shadow-lg hover:bg-[#9B2335]/90"
|
||||||
|
: "text-gray-300 hover:text-white hover:bg-gray-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<UserPlus className={`${collapsed ? "mr-0" : "mr-3"} size-5`} />
|
||||||
|
{!collapsed && <span className="font-medium">Create User</span>}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link to="/admin/settings">
|
<Link to="/admin/settings">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={`w-full justify-start ${
|
className={`w-full justify-start rounded-lg transition-all duration-300 ${
|
||||||
isActive("/admin/settings")
|
isActive("/admin/settings")
|
||||||
? "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"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Settings className={`${collapsed ? "mr-0" : "mr-2"} size-5`} />
|
<Settings className={`${collapsed ? "mr-0" : "mr-3"} size-5`} />
|
||||||
{!collapsed && <span>Settings</span>}
|
{!collapsed && <span className="font-medium">Settings</span>}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -161,21 +181,14 @@ export default function Sidebar() {
|
||||||
</nav>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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,21 +78,28 @@ function DialogContent({
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
DialogContent.displayName = "DialogContent"
|
||||||
|
|
||||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
const DialogHeader = React.forwardRef<
|
||||||
return (
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
|
ref={ref}
|
||||||
data-slot="dialog-header"
|
data-slot="dialog-header"
|
||||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
))
|
||||||
}
|
DialogHeader.displayName = "DialogHeader"
|
||||||
|
|
||||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
const DialogFooter = React.forwardRef<
|
||||||
return (
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
|
ref={ref}
|
||||||
data-slot="dialog-footer"
|
data-slot="dialog-footer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
|
@ -90,34 +107,34 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...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,
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
};
|
||||||
|
|
@ -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 }
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
|
||||||
// Reset state when ID changes
|
|
||||||
setLoading(true);
|
|
||||||
setMigrant(null);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
setError('Missing migrant ID');
|
setError('No ID provided in the URL.');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
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;
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,24 @@
|
||||||
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;
|
class ApiService {
|
||||||
|
private api: AxiosInstance;
|
||||||
|
|
||||||
const api = axios.create({
|
constructor() {
|
||||||
baseURL: API_BASE_URL,
|
this.api = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_URL,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
});
|
||||||
|
|
||||||
api.interceptors.request.use((config) => {
|
// Request Interceptor
|
||||||
|
this.api.interceptors.request.use((config) => {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
api.interceptors.response.use(
|
// Response Interceptor
|
||||||
|
this.api.interceptors.response.use(
|
||||||
(res) => res,
|
(res) => res,
|
||||||
(err) => {
|
(err) => {
|
||||||
if (err.response?.status === 401) {
|
if (err.response?.status === 401) {
|
||||||
|
|
@ -25,12 +28,15 @@ api.interceptors.response.use(
|
||||||
}
|
}
|
||||||
return Promise.reject(err);
|
return Promise.reject(err);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
get baseURL(): string {
|
||||||
|
return this.api.defaults.baseURL || "";
|
||||||
|
}
|
||||||
|
|
||||||
class ApiService {
|
|
||||||
// --- 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();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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[]
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
||||||
}
|
// }
|
||||||
|
|
@ -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,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
@ -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 '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue