Crud Operation

This commit is contained in:
mark 2025-05-21 21:22:17 +08:00
parent 75e2e9c964
commit 1cda1cbf72
32 changed files with 2859 additions and 896 deletions

182
package-lock.json generated
View File

@ -13,12 +13,15 @@
"@radix-ui/react-dialog": "^1.1.13",
"@radix-ui/react-dropdown-menu": "^2.1.14",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-radio-group": "^1.3.6",
"@radix-ui/react-select": "^2.2.4",
"@radix-ui/react-separator": "^1.1.6",
"@radix-ui/react-slot": "^1.2.2",
"@radix-ui/react-switch": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.11",
"@radix-ui/react-toast": "^1.2.13",
"@tailwindcss/vite": "^4.1.6",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -27,6 +30,7 @@
"next-themes": "^0.4.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-popover": "^0.5.10",
"react-router-dom": "^7.6.0",
"sonner": "^2.0.3",
"tailwind-merge": "^3.3.0"
@ -1506,6 +1510,38 @@
}
}
},
"node_modules/@radix-ui/react-radio-group": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.6.tgz",
"integrity": "sha512-1tfTAqnYZNVwSpFhCT273nzK8qGBReeYnNTPspCggqk1fvIrfVxJekIuBFidNivzpdiMqDwVGnQvHqXrRPM4Og==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-presence": "1.1.4",
"@radix-ui/react-primitive": "2.1.2",
"@radix-ui/react-roving-focus": "1.1.9",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.9.tgz",
@ -1619,6 +1655,35 @@
}
}
},
"node_modules/@radix-ui/react-switch": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.4.tgz",
"integrity": "sha512-yZCky6XZFnR7pcGonJkr9VyNRu46KcYAbyg1v/gVVCZUr8UJ4x+RpncC27hHtiZ15jC+3WS8Yg/JSgyIHnYYsQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.2",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.11.tgz",
@ -2341,6 +2406,39 @@
"vite": "^5.2.0 || ^6"
}
},
"node_modules/@tanstack/react-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
"license": "MIT",
"dependencies": {
"@tanstack/table-core": "8.21.3"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -3086,6 +3184,15 @@
"node": ">= 8"
}
},
"node_modules/css-vendor": {
"version": "0.3.8",
"resolved": "https://registry.npmjs.org/css-vendor/-/css-vendor-0.3.8.tgz",
"integrity": "sha512-Vx/Vl3zsHj32Z+WTNzGjd2iSbSIJTYHMmyGUT2nzCjj0Xk4qLfwpQ8nF6TQ5oo3Cf0s/An3DTc7LclH1BkAXbQ==",
"license": "MIT",
"dependencies": {
"is-in-browser": "^1.0.2"
}
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@ -4057,6 +4164,12 @@
"node": ">=0.10.0"
}
},
"node_modules/is-in-browser": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz",
"integrity": "sha512-FeXIBgG/CPGd/WUxuEyvgGTEfwiG9Z4EKGxjNMRqviiIIfsmgrpnHLffEDdwUHqNva1VEW91o3xBT/m8Elgl9g==",
"license": "MIT"
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@ -4399,12 +4512,36 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash._getnative": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/lodash._getnative/-/lodash._getnative-3.9.1.tgz",
"integrity": "sha512-RrL9VxMEPyDMHOd9uFbvMe8X55X16/cGM5IgOKgRElQZutpX89iS6vwl64duTV1/16w5JY7tuFNXqoekmh1EmA==",
"license": "MIT"
},
"node_modules/lodash.debounce": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-3.1.1.tgz",
"integrity": "sha512-lcmJwMpdPAtChA4hfiwxTtgFeNAaow701wWUgVUqeD0XJF7vMXIN+bu/2FJSGxT0NUbZy9g9VFrlOFfPjl+0Ew==",
"license": "MIT",
"dependencies": {
"lodash._getnative": "^3.0.0"
}
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"dev": true
},
"node_modules/lodash.throttle": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-3.0.4.tgz",
"integrity": "sha512-dRU/xiF4W8a521NYnQosG5drDqv4+hp3ND6yWNJUMnwO1E87Q/A7oc9M/g6pk29K9U3j/ZWhM3BAQZyr/P6TTQ==",
"license": "MIT",
"dependencies": {
"lodash.debounce": "^3.0.0"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@ -4638,7 +4775,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
@ -4839,6 +4975,17 @@
"node": ">= 0.8.0"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@ -4948,6 +5095,39 @@
"react": "^18.3.1"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-popover": {
"version": "0.5.10",
"resolved": "https://registry.npmjs.org/react-popover/-/react-popover-0.5.10.tgz",
"integrity": "sha512-5SYDTfncywSH00I70oHd4gFRUR8V0rJ4sRADSI/P6G0RVXp9jUgaWloJ0Bk+SFnjpLPuipTKuzQNNd2CTs5Hrw==",
"license": "MIT",
"dependencies": {
"css-vendor": "^0.3.1",
"debug": "^2.6.8",
"lodash.throttle": "^3.0.3",
"prop-types": "^15.5.10"
}
},
"node_modules/react-popover/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/react-popover/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",

View File

@ -15,12 +15,15 @@
"@radix-ui/react-dialog": "^1.1.13",
"@radix-ui/react-dropdown-menu": "^2.1.14",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-radio-group": "^1.3.6",
"@radix-ui/react-select": "^2.2.4",
"@radix-ui/react-separator": "^1.1.6",
"@radix-ui/react-slot": "^1.2.2",
"@radix-ui/react-switch": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.11",
"@radix-ui/react-toast": "^1.2.13",
"@tailwindcss/vite": "^4.1.6",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -29,6 +32,7 @@
"next-themes": "^0.4.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-popover": "^0.5.10",
"react-router-dom": "^7.6.0",
"sonner": "^2.0.3",
"tailwind-merge": "^3.3.0"

View File

@ -1,7 +1,6 @@
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import MigrantProfilePage from "./pages/MigrantProfilePage";
import NotFoundPage from "./pages/NotFoundPage";
import "./App.css";
import LoginPage from "./components/admin/LoginPage";
import Migrants from "./components/admin/Migrants";
import ProfileSettings from "./components/ui/ProfileSettings";
@ -9,6 +8,10 @@ import AdminDashboardPage from "./components/admin/AdminDashboard";
import HomePage from "./pages/HomePage";
import RegisterPage from "./components/admin/Register";
import AddMigrantPage from "./components/admin/AddMigrant";
import SettingsPage from "./components/admin/Setting";
import ReportsPage from "./components/admin/Reports";
import EditMigrant from "./components/admin/EditMigrant";
import "./App.css";
function App() {
return (
@ -18,6 +21,9 @@ function App() {
<Route path="/admin/migrants" element={<Migrants />} />
<Route path="/admin" element={<AdminDashboardPage />} />
<Route path="/admin/migrants/add" element={<AddMigrantPage />} />
<Route path="/admin/settings" element={<SettingsPage />} />
<Route path="/admin/reports" element={<ReportsPage />} />
<Route path="/admin/migrants/edit/:id" element={<EditMigrant />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/" element={<HomePage />} />

View File

@ -1,66 +1,52 @@
"use client"
import { useState } from "react";
import { ArrowLeft, Save } from "lucide-react";
import { useState, useRef } from "react";
import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import apiService from "@/services/apiService";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import Header from "../layout/Header";
import Sidebar from "../layout/Sidebar";
import { PersonalInfoTab } from "./migrant/PersonalInfoTab";
import { MigrationDetailsTab } from "./migrant/MigrationDetailsTab";
import { LocationsTab } from "./migrant/LocationsTab";
import { InterneeDetailsTab } from "./migrant/InterneeDetailsTab";
import { PhotosTab } from "./migrant/PhotosTab";
import { NotesTab } from "./migrant/NotesTab";
import MigrantForm from "@/components/admin/migrant/MigrationForm";
import type { MigrantFormRef } from "@/components/admin/migrant/MigrationForm";
import AddDialog from "@/components/admin/migrant/table/AddDialog";
export default function AddUserPage() {
const [formData, setFormData] = useState({
surname: "",
christian_name: "",
full_name: "",
date_of_birth: "",
date_of_death: "",
place_of_birth: "",
home_at_death: "",
occupation: "",
names_of_parents: "",
names_of_children: "",
data_source: "",
reference: "",
cav: "",
id_card_no: "",
date_of_arrival_australia: "",
date_of_arrival_nt: "",
date_of_naturalisation: "",
corps_issued: "",
no_of_cert: "",
issued_at: "",
});
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 handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { id, value } = e.target;
setFormData(prev => ({ ...prev, [id]: value }));
const handleCreate = async (data: any) => {
// Temporarily store the form data and show confirmation dialog
setFormData(data);
setShowConfirmDialog(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const handleConfirmCreate = async () => {
if (!formData) return;
setIsSubmitting(true);
try {
const res = await apiService.createPerson(formData);
await apiService.createPerson(formData);
alert("Migrant created successfully!");
console.log(res);
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 (
<div className="flex min-h-dvh bg-neutral-50">
<div className="flex min-h-dvh bg-[#f8f5f2]">
<Sidebar />
<div className="flex-1">
<div className="flex-1 md:ml-16 lg:ml-64 w-full transition-all duration-300">
<Header title="Add New Migrant" />
<main className="p-6">
<div className="flex items-center mb-6">
@ -74,53 +60,17 @@ export default function AddUserPage() {
</h1>
</div>
<form onSubmit={handleSubmit}>
<Tabs defaultValue="personal" className="mb-8">
<TabsList className="bg-neutral-100 mb-6">
<TabsTrigger value="personal">Personal Information</TabsTrigger>
<TabsTrigger value="migration">Migration Details</TabsTrigger>
<TabsTrigger value="locations">Locations</TabsTrigger>
<TabsTrigger value="internee">Internee Details</TabsTrigger>
<TabsTrigger value="photos">Photos & Documents</TabsTrigger>
<TabsTrigger value="notes">Additional Notes</TabsTrigger>
</TabsList>
<TabsContent value="personal" className="m-0">
<PersonalInfoTab formData={formData} handleInputChange={handleInputChange} />
</TabsContent>
<TabsContent value="migration" className="m-0">
<MigrationDetailsTab formData={formData} handleInputChange={handleInputChange} />
</TabsContent>
<TabsContent value="locations" className="m-0">
<LocationsTab />
</TabsContent>
<TabsContent value="internee" className="m-0">
<InterneeDetailsTab />
</TabsContent>
<TabsContent value="photos" className="m-0">
<PhotosTab />
</TabsContent>
<TabsContent value="notes" className="m-0">
<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" /> Save Migrant Record
</Button>
</div>
</form>
<MigrantForm ref={formRef} mode="add" onSubmit={handleCreate} />
</main>
</div>
{/* Add Confirmation Dialog */}
<AddDialog
open={showConfirmDialog}
onOpenChange={setShowConfirmDialog}
onConfirm={handleConfirmCreate}
isSubmitting={isSubmitting}
/>
</div>
);
}

View File

@ -4,13 +4,14 @@ import {
Calendar,
Clock,
Database,
FileText,
PlusCircle,
Search,
User,
Users,
Flag,
AlertCircle,
} from "lucide-react";
import { Link } from "react-router-dom";
import { Link, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import {
@ -32,14 +33,100 @@ import Header from "../layout/Header";
import Sidebar from "../layout/Sidebar";
import RecentActivityList from "../common/RecentActivity";
import StatCard from "../common/StatCard";
import ApiService from "@/services/apiService";
import type { DashboardStats } from "@/types/api";
import { useEffect } from "react";
export default function DashboardPage() {
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState("");
const [results, setResults] = useState<any[]>([]); // TODO: Replace any with proper type if available
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(() => {
const fetchStats = async () => {
try {
setLoading(true);
const response = await ApiService.getDashboardStats();
if (response.success) {
setStats(response.data);
} else {
setError('Failed to load dashboard data');
}
} catch (err) {
setError('An error occurred while fetching dashboard data');
console.error(err);
} finally {
setLoading(false);
}
};
fetchStats();
}, []);
if (loading) {
return (
<div className="flex min-h-dvh bg-neutral-50">
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4 mb-8">
{[...Array(4)].map((_, index) => (
<div key={index} className="bg-white rounded-lg shadow p-6 animate-pulse">
<div className="h-4 bg-gray-200 rounded w-1/2 mb-4"></div>
<div className="h-8 bg-gray-200 rounded w-1/4 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
</div>
))}
</div>
);
}
if (error) {
return (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md mb-8">
{error}
</div>
);
}
return (
<div className="flex min-h-dvh bg-[#f8f5f2]">
<Sidebar />
<div className="flex-1">
<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="mb-8">
@ -51,30 +138,31 @@ export default function DashboardPage() {
</p>
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4 mb-8">
<StatCard
title="Total Migrants"
value="1,248"
description="+12 this month"
value={stats?.total_migrants || 0}
description={`+${stats?.new_this_month || 0} this month`}
icon={<Users className="size-5 text-green-600" />}
/>
<StatCard
title="Recent Additions"
value="24"
value={stats?.recent_additions || 0}
description="Last 30 days"
icon={<PlusCircle className="size-5 text-blue-600" />}
/>
<StatCard
title="Pending Reviews"
value="8"
value={stats?.pending_reviews || 0}
description="Needs attention"
icon={<Clock className="size-5 text-amber-600" />}
/>
<StatCard
title="Total Documents"
value="3,542"
description="Photos and records"
icon={<FileText className="size-5 text-red-600" />}
title="Incomplete Records"
value={stats?.incomplete_records || 0}
description="Need more information"
icon={<AlertCircle className="size-5 text-red-600" />}
/>
</div>
@ -85,6 +173,7 @@ export default function DashboardPage() {
<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
@ -94,17 +183,48 @@ export default function DashboardPage() {
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button variant="outline" size="sm" className="text-sm">
<Button type="button" variant="outline" size="sm" className="text-sm">
<Calendar className="mr-1 size-4" /> By Date
</Button>
<Button variant="outline" size="sm" className="text-sm">
<Button type="button" variant="outline" size="sm" className="text-sm">
<User className="mr-1 size-4" /> By Name
</Button>
<Button variant="outline" size="sm" className="text-sm">
<Button type="button" variant="outline" size="sm" className="text-sm">
<Database className="mr-1 size-4" /> Advanced
</Button>
</div>
</form>
{/* Results */}
<div className="mt-6 space-y-2">
{searchLoading ? (
<div className="text-center py-4">
<div className="inline-block h-6 w-6 animate-spin rounded-full border-2 border-solid border-green-600 border-r-transparent"></div>
<p className="mt-2 text-sm text-gray-500">Searching...</p>
</div>
) : results.length > 0 ? (
results.map((person) => (
<div
key={person.person_id}
onClick={() => navigate(`/migrants/${person.person_id}`)}
className="cursor-pointer border rounded px-4 py-3 hover:bg-neutral-100 transition"
>
<div className="font-medium">{person.full_name}</div>
{person.migration?.date_of_arrival_nt && (
<div className="text-sm text-gray-600">
Date of Arrival: {person.migration.date_of_arrival_nt}
</div>
)}
</div>
))
) : (
searchQuery.trim() && (
<p className="text-sm text-gray-500 mt-4">No results found.</p>
)
)}
</div>
</CardContent>
</Card>

View File

@ -0,0 +1,101 @@
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 { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import UpdateDialog from "./migrant/table/UpdateDialog";
export default function EditUserPage() {
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 (
<div className="flex min-h-dvh bg-[#f8f5f2]">
<Sidebar />
<div className="flex-1 md:ml-16 lg:ml-64 w-full transition-all duration-300">
<Header title="Edit Migrant" />
<main className="p-6">
<div className="flex items-center mb-6">
<Link to="/admin/migrants">
<Button variant="ghost" size="sm" className="gap-1">
<ArrowLeft className="size-4" /> Back to Migrants
</Button>
</Link>
<h1 className="text-3xl font-serif font-bold text-neutral-800 ml-4">
Edit Migrant
</h1>
</div>
<MigrantForm
initialData={formData}
mode="edit"
onSubmit={handleFormSubmit}
/>
<UpdateDialog
open={open}
onOpenChange={setOpen}
onConfirm={handleUpdate}
isSubmitting={isSubmitting}
/>
</main>
</div>
</div>
);
}

View File

@ -3,10 +3,12 @@
import type React from "react"
import { useState } from "react"
import { motion } from "framer-motion"
import { useNavigate } from "react-router-dom"
import { Eye, EyeOff, Lock, Mail } from "lucide-react"
import { Link } from "react-router-dom";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import apiService from "@/services/apiService"
export default function LoginPage() {
@ -42,184 +44,78 @@ export default function LoginPage() {
setIsLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-[#E8DCCA] bg-opacity-30">
<div className="absolute inset-0 overflow-hidden z-0">
<motion.div
className="absolute inset-0 bg-[url('/italian-migrants-historical.jpg')] bg-cover bg-center"
initial={{ opacity: 0 }}
animate={{ opacity: 0.15 }}
transition={{ duration: 1.5 }}
/>
<div className="absolute inset-0 bg-gradient-to-b from-[#9B2335]/20 to-[#01796F]/20" />
<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>
<motion.div
className="max-w-md w-full mx-4 bg-white rounded-lg shadow-xl overflow-hidden z-10"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
<div className="h-2 bg-gradient-to-r from-[#9B2335] via-[#E8DCCA] to-[#01796F]" />
<div className="px-8 pt-8 pb-6">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3, duration: 0.6 }}
className="text-center"
>
<h2 className="text-2xl font-serif font-bold text-[#1A2A57]">Admin Access</h2>
<p className="text-gray-600 mt-1 italic">Northern Territory Italian Migration History</p>
</motion.div>
<CardHeader className="space-y-1 text-center border-b border-neutral-100 pb-6 bg-gradient-to-b from-neutral-50 to-white">
<div className="flex justify-center mb-2">
<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">
<span className="text-2xl font-bold text-neutral-800">NT</span>
</div>
<motion.div
className="px-8 pb-8"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5, duration: 0.6 }}
>
{error && (
<motion.div
className="mb-4 p-3 bg-red-50 border-l-4 border-[#9B2335] text-[#9B2335]"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
>
{error}
</motion.div>
)}
</div>
<CardTitle className="text-2xl font-serif">Italian Migrants Database</CardTitle>
<CardDescription>Enter your credentials to access the admin panel</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<div className="mb-6">
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<CardContent className="space-y-4 pt-6 bg-white">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Mail className="h-5 w-5 text-gray-400" />
</div>
<input
<Mail className="absolute left-3 top-2.5 size-5 text-neutral-500" />
<Input
id="email"
type="email"
placeholder="admin@example.com"
className="pl-10 border-neutral-300 focus-visible:ring-green-600 shadow-sm"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-[#01796F] focus:border-[#01796F]"
placeholder="admin@example.com"
required
/>
</div>
</div>
<div className="mb-6">
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400" />
</div>
<input
<Lock className="absolute left-3 top-2.5 size-5 text-neutral-500" />
<Input
id="password"
type={showPassword ? "text" : "password"}
className="pl-10 pr-10 border-neutral-300 focus-visible:ring-green-600 shadow-sm"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="block w-full pl-10 pr-10 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-[#01796F] focus:border-[#01796F]"
placeholder="••••••••"
required
/>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
<button
type="button"
className="absolute right-3 top-2.5 text-neutral-500 hover:text-neutral-800"
onClick={() => setShowPassword(!showPassword)}
className="text-gray-400 hover:text-gray-500 focus:outline-none"
>
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
{showPassword ? <EyeOff className="size-5" /> : <Eye className="size-5" />}
</button>
</div>
</div>
</div>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center">
<input
id="remember-me"
name="remember-me"
type="checkbox"
className="h-4 w-4 text-[#01796F] focus:ring-[#01796F] border-gray-300 rounded"
/>
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-700">
Remember me
</label>
</div>
<div className="text-sm">
<Link to="#" className="font-medium text-[#01796F] hover:text-[#01796F]/80">
Forgot password?
</Link>
</div>
</div>
<motion.button
</CardContent>
<CardFooter className="bg-gradient-to-b mb-5 mt-8 from-white to-neutral-50">
<Button
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}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-white bg-[#9B2335] hover:bg-[#9B2335]/90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#9B2335] disabled:opacity-50 disabled:cursor-not-allowed"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{isLoading ? (
<div className="flex items-center">
<svg
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Signing in...
</div>
) : (
"Sign in"
)}
</motion.button>
{isLoading ? "Authenticating..." : "Sign In"}
</Button>
</CardFooter>
</form>
<div className="mt-6 flex items-center justify-center">
<div className="h-px bg-gray-300 w-full" />
<span className="px-2 text-sm text-gray-500">or</span>
<div className="h-px bg-gray-300 w-full" />
</Card>
</div>
<div className="mt-6 text-center">
<Link to="/" className="text-sm font-medium text-[#1A2A57] hover:text-[#1A2A57]/80">
Return to public site
</Link>
</div>
</motion.div>
</motion.div>
<motion.div
className="absolute bottom-4 text-center text-xs text-gray-500"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1, duration: 0.6 }}
>
© {new Date().getFullYear()} Northern Territory Italian Migration History Project
</motion.div>
</div>
)
}

View File

@ -1,243 +1,103 @@
"use client"
import { useState } from "react"
import { ArrowUpDown, Download, Filter, MoreHorizontal, PlusCircle, Search, Trash2, Upload } from "lucide-react"
import { useEffect, useState } from "react"
import { Link } from "react-router-dom"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { PlusCircle, Filter, Upload, Download } from "lucide-react"
import Header from "@/components/layout/Header"
import Sidebar from "@/components/layout/Sidebar"
// Sample data for migrants
const migrants = [
{
id: 1,
name: "Marco Rossi",
birthDate: "1935-05-12",
birthPlace: "Rome, Italy",
arrivalDate: "1952-08-23",
occupation: "Carpenter",
hasPhotos: true,
},
{
id: 2,
name: "Sofia Bianchi",
birthDate: "1942-11-03",
birthPlace: "Naples, Italy",
arrivalDate: "1960-02-15",
occupation: "Seamstress",
hasPhotos: true,
},
{
id: 3,
name: "Antonio Esposito",
birthDate: "1928-07-22",
birthPlace: "Milan, Italy",
arrivalDate: "1950-10-05",
occupation: "Farmer",
hasPhotos: false,
},
{
id: 4,
name: "Lucia Romano",
birthDate: "1940-03-18",
birthPlace: "Florence, Italy",
arrivalDate: "1958-06-30",
occupation: "Teacher",
hasPhotos: true,
},
{
id: 5,
name: "Giuseppe Colombo",
birthDate: "1932-09-08",
birthPlace: "Venice, Italy",
arrivalDate: "1955-12-10",
occupation: "Fisherman",
hasPhotos: false,
},
]
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() {
const [searchQuery, setSearchQuery] = useState("")
const [selectedMigrants, setSelectedMigrants] = useState<number[]>([])
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 toggleSelectAll = () => {
if (selectedMigrants.length === migrants.length) {
setSelectedMigrants([])
} else {
setSelectedMigrants(migrants.map((m) => m.id))
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)
}
}
const toggleSelectMigrant = (id: number) => {
if (selectedMigrants.includes(id)) {
setSelectedMigrants(selectedMigrants.filter((m) => m !== id))
} else {
setSelectedMigrants([...selectedMigrants, id])
}
}
useEffect(() => {
fetchMigrants()
}, [])
const filteredMigrants = migrants.filter(
(migrant) =>
migrant.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
migrant.birthPlace.toLowerCase().includes(searchQuery.toLowerCase()) ||
migrant.occupation.toLowerCase().includes(searchQuery.toLowerCase()),
)
const handlePageChange = (url?: string) => url && fetchMigrants(url)
return (
<div className="flex flex-col md:flex-row min-h-dvh bg-neutral-50">
<div className="flex min-h-dvh bg-[#f8f5f2]">
<Sidebar />
<div className="flex-1 w-full">
<div className="flex-1 md:ml-16 lg:ml-64">
<Header title="Migrants Management" />
<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 sm:gap-0">
<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>
<Link to="/admin/migrants/add">
<Button className="bg-green-700 hover:bg-green-800">
<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
</Button>
</Link>
</div>
<Card className="mb-6 md:mb-8">
<CardHeader className="pb-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 sm:gap-0">
<CardTitle className="text-xl font-serif">Search & Filter</CardTitle>
<Button variant="outline" size="sm">
<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-3">
<div className="relative">
<Search className="absolute left-3 top-2.5 size-5 text-neutral-500" />
<div className="grid gap-4 md:grid-cols-2">
<Input
placeholder="Search migrants..."
className="pl-10 border-neutral-300"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-3 border-neutral-300 bg-white shadow-sm"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
<div className="flex gap-2">
<Button variant="outline" className="flex-1">
<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">
<Button variant="outline" className="flex-1 bg-white shadow-sm border-neutral-200">
<Download className="mr-2 size-4" /> Export
</Button>
</div>
<div className="flex justify-end">
{selectedMigrants.length > 0 && (
<Button variant="destructive" size="sm">
<Trash2 className="mr-2 size-4" /> Delete Selected ({selectedMigrants.length})
</Button>
)}
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-0 overflow-auto">
<div className="min-w-[800px]">
<Table>
<TableHeader className="bg-neutral-100">
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={selectedMigrants.length === migrants.length && migrants.length > 0}
onCheckedChange={toggleSelectAll}
aria-label="Select all"
<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()}
/>
</TableHead>
<TableHead>
<div className="flex items-center">
Name
<ArrowUpDown className="ml-2 size-4" />
</div>
</TableHead>
<TableHead>
<div className="flex items-center">
Birth Date
<ArrowUpDown className="ml-2 size-4" />
</div>
</TableHead>
<TableHead>Birth Place</TableHead>
<TableHead>
<div className="flex items-center">
Arrival Date
<ArrowUpDown className="ml-2 size-4" />
</div>
</TableHead>
<TableHead>Occupation</TableHead>
<TableHead>Photos</TableHead>
<TableHead className="w-12">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredMigrants.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-neutral-500">
No migrants found matching your search criteria.
</TableCell>
</TableRow>
) : (
filteredMigrants.map((migrant) => (
<TableRow key={migrant.id} className="hover:bg-neutral-50">
<TableCell>
<Checkbox
checked={selectedMigrants.includes(migrant.id)}
onCheckedChange={() => toggleSelectMigrant(migrant.id)}
aria-label={`Select ${migrant.name}`}
/>
</TableCell>
<TableCell className="font-medium">{migrant.name}</TableCell>
<TableCell>{new Date(migrant.birthDate).toLocaleDateString()}</TableCell>
<TableCell>{migrant.birthPlace}</TableCell>
<TableCell>{new Date(migrant.arrivalDate).toLocaleDateString()}</TableCell>
<TableCell>{migrant.occupation}</TableCell>
<TableCell>
{migrant.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>
)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="size-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link to={`/admin/migrants/edit/${migrant.id}`}>
<DropdownMenuItem>Edit</DropdownMenuItem>
</Link>
<DropdownMenuItem className="text-red-600">Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</main>
</div>
</div>

View File

@ -0,0 +1,442 @@
"use client"
import { useState } from "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 { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import Header from "@/components/layout/Header"
import Sidebar from "@/components/layout/Sidebar"
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 (
<div className="flex min-h-dvh bg-[#f8f5f2]">
<Sidebar />
<div className="flex-1 md:ml-16 lg:ml-64 w-full transition-all duration-300">
<Header title="Reports" />
<main className="p-4 md:p-6">
<div className="mb-6">
<h1 className="text-2xl md:text-3xl font-serif font-bold text-neutral-800 mb-2">Data Reports</h1>
<p className="text-neutral-600">Generate and analyze reports from the Italian Migrants Database</p>
</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 */}
<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 */}
<TabsContent value="demographics" 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">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>
<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">Family Status</CardTitle>
<CardDescription>Family composition of Italian migrants</CardDescription>
</CardHeader>
<CardContent>
<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="size-16 rounded-full bg-green-100 flex items-center justify-center mb-4">
<User className="size-8 text-green-600" />
</div>
<h3 className="text-2xl font-bold">42%</h3>
<p className="text-neutral-600">Single</p>
</div>
<div className="bg-white rounded-lg p-4 shadow-inner flex flex-col items-center justify-center">
<div className="size-16 rounded-full bg-neutral-100 flex items-center justify-center mb-4">
<div className="flex -space-x-2">
<User className="size-8 text-neutral-600" />
<User className="size-8 text-neutral-400" />
</div>
</div>
<h3 className="text-2xl font-bold">35%</h3>
<p className="text-neutral-600">Married</p>
</div>
<div className="bg-white rounded-lg p-4 shadow-inner flex flex-col items-center justify-center">
<div className="size-16 rounded-full bg-red-100 flex items-center justify-center mb-4">
<div className="flex -space-x-4">
<User className="size-8 text-red-600" />
<User className="size-6 text-red-400" />
<User className="size-4 text-red-300" />
</div>
</div>
<h3 className="text-2xl font-bold">23%</h3>
<p className="text-neutral-600">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>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</main>
</div>
</div>
)
}

View File

@ -0,0 +1,219 @@
"use client"
import { useState } from "react"
import { Key, Lock } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Textarea } from "@/components/ui/textarea"
import Header from "@/components/layout/Header"
import Sidebar from "@/components/layout/Sidebar"
export default function SettingsPage() {
const [loading, setLoading] = useState(false)
const handleSaveSettings = () => {
setLoading(true)
setTimeout(() => {
setLoading(false)
}, 1000)
}
return (
<div className="flex min-h-dvh bg-[#f8f5f2]">
<Sidebar />
<div className="flex-1 md:ml-16 lg:ml-64 w-full transition-all duration-300">
<Header title="Settings" />
<main className="p-4 md:p-6">
<div className="mb-6">
<h1 className="text-2xl md:text-3xl font-serif font-bold text-neutral-800 mb-2">User Settings</h1>
<p className="text-neutral-600">Manage your account preferences and settings</p>
</div>
<Tabs defaultValue="profile" className="space-y-6">
<TabsList className="bg-white shadow-sm border border-neutral-200">
<TabsTrigger
value="profile"
className="data-[state=active]:bg-green-50 data-[state=active]:text-green-800"
>
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>
<CardContent className="space-y-6">
<div className="flex flex-col md:flex-row gap-6">
<div className="flex-1 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="firstName">First Name</Label>
<Input id="firstName" defaultValue="Admin" className="border-neutral-300" />
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Last Name</Label>
<Input id="lastName" defaultValue="User" className="border-neutral-300" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
type="email"
defaultValue="admin@example.com"
className="border-neutral-300"
/>
</div>
<div className="space-y-2">
<Label htmlFor="phone">Phone Number</Label>
<Input id="phone" type="tel" defaultValue="+1 (555) 123-4567" className="border-neutral-300" />
</div>
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Input
id="role"
defaultValue="Database Administrator"
className="border-neutral-300"
readOnly
/>
</div>
</div>
<div className="md:w-1/3 flex flex-col items-center">
<div className="mb-4">
<div className="size-32 rounded-full bg-gradient-to-r from-green-600 via-white to-red-600 flex items-center justify-center shadow-md">
<span className="text-4xl font-bold text-neutral-800">A</span>
</div>
</div>
<Button className="bg-gradient-to-r from-green-700 to-green-600 hover:from-green-800 hover:to-green-700 shadow-md mb-2 w-full">
Upload New Photo
</Button>
<Button variant="outline" className="w-full">
Remove Photo
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="bio">Bio</Label>
<Textarea
id="bio"
className="min-h-[100px] border-neutral-300"
placeholder="Tell us about yourself"
defaultValue="Administrator for the Italian Migrants Database project. Responsible for data management and user access."
/>
</div>
<div className="flex justify-end">
<Button
onClick={handleSaveSettings}
className="bg-gradient-to-r from-green-700 to-green-600 hover:from-green-800 hover:to-green-700 shadow-md"
disabled={loading}
>
{loading ? "Saving..." : "Save Profile"}
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Account Settings */}
<TabsContent value="account" className="space-y-6">
<Card className="border-0 shadow-md overflow-hidden">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-green-600 via-white to-red-600"></div>
<CardHeader>
<CardTitle className="text-xl font-serif">Account Security</CardTitle>
<CardDescription>Manage your password and security settings</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current-password">Current Password</Label>
<Input id="current-password" type="password" className="border-neutral-300" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="new-password">New Password</Label>
<Input id="new-password" type="password" className="border-neutral-300" />
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm New Password</Label>
<Input id="confirm-password" type="password" className="border-neutral-300" />
</div>
</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}
>
<Key className="mr-2 size-4" />
{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>
<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>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</main>
</div>
</div>
)
}

View File

@ -0,0 +1,138 @@
import { useState, forwardRef, useImperativeHandle } from "react";
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 { MigrationDetailsTab } from "./MigrationDetailsTab";
import { LocationsTab } from "./LocationsTab";
import { InterneeDetailsTab } from "./InterneeDetailsTab";
import { PhotosTab } from "./PhotosTab";
import { NotesTab } from "./NotesTab";
type FormDataType = {
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;
date_of_arrival_australia: string;
date_of_arrival_nt: string;
date_of_naturalisation: string;
corps_issued: string;
no_of_cert: string;
issued_at: string;
};
type MigrantFormProps = {
initialData?: Partial<FormDataType>;
mode?: "add" | "edit";
onSubmit: (formData: FormDataType) => Promise<void>;
};
export type MigrantFormRef = {
resetForm: () => void;
};
const MigrantForm = forwardRef<MigrantFormRef, MigrantFormProps>(function MigrantForm(
{
initialData = {},
mode = "add",
onSubmit,
}: MigrantFormProps,
ref
) {
const defaultFormData: FormDataType = {
surname: "",
christian_name: "",
full_name: "",
date_of_birth: "",
date_of_death: "",
place_of_birth: "",
home_at_death: "",
occupation: "",
names_of_parents: "",
names_of_children: "",
data_source: "",
reference: "",
cav: "",
id_card_no: "",
date_of_arrival_australia: "",
date_of_arrival_nt: "",
date_of_naturalisation: "",
corps_issued: "",
no_of_cert: "",
issued_at: "",
...initialData,
};
const [formData, setFormData] = useState<FormDataType>(defaultFormData);
useImperativeHandle(ref, () => ({
resetForm: () => setFormData(defaultFormData)
}));
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { id, value } = e.target;
setFormData((prev) => ({ ...prev, [id]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await onSubmit(formData);
};
return (
<form onSubmit={handleSubmit}>
<Tabs defaultValue="personal" className="mb-8">
<TabsList className="bg-neutral-100 mb-6">
<TabsTrigger value="personal">Personal Information</TabsTrigger>
<TabsTrigger value="migration">Migration Details</TabsTrigger>
<TabsTrigger value="locations">Locations</TabsTrigger>
<TabsTrigger value="internee">Internee Details</TabsTrigger>
<TabsTrigger value="photos">Photos & Documents</TabsTrigger>
<TabsTrigger value="notes">Additional Notes</TabsTrigger>
</TabsList>
<TabsContent value="personal">
<PersonalInfoTab formData={formData} handleInputChange={handleInputChange} />
</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>
</form>
);
});
export default MigrantForm;

View File

@ -0,0 +1,58 @@
import { Check,Loader2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
interface AddDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
isSubmitting: boolean
}
export default function AddDialog({
open,
onOpenChange,
onConfirm,
isSubmitting,
}: AddDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-xl font-serif text-green-600">Confirm New Migrant</DialogTitle>
<DialogDescription>
Are you sure you want to add this new migrant to the database?
</DialogDescription>
</DialogHeader>
<div className="py-4">
<p className="text-neutral-600">
Please verify the information before confirming. Once added, you can edit the record later if needed.
</p>
</div>
<DialogFooter className="flex flex-col-reverse sm:flex-row sm:justify-between sm:space-x-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
{isSubmitting ? (
<Button variant="default" onClick={onConfirm}>
<Loader2 className="mr-2 size-4 animate-spin" /> Processing...
</Button>
) : (
<Button variant="default" onClick={onConfirm}>
<Check className="mr-2 size-4" /> Confirm
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,103 @@
import { Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { useState } from "react"
import apiService from "@/services/apiService"
import { toast } from "sonner"
interface DeleteDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
bulkDelete: boolean
selectedCount: number
ids: string[]
onDeleteSuccess?: () => void
}
export default function DeleteDialog({
open,
onOpenChange,
bulkDelete,
selectedCount,
ids,
onDeleteSuccess,
}: DeleteDialogProps) {
const [loading, setLoading] = useState(false)
const handleDelete = async () => {
// Validate that we have IDs to delete
if (!ids.length) {
toast.error("No records to delete", {
description: "Could not find valid IDs for deletion."
})
onOpenChange(false)
return
}
setLoading(true)
try {
// Use Promise.all if bulk deleting
if (bulkDelete) {
await Promise.all(ids.map(id => apiService.deletePerson(id)))
toast.success(`${ids.length} records deleted`, {
description: "The selected migrant records have been removed."
})
} else {
await apiService.deletePerson(ids[0])
toast.success("Record deleted", {
description: "The migrant record has been successfully removed."
})
}
// Notify parent component about successful deletion
onDeleteSuccess?.()
onOpenChange(false)
} catch (error: any) {
console.error("Failed to delete record(s):", error)
toast.error("Delete operation failed", {
description: error.response?.data?.message || "An unexpected error occurred during deletion."
})
} finally {
setLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-xl font-serif text-red-600">
Confirm Deletion
</DialogTitle>
<DialogDescription>
{bulkDelete
? `Are you sure you want to delete ${selectedCount} selected records?`
: "Are you sure you want to delete this migrant record?"}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<p className="text-neutral-600">
This action cannot be undone. This will permanently delete the
{bulkDelete ? " selected records" : " record"} from the database.
</p>
</div>
<DialogFooter className="flex flex-col-reverse sm:flex-row sm:justify-between sm:space-x-2">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={loading}>
<Trash2 className="mr-2 size-4" />
{loading ? "Deleting..." : `Delete ${bulkDelete ? "Selected" : "Record"}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,59 @@
import { LogOut } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
interface LogoutDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
isSubmitting: boolean
}
export default function LogoutDialog({
open,
onOpenChange,
onConfirm,
isSubmitting,
}: LogoutDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-xl font-serif text-yellow-600">Confirm Logout</DialogTitle>
<DialogDescription>
Are you sure you want to log out of your account?
</DialogDescription>
</DialogHeader>
<div className="py-4">
<p className="text-neutral-600">
You will be redirected to the login page. Make sure all unsaved changes are saved before logging out.
</p>
</div>
<DialogFooter className="flex flex-col-reverse sm:flex-row sm:justify-between sm:space-x-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
{isSubmitting ? (
<Button variant="default" onClick={onConfirm}>
<LogOut className="mr-2 size-4 animate-spin" /> Processing...
</Button>
) : (
<Button variant="default" onClick={onConfirm}>
<LogOut className="mr-2 size-4" /> Confirm
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

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

View File

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

View File

@ -0,0 +1,57 @@
import { Loader2, Save } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
interface AddDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
isSubmitting: boolean
}
export default function UpdateDialog({
open,
onOpenChange,
onConfirm,
isSubmitting,
}: AddDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-xl font-serif text-blue-600">Confirm Update</DialogTitle>
<DialogDescription>
Are you sure you want to update this migrant's information?
</DialogDescription>
</DialogHeader>
<div className="py-4">
<p className="text-neutral-600">
Double-check the changes before confirming. This will overwrite the current data.
</p>
</div>
<DialogFooter className="flex flex-col-reverse sm:flex-row sm:justify-between sm:space-x-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
{isSubmitting ? (
<Button variant="default" onClick={onConfirm}>
<Loader2 className="mr-2 size-4 animate-spin" /> Processing...
</Button>
) : (
<Button variant="default" onClick={onConfirm}>
<Save className="mr-2 size-4" /> Update
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

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

View File

@ -1,7 +1,69 @@
import { motion } from "framer-motion"
import { PlusCircle, FileText, Users, BarChart2, Database } from "lucide-react"
"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"
@ -13,14 +75,15 @@ export default function RecentActivity() {
<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">
{[
{ action: "Added new migrant", user: "Admin", time: "10 minutes ago", type: "add" },
{ action: "Updated record #1248", user: "Admin", time: "2 hours ago", type: "update" },
{ action: "Generated monthly report", user: "System", time: "Yesterday", type: "report" },
{ action: "Deleted duplicate record", user: "Admin", time: "2 days ago", type: "delete" },
{ action: "Imported 15 new records", user: "Admin", time: "1 week ago", type: "import" },
].map((activity, index) => (
{logs.map((log, index) => {
const type = getType(log)
return (
<motion.div
key={index}
className="flex items-start"
@ -28,40 +91,20 @@ export default function RecentActivity() {
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3, delay: 0.5 + index * 0.1 }}
>
<div
className={`p-2 rounded-full mr-4 ${
activity.type === "add"
? "bg-green-100 text-green-600"
: activity.type === "update"
? "bg-blue-100 text-blue-600"
: activity.type === "delete"
? "bg-red-100 text-red-600"
: activity.type === "report"
? "bg-purple-100 text-purple-600"
: "bg-yellow-100 text-yellow-600"
}`}
>
{activity.type === "add" ? (
<PlusCircle className="h-5 w-5" />
) : activity.type === "update" ? (
<FileText className="h-5 w-5" />
) : activity.type === "delete" ? (
<Users className="h-5 w-5" />
) : activity.type === "report" ? (
<BarChart2 className="h-5 w-5" />
) : (
<Database className="h-5 w-5" />
)}
<div className={`p-2 rounded-full mr-4 ${getColorClass(type)}`}>
{getIcon(type)}
</div>
<div>
<p className="font-medium">{activity.action}</p>
<p className="font-medium">{log.description}</p>
<p className="text-sm text-gray-500">
By {activity.user} {activity.time}
By {log.causer_name} {new Date(log.created_at).toLocaleString()}
</p>
</div>
</motion.div>
))}
)
})}
</div>
)}
</div>
</motion.div>
)

View File

@ -4,7 +4,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
interface StatCardProps {
title: string
value: string
value: number
description: string
icon: ReactNode
}

View File

@ -1,69 +1,84 @@
"use client";
import { useState, useEffect, type ChangeEvent, type FormEvent } from "react";
import type { SearchParams } from "@/types/search";
import { Button } from "@/components/ui/button";
import { Search } from "lucide-react";
import React, { useState } from 'react';
import historicalSearchService, { type SearchParams, type SearchResponse } from '@/services/historicalService';
import { Button } from '@/components/ui/button';
import { Search } from 'lucide-react';
import SearchResults from './SearchResults';
interface SearchFormProps {
onSearch: (params: SearchParams) => void;
onReset?: () => void;
initialQuery?: string;
}
// Default form data
const defaultData: SearchParams = {
firstName: "",
lastName: "",
ageAtMigration: "",
yearOfArrival: "",
regionOfOrigin: "all",
settlementLocation: "all",
};
const SearchForm: React.FC<SearchFormProps> = ({ initialQuery = '' }) => {
// State for search
const [formData, setFormData] = useState<SearchParams>({
query: initialQuery,
page: 1,
per_page: 10
});
const [searchResponse, setSearchResponse] = useState<SearchResponse | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [hasSearched, setHasSearched] = useState<boolean>(false);
// Form field definitions to make the JSX cleaner
// Form fields configuration
const textFields = [
{ id: "firstName", label: "First Name (Christian Name)", type: "text" },
{ id: "lastName", label: "Last Name (Surname)", type: "text" },
{ id: "ageAtMigration", label: "Age at Migration", type: "number", min: 0, max: 120 },
{
id: "yearOfArrival",
label: "Date of Arrival in NT (Year)",
type: "number",
min: 1800,
max: new Date().getFullYear()
},
{ 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' }
];
const selectFields = [
{ id: "regionOfOrigin", label: "Region of Origin", options: ["all"] },
{ id: "settlementLocation", label: "Settlement Location", options: ["all"] },
];
const SearchForm = ({ onSearch, onReset }: SearchFormProps) => {
const [formData, setFormData] = useState(defaultData);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
setIsLoading(false);
}, []);
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
// Handle form input changes
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData(prevData => ({ ...prevData, [name]: value }));
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleSubmit = (e: FormEvent) => {
// Handle search form submission
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
onSearch(formData);
setIsLoading(true);
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 handleReset = () => {
setFormData(defaultData);
if (onReset) onReset();
setFormData({ page: 1, per_page: 10 });
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 (
<form onSubmit={handleSubmit} className="card p-6 mb-8">
<div className="search-container">
<h2 className="text-2xl font-bold mb-6">Historical Records Search</h2>
{/* Search Form */}
<form onSubmit={handleSubmit} className="card p-6 mb-8 bg-white shadow-sm rounded-lg border border-gray-100">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Text and number input fields */}
{textFields.map(({ id, label, type, min, max }) => (
@ -84,31 +99,6 @@ const SearchForm = ({ onSearch, onReset }: SearchFormProps) => {
/>
</div>
))}
{selectFields.map(({ id, label, options }) => (
<div key={id} className="space-y-2">
<label htmlFor={id} className="block text-sm font-medium">
{label}
</label>
<select
id={id}
name={id}
value={formData[id as keyof SearchParams] || "all"}
onChange={handleChange}
className="w-full p-2 border border-gray-300 rounded-md"
disabled={isLoading}
>
{options.map(opt => (
<option key={opt} value={opt}>
{opt === "all" ? `All ${label.split(" ")[0]}s` : opt}
</option>
))}
</select>
{isLoading && (
<p className="text-xs text-gray-500">Loading {label.toLowerCase()}...</p>
)}
</div>
))}
</div>
<div className="flex flex-wrap gap-4 mt-8 justify-end">
@ -129,6 +119,78 @@ const SearchForm = ({ onSearch, onReset }: SearchFormProps) => {
</Button>
</div>
</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>
);
};

View File

@ -31,43 +31,8 @@ const SearchSection = () => {
setIsSearching(true);
setSearchResults([]);
try {
// Display a loading message in the UI before making the API call
// API call with small delay to ensure loader is visible
setTimeout(async () => {
try {
// Use apiService.searchPeople with exact matching
const results = await apiService.searchPeople(params);
setSearchResults(results);
setHasSearched(true);
// Check if the results array is empty
setNoResultsFound(results.length === 0);
// Log successful search
console.log(`Found ${results.length} exact match results using apiService.searchPeople`);
} catch (error) {
console.error("Error searching people:", error);
// Display user-friendly error message
setSearchResults([]);
setHasSearched(true);
setNoResultsFound(true);
} finally {
setIsSearching(false);
}
}, 500); // Short delay to ensure loader is visible
} catch (error) {
console.error("Unexpected error:", error);
setIsSearching(false);
setHasSearched(true);
}
};
const handleReset = () => {
setSearchResults([]);
setHasSearched(false);
setNoResultsFound(false);
};
return (
<section className="py-16 px-4 md:px-8 bg-gray-50">
@ -75,7 +40,7 @@ const SearchSection = () => {
<h2 className="text-3xl md:text-4xl font-bold mb-8 text-center">
Search Historical Records
</h2>
<SearchForm onSearch={handleSearch} onReset={handleReset} />
<SearchForm />
{isSearching && (
<div className="flex justify-center my-8">
<Loader2 className="h-12 w-12 animate-spin text-primary" />

View File

@ -1,15 +1,19 @@
import { useState } from "react"
import { BarChart3, FileText, Home, LogOut, Settings, Users } from "lucide-react"
import { BarChart3, Home, LogOut, Settings, Users, Menu, X } from "lucide-react"
import { Link } from "react-router-dom"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import apiService from "@/services/apiService"
import LogoutDialog from "@/components/admin/migrant/table/LogoutDialog"
export default function Sidebar() {
const [collapsed, setCollapsed] = useState(false)
const [mobileOpen, setMobileOpen] = useState(false)
const [logoutDialogOpen, setLogoutDialogOpen] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false);
const handleLogout = async () => {
try {
setIsSubmitting(true);
await apiService.logout();
alert("Logged out successfully");
setTimeout(() => {
@ -17,37 +21,88 @@ export default function Sidebar() {
}, 1000); // Delay so the alert shows
} catch (err) {
alert("Logout failed. Please try again.");
} finally {
setIsSubmitting(false);
setLogoutDialogOpen(false);
}
};
const isActive = (path : string) => {
// For dashboard, match exactly /admin or /admin/
if (path === '/admin/') {
return location.pathname === '/admin' || location.pathname === '/admin/';
}
// For all other routes, use exact matching
return location.pathname === path;
};
return (
<div
className={`bg-white border-r border-neutral-200 h-dvh transition-all duration-300 ${collapsed ? "w-16" : "w-64"}`}
<>
<LogoutDialog
open={logoutDialogOpen}
onOpenChange={setLogoutDialogOpen}
onConfirm={handleLogout}
isSubmitting={isSubmitting}
/>
{/* Mobile menu button - only visible on small screens */}
<button
className="md:hidden fixed top-4 left-4 z-50 bg-white rounded-md p-2 shadow-md border border-neutral-200"
onClick={() => setMobileOpen(!mobileOpen)}
aria-label="Toggle menu"
>
<div className="flex flex-col h-full">
<div className="p-4 flex items-center justify-center border-b border-neutral-200">
{mobileOpen ? <X className="size-5" /> : <Menu className="size-5" />}
</button>
{/* Sidebar backdrop for mobile */}
{mobileOpen && (
<div className="md:hidden fixed inset-0 bg-black/50 z-40" onClick={() => setMobileOpen(false)}></div>
)}
{/* Sidebar */}
<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
${collapsed ? "w-16" : "w-64"}
${mobileOpen ? "left-0" : "-left-full md:left-0"}
transition-all duration-300
`}
>
{/* Italian flag stripe at the top */}
<div className="flex h-1.5 flex-shrink-0">
<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 */}
<div className="p-4 flex items-center justify-center border-b border-neutral-200 flex-shrink-0">
{!collapsed && (
<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">
<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">
<span className="text-sm font-bold text-neutral-800">NT</span>
</div>
<h1 className="text-lg font-serif font-bold text-neutral-800">Italian Migrants</h1>
</div>
)}
{collapsed && (
<div className="size-8 rounded-full bg-gradient-to-r from-green-600 via-white to-red-600 flex items-center justify-center">
<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">
<span className="text-sm font-bold text-neutral-800">NT</span>
</div>
)}
</div>
<nav className="flex-1 py-4 overflow-y-auto">
{/* Scrollable navigation area */}
<nav className="flex-1 overflow-y-auto py-4 scrollbar-thin scrollbar-thumb-neutral-300 scrollbar-track-transparent">
<ul className="space-y-1 px-2">
<li>
<Link to="/admin/">
<Button
variant="ghost"
className={`w-full justify-start ${location.pathname === "/admin/" ? "bg-neutral-100 text-neutral-900" : "text-neutral-700"}`}
className={`w-full justify-start ${
isActive("/admin/")
? "bg-green-50 text-green-700 hover:bg-green-100 hover:text-green-800 shadow-sm"
: "text-neutral-700"
}`}
>
<Home className={`${collapsed ? "mr-0" : "mr-2"} size-5`} />
{!collapsed && <span>Dashboard</span>}
@ -58,7 +113,11 @@ export default function Sidebar() {
<Link to="/admin/migrants">
<Button
variant="ghost"
className={`w-full justify-start ${location.pathname === "/admin/migrants" ? "bg-neutral-100 text-neutral-900" : "text-neutral-700"}`}
className={`w-full justify-start ${
isActive("/admin/migrants")
? "bg-green-50 text-green-700 hover:bg-green-100 hover:text-green-800 shadow-sm"
: "text-neutral-700"
}`}
>
<Users className={`${collapsed ? "mr-0" : "mr-2"} size-5`} />
{!collapsed && <span>Migrants</span>}
@ -69,34 +128,29 @@ export default function Sidebar() {
<Link to="/admin/reports">
<Button
variant="ghost"
className={`w-full justify-start ${location.pathname === "/admin/reports" ? "bg-neutral-100 text-neutral-900" : "text-neutral-700"}`}
className={`w-full justify-start ${
isActive("/admin/reports")
? "bg-green-50 text-green-700 hover:bg-green-100 hover:text-green-800 shadow-sm"
: "text-neutral-700"
}`}
>
<BarChart3 className={`${collapsed ? "mr-0" : "mr-2"} size-5`} />
{!collapsed && <span>Reports</span>}
</Button>
</Link>
</li>
<li>
<Link to="/admin/documents">
<Button
variant="ghost"
className={`w-full justify-start ${location.pathname === "/admin/documents" ? "bg-neutral-100 text-neutral-900" : "text-neutral-700"}`}
>
<FileText className={`${collapsed ? "mr-0" : "mr-2"} size-5`} />
{!collapsed && <span>Documents</span>}
</Button>
</Link>
</li>
</ul>
<Separator className="my-4" />
<ul className="space-y-1 px-2">
<li>
<Link to="/admin/settings">
<Button
variant="ghost"
className={`w-full justify-start ${location.pathname === "/admin/settings" ? "bg-neutral-100 text-neutral-900" : "text-neutral-700"}`}
className={`w-full justify-start ${
isActive("/admin/settings")
? "bg-green-50 text-green-700 hover:bg-green-100 hover:text-green-800 shadow-sm"
: "text-neutral-700"
}`}
>
<Settings className={`${collapsed ? "mr-0" : "mr-2"} size-5`} />
{!collapsed && <span>Settings</span>}
@ -106,19 +160,25 @@ export default function Sidebar() {
</ul>
</nav>
<div className="p-4 border-t border-neutral-200">
<Button onClick={handleLogout} variant="ghost" className="w-full justify-start text-neutral-700">
{/* Sidebar footer */}
<div className="p-4 border-t border-neutral-200 flex-shrink-0">
<Button
variant="ghost"
className="w-full justify-start text-neutral-700 hover:bg-red-50 hover:text-red-700"
onClick={() => setLogoutDialogOpen(true)}
>
<LogOut className={`${collapsed ? "mr-0" : "mr-2"} size-5`} />
{!collapsed && <span>Logout</span>}
</Button>
</div>
<div className="p-2 border-t border-neutral-200">
{/* 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>
</div>
</div>
</div>
</aside>
</>
)
}

View File

@ -35,25 +35,23 @@ const buttonVariants = cva(
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
const Button = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
}
>(({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
})
export { Button, buttonVariants }

View File

@ -0,0 +1,43 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }

View File

@ -0,0 +1,29 @@
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@ -42,7 +42,7 @@
}
:root {
--radius: 0.625rem;
--radius: 0.325rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);

View File

@ -1,33 +1,26 @@
import axios from "axios";
import type { SearchResult, SearchParams } from "@/types/search";
import type { CreatePersonPayload, Person, PaginatedResponse } from "@/types/api";
import type { DashboardResponse, Person } from "@/types/api";
import type { SearchParams, SearchResult } from "@/types/search";
// API base URL from environment variables
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
// Axios instance with base config
const api = axios.create({
baseURL: API_BASE_URL,
headers: { "Content-Type": "application/json" },
});
// Attach token to each request if available
api.interceptors.request.use((config) => {
const token = localStorage.getItem("token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// Handle unauthorized responses globally
api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem("token");
localStorage.removeItem("user");
window.location.href = "/login";
}
return Promise.reject(err);
@ -35,96 +28,87 @@ api.interceptors.response.use(
);
class ApiService {
// --- AUTH ---
async login(params: { email: string; password: string }) {
return api.post("/api/login", params).then((res) => {
localStorage.setItem("token", res.data.token);
localStorage.setItem("user", JSON.stringify(res.data.user));
return res.data;
});
}
async register(params: { name: string; email: string; password: string }) {
return api.post("/api/register", params).then((res) => res.data);
}
async logout() {
return api.post("/api/logout").then((res) => {
localStorage.removeItem("token");
localStorage.removeItem("user");
return res.data;
});
}
// --- MIGRANTS ---
async getMigrants(page = 1, perPage = 10) {
return api.get("/api/persons", {
params: { page, per_page: perPage },
}).then((res) => res.data);
}
async getMigrantsByUrl(url: string) {
return api.get(url).then((res) => res.data);
}
async getPersonById(id: string | number): Promise<Person> {
return api.get(`/api/persons/${id}`).then((res) => res.data.data);
}
async createPerson(formData: any) {
return api.post("/api/persons", formData).then((res) => res.data);
}
async updatePerson(id: string | number, formData: any) {
return api.put(`/api/persons/${id}`, formData).then((res) => res.data);
}
async deletePerson(id: string | number) {
return api.delete(`/api/persons/${id}`).then((res) => res.data);
}
// --- SEARCH ---
async getRecordById(id: string): Promise<SearchResult> {
const { data } = await api.get(`/api/migrants/${id}`);
return data.data;
return api.get(`/api/persons/${id}`).then((res) => res.data.data);
}
async searchPeople(params: SearchParams): Promise<SearchResult[]> {
const filteredParams = Object.fromEntries(
Object.entries(params).filter(
([_, value]) => value && value !== "all"
)
Object.entries(params).filter(([_, v]) => v && v !== "all")
);
if (Object.keys(filteredParams).length === 0) return [];
try {
const { data } = await api.get("/api/persons/search", {
params: { ...filteredParams, exactMatch: "true" },
});
return data.success && data.data ? data.data.data : [];
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown search error";
throw new Error(`[API] Search failed: ${message}`);
}
return api.get("/api/persons/search", {
params: { ...filteredParams, exactMatch: true },
}).then((res) =>
res.data.success && res.data.data ? res.data.data.data : []
);
}
async register(params: { name: string; email: string; password: string }) {
const { data } = await api.post("/api/register", params);
return data;
//DASHBOARD
async getDashboardStats(): Promise<DashboardResponse> {
const response = await api.get<DashboardResponse>('/api/dashboard/stats');
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 login(params: { email: string; password: string }) {
const { data } = await api.post("/api/login", params);
localStorage.setItem("token", data.token);
localStorage.setItem("user", JSON.stringify(data.user));
return data;
}
async logout() {
try {
const { data } = await api.post("/api/logout");
localStorage.removeItem("token");
localStorage.removeItem("user");
return data;
} catch (error) {
console.error("[logout] Logout failed:", error);
throw error;
}
}
async createPerson(payload: CreatePersonPayload): Promise<Person> {
const { data } = await api.post("/api/persons", payload);
return data.data;
}
// ✅ Updated to support pagination
async getMigrants(page: number = 1): Promise<PaginatedResponse<Person>> {
const { data } = await api.get(`/api/persons?page=${page}`);
if (data.success && data.data) {
return {
data: data.data.data,
meta: data.data.meta,
links: data.data.links,
};
}
return {
data: [],
meta: {
total: 0,
count: 0,
per_page: 10,
current_page: 1,
last_page: 1,
},
links: {
first: null,
last: null,
prev: null,
next: null,
},
};
}
async deleteMigrant(id: string) {
const { data } = await api.delete(`/api/persons/${id}`);
return data;
}
}
export default new ApiService();

View File

@ -0,0 +1,149 @@
import axios from 'axios';
// Interfaces
export interface SearchParams {
query?: string;
page?: number;
per_page?: number;
}
// Pagination metadata structure
export interface Pagination {
total: number;
currentPage: number;
totalPages: number;
perPage: number;
}
// Migration details for a person
export interface Migration {
date_of_arrival_aus?: string;
date_of_arrival_nt?: string;
arrival_period?: string;
data_source?: string;
}
// Naturalization details for a person
export interface Naturalization {
certificate_number?: string;
date_of_naturalization?: string;
previous_nationality?: string;
place_of_naturalization?: string;
}
// Residence details for a person
export interface Residence {
town_or_city?: string;
home_at_death?: string;
}
// Family details for a person
export interface Family {
spouse_name?: string;
spouse_origin?: string;
number_of_children?: number;
names_of_children?: string;
additional_notes?: string;
}
// Internment details for a person
export interface Internment {
was_interned: boolean;
camp_name?: string;
date_of_internment?: string;
date_of_release?: string;
additional_notes?: string;
}
// A single search result representing a person record
export interface SearchResult {
person_id: string;
surname: string;
christian_name: string;
full_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;
migration: Migration;
naturalization: Naturalization;
residence: Residence;
family: Family;
internment: Internment;
}
// The overall search response from the API
export interface SearchResponse {
data: SearchResult[];
pagination: Pagination;
success: boolean;
message: string;
}
// API base URL - replace with your actual API URL
const API_BASE_URL ='http://localhost:8000';
class HistoricalSearchService {
/**
* Search for historical records
* @param params SearchParams object containing search parameters
* @returns Promise with SearchResponse
*/
async searchRecords(params: SearchParams = {}): Promise<SearchResponse> {
try {
const response = await axios.get(`${API_BASE_URL}/api/historical/search`, {
params: {
query: params.query || '',
page: params.page || 1,
per_page: params.per_page || 10
}
});
// Ensure the response matches the expected format
if (!response.data.pagination) {
// Create a default response structure if pagination is missing
return {
data: response.data.data || [],
pagination: {
total: response.data.total || 0,
currentPage: response.data.current_page || 1,
totalPages: response.data.last_page || 1,
perPage: response.data.per_page || 10
},
success: response.data.success !== undefined ? response.data.success : true,
message: response.data.message || 'Records fetched'
};
}
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(`Search failed: ${error.response?.data?.message || error.message}`);
}
throw new Error('An unexpected error occurred');
}
}
/**
* Get a single person record by ID
* @param personId The ID of the person to retrieve
* @returns Promise with SearchResult
*/
async getPersonById(personId: string): Promise<SearchResult> {
try {
const response = await axios.get(`${API_BASE_URL}/person/${personId}`);
return response.data.data;
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(`Failed to fetch person: ${error.response?.data?.message || error.message}`);
}
throw new Error('An unexpected error occurred');
}
}
}
export const historicalSearchService = new HistoricalSearchService();
export default historicalSearchService;

View File

@ -62,7 +62,7 @@ export const searchPeople = async (params: SearchParams): Promise<SearchResult[]
* @param id - The migrant's unique identifier
* @returns Promise containing the migrant's complete profile or null if not found
*/
export const getMigrantById = async (id: string): Promise<MigrantProfile | null> => {
export const getPersonById = async (id: string): Promise<MigrantProfile | null> => {
try {
console.log(`Making API request to: ${API_BASE_URL}/api/migrants/${id}`);
@ -116,7 +116,7 @@ export const getMigrantById = async (id: string): Promise<MigrantProfile | null>
return null;
} catch (error: any) {
if (axios.isAxiosError(error)) {
console.error('Axios error in getMigrantById:', {
console.error('Axios error in getPersonById:', {
status: error.response?.status,
statusText: error.response?.statusText,
url: error.config?.url,

View File

@ -59,7 +59,7 @@ export interface Internment {
}
export interface Person {
person_id?: number;
person_id?: string;
surname?: string;
christian_name?: string;
full_name?: string;
@ -78,9 +78,10 @@ export interface Person {
residence?: Residence | null;
family?: Family | null;
internment?: Internment | null;
has_photos?: boolean;
}
export interface CreatePersonPayload {
export interface createMigrantPayload {
surname: string;
christian_name: string;
full_name: string;
@ -107,15 +108,39 @@ export interface PaginationMeta {
last_page: number;
}
export interface PaginationLinks {
first: string | null;
last: string | null;
prev: string | null;
next: string | null;
export interface Pagination {
current_page: number;
per_page: number;
total: number;
next_page_url: string | null;
prev_page_url: string | null;
}
export interface PaginatedResponse<T> {
export interface ApiResponse<T> {
success: boolean;
message: string;
data: T[];
meta: PaginationMeta;
links: PaginationLinks;
pagination: Pagination;
}
//DASHBOARD STATS
export interface DashboardStats {
total_migrants: number;
new_this_month: number;
recent_additions: number;
pending_reviews: number;
incomplete_records: number;
}
export interface DashboardResponse {
success: boolean;
data: DashboardStats;
}
export interface ActivityLog {
log_name: string;
description: string;
causer_name: string;
subject_id: number;
created_at: string;
}

View File

@ -1,10 +1,10 @@
import axios from 'axios';
// Interfaces
export interface SearchParams {
firstName?: string;
lastName?: string;
ageAtMigration?: string;
yearOfArrival?: string;
regionOfOrigin?: string;
settlementLocation?: string;
query?: string;
page?: number;
per_page?: number;
}
// Pagination metadata structure