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-dialog": "^1.1.13",
"@radix-ui/react-dropdown-menu": "^2.1.14", "@radix-ui/react-dropdown-menu": "^2.1.14",
"@radix-ui/react-label": "^2.1.6", "@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-radio-group": "^1.3.6",
"@radix-ui/react-select": "^2.2.4", "@radix-ui/react-select": "^2.2.4",
"@radix-ui/react-separator": "^1.1.6", "@radix-ui/react-separator": "^1.1.6",
"@radix-ui/react-slot": "^1.2.2", "@radix-ui/react-slot": "^1.2.2",
"@radix-ui/react-switch": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.11", "@radix-ui/react-tabs": "^1.1.11",
"@radix-ui/react-toast": "^1.2.13", "@radix-ui/react-toast": "^1.2.13",
"@tailwindcss/vite": "^4.1.6", "@tailwindcss/vite": "^4.1.6",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.9.0", "axios": "^1.9.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -27,6 +30,7 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-popover": "^0.5.10",
"react-router-dom": "^7.6.0", "react-router-dom": "^7.6.0",
"sonner": "^2.0.3", "sonner": "^2.0.3",
"tailwind-merge": "^3.3.0" "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": { "node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.9", "version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.9.tgz", "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": { "node_modules/@radix-ui/react-tabs": {
"version": "1.1.11", "version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.11.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.11.tgz",
@ -2341,6 +2406,39 @@
"vite": "^5.2.0 || ^6" "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": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -3086,6 +3184,15 @@
"node": ">= 8" "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": { "node_modules/csstype": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@ -4057,6 +4164,12 @@
"node": ">=0.10.0" "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": { "node_modules/is-number": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@ -4399,12 +4512,36 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"dev": true "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": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@ -4638,7 +4775,6 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -4839,6 +4975,17 @@
"node": ">= 0.8.0" "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": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@ -4948,6 +5095,39 @@
"react": "^18.3.1" "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": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "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-dialog": "^1.1.13",
"@radix-ui/react-dropdown-menu": "^2.1.14", "@radix-ui/react-dropdown-menu": "^2.1.14",
"@radix-ui/react-label": "^2.1.6", "@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-radio-group": "^1.3.6",
"@radix-ui/react-select": "^2.2.4", "@radix-ui/react-select": "^2.2.4",
"@radix-ui/react-separator": "^1.1.6", "@radix-ui/react-separator": "^1.1.6",
"@radix-ui/react-slot": "^1.2.2", "@radix-ui/react-slot": "^1.2.2",
"@radix-ui/react-switch": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.11", "@radix-ui/react-tabs": "^1.1.11",
"@radix-ui/react-toast": "^1.2.13", "@radix-ui/react-toast": "^1.2.13",
"@tailwindcss/vite": "^4.1.6", "@tailwindcss/vite": "^4.1.6",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.9.0", "axios": "^1.9.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -29,6 +32,7 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-popover": "^0.5.10",
"react-router-dom": "^7.6.0", "react-router-dom": "^7.6.0",
"sonner": "^2.0.3", "sonner": "^2.0.3",
"tailwind-merge": "^3.3.0" "tailwind-merge": "^3.3.0"

View File

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

View File

@ -1,66 +1,52 @@
"use client" import { useState, useRef } from "react";
import { useState } from "react";
import { ArrowLeft, Save } from "lucide-react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import apiService from "@/services/apiService"; import apiService from "@/services/apiService";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import Header from "../layout/Header"; import Header from "../layout/Header";
import Sidebar from "../layout/Sidebar"; import Sidebar from "../layout/Sidebar";
import { PersonalInfoTab } from "./migrant/PersonalInfoTab"; import MigrantForm from "@/components/admin/migrant/MigrationForm";
import { MigrationDetailsTab } from "./migrant/MigrationDetailsTab"; import type { MigrantFormRef } from "@/components/admin/migrant/MigrationForm";
import { LocationsTab } from "./migrant/LocationsTab"; import AddDialog from "@/components/admin/migrant/table/AddDialog";
import { InterneeDetailsTab } from "./migrant/InterneeDetailsTab";
import { PhotosTab } from "./migrant/PhotosTab";
import { NotesTab } from "./migrant/NotesTab";
export default function AddUserPage() { export default function AddMigrant() {
const [formData, setFormData] = useState({ const formRef = useRef<MigrantFormRef>(null);
surname: "", const [showConfirmDialog, setShowConfirmDialog] = useState(false);
christian_name: "", const [formData, setFormData] = useState<any>(null);
full_name: "", const [isSubmitting, setIsSubmitting] = useState(false);
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: "",
});
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { const handleCreate = async (data: any) => {
const { id, value } = e.target; // Temporarily store the form data and show confirmation dialog
setFormData(prev => ({ ...prev, [id]: value })); setFormData(data);
setShowConfirmDialog(true);
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleConfirmCreate = async () => {
e.preventDefault(); if (!formData) return;
setIsSubmitting(true);
try { try {
const res = await apiService.createPerson(formData); await apiService.createPerson(formData);
alert("Migrant created successfully!"); 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) { } catch (err) {
alert("Failed to create migrant."); alert("Failed to create migrant.");
console.error(err); console.error(err);
} finally {
setIsSubmitting(false);
} }
}; };
return ( return (
<div className="flex min-h-dvh bg-neutral-50"> <div className="flex min-h-dvh bg-[#f8f5f2]">
<Sidebar /> <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" /> <Header title="Add New Migrant" />
<main className="p-6"> <main className="p-6">
<div className="flex items-center mb-6"> <div className="flex items-center mb-6">
@ -74,53 +60,17 @@ export default function AddUserPage() {
</h1> </h1>
</div> </div>
<form onSubmit={handleSubmit}> <MigrantForm ref={formRef} mode="add" onSubmit={handleCreate} />
<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>
</main> </main>
</div> </div>
{/* Add Confirmation Dialog */}
<AddDialog
open={showConfirmDialog}
onOpenChange={setShowConfirmDialog}
onConfirm={handleConfirmCreate}
isSubmitting={isSubmitting}
/>
</div> </div>
); );
} }

View File

@ -4,13 +4,14 @@ import {
Calendar, Calendar,
Clock, Clock,
Database, Database,
FileText,
PlusCircle, PlusCircle,
Search, Search,
User, User,
Users, Users,
Flag,
AlertCircle,
} from "lucide-react"; } from "lucide-react";
import { Link } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -32,14 +33,100 @@ import Header from "../layout/Header";
import Sidebar from "../layout/Sidebar"; import Sidebar from "../layout/Sidebar";
import RecentActivityList from "../common/RecentActivity"; import RecentActivityList from "../common/RecentActivity";
import StatCard from "../common/StatCard"; import StatCard from "../common/StatCard";
import ApiService from "@/services/apiService";
import type { DashboardStats } from "@/types/api";
import { useEffect } from "react";
export default function DashboardPage() { export default function DashboardPage() {
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState(""); 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 ( 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 /> <Sidebar />
<div className="flex-1"> <div className="flex-1 md:ml-16 lg:ml-64 w-full transition-all duration-300">
<Header title="Dashboard" /> <Header title="Dashboard" />
<main className="p-6"> <main className="p-6">
<div className="mb-8"> <div className="mb-8">
@ -51,30 +138,31 @@ export default function DashboardPage() {
</p> </p>
</div> </div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4 mb-8"> <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4 mb-8">
<StatCard <StatCard
title="Total Migrants" title="Total Migrants"
value="1,248" value={stats?.total_migrants || 0}
description="+12 this month" description={`+${stats?.new_this_month || 0} this month`}
icon={<Users className="size-5 text-green-600" />} icon={<Users className="size-5 text-green-600" />}
/> />
<StatCard <StatCard
title="Recent Additions" title="Recent Additions"
value="24" value={stats?.recent_additions || 0}
description="Last 30 days" description="Last 30 days"
icon={<PlusCircle className="size-5 text-blue-600" />} icon={<PlusCircle className="size-5 text-blue-600" />}
/> />
<StatCard <StatCard
title="Pending Reviews" title="Pending Reviews"
value="8" value={stats?.pending_reviews || 0}
description="Needs attention" description="Needs attention"
icon={<Clock className="size-5 text-amber-600" />} icon={<Clock className="size-5 text-amber-600" />}
/> />
<StatCard <StatCard
title="Total Documents" title="Incomplete Records"
value="3,542" value={stats?.incomplete_records || 0}
description="Photos and records" description="Need more information"
icon={<FileText className="size-5 text-red-600" />} icon={<AlertCircle className="size-5 text-red-600" />}
/> />
</div> </div>
@ -85,6 +173,7 @@ export default function DashboardPage() {
<CardDescription>Find migrant records quickly</CardDescription> <CardDescription>Find migrant records quickly</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form onSubmit={handleSearchSubmit}>
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-2.5 size-5 text-neutral-500" /> <Search className="absolute left-3 top-2.5 size-5 text-neutral-500" />
<Input <Input
@ -94,17 +183,48 @@ export default function DashboardPage() {
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
</div> </div>
<div className="mt-4 flex flex-wrap gap-2"> <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 <Calendar className="mr-1 size-4" /> By Date
</Button> </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 <User className="mr-1 size-4" /> By Name
</Button> </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 <Database className="mr-1 size-4" /> Advanced
</Button> </Button>
</div> </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> </CardContent>
</Card> </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 type React from "react"
import { useState } from "react" import { useState } from "react"
import { motion } from "framer-motion"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { Eye, EyeOff, Lock, Mail } from "lucide-react" import { Eye, EyeOff, Lock, Mail } from "lucide-react"
import { 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" import apiService from "@/services/apiService"
export default function LoginPage() { export default function LoginPage() {
@ -42,184 +44,78 @@ export default function LoginPage() {
setIsLoading(false) setIsLoading(false)
} }
} }
return ( return (
<div className="min-h-screen flex items-center justify-center bg-[#E8DCCA] bg-opacity-30"> <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 overflow-hidden z-0"> <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>
<motion.div <div className="w-full max-w-md px-4 relative z-10">
className="absolute inset-0 bg-[url('/italian-migrants-historical.jpg')] bg-cover bg-center" <Card className="border-0 py-0 shadow-2xl overflow-hidden">
initial={{ opacity: 0 }} {/* Italian flag stripe at the top */}
animate={{ opacity: 0.15 }} <div className="flex h-2">
transition={{ duration: 1.5 }} <div className="w-1/3 bg-green-600"></div>
/> <div className="w-1/3 bg-white"></div>
<div className="absolute inset-0 bg-gradient-to-b from-[#9B2335]/20 to-[#01796F]/20" /> <div className="w-1/3 bg-red-600"></div>
</div> </div>
<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">
className="max-w-md w-full mx-4 bg-white rounded-lg shadow-xl overflow-hidden z-10" <div className="flex justify-center mb-2">
initial={{ opacity: 0, y: 20 }} <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">
animate={{ opacity: 1, y: 0 }} <span className="text-2xl font-bold text-neutral-800">NT</span>
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>
</div> </div>
</div>
<motion.div <CardTitle className="text-2xl font-serif">Italian Migrants Database</CardTitle>
className="px-8 pb-8" <CardDescription>Enter your credentials to access the admin panel</CardDescription>
initial={{ opacity: 0 }} </CardHeader>
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>
)}
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="mb-6"> <CardContent className="space-y-4 pt-6 bg-white">
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1"> <div className="space-y-2">
Email <Label htmlFor="email">Email</Label>
</label>
<div className="relative"> <div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <Mail className="absolute left-3 top-2.5 size-5 text-neutral-500" />
<Mail className="h-5 w-5 text-gray-400" /> <Input
</div>
<input
id="email" id="email"
type="email" type="email"
placeholder="admin@example.com"
className="pl-10 border-neutral-300 focus-visible:ring-green-600 shadow-sm"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} 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 required
/> />
</div> </div>
</div> </div>
<div className="space-y-2">
<div className="mb-6"> <Label htmlFor="password">Password</Label>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<div className="relative"> <div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <Lock className="absolute left-3 top-2.5 size-5 text-neutral-500" />
<Lock className="h-5 w-5 text-gray-400" /> <Input
</div>
<input
id="password" id="password"
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
className="pl-10 pr-10 border-neutral-300 focus-visible:ring-green-600 shadow-sm"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} 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 required
/> />
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
<button <button
type="button" type="button"
className="absolute right-3 top-2.5 text-neutral-500 hover:text-neutral-800"
onClick={() => setShowPassword(!showPassword)} 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> </button>
</div> </div>
</div> </div>
</div> </CardContent>
<CardFooter className="bg-gradient-to-b mb-5 mt-8 from-white to-neutral-50">
<div className="flex items-center justify-between mb-6"> <Button
<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
type="submit" 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} 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 ? ( {isLoading ? "Authenticating..." : "Sign In"}
<div className="flex items-center"> </Button>
<svg </CardFooter>
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>
</form> </form>
</Card>
<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" />
</div> </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> </div>
) )
} }

View File

@ -1,243 +1,103 @@
"use client" "use client"
import { useState } from "react" import { useEffect, useState } from "react"
import { ArrowUpDown, Download, Filter, MoreHorizontal, PlusCircle, Search, Trash2, Upload } from "lucide-react" import { Link } from "react-router-dom"
import {Link} from "react-router-dom" import { PlusCircle, Filter, Upload, Download } from "lucide-react"
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 Header from "@/components/layout/Header" import Header from "@/components/layout/Header"
import Sidebar from "@/components/layout/Sidebar" import Sidebar from "@/components/layout/Sidebar"
import { Button } from "@/components/ui/button"
// Sample data for migrants import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
const migrants = [ import { Input } from "@/components/ui/input"
{ import MigrantTable from "@/components/admin/migrant/table/MigrantTable"
id: 1, import apiService from "@/services/apiService"
name: "Marco Rossi", import type { Person, Pagination } from "@/types/api"
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,
},
]
export default function MigrantsPage() { export default function MigrantsPage() {
const [searchQuery, setSearchQuery] = useState("") const [migrants, setMigrants] = useState<Person[]>([])
const [selectedMigrants, setSelectedMigrants] = useState<number[]>([]) 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 = () => { const fetchMigrants = async (url?: string) => {
if (selectedMigrants.length === migrants.length) { setLoading(true)
setSelectedMigrants([]) try {
} else { const res = url ? await apiService.getMigrantsByUrl(url) : await apiService.getMigrants(pagination.current_page)
setSelectedMigrants(migrants.map((m) => m.id)) 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) => { useEffect(() => {
if (selectedMigrants.includes(id)) { fetchMigrants()
setSelectedMigrants(selectedMigrants.filter((m) => m !== id)) }, [])
} else {
setSelectedMigrants([...selectedMigrants, id])
}
}
const handlePageChange = (url?: string) => url && fetchMigrants(url)
const filteredMigrants = migrants.filter(
(migrant) =>
migrant.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
migrant.birthPlace.toLowerCase().includes(searchQuery.toLowerCase()) ||
migrant.occupation.toLowerCase().includes(searchQuery.toLowerCase()),
)
return ( return (
<div className="flex flex-col md:flex-row min-h-dvh bg-neutral-50"> <div className="flex min-h-dvh bg-[#f8f5f2]">
<Sidebar /> <Sidebar />
<div className="flex-1 w-full"> <div className="flex-1 md:ml-16 lg:ml-64">
<Header title="Migrants Management" /> <Header title="Migrants Management" />
<main className="p-4 md:p-6"> <main className="p-4 md:p-6">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 gap-4 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> <h1 className="text-2xl md:text-3xl font-serif font-bold text-neutral-800">Migrants Database</h1>
<Link to="/admin/migrants/add"> <Link to="/admin/migrants/add">
<Button className="bg-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 <PlusCircle className="mr-2 size-4" /> Add New Migrant
</Button> </Button>
</Link> </Link>
</div> </div>
<Card className="mb-6 md:mb-8"> <Card className="mb-6 border-0 shadow-md bg-gradient-to-br from-neutral-50 to-neutral-100">
<CardHeader className="pb-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 sm:gap-0"> <div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-green-600 via-white to-red-600" />
<CardTitle className="text-xl font-serif">Search & Filter</CardTitle> <CardHeader className="pb-3 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<Button variant="outline" size="sm"> <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 <Filter className="mr-2 size-4" /> Advanced Filters
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2">
<div className="relative">
<Search className="absolute left-3 top-2.5 size-5 text-neutral-500" />
<Input <Input
placeholder="Search migrants..." placeholder="Search migrants..."
className="pl-10 border-neutral-300" className="pl-3 border-neutral-300 bg-white shadow-sm"
value={searchQuery} value={filter}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setFilter(e.target.value)}
/> />
</div>
<div className="flex gap-2"> <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 <Upload className="mr-2 size-4" /> Import
</Button> </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 <Download className="mr-2 size-4" /> Export
</Button> </Button>
</div> </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> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <MigrantTable
<CardContent className="p-0 overflow-auto"> data={migrants}
<div className="min-w-[800px]"> globalFilter={filter}
<Table> loading={loading}
<TableHeader className="bg-neutral-100"> page={pagination.current_page}
<TableRow> meta={{ ...pagination, count: migrants.length, last_page: Math.ceil(pagination.total / pagination.per_page) }}
<TableHead className="w-12"> onNextPage={() => handlePageChange(pagination.next_page_url!)}
<Checkbox onPrevPage={() => handlePageChange(pagination.prev_page_url!)}
checked={selectedMigrants.length === migrants.length && migrants.length > 0} onRefresh={() => fetchMigrants()}
onCheckedChange={toggleSelectAll}
aria-label="Select all"
/> />
</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> </main>
</div> </div>
</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" "use client"
import { PlusCircle, FileText, Users, BarChart2, Database } from "lucide-react"
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() { 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 ( return (
<motion.div <motion.div
className="bg-white rounded-lg shadow" className="bg-white rounded-lg shadow"
@ -13,14 +75,15 @@ export default function RecentActivity() {
<h2 className="text-lg font-medium">Recent Activity</h2> <h2 className="text-lg font-medium">Recent Activity</h2>
</div> </div>
<div className="p-6"> <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"> <div className="space-y-6">
{[ {logs.map((log, index) => {
{ action: "Added new migrant", user: "Admin", time: "10 minutes ago", type: "add" }, const type = getType(log)
{ action: "Updated record #1248", user: "Admin", time: "2 hours ago", type: "update" }, return (
{ 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) => (
<motion.div <motion.div
key={index} key={index}
className="flex items-start" className="flex items-start"
@ -28,40 +91,20 @@ export default function RecentActivity() {
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3, delay: 0.5 + index * 0.1 }} transition={{ duration: 0.3, delay: 0.5 + index * 0.1 }}
> >
<div <div className={`p-2 rounded-full mr-4 ${getColorClass(type)}`}>
className={`p-2 rounded-full mr-4 ${ {getIcon(type)}
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> </div>
<div> <div>
<p className="font-medium">{activity.action}</p> <p className="font-medium">{log.description}</p>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
By {activity.user} {activity.time} By {log.causer_name} {new Date(log.created_at).toLocaleString()}
</p> </p>
</div> </div>
</motion.div> </motion.div>
))} )
})}
</div> </div>
)}
</div> </div>
</motion.div> </motion.div>
) )

View File

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

View File

@ -1,69 +1,84 @@
"use client"; import React, { useState } from 'react';
import historicalSearchService, { type SearchParams, type SearchResponse } from '@/services/historicalService';
import { useState, useEffect, type ChangeEvent, type FormEvent } from "react"; import { Button } from '@/components/ui/button';
import type { SearchParams } from "@/types/search"; import { Search } from 'lucide-react';
import { Button } from "@/components/ui/button"; import SearchResults from './SearchResults';
import { Search } from "lucide-react";
interface SearchFormProps { interface SearchFormProps {
onSearch: (params: SearchParams) => void; initialQuery?: string;
onReset?: () => void;
} }
// Default form data const SearchForm: React.FC<SearchFormProps> = ({ initialQuery = '' }) => {
const defaultData: SearchParams = { // State for search
firstName: "", const [formData, setFormData] = useState<SearchParams>({
lastName: "", query: initialQuery,
ageAtMigration: "", page: 1,
yearOfArrival: "", per_page: 10
regionOfOrigin: "all", });
settlementLocation: "all", 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 = [ const textFields = [
{ id: "firstName", label: "First Name (Christian Name)", type: "text" }, { id: 'name', label: 'Name', type: 'text' },
{ id: "lastName", label: "Last Name (Surname)", type: "text" }, { id: 'birth_year', label: 'Birth Year', type: 'number', min: 1800, max: 2000 },
{ id: "ageAtMigration", label: "Age at Migration", type: "number", min: 0, max: 120 }, { id: 'place_of_birth', label: 'Place of Birth', type: 'text' },
{ { id: 'arrival_year', label: 'Arrival Year', type: 'number', min: 1800, max: 2000 },
id: "yearOfArrival", { id: 'occupation', label: 'Occupation', type: 'text' },
label: "Date of Arrival in NT (Year)", { id: 'residence', label: 'Place of Residence', type: 'text' }
type: "number", ];
min: 1800,
max: new Date().getFullYear()
},
];
const selectFields = [ // Handle form input changes
{ id: "regionOfOrigin", label: "Region of Origin", options: ["all"] }, const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
{ 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>) => {
const { name, value } = e.target; 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(); 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 = () => { const handleReset = () => {
setFormData(defaultData); setFormData({ page: 1, per_page: 10 });
if (onReset) onReset(); setSearchResponse(null);
setHasSearched(false);
};
// Handle pagination
const handlePageChange = async (newPage: number) => {
setIsLoading(true);
try {
const response = await historicalSearchService.searchRecords({ ...formData, page: newPage });
setSearchResponse(response);
} catch (err) {
setError(err instanceof Error ? err.message : 'An unknown error occurred');
} finally {
setIsLoading(false);
}
}; };
return ( return (
<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"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Text and number input fields */} {/* Text and number input fields */}
{textFields.map(({ id, label, type, min, max }) => ( {textFields.map(({ id, label, type, min, max }) => (
@ -84,31 +99,6 @@ const SearchForm = ({ onSearch, onReset }: SearchFormProps) => {
/> />
</div> </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>
<div className="flex flex-wrap gap-4 mt-8 justify-end"> <div className="flex flex-wrap gap-4 mt-8 justify-end">
@ -129,6 +119,78 @@ const SearchForm = ({ onSearch, onReset }: SearchFormProps) => {
</Button> </Button>
</div> </div>
</form> </form>
{/* Loading State */}
{isLoading && (
<div className="flex justify-center my-8">
<div className="loading-spinner animate-spin h-10 w-10 border-4 border-gray-300 border-t-blue-600 rounded-full"></div>
</div>
)}
{/* Error State */}
{error && (
<div className="error-message bg-red-50 p-4 rounded border border-red-200 text-red-700 mb-6">
Error: {error}
</div>
)}
{/* Results Summary */}
{hasSearched && searchResponse && searchResponse.pagination && !isLoading && (
<div className="results-summary mb-4">
Found {searchResponse.pagination.total} results
</div>
)}
{/* Display search results using the SearchResults component */}
{hasSearched && searchResponse && !isLoading && (
<SearchResults
results={searchResponse.data}
isLoading={isLoading}
hasSearched={hasSearched}
/>
)}
{/* Pagination Controls */}
{hasSearched && searchResponse?.pagination && searchResponse.pagination.totalPages > 1 && (
<div className="pagination-controls mt-6 flex justify-center gap-2">
<button
onClick={() => handlePageChange(1)}
disabled={searchResponse.pagination.currentPage === 1}
className="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded disabled:opacity-50"
>
First
</button>
<button
onClick={() => handlePageChange(searchResponse.pagination.currentPage - 1)}
disabled={searchResponse.pagination.currentPage === 1}
className="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded disabled:opacity-50"
>
Previous
</button>
<span className="px-3 py-1">
Page {searchResponse.pagination.currentPage} of {searchResponse.pagination.totalPages}
</span>
<button
onClick={() => handlePageChange(searchResponse.pagination.currentPage + 1)}
disabled={searchResponse.pagination.currentPage === searchResponse.pagination.totalPages}
className="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded disabled:opacity-50"
>
Next
</button>
<button
onClick={() => handlePageChange(searchResponse.pagination.totalPages)}
disabled={searchResponse.pagination.currentPage === searchResponse.pagination.totalPages}
className="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded disabled:opacity-50"
>
Last
</button>
</div>
)}
</div>
); );
}; };

View File

@ -31,43 +31,8 @@ const SearchSection = () => {
setIsSearching(true); setIsSearching(true);
setSearchResults([]); 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 ( return (
<section className="py-16 px-4 md:px-8 bg-gray-50"> <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"> <h2 className="text-3xl md:text-4xl font-bold mb-8 text-center">
Search Historical Records Search Historical Records
</h2> </h2>
<SearchForm onSearch={handleSearch} onReset={handleReset} /> <SearchForm />
{isSearching && ( {isSearching && (
<div className="flex justify-center my-8"> <div className="flex justify-center my-8">
<Loader2 className="h-12 w-12 animate-spin text-primary" /> <Loader2 className="h-12 w-12 animate-spin text-primary" />

View File

@ -1,15 +1,19 @@
import { useState } from "react" 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 { Link } from "react-router-dom"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import apiService from "@/services/apiService" import apiService from "@/services/apiService"
import LogoutDialog from "@/components/admin/migrant/table/LogoutDialog"
export default function Sidebar() { export default function Sidebar() {
const [collapsed, setCollapsed] = useState(false) const [collapsed, setCollapsed] = useState(false)
const [mobileOpen, setMobileOpen] = useState(false)
const [logoutDialogOpen, setLogoutDialogOpen] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false);
const handleLogout = async () => { const handleLogout = async () => {
try { try {
setIsSubmitting(true);
await apiService.logout(); await apiService.logout();
alert("Logged out successfully"); alert("Logged out successfully");
setTimeout(() => { setTimeout(() => {
@ -17,37 +21,88 @@ export default function Sidebar() {
}, 1000); // Delay so the alert shows }, 1000); // Delay so the alert shows
} catch (err) { } catch (err) {
alert("Logout failed. Please try again."); alert("Logout failed. Please try again.");
} finally {
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 ( 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"> {mobileOpen ? <X className="size-5" /> : <Menu className="size-5" />}
<div className="p-4 flex items-center justify-center border-b border-neutral-200"> </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 && ( {!collapsed && (
<div className="flex items-center"> <div className="flex items-center">
<div className="size-8 rounded-full bg-gradient-to-r from-green-600 via-white to-red-600 flex items-center justify-center mr-2"> <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> <span className="text-sm font-bold text-neutral-800">NT</span>
</div> </div>
<h1 className="text-lg font-serif font-bold text-neutral-800">Italian Migrants</h1> <h1 className="text-lg font-serif font-bold text-neutral-800">Italian Migrants</h1>
</div> </div>
)} )}
{collapsed && ( {collapsed && (
<div className="size-8 rounded-full bg-gradient-to-r from-green-600 via-white to-red-600 flex items-center justify-center"> <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> <span className="text-sm font-bold text-neutral-800">NT</span>
</div> </div>
)} )}
</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"> <ul className="space-y-1 px-2">
<li> <li>
<Link to="/admin/ "> <Link to="/admin/">
<Button <Button
variant="ghost" 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`} /> <Home className={`${collapsed ? "mr-0" : "mr-2"} size-5`} />
{!collapsed && <span>Dashboard</span>} {!collapsed && <span>Dashboard</span>}
@ -58,7 +113,11 @@ export default function Sidebar() {
<Link to="/admin/migrants"> <Link to="/admin/migrants">
<Button <Button
variant="ghost" 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`} /> <Users className={`${collapsed ? "mr-0" : "mr-2"} size-5`} />
{!collapsed && <span>Migrants</span>} {!collapsed && <span>Migrants</span>}
@ -69,34 +128,29 @@ export default function Sidebar() {
<Link to="/admin/reports"> <Link to="/admin/reports">
<Button <Button
variant="ghost" 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`} /> <BarChart3 className={`${collapsed ? "mr-0" : "mr-2"} size-5`} />
{!collapsed && <span>Reports</span>} {!collapsed && <span>Reports</span>}
</Button> </Button>
</Link> </Link>
</li> </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> </ul>
<Separator className="my-4" /> <Separator className="my-4" />
<ul className="space-y-1 px-2"> <ul className="space-y-1 px-2">
<li> <li>
<Link to="/admin/settings"> <Link to="/admin/settings">
<Button <Button
variant="ghost" 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`} /> <Settings className={`${collapsed ? "mr-0" : "mr-2"} size-5`} />
{!collapsed && <span>Settings</span>} {!collapsed && <span>Settings</span>}
@ -106,19 +160,25 @@ export default function Sidebar() {
</ul> </ul>
</nav> </nav>
<div className="p-4 border-t border-neutral-200"> {/* Sidebar footer */}
<Button onClick={handleLogout} variant="ghost" className="w-full justify-start text-neutral-700"> <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`} /> <LogOut className={`${collapsed ? "mr-0" : "mr-2"} size-5`} />
{!collapsed && <span>Logout</span>} {!collapsed && <span>Logout</span>}
</Button> </Button>
</div> </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)}> <Button variant="ghost" size="sm" className="w-full" onClick={() => setCollapsed(!collapsed)}>
{collapsed ? ">>" : "<<"} {collapsed ? ">>" : "<<"}
</Button> </Button>
</div> </div>
</div> </aside>
</div> </>
) )
} }

View File

@ -35,25 +35,23 @@ const buttonVariants = cva(
} }
) )
function Button({ const Button = React.forwardRef<
className, HTMLButtonElement,
variant, React.ComponentProps<"button"> &
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean asChild?: boolean
}) { }
>(({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : "button"
return ( return (
<Comp <Comp
data-slot="button" data-slot="button"
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props} {...props}
/> />
) )
} })
export { Button, buttonVariants } 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 { :root {
--radius: 0.625rem; --radius: 0.325rem;
--background: oklch(1 0 0); --background: oklch(1 0 0);
--foreground: oklch(0.145 0 0); --foreground: oklch(0.145 0 0);
--card: oklch(1 0 0); --card: oklch(1 0 0);

View File

@ -1,33 +1,26 @@
import axios from "axios"; import axios from "axios";
import type { SearchResult, SearchParams } from "@/types/search"; import type { DashboardResponse, Person } from "@/types/api";
import type { CreatePersonPayload, Person, PaginatedResponse } 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; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
// Axios instance with base config
const api = axios.create({ const api = axios.create({
baseURL: API_BASE_URL, baseURL: API_BASE_URL,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); });
// Attach token to each request if available
api.interceptors.request.use((config) => { api.interceptors.request.use((config) => {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
if (token) { if (token) config.headers.Authorization = `Bearer ${token}`;
config.headers.Authorization = `Bearer ${token}`;
}
return config; return config;
}); });
// Handle unauthorized responses globally
api.interceptors.response.use( api.interceptors.response.use(
(res) => res, (res) => res,
(err) => { (err) => {
if (err.response?.status === 401) { if (err.response?.status === 401) {
localStorage.removeItem("token"); localStorage.removeItem("token");
localStorage.removeItem("user"); localStorage.removeItem("user");
window.location.href = "/login"; window.location.href = "/login";
} }
return Promise.reject(err); return Promise.reject(err);
@ -35,96 +28,87 @@ api.interceptors.response.use(
); );
class ApiService { 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> { async getRecordById(id: string): Promise<SearchResult> {
const { data } = await api.get(`/api/migrants/${id}`); return api.get(`/api/persons/${id}`).then((res) => res.data.data);
return data.data;
} }
async searchPeople(params: SearchParams): Promise<SearchResult[]> { async searchPeople(params: SearchParams): Promise<SearchResult[]> {
const filteredParams = Object.fromEntries( const filteredParams = Object.fromEntries(
Object.entries(params).filter( Object.entries(params).filter(([_, v]) => v && v !== "all")
([_, value]) => value && value !== "all"
)
); );
if (Object.keys(filteredParams).length === 0) return []; if (Object.keys(filteredParams).length === 0) return [];
try { return api.get("/api/persons/search", {
const { data } = await api.get("/api/persons/search", { params: { ...filteredParams, exactMatch: true },
params: { ...filteredParams, exactMatch: "true" }, }).then((res) =>
}); res.data.success && res.data.data ? res.data.data.data : []
);
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}`);
}
} }
async register(params: { name: string; email: string; password: string }) { //DASHBOARD
const { data } = await api.post("/api/register", params); async getDashboardStats(): Promise<DashboardResponse> {
return data; 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(); 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 * @param id - The migrant's unique identifier
* @returns Promise containing the migrant's complete profile or null if not found * @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 { try {
console.log(`Making API request to: ${API_BASE_URL}/api/migrants/${id}`); 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; return null;
} catch (error: any) { } catch (error: any) {
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
console.error('Axios error in getMigrantById:', { console.error('Axios error in getPersonById:', {
status: error.response?.status, status: error.response?.status,
statusText: error.response?.statusText, statusText: error.response?.statusText,
url: error.config?.url, url: error.config?.url,

View File

@ -59,7 +59,7 @@ export interface Internment {
} }
export interface Person { export interface Person {
person_id?: number; person_id?: string;
surname?: string; surname?: string;
christian_name?: string; christian_name?: string;
full_name?: string; full_name?: string;
@ -78,9 +78,10 @@ export interface Person {
residence?: Residence | null; residence?: Residence | null;
family?: Family | null; family?: Family | null;
internment?: Internment | null; internment?: Internment | null;
has_photos?: boolean;
} }
export interface CreatePersonPayload { export interface createMigrantPayload {
surname: string; surname: string;
christian_name: string; christian_name: string;
full_name: string; full_name: string;
@ -107,15 +108,39 @@ export interface PaginationMeta {
last_page: number; last_page: number;
} }
export interface PaginationLinks {
first: string | null; export interface Pagination {
last: string | null; current_page: number;
prev: string | null; per_page: number;
next: string | null; 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[]; data: T[];
meta: PaginationMeta; pagination: Pagination;
links: PaginationLinks;
} }
//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 { export interface SearchParams {
firstName?: string; query?: string;
lastName?: string; page?: number;
ageAtMigration?: string; per_page?: number;
yearOfArrival?: string;
regionOfOrigin?: string;
settlementLocation?: string;
} }
// Pagination metadata structure // Pagination metadata structure