Crud Operation
This commit is contained in:
parent
75e2e9c964
commit
1cda1cbf72
|
|
@ -13,12 +13,15 @@
|
|||
"@radix-ui/react-dialog": "^1.1.13",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.14",
|
||||
"@radix-ui/react-label": "^2.1.6",
|
||||
"@radix-ui/react-radio-group": "^1.3.6",
|
||||
"@radix-ui/react-select": "^2.2.4",
|
||||
"@radix-ui/react-separator": "^1.1.6",
|
||||
"@radix-ui/react-slot": "^1.2.2",
|
||||
"@radix-ui/react-switch": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.11",
|
||||
"@radix-ui/react-toast": "^1.2.13",
|
||||
"@tailwindcss/vite": "^4.1.6",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "^1.9.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
|
@ -27,6 +30,7 @@
|
|||
"next-themes": "^0.4.6",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-popover": "^0.5.10",
|
||||
"react-router-dom": "^7.6.0",
|
||||
"sonner": "^2.0.3",
|
||||
"tailwind-merge": "^3.3.0"
|
||||
|
|
@ -1506,6 +1510,38 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-radio-group": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.6.tgz",
|
||||
"integrity": "sha512-1tfTAqnYZNVwSpFhCT273nzK8qGBReeYnNTPspCggqk1fvIrfVxJekIuBFidNivzpdiMqDwVGnQvHqXrRPM4Og==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.2",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-presence": "1.1.4",
|
||||
"@radix-ui/react-primitive": "2.1.2",
|
||||
"@radix-ui/react-roving-focus": "1.1.9",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-previous": "1.1.1",
|
||||
"@radix-ui/react-use-size": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-roving-focus": {
|
||||
"version": "1.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.9.tgz",
|
||||
|
|
@ -1619,6 +1655,35 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-switch": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.4.tgz",
|
||||
"integrity": "sha512-yZCky6XZFnR7pcGonJkr9VyNRu46KcYAbyg1v/gVVCZUr8UJ4x+RpncC27hHtiZ15jC+3WS8Yg/JSgyIHnYYsQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.2",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.2",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-previous": "1.1.1",
|
||||
"@radix-ui/react-use-size": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tabs": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.11.tgz",
|
||||
|
|
@ -2341,6 +2406,39 @@
|
|||
"vite": "^5.2.0 || ^6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-table": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
|
||||
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/table-core": "8.21.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/table-core": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
||||
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
|
|
@ -3086,6 +3184,15 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-vendor": {
|
||||
"version": "0.3.8",
|
||||
"resolved": "https://registry.npmjs.org/css-vendor/-/css-vendor-0.3.8.tgz",
|
||||
"integrity": "sha512-Vx/Vl3zsHj32Z+WTNzGjd2iSbSIJTYHMmyGUT2nzCjj0Xk4qLfwpQ8nF6TQ5oo3Cf0s/An3DTc7LclH1BkAXbQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-in-browser": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
|
|
@ -4057,6 +4164,12 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-in-browser": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz",
|
||||
"integrity": "sha512-FeXIBgG/CPGd/WUxuEyvgGTEfwiG9Z4EKGxjNMRqviiIIfsmgrpnHLffEDdwUHqNva1VEW91o3xBT/m8Elgl9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-number": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
|
|
@ -4399,12 +4512,36 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash._getnative": {
|
||||
"version": "3.9.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash._getnative/-/lodash._getnative-3.9.1.tgz",
|
||||
"integrity": "sha512-RrL9VxMEPyDMHOd9uFbvMe8X55X16/cGM5IgOKgRElQZutpX89iS6vwl64duTV1/16w5JY7tuFNXqoekmh1EmA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.debounce": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-3.1.1.tgz",
|
||||
"integrity": "sha512-lcmJwMpdPAtChA4hfiwxTtgFeNAaow701wWUgVUqeD0XJF7vMXIN+bu/2FJSGxT0NUbZy9g9VFrlOFfPjl+0Ew==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash._getnative": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/lodash.throttle": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-3.0.4.tgz",
|
||||
"integrity": "sha512-dRU/xiF4W8a521NYnQosG5drDqv4+hp3ND6yWNJUMnwO1E87Q/A7oc9M/g6pk29K9U3j/ZWhM3BAQZyr/P6TTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash.debounce": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
|
|
@ -4638,7 +4775,6 @@
|
|||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -4839,6 +4975,17 @@
|
|||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
|
|
@ -4948,6 +5095,39 @@
|
|||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-popover": {
|
||||
"version": "0.5.10",
|
||||
"resolved": "https://registry.npmjs.org/react-popover/-/react-popover-0.5.10.tgz",
|
||||
"integrity": "sha512-5SYDTfncywSH00I70oHd4gFRUR8V0rJ4sRADSI/P6G0RVXp9jUgaWloJ0Bk+SFnjpLPuipTKuzQNNd2CTs5Hrw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-vendor": "^0.3.1",
|
||||
"debug": "^2.6.8",
|
||||
"lodash.throttle": "^3.0.3",
|
||||
"prop-types": "^15.5.10"
|
||||
}
|
||||
},
|
||||
"node_modules/react-popover/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-popover/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
|
|
|
|||
|
|
@ -15,12 +15,15 @@
|
|||
"@radix-ui/react-dialog": "^1.1.13",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.14",
|
||||
"@radix-ui/react-label": "^2.1.6",
|
||||
"@radix-ui/react-radio-group": "^1.3.6",
|
||||
"@radix-ui/react-select": "^2.2.4",
|
||||
"@radix-ui/react-separator": "^1.1.6",
|
||||
"@radix-ui/react-slot": "^1.2.2",
|
||||
"@radix-ui/react-switch": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.11",
|
||||
"@radix-ui/react-toast": "^1.2.13",
|
||||
"@tailwindcss/vite": "^4.1.6",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "^1.9.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
|
@ -29,6 +32,7 @@
|
|||
"next-themes": "^0.4.6",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-popover": "^0.5.10",
|
||||
"react-router-dom": "^7.6.0",
|
||||
"sonner": "^2.0.3",
|
||||
"tailwind-merge": "^3.3.0"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
|
||||
import MigrantProfilePage from "./pages/MigrantProfilePage";
|
||||
import NotFoundPage from "./pages/NotFoundPage";
|
||||
import "./App.css";
|
||||
import LoginPage from "./components/admin/LoginPage";
|
||||
import Migrants from "./components/admin/Migrants";
|
||||
import ProfileSettings from "./components/ui/ProfileSettings";
|
||||
|
|
@ -9,6 +8,10 @@ import AdminDashboardPage from "./components/admin/AdminDashboard";
|
|||
import HomePage from "./pages/HomePage";
|
||||
import RegisterPage from "./components/admin/Register";
|
||||
import AddMigrantPage from "./components/admin/AddMigrant";
|
||||
import SettingsPage from "./components/admin/Setting";
|
||||
import ReportsPage from "./components/admin/Reports";
|
||||
import EditMigrant from "./components/admin/EditMigrant";
|
||||
import "./App.css";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
|
|
@ -18,6 +21,9 @@ function App() {
|
|||
<Route path="/admin/migrants" element={<Migrants />} />
|
||||
<Route path="/admin" element={<AdminDashboardPage />} />
|
||||
<Route path="/admin/migrants/add" element={<AddMigrantPage />} />
|
||||
<Route path="/admin/settings" element={<SettingsPage />} />
|
||||
<Route path="/admin/reports" element={<ReportsPage />} />
|
||||
<Route path="/admin/migrants/edit/:id" element={<EditMigrant />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/" element={<HomePage />} />
|
||||
|
|
|
|||
|
|
@ -1,66 +1,52 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { useState, useRef } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import apiService from "@/services/apiService";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
import Header from "../layout/Header";
|
||||
import Sidebar from "../layout/Sidebar";
|
||||
import { PersonalInfoTab } from "./migrant/PersonalInfoTab";
|
||||
import { MigrationDetailsTab } from "./migrant/MigrationDetailsTab";
|
||||
import { LocationsTab } from "./migrant/LocationsTab";
|
||||
import { InterneeDetailsTab } from "./migrant/InterneeDetailsTab";
|
||||
import { PhotosTab } from "./migrant/PhotosTab";
|
||||
import { NotesTab } from "./migrant/NotesTab";
|
||||
import MigrantForm from "@/components/admin/migrant/MigrationForm";
|
||||
import type { MigrantFormRef } from "@/components/admin/migrant/MigrationForm";
|
||||
import AddDialog from "@/components/admin/migrant/table/AddDialog";
|
||||
|
||||
export default function AddUserPage() {
|
||||
const [formData, setFormData] = useState({
|
||||
surname: "",
|
||||
christian_name: "",
|
||||
full_name: "",
|
||||
date_of_birth: "",
|
||||
date_of_death: "",
|
||||
place_of_birth: "",
|
||||
home_at_death: "",
|
||||
occupation: "",
|
||||
names_of_parents: "",
|
||||
names_of_children: "",
|
||||
data_source: "",
|
||||
reference: "",
|
||||
cav: "",
|
||||
id_card_no: "",
|
||||
date_of_arrival_australia: "",
|
||||
date_of_arrival_nt: "",
|
||||
date_of_naturalisation: "",
|
||||
corps_issued: "",
|
||||
no_of_cert: "",
|
||||
issued_at: "",
|
||||
});
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { id, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [id]: value }));
|
||||
export default function AddMigrant() {
|
||||
const formRef = useRef<MigrantFormRef>(null);
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [formData, setFormData] = useState<any>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleCreate = async (data: any) => {
|
||||
// Temporarily store the form data and show confirmation dialog
|
||||
setFormData(data);
|
||||
setShowConfirmDialog(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const handleConfirmCreate = async () => {
|
||||
if (!formData) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const res = await apiService.createPerson(formData);
|
||||
await apiService.createPerson(formData);
|
||||
alert("Migrant created successfully!");
|
||||
console.log(res);
|
||||
setFormData(null);
|
||||
setShowConfirmDialog(false);
|
||||
|
||||
// Reset the form to its initial state
|
||||
if (formRef.current) {
|
||||
formRef.current.resetForm();
|
||||
}
|
||||
// Optionally: clear form or redirect
|
||||
} catch (err) {
|
||||
alert("Failed to create migrant.");
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh bg-neutral-50">
|
||||
<div className="flex min-h-dvh bg-[#f8f5f2]">
|
||||
<Sidebar />
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 md:ml-16 lg:ml-64 w-full transition-all duration-300">
|
||||
<Header title="Add New Migrant" />
|
||||
<main className="p-6">
|
||||
<div className="flex items-center mb-6">
|
||||
|
|
@ -74,53 +60,17 @@ export default function AddUserPage() {
|
|||
</h1>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Tabs defaultValue="personal" className="mb-8">
|
||||
<TabsList className="bg-neutral-100 mb-6">
|
||||
<TabsTrigger value="personal">Personal Information</TabsTrigger>
|
||||
<TabsTrigger value="migration">Migration Details</TabsTrigger>
|
||||
<TabsTrigger value="locations">Locations</TabsTrigger>
|
||||
<TabsTrigger value="internee">Internee Details</TabsTrigger>
|
||||
<TabsTrigger value="photos">Photos & Documents</TabsTrigger>
|
||||
<TabsTrigger value="notes">Additional Notes</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="personal" className="m-0">
|
||||
<PersonalInfoTab formData={formData} handleInputChange={handleInputChange} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="migration" className="m-0">
|
||||
<MigrationDetailsTab formData={formData} handleInputChange={handleInputChange} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="locations" className="m-0">
|
||||
<LocationsTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="internee" className="m-0">
|
||||
<InterneeDetailsTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="photos" className="m-0">
|
||||
<PhotosTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="notes" className="m-0">
|
||||
<NotesTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<Button variant="outline" type="button">
|
||||
Save as Draft
|
||||
</Button>
|
||||
<Button type="submit" className="bg-green-700 hover:bg-green-800">
|
||||
<Save className="mr-2 size-4" /> Save Migrant Record
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<MigrantForm ref={formRef} mode="add" onSubmit={handleCreate} />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Add Confirmation Dialog */}
|
||||
<AddDialog
|
||||
open={showConfirmDialog}
|
||||
onOpenChange={setShowConfirmDialog}
|
||||
onConfirm={handleConfirmCreate}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,13 +4,14 @@ import {
|
|||
Calendar,
|
||||
Clock,
|
||||
Database,
|
||||
FileText,
|
||||
PlusCircle,
|
||||
Search,
|
||||
User,
|
||||
Users,
|
||||
Flag,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -32,17 +33,103 @@ import Header from "../layout/Header";
|
|||
import Sidebar from "../layout/Sidebar";
|
||||
import RecentActivityList from "../common/RecentActivity";
|
||||
import StatCard from "../common/StatCard";
|
||||
import ApiService from "@/services/apiService";
|
||||
import type { DashboardStats } from "@/types/api";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [results, setResults] = useState<any[]>([]); // TODO: Replace any with proper type if available
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [searchLoading, setSearchLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const delayDebounce = setTimeout(() => {
|
||||
if (searchQuery.trim()) {
|
||||
handleSearch();
|
||||
} else {
|
||||
setResults([]);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(delayDebounce);
|
||||
}, [searchQuery]);
|
||||
|
||||
const handleSearch = async () => {
|
||||
const trimmed = searchQuery.trim();
|
||||
if (!trimmed) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchLoading(true);
|
||||
try {
|
||||
const data = await ApiService.searchPeople({ query: trimmed });
|
||||
setResults(data);
|
||||
} catch (error) {
|
||||
console.error("Search failed:", error);
|
||||
setResults([]);
|
||||
} finally {
|
||||
setSearchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
handleSearch();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await ApiService.getDashboardStats();
|
||||
if (response.success) {
|
||||
setStats(response.data);
|
||||
} else {
|
||||
setError('Failed to load dashboard data');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('An error occurred while fetching dashboard data');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
}, []);
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="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-neutral-50">
|
||||
<div className="flex min-h-dvh bg-[#f8f5f2]">
|
||||
<Sidebar />
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 md:ml-16 lg:ml-64 w-full transition-all duration-300">
|
||||
<Header title="Dashboard" />
|
||||
<main className="p-6">
|
||||
<div className="mb-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-serif font-bold text-neutral-800 mb-2">
|
||||
Welcome, Admin
|
||||
</h1>
|
||||
|
|
@ -51,40 +138,42 @@ export default function DashboardPage() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4 mb-8">
|
||||
<StatCard
|
||||
title="Total Migrants"
|
||||
value="1,248"
|
||||
description="+12 this month"
|
||||
value={stats?.total_migrants || 0}
|
||||
description={`+${stats?.new_this_month || 0} this month`}
|
||||
icon={<Users className="size-5 text-green-600" />}
|
||||
/>
|
||||
<StatCard
|
||||
title="Recent Additions"
|
||||
value="24"
|
||||
value={stats?.recent_additions || 0}
|
||||
description="Last 30 days"
|
||||
icon={<PlusCircle className="size-5 text-blue-600" />}
|
||||
/>
|
||||
<StatCard
|
||||
title="Pending Reviews"
|
||||
value="8"
|
||||
value={stats?.pending_reviews || 0}
|
||||
description="Needs attention"
|
||||
icon={<Clock className="size-5 text-amber-600" />}
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Documents"
|
||||
value="3,542"
|
||||
description="Photos and records"
|
||||
icon={<FileText className="size-5 text-red-600" />}
|
||||
title="Incomplete Records"
|
||||
value={stats?.incomplete_records || 0}
|
||||
description="Need more information"
|
||||
icon={<AlertCircle className="size-5 text-red-600" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 mb-8">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-xl font-serif">Quick Search</CardTitle>
|
||||
<CardDescription>Find migrant records quickly</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-xl font-serif">Quick Search</CardTitle>
|
||||
<CardDescription>Find migrant records quickly</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSearchSubmit}>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-2.5 size-5 text-neutral-500" />
|
||||
<Input
|
||||
|
|
@ -94,19 +183,50 @@ export default function DashboardPage() {
|
|||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" className="text-sm">
|
||||
<Button type="button" variant="outline" size="sm" className="text-sm">
|
||||
<Calendar className="mr-1 size-4" /> By Date
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="text-sm">
|
||||
<Button type="button" variant="outline" size="sm" className="text-sm">
|
||||
<User className="mr-1 size-4" /> By Name
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="text-sm">
|
||||
<Button type="button" variant="outline" size="sm" className="text-sm">
|
||||
<Database className="mr-1 size-4" /> Advanced
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</form>
|
||||
|
||||
{/* Results */}
|
||||
<div className="mt-6 space-y-2">
|
||||
{searchLoading ? (
|
||||
<div className="text-center py-4">
|
||||
<div className="inline-block h-6 w-6 animate-spin rounded-full border-2 border-solid border-green-600 border-r-transparent"></div>
|
||||
<p className="mt-2 text-sm text-gray-500">Searching...</p>
|
||||
</div>
|
||||
) : results.length > 0 ? (
|
||||
results.map((person) => (
|
||||
<div
|
||||
key={person.person_id}
|
||||
onClick={() => navigate(`/migrants/${person.person_id}`)}
|
||||
className="cursor-pointer border rounded px-4 py-3 hover:bg-neutral-100 transition"
|
||||
>
|
||||
<div className="font-medium">{person.full_name}</div>
|
||||
{person.migration?.date_of_arrival_nt && (
|
||||
<div className="text-sm text-gray-600">
|
||||
Date of Arrival: {person.migration.date_of_arrival_nt}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
searchQuery.trim() && (
|
||||
<p className="text-sm text-gray-500 mt-4">No results found.</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
|
|
@ -169,4 +289,4 @@ export default function DashboardPage() {
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,10 +3,12 @@
|
|||
import type React from "react"
|
||||
|
||||
import { useState } from "react"
|
||||
import { motion } from "framer-motion"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { Eye, EyeOff, Lock, Mail } from "lucide-react"
|
||||
import { Link } from "react-router-dom";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import apiService from "@/services/apiService"
|
||||
|
||||
export default function LoginPage() {
|
||||
|
|
@ -42,184 +44,78 @@ export default function LoginPage() {
|
|||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#E8DCCA] bg-opacity-30">
|
||||
<div className="absolute inset-0 overflow-hidden z-0">
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-[url('/italian-migrants-historical.jpg')] bg-cover bg-center"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.15 }}
|
||||
transition={{ duration: 1.5 }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-[#9B2335]/20 to-[#01796F]/20" />
|
||||
</div>
|
||||
<div className="min-h-dvh flex items-center justify-center bg-[url('/placeholder.svg?height=1080&width=1920')] bg-cover bg-center">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-green-900/80 via-neutral-900/70 to-red-900/80 backdrop-blur-sm"></div>
|
||||
<div className="w-full max-w-md px-4 relative z-10">
|
||||
<Card className="border-0 py-0 shadow-2xl overflow-hidden">
|
||||
{/* Italian flag stripe at the top */}
|
||||
<div className="flex h-2">
|
||||
<div className="w-1/3 bg-green-600"></div>
|
||||
<div className="w-1/3 bg-white"></div>
|
||||
<div className="w-1/3 bg-red-600"></div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="max-w-md w-full mx-4 bg-white rounded-lg shadow-xl overflow-hidden z-10"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<div className="h-2 bg-gradient-to-r from-[#9B2335] via-[#E8DCCA] to-[#01796F]" />
|
||||
|
||||
<div className="px-8 pt-8 pb-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.3, duration: 0.6 }}
|
||||
className="text-center"
|
||||
>
|
||||
<h2 className="text-2xl font-serif font-bold text-[#1A2A57]">Admin Access</h2>
|
||||
<p className="text-gray-600 mt-1 italic">Northern Territory Italian Migration History</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="px-8 pb-8"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5, duration: 0.6 }}
|
||||
>
|
||||
{error && (
|
||||
<motion.div
|
||||
className="mb-4 p-3 bg-red-50 border-l-4 border-[#9B2335] text-[#9B2335]"
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
>
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-6">
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Mail className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-[#01796F] focus:border-[#01796F]"
|
||||
placeholder="admin@example.com"
|
||||
required
|
||||
/>
|
||||
<CardHeader className="space-y-1 text-center border-b border-neutral-100 pb-6 bg-gradient-to-b from-neutral-50 to-white">
|
||||
<div className="flex justify-center mb-2">
|
||||
<div className="size-16 rounded-full bg-gradient-to-r from-green-600 via-white to-red-600 flex items-center justify-center shadow-md">
|
||||
<span className="text-2xl font-bold text-neutral-800">NT</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
<CardTitle className="text-2xl font-serif">Italian Migrants Database</CardTitle>
|
||||
<CardDescription>Enter your credentials to access the admin panel</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4 pt-6 bg-white">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-2.5 size-5 text-neutral-500" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="admin@example.com"
|
||||
className="pl-10 border-neutral-300 focus-visible:ring-green-600 shadow-sm"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="block w-full pl-10 pr-10 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-[#01796F] focus:border-[#01796F]"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-2.5 size-5 text-neutral-500" />
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
className="pl-10 pr-10 border-neutral-300 focus-visible:ring-green-600 shadow-sm"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-2.5 text-neutral-500 hover:text-neutral-800"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="text-gray-400 hover:text-gray-500 focus:outline-none"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
{showPassword ? <EyeOff className="size-5" /> : <Eye className="size-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="remember-me"
|
||||
name="remember-me"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-[#01796F] focus:ring-[#01796F] border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-700">
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<Link to="#" className="font-medium text-[#01796F] hover:text-[#01796F]/80">
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-white bg-[#9B2335] hover:bg-[#9B2335]/90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#9B2335] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center">
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
Signing in...
|
||||
</div>
|
||||
) : (
|
||||
"Sign in"
|
||||
)}
|
||||
</motion.button>
|
||||
</CardContent>
|
||||
<CardFooter className="bg-gradient-to-b mb-5 mt-8 from-white to-neutral-50">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-gradient-to-r from-green-700 to-green-600 hover:from-green-800 hover:to-green-700 text-white shadow-md"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Authenticating..." : "Sign In"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 flex items-center justify-center">
|
||||
<div className="h-px bg-gray-300 w-full" />
|
||||
<span className="px-2 text-sm text-gray-500">or</span>
|
||||
<div className="h-px bg-gray-300 w-full" />
|
||||
</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>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,243 +1,103 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ArrowUpDown, Download, Filter, MoreHorizontal, PlusCircle, Search, Trash2, Upload } from "lucide-react"
|
||||
import {Link} from "react-router-dom"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { PlusCircle, Filter, Upload, Download } from "lucide-react"
|
||||
|
||||
import Header from "@/components/layout/Header"
|
||||
import Sidebar from "@/components/layout/Sidebar"
|
||||
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 Sidebar from "@/components/layout/Sidebar"
|
||||
|
||||
// Sample data for migrants
|
||||
const migrants = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Marco Rossi",
|
||||
birthDate: "1935-05-12",
|
||||
birthPlace: "Rome, Italy",
|
||||
arrivalDate: "1952-08-23",
|
||||
occupation: "Carpenter",
|
||||
hasPhotos: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Sofia Bianchi",
|
||||
birthDate: "1942-11-03",
|
||||
birthPlace: "Naples, Italy",
|
||||
arrivalDate: "1960-02-15",
|
||||
occupation: "Seamstress",
|
||||
hasPhotos: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Antonio Esposito",
|
||||
birthDate: "1928-07-22",
|
||||
birthPlace: "Milan, Italy",
|
||||
arrivalDate: "1950-10-05",
|
||||
occupation: "Farmer",
|
||||
hasPhotos: false,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Lucia Romano",
|
||||
birthDate: "1940-03-18",
|
||||
birthPlace: "Florence, Italy",
|
||||
arrivalDate: "1958-06-30",
|
||||
occupation: "Teacher",
|
||||
hasPhotos: true,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "Giuseppe Colombo",
|
||||
birthDate: "1932-09-08",
|
||||
birthPlace: "Venice, Italy",
|
||||
arrivalDate: "1955-12-10",
|
||||
occupation: "Fisherman",
|
||||
hasPhotos: false,
|
||||
},
|
||||
]
|
||||
import MigrantTable from "@/components/admin/migrant/table/MigrantTable"
|
||||
import apiService from "@/services/apiService"
|
||||
import type { Person, Pagination } from "@/types/api"
|
||||
|
||||
export default function MigrantsPage() {
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [selectedMigrants, setSelectedMigrants] = useState<number[]>([])
|
||||
const [migrants, setMigrants] = useState<Person[]>([])
|
||||
const [filter, setFilter] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [pagination, setPagination] = useState<Pagination>({
|
||||
current_page: 1,
|
||||
per_page: 10,
|
||||
total: 0,
|
||||
next_page_url: null,
|
||||
prev_page_url: null,
|
||||
})
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedMigrants.length === migrants.length) {
|
||||
setSelectedMigrants([])
|
||||
} else {
|
||||
setSelectedMigrants(migrants.map((m) => m.id))
|
||||
const fetchMigrants = async (url?: string) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = url ? await apiService.getMigrantsByUrl(url) : await apiService.getMigrants(pagination.current_page)
|
||||
const { data, current_page, per_page, total, next_page_url, prev_page_url } = res.data
|
||||
setMigrants(data)
|
||||
setPagination({ current_page, per_page, total, next_page_url, prev_page_url })
|
||||
} catch (err) {
|
||||
console.error("Error fetching migrants:", err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelectMigrant = (id: number) => {
|
||||
if (selectedMigrants.includes(id)) {
|
||||
setSelectedMigrants(selectedMigrants.filter((m) => m !== id))
|
||||
} else {
|
||||
setSelectedMigrants([...selectedMigrants, id])
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
fetchMigrants()
|
||||
}, [])
|
||||
|
||||
|
||||
|
||||
const filteredMigrants = migrants.filter(
|
||||
(migrant) =>
|
||||
migrant.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
migrant.birthPlace.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
migrant.occupation.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
const handlePageChange = (url?: string) => url && fetchMigrants(url)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row min-h-dvh bg-neutral-50">
|
||||
<div className="flex min-h-dvh bg-[#f8f5f2]">
|
||||
<Sidebar />
|
||||
<div className="flex-1 w-full">
|
||||
<div className="flex-1 md:ml-16 lg:ml-64">
|
||||
<Header title="Migrants Management" />
|
||||
<main className="p-4 md:p-6">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 gap-4 sm:gap-0">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 gap-4">
|
||||
<h1 className="text-2xl md:text-3xl font-serif font-bold text-neutral-800">Migrants Database</h1>
|
||||
<Link to="/admin/migrants/add">
|
||||
<Button className="bg-green-700 hover:bg-green-800">
|
||||
<Button className="bg-gradient-to-r from-green-700 to-green-600 hover:from-green-800 hover:to-green-700 shadow-md">
|
||||
<PlusCircle className="mr-2 size-4" /> Add New Migrant
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6 md:mb-8">
|
||||
<CardHeader className="pb-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 sm:gap-0">
|
||||
<CardTitle className="text-xl font-serif">Search & Filter</CardTitle>
|
||||
<Button variant="outline" size="sm">
|
||||
<Card className="mb-6 border-0 shadow-md bg-gradient-to-br from-neutral-50 to-neutral-100">
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-green-600 via-white to-red-600" />
|
||||
<CardHeader className="pb-3 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<CardTitle className="text-xl font-serif text-neutral-800">Search & Filter</CardTitle>
|
||||
<Button variant="outline" size="sm" className="bg-white shadow-sm border-neutral-200">
|
||||
<Filter className="mr-2 size-4" /> Advanced Filters
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-2.5 size-5 text-neutral-500" />
|
||||
<Input
|
||||
placeholder="Search migrants..."
|
||||
className="pl-10 border-neutral-300"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Input
|
||||
placeholder="Search migrants..."
|
||||
className="pl-3 border-neutral-300 bg-white shadow-sm"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="flex-1">
|
||||
<Button variant="outline" className="flex-1 bg-white shadow-sm border-neutral-200">
|
||||
<Upload className="mr-2 size-4" /> Import
|
||||
</Button>
|
||||
<Button variant="outline" className="flex-1">
|
||||
<Button variant="outline" className="flex-1 bg-white shadow-sm border-neutral-200">
|
||||
<Download className="mr-2 size-4" /> Export
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
{selectedMigrants.length > 0 && (
|
||||
<Button variant="destructive" size="sm">
|
||||
<Trash2 className="mr-2 size-4" /> Delete Selected ({selectedMigrants.length})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0 overflow-auto">
|
||||
<div className="min-w-[800px]">
|
||||
<Table>
|
||||
<TableHeader className="bg-neutral-100">
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
<Checkbox
|
||||
checked={selectedMigrants.length === migrants.length && migrants.length > 0}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</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>
|
||||
<MigrantTable
|
||||
data={migrants}
|
||||
globalFilter={filter}
|
||||
loading={loading}
|
||||
page={pagination.current_page}
|
||||
meta={{ ...pagination, count: migrants.length, last_page: Math.ceil(pagination.total / pagination.per_page) }}
|
||||
onNextPage={() => handlePageChange(pagination.next_page_url!)}
|
||||
onPrevPage={() => handlePageChange(pagination.prev_page_url!)}
|
||||
onRefresh={() => fetchMigrants()}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,68 +1,111 @@
|
|||
import { motion } from "framer-motion"
|
||||
import { PlusCircle, FileText, Users, BarChart2, Database } from "lucide-react"
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import ApiService from '@/services/apiService';
|
||||
import type { ActivityLog } from '@/types/api';
|
||||
import { motion } from 'framer-motion';
|
||||
import { PlusCircle, FileText, Users, BarChart2, Database } from 'lucide-react';
|
||||
|
||||
export default function RecentActivity() {
|
||||
const [logs, setLogs] = useState<ActivityLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
const data = await ApiService.getRecentActivityLogs();
|
||||
setLogs(data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching activity logs:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchLogs();
|
||||
}, []);
|
||||
|
||||
const getType = (log: ActivityLog) => {
|
||||
const action = log.description.toLowerCase()
|
||||
if (action.includes("add")) return "add"
|
||||
if (action.includes("update")) return "update"
|
||||
if (action.includes("delete")) return "delete"
|
||||
if (action.includes("report")) return "report"
|
||||
return "import"
|
||||
}
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "add":
|
||||
return <PlusCircle className="h-5 w-5" />
|
||||
case "update":
|
||||
return <FileText className="h-5 w-5" />
|
||||
case "delete":
|
||||
return <Users className="h-5 w-5" />
|
||||
case "report":
|
||||
return <BarChart2 className="h-5 w-5" />
|
||||
default:
|
||||
return <Database className="h-5 w-5" />
|
||||
}
|
||||
}
|
||||
|
||||
const getColorClass = (type: string) => {
|
||||
switch (type) {
|
||||
case "add":
|
||||
return "bg-green-100 text-green-600"
|
||||
case "update":
|
||||
return "bg-blue-100 text-blue-600"
|
||||
case "delete":
|
||||
return "bg-red-100 text-red-600"
|
||||
case "report":
|
||||
return "bg-purple-100 text-purple-600"
|
||||
default:
|
||||
return "bg-yellow-100 text-yellow-600"
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="bg-white rounded-lg shadow"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
>
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-medium">Recent Activity</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="space-y-6">
|
||||
{[
|
||||
{ action: "Added new migrant", user: "Admin", time: "10 minutes ago", type: "add" },
|
||||
{ action: "Updated record #1248", user: "Admin", time: "2 hours ago", type: "update" },
|
||||
{ action: "Generated monthly report", user: "System", time: "Yesterday", type: "report" },
|
||||
{ action: "Deleted duplicate record", user: "Admin", time: "2 days ago", type: "delete" },
|
||||
{ action: "Imported 15 new records", user: "Admin", time: "1 week ago", type: "import" },
|
||||
].map((activity, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="flex items-start"
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.5 + index * 0.1 }}
|
||||
>
|
||||
<div
|
||||
className={`p-2 rounded-full mr-4 ${
|
||||
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>
|
||||
<p className="font-medium">{activity.action}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
By {activity.user} • {activity.time}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
className="bg-white rounded-lg shadow"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
>
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-medium">Recent Activity</h2>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
<div className="p-6">
|
||||
{loading ? (
|
||||
<p className="text-gray-500">Loading...</p>
|
||||
) : logs.length === 0 ? (
|
||||
<p className="text-gray-500">No recent activity found.</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{logs.map((log, index) => {
|
||||
const type = getType(log)
|
||||
return (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="flex items-start"
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.5 + index * 0.1 }}
|
||||
>
|
||||
<div className={`p-2 rounded-full mr-4 ${getColorClass(type)}`}>
|
||||
{getIcon(type)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{log.description}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
By {log.causer_name} • {new Date(log.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||
|
||||
interface StatCardProps {
|
||||
title: string
|
||||
value: string
|
||||
value: number
|
||||
description: string
|
||||
icon: ReactNode
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,134 +1,196 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, type ChangeEvent, type FormEvent } from "react";
|
||||
import type { SearchParams } from "@/types/search";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search } from "lucide-react";
|
||||
import React, { useState } from 'react';
|
||||
import historicalSearchService, { type SearchParams, type SearchResponse } from '@/services/historicalService';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Search } from 'lucide-react';
|
||||
import SearchResults from './SearchResults';
|
||||
|
||||
interface SearchFormProps {
|
||||
onSearch: (params: SearchParams) => void;
|
||||
onReset?: () => void;
|
||||
initialQuery?: string;
|
||||
}
|
||||
|
||||
// Default form data
|
||||
const defaultData: SearchParams = {
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
ageAtMigration: "",
|
||||
yearOfArrival: "",
|
||||
regionOfOrigin: "all",
|
||||
settlementLocation: "all",
|
||||
};
|
||||
const SearchForm: React.FC<SearchFormProps> = ({ initialQuery = '' }) => {
|
||||
// State for search
|
||||
const [formData, setFormData] = useState<SearchParams>({
|
||||
query: initialQuery,
|
||||
page: 1,
|
||||
per_page: 10
|
||||
});
|
||||
const [searchResponse, setSearchResponse] = useState<SearchResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasSearched, setHasSearched] = useState<boolean>(false);
|
||||
|
||||
// Form field definitions to make the JSX cleaner
|
||||
const textFields = [
|
||||
{ id: "firstName", label: "First Name (Christian Name)", type: "text" },
|
||||
{ id: "lastName", label: "Last Name (Surname)", type: "text" },
|
||||
{ id: "ageAtMigration", label: "Age at Migration", type: "number", min: 0, max: 120 },
|
||||
{
|
||||
id: "yearOfArrival",
|
||||
label: "Date of Arrival in NT (Year)",
|
||||
type: "number",
|
||||
min: 1800,
|
||||
max: new Date().getFullYear()
|
||||
},
|
||||
];
|
||||
// Form fields configuration
|
||||
const textFields = [
|
||||
{ id: 'name', label: 'Name', type: 'text' },
|
||||
{ id: 'birth_year', label: 'Birth Year', type: 'number', min: 1800, max: 2000 },
|
||||
{ id: 'place_of_birth', label: 'Place of Birth', type: 'text' },
|
||||
{ id: 'arrival_year', label: 'Arrival Year', type: 'number', min: 1800, max: 2000 },
|
||||
{ id: 'occupation', label: 'Occupation', type: 'text' },
|
||||
{ id: 'residence', label: 'Place of Residence', type: 'text' }
|
||||
];
|
||||
|
||||
const selectFields = [
|
||||
{ id: "regionOfOrigin", label: "Region of Origin", options: ["all"] },
|
||||
{ id: "settlementLocation", label: "Settlement Location", options: ["all"] },
|
||||
];
|
||||
|
||||
const SearchForm = ({ onSearch, onReset }: SearchFormProps) => {
|
||||
const [formData, setFormData] = useState(defaultData);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
// Handle form input changes
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prevData => ({ ...prevData, [name]: value }));
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
// Handle search form submission
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSearch(formData);
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setHasSearched(true);
|
||||
|
||||
try {
|
||||
const response = await historicalSearchService.searchRecords(formData);
|
||||
setSearchResponse(response);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An unknown error occurred');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle form reset
|
||||
const handleReset = () => {
|
||||
setFormData(defaultData);
|
||||
if (onReset) onReset();
|
||||
setFormData({ page: 1, per_page: 10 });
|
||||
setSearchResponse(null);
|
||||
setHasSearched(false);
|
||||
};
|
||||
|
||||
// Handle pagination
|
||||
const handlePageChange = async (newPage: number) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await historicalSearchService.searchRecords({ ...formData, page: newPage });
|
||||
setSearchResponse(response);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An unknown error occurred');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="card p-6 mb-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* Text and number input fields */}
|
||||
{textFields.map(({ id, label, type, min, max }) => (
|
||||
<div key={id} className="space-y-2">
|
||||
<label htmlFor={id} className="block text-sm font-medium">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
name={id}
|
||||
type={type}
|
||||
value={formData[id as keyof SearchParams] || ""}
|
||||
onChange={handleChange}
|
||||
placeholder={`Enter ${label.toLowerCase()}`}
|
||||
min={min}
|
||||
max={max}
|
||||
className="w-full p-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="search-container">
|
||||
<h2 className="text-2xl font-bold mb-6">Historical Records Search</h2>
|
||||
|
||||
{/* Search Form */}
|
||||
<form onSubmit={handleSubmit} className="card p-6 mb-8 bg-white shadow-sm rounded-lg border border-gray-100">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* Text and number input fields */}
|
||||
{textFields.map(({ id, label, type, min, max }) => (
|
||||
<div key={id} className="space-y-2">
|
||||
<label htmlFor={id} className="block text-sm font-medium">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
name={id}
|
||||
type={type}
|
||||
value={formData[id as keyof SearchParams] || ""}
|
||||
onChange={handleChange}
|
||||
placeholder={`Enter ${label.toLowerCase()}`}
|
||||
min={min}
|
||||
max={max}
|
||||
className="w-full p-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectFields.map(({ id, label, options }) => (
|
||||
<div key={id} className="space-y-2">
|
||||
<label htmlFor={id} className="block text-sm font-medium">
|
||||
{label}
|
||||
</label>
|
||||
<select
|
||||
id={id}
|
||||
name={id}
|
||||
value={formData[id as keyof SearchParams] || "all"}
|
||||
onChange={handleChange}
|
||||
className="w-full p-2 border border-gray-300 rounded-md"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{options.map(opt => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt === "all" ? `All ${label.split(" ")[0]}s` : opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{isLoading && (
|
||||
<p className="text-xs text-gray-500">Loading {label.toLowerCase()}...</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 mt-8 justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
className="border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-gradient-to-r from-green-600 via-white to-red-600 text-gray-800 hover:from-green-700 hover:via-gray-100 hover:to-red-700 font-medium"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Search className="mr-2 h-4 w-4" /> Search Records
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="flex flex-wrap gap-4 mt-8 justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
className="border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-gradient-to-r from-green-600 via-white to-red-600 text-gray-800 hover:from-green-700 hover:via-gray-100 hover:to-red-700 font-medium"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Search className="mr-2 h-4 w-4" /> Search Records
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div className="flex justify-center my-8">
|
||||
<div className="loading-spinner animate-spin h-10 w-10 border-4 border-gray-300 border-t-blue-600 rounded-full"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="error-message bg-red-50 p-4 rounded border border-red-200 text-red-700 mb-6">
|
||||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results Summary */}
|
||||
{hasSearched && searchResponse && searchResponse.pagination && !isLoading && (
|
||||
<div className="results-summary mb-4">
|
||||
Found {searchResponse.pagination.total} results
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Display search results using the SearchResults component */}
|
||||
{hasSearched && searchResponse && !isLoading && (
|
||||
<SearchResults
|
||||
results={searchResponse.data}
|
||||
isLoading={isLoading}
|
||||
hasSearched={hasSearched}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{hasSearched && searchResponse?.pagination && searchResponse.pagination.totalPages > 1 && (
|
||||
<div className="pagination-controls mt-6 flex justify-center gap-2">
|
||||
<button
|
||||
onClick={() => handlePageChange(1)}
|
||||
disabled={searchResponse.pagination.currentPage === 1}
|
||||
className="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded disabled:opacity-50"
|
||||
>
|
||||
First
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handlePageChange(searchResponse.pagination.currentPage - 1)}
|
||||
disabled={searchResponse.pagination.currentPage === 1}
|
||||
className="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<span className="px-3 py-1">
|
||||
Page {searchResponse.pagination.currentPage} of {searchResponse.pagination.totalPages}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => handlePageChange(searchResponse.pagination.currentPage + 1)}
|
||||
disabled={searchResponse.pagination.currentPage === searchResponse.pagination.totalPages}
|
||||
className="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handlePageChange(searchResponse.pagination.totalPages)}
|
||||
disabled={searchResponse.pagination.currentPage === searchResponse.pagination.totalPages}
|
||||
className="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded disabled:opacity-50"
|
||||
>
|
||||
Last
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -31,43 +31,8 @@ const SearchSection = () => {
|
|||
setIsSearching(true);
|
||||
setSearchResults([]);
|
||||
|
||||
try {
|
||||
// Display a loading message in the UI before making the API call
|
||||
// API call with small delay to ensure loader is visible
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// Use apiService.searchPeople with exact matching
|
||||
const results = await apiService.searchPeople(params);
|
||||
setSearchResults(results);
|
||||
setHasSearched(true);
|
||||
|
||||
// Check if the results array is empty
|
||||
setNoResultsFound(results.length === 0);
|
||||
|
||||
// Log successful search
|
||||
console.log(`Found ${results.length} exact match results using apiService.searchPeople`);
|
||||
} catch (error) {
|
||||
console.error("Error searching people:", error);
|
||||
// Display user-friendly error message
|
||||
setSearchResults([]);
|
||||
setHasSearched(true);
|
||||
setNoResultsFound(true);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, 500); // Short delay to ensure loader is visible
|
||||
} catch (error) {
|
||||
console.error("Unexpected error:", error);
|
||||
setIsSearching(false);
|
||||
setHasSearched(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSearchResults([]);
|
||||
setHasSearched(false);
|
||||
setNoResultsFound(false);
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-16 px-4 md:px-8 bg-gray-50">
|
||||
|
|
@ -75,7 +40,7 @@ const SearchSection = () => {
|
|||
<h2 className="text-3xl md:text-4xl font-bold mb-8 text-center">
|
||||
Search Historical Records
|
||||
</h2>
|
||||
<SearchForm onSearch={handleSearch} onReset={handleReset} />
|
||||
<SearchForm />
|
||||
{isSearching && (
|
||||
<div className="flex justify-center my-8">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
|
||||
import { useState } from "react"
|
||||
import { BarChart3, FileText, Home, LogOut, Settings, Users } from "lucide-react"
|
||||
import { BarChart3, Home, LogOut, Settings, Users, Menu, X } from "lucide-react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import apiService from "@/services/apiService"
|
||||
import LogoutDialog from "@/components/admin/migrant/table/LogoutDialog"
|
||||
export default function Sidebar() {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const [logoutDialogOpen, setLogoutDialogOpen] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
await apiService.logout();
|
||||
alert("Logged out successfully");
|
||||
setTimeout(() => {
|
||||
|
|
@ -17,108 +21,164 @@ export default function Sidebar() {
|
|||
}, 1000); // Delay so the alert shows
|
||||
} catch (err) {
|
||||
alert("Logout failed. Please try again.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
setLogoutDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isActive = (path : string) => {
|
||||
// For dashboard, match exactly /admin or /admin/
|
||||
if (path === '/admin/') {
|
||||
return location.pathname === '/admin' || location.pathname === '/admin/';
|
||||
}
|
||||
|
||||
// For all other routes, use exact matching
|
||||
return location.pathname === path;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-white border-r border-neutral-200 h-dvh transition-all duration-300 ${collapsed ? "w-16" : "w-64"}`}
|
||||
>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-4 flex items-center justify-center border-b border-neutral-200">
|
||||
<>
|
||||
<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"
|
||||
>
|
||||
{mobileOpen ? <X className="size-5" /> : <Menu className="size-5" />}
|
||||
</button>
|
||||
|
||||
{/* Sidebar backdrop for mobile */}
|
||||
{mobileOpen && (
|
||||
<div className="md:hidden fixed inset-0 bg-black/50 z-40" onClick={() => setMobileOpen(false)}></div>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`bg-gradient-to-b from-neutral-50 to-white border-r border-neutral-200 shadow-md fixed h-full z-50 flex flex-col
|
||||
${collapsed ? "w-16" : "w-64"}
|
||||
${mobileOpen ? "left-0" : "-left-full md:left-0"}
|
||||
transition-all duration-300
|
||||
`}
|
||||
>
|
||||
{/* Italian flag stripe at the top */}
|
||||
<div className="flex h-1.5 flex-shrink-0">
|
||||
<div className="w-1/3 bg-green-600"></div>
|
||||
<div className="w-1/3 bg-white"></div>
|
||||
<div className="w-1/3 bg-red-600"></div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar header */}
|
||||
<div className="p-4 flex items-center justify-center border-b border-neutral-200 flex-shrink-0">
|
||||
{!collapsed && (
|
||||
<div className="flex items-center">
|
||||
<div className="size-8 rounded-full bg-gradient-to-r from-green-600 via-white to-red-600 flex items-center justify-center mr-2">
|
||||
<div className="size-8 rounded-full bg-gradient-to-r from-green-600 via-white to-red-600 flex items-center justify-center mr-2 shadow-md">
|
||||
<span className="text-sm font-bold text-neutral-800">NT</span>
|
||||
</div>
|
||||
<h1 className="text-lg font-serif font-bold text-neutral-800">Italian Migrants</h1>
|
||||
</div>
|
||||
)}
|
||||
{collapsed && (
|
||||
<div className="size-8 rounded-full bg-gradient-to-r from-green-600 via-white to-red-600 flex items-center justify-center">
|
||||
<div className="size-8 rounded-full bg-gradient-to-r from-green-600 via-white to-red-600 flex items-center justify-center shadow-md">
|
||||
<span className="text-sm font-bold text-neutral-800">NT</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 py-4 overflow-y-auto">
|
||||
<ul className="space-y-1 px-2">
|
||||
<li>
|
||||
<Link to="/admin/ ">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`w-full justify-start ${location.pathname === "/admin/" ? "bg-neutral-100 text-neutral-900" : "text-neutral-700"}`}
|
||||
>
|
||||
<Home className={`${collapsed ? "mr-0" : "mr-2"} size-5`} />
|
||||
{!collapsed && <span>Dashboard</span>}
|
||||
</Button>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/admin/migrants">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`w-full justify-start ${location.pathname === "/admin/migrants" ? "bg-neutral-100 text-neutral-900" : "text-neutral-700"}`}
|
||||
>
|
||||
<Users className={`${collapsed ? "mr-0" : "mr-2"} size-5`} />
|
||||
{!collapsed && <span>Migrants</span>}
|
||||
</Button>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/admin/reports">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`w-full justify-start ${location.pathname === "/admin/reports" ? "bg-neutral-100 text-neutral-900" : "text-neutral-700"}`}
|
||||
>
|
||||
<BarChart3 className={`${collapsed ? "mr-0" : "mr-2"} size-5`} />
|
||||
{!collapsed && <span>Reports</span>}
|
||||
</Button>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/admin/documents">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`w-full justify-start ${location.pathname === "/admin/documents" ? "bg-neutral-100 text-neutral-900" : "text-neutral-700"}`}
|
||||
>
|
||||
<FileText className={`${collapsed ? "mr-0" : "mr-2"} size-5`} />
|
||||
{!collapsed && <span>Documents</span>}
|
||||
</Button>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
{/* Scrollable navigation area */}
|
||||
<nav className="flex-1 overflow-y-auto py-4 scrollbar-thin scrollbar-thumb-neutral-300 scrollbar-track-transparent">
|
||||
<ul className="space-y-1 px-2">
|
||||
<li>
|
||||
<Link to="/admin/">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`w-full justify-start ${
|
||||
isActive("/admin/")
|
||||
? "bg-green-50 text-green-700 hover:bg-green-100 hover:text-green-800 shadow-sm"
|
||||
: "text-neutral-700"
|
||||
}`}
|
||||
>
|
||||
<Home className={`${collapsed ? "mr-0" : "mr-2"} size-5`} />
|
||||
{!collapsed && <span>Dashboard</span>}
|
||||
</Button>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/admin/migrants">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`w-full justify-start ${
|
||||
isActive("/admin/migrants")
|
||||
? "bg-green-50 text-green-700 hover:bg-green-100 hover:text-green-800 shadow-sm"
|
||||
: "text-neutral-700"
|
||||
}`}
|
||||
>
|
||||
<Users className={`${collapsed ? "mr-0" : "mr-2"} size-5`} />
|
||||
{!collapsed && <span>Migrants</span>}
|
||||
</Button>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/admin/reports">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`w-full justify-start ${
|
||||
isActive("/admin/reports")
|
||||
? "bg-green-50 text-green-700 hover:bg-green-100 hover:text-green-800 shadow-sm"
|
||||
: "text-neutral-700"
|
||||
}`}
|
||||
>
|
||||
<BarChart3 className={`${collapsed ? "mr-0" : "mr-2"} size-5`} />
|
||||
{!collapsed && <span>Reports</span>}
|
||||
</Button>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
<Separator className="my-4" />
|
||||
<ul className="space-y-1 px-2">
|
||||
<li>
|
||||
<Link to="/admin/settings">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`w-full justify-start ${
|
||||
isActive("/admin/settings")
|
||||
? "bg-green-50 text-green-700 hover:bg-green-100 hover:text-green-800 shadow-sm"
|
||||
: "text-neutral-700"
|
||||
}`}
|
||||
>
|
||||
<Settings className={`${collapsed ? "mr-0" : "mr-2"} size-5`} />
|
||||
{!collapsed && <span>Settings</span>}
|
||||
</Button>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<ul className="space-y-1 px-2">
|
||||
<li>
|
||||
<Link to="/admin/settings">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`w-full justify-start ${location.pathname === "/admin/settings" ? "bg-neutral-100 text-neutral-900" : "text-neutral-700"}`}
|
||||
>
|
||||
<Settings className={`${collapsed ? "mr-0" : "mr-2"} size-5`} />
|
||||
{!collapsed && <span>Settings</span>}
|
||||
</Button>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-neutral-200">
|
||||
<Button onClick={handleLogout} variant="ghost" className="w-full justify-start text-neutral-700">
|
||||
{/* Sidebar footer */}
|
||||
<div className="p-4 border-t border-neutral-200 flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-neutral-700 hover:bg-red-50 hover:text-red-700"
|
||||
onClick={() => setLogoutDialogOpen(true)}
|
||||
>
|
||||
<LogOut className={`${collapsed ? "mr-0" : "mr-2"} size-5`} />
|
||||
{!collapsed && <span>Logout</span>}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-2 border-t border-neutral-200">
|
||||
{/* Only show collapse button on desktop */}
|
||||
<div className="p-2 border-t border-neutral-200 hidden md:block flex-shrink-0">
|
||||
<Button variant="ghost" size="sm" className="w-full" onClick={() => setCollapsed(!collapsed)}>
|
||||
{collapsed ? ">>" : "<<"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -35,25 +35,23 @@ const buttonVariants = cva(
|
|||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Button = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}
|
||||
>(({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export { Button, buttonVariants }
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -42,7 +42,7 @@
|
|||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--radius: 0.325rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
|
|
|
|||
|
|
@ -1,33 +1,26 @@
|
|||
import axios from "axios";
|
||||
import type { SearchResult, SearchParams } from "@/types/search";
|
||||
import type { CreatePersonPayload, Person, PaginatedResponse } from "@/types/api";
|
||||
import type { DashboardResponse, Person } from "@/types/api";
|
||||
import type { SearchParams, SearchResult } from "@/types/search";
|
||||
|
||||
// API base URL from environment variables
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
// Axios instance with base config
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
// Attach token to each request if available
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem("token");
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
});
|
||||
|
||||
// Handle unauthorized responses globally
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("user");
|
||||
|
||||
window.location.href = "/login";
|
||||
}
|
||||
return Promise.reject(err);
|
||||
|
|
@ -35,96 +28,87 @@ api.interceptors.response.use(
|
|||
);
|
||||
|
||||
class ApiService {
|
||||
// --- AUTH ---
|
||||
async login(params: { email: string; password: string }) {
|
||||
return api.post("/api/login", params).then((res) => {
|
||||
localStorage.setItem("token", res.data.token);
|
||||
localStorage.setItem("user", JSON.stringify(res.data.user));
|
||||
return res.data;
|
||||
});
|
||||
}
|
||||
|
||||
async register(params: { name: string; email: string; password: string }) {
|
||||
return api.post("/api/register", params).then((res) => res.data);
|
||||
}
|
||||
|
||||
async logout() {
|
||||
return api.post("/api/logout").then((res) => {
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("user");
|
||||
return res.data;
|
||||
});
|
||||
}
|
||||
|
||||
// --- MIGRANTS ---
|
||||
async getMigrants(page = 1, perPage = 10) {
|
||||
return api.get("/api/persons", {
|
||||
params: { page, per_page: perPage },
|
||||
}).then((res) => res.data);
|
||||
}
|
||||
|
||||
async getMigrantsByUrl(url: string) {
|
||||
return api.get(url).then((res) => res.data);
|
||||
}
|
||||
|
||||
async getPersonById(id: string | number): Promise<Person> {
|
||||
return api.get(`/api/persons/${id}`).then((res) => res.data.data);
|
||||
}
|
||||
|
||||
async createPerson(formData: any) {
|
||||
return api.post("/api/persons", formData).then((res) => res.data);
|
||||
}
|
||||
|
||||
async updatePerson(id: string | number, formData: any) {
|
||||
return api.put(`/api/persons/${id}`, formData).then((res) => res.data);
|
||||
}
|
||||
|
||||
async deletePerson(id: string | number) {
|
||||
return api.delete(`/api/persons/${id}`).then((res) => res.data);
|
||||
}
|
||||
|
||||
// --- SEARCH ---
|
||||
async getRecordById(id: string): Promise<SearchResult> {
|
||||
const { data } = await api.get(`/api/migrants/${id}`);
|
||||
return data.data;
|
||||
return api.get(`/api/persons/${id}`).then((res) => res.data.data);
|
||||
}
|
||||
|
||||
async searchPeople(params: SearchParams): Promise<SearchResult[]> {
|
||||
const filteredParams = Object.fromEntries(
|
||||
Object.entries(params).filter(
|
||||
([_, value]) => value && value !== "all"
|
||||
)
|
||||
Object.entries(params).filter(([_, v]) => v && v !== "all")
|
||||
);
|
||||
|
||||
if (Object.keys(filteredParams).length === 0) return [];
|
||||
|
||||
try {
|
||||
const { data } = await api.get("/api/persons/search", {
|
||||
params: { ...filteredParams, exactMatch: "true" },
|
||||
});
|
||||
|
||||
return data.success && data.data ? data.data.data : [];
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Unknown search error";
|
||||
throw new Error(`[API] Search failed: ${message}`);
|
||||
}
|
||||
return api.get("/api/persons/search", {
|
||||
params: { ...filteredParams, exactMatch: true },
|
||||
}).then((res) =>
|
||||
res.data.success && res.data.data ? res.data.data.data : []
|
||||
);
|
||||
}
|
||||
|
||||
async register(params: { name: string; email: string; password: string }) {
|
||||
const { data } = await api.post("/api/register", params);
|
||||
return data;
|
||||
//DASHBOARD
|
||||
async getDashboardStats(): Promise<DashboardResponse> {
|
||||
const response = await api.get<DashboardResponse>('/api/dashboard/stats');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async 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 getRecentActivityLogs() {
|
||||
return api.get("/api/activity-logs").then((res) => res.data.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;
|
||||
async searchByText(query: string): Promise<Person[]> {
|
||||
if (!query.trim()) return [];
|
||||
const res = await api.get("/api/historical/search", { params: { query } });
|
||||
return res.data?.data ?? [];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default new ApiService();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -62,7 +62,7 @@ export const searchPeople = async (params: SearchParams): Promise<SearchResult[]
|
|||
* @param id - The migrant's unique identifier
|
||||
* @returns Promise containing the migrant's complete profile or null if not found
|
||||
*/
|
||||
export const getMigrantById = async (id: string): Promise<MigrantProfile | null> => {
|
||||
export const getPersonById = async (id: string): Promise<MigrantProfile | null> => {
|
||||
|
||||
try {
|
||||
console.log(`Making API request to: ${API_BASE_URL}/api/migrants/${id}`);
|
||||
|
|
@ -116,7 +116,7 @@ export const getMigrantById = async (id: string): Promise<MigrantProfile | null>
|
|||
return null;
|
||||
} catch (error: any) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
console.error('Axios error in getMigrantById:', {
|
||||
console.error('Axios error in getPersonById:', {
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
url: error.config?.url,
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ export interface Internment {
|
|||
}
|
||||
|
||||
export interface Person {
|
||||
person_id?: number;
|
||||
person_id?: string;
|
||||
surname?: string;
|
||||
christian_name?: string;
|
||||
full_name?: string;
|
||||
|
|
@ -78,9 +78,10 @@ export interface Person {
|
|||
residence?: Residence | null;
|
||||
family?: Family | null;
|
||||
internment?: Internment | null;
|
||||
has_photos?: boolean;
|
||||
}
|
||||
|
||||
export interface CreatePersonPayload {
|
||||
export interface createMigrantPayload {
|
||||
surname: string;
|
||||
christian_name: string;
|
||||
full_name: string;
|
||||
|
|
@ -107,15 +108,39 @@ export interface PaginationMeta {
|
|||
last_page: number;
|
||||
}
|
||||
|
||||
export interface PaginationLinks {
|
||||
first: string | null;
|
||||
last: string | null;
|
||||
prev: string | null;
|
||||
next: string | null;
|
||||
|
||||
export interface Pagination {
|
||||
current_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
next_page_url: string | null;
|
||||
prev_page_url: string | null;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: T[];
|
||||
meta: PaginationMeta;
|
||||
links: PaginationLinks;
|
||||
}
|
||||
pagination: Pagination;
|
||||
}
|
||||
//DASHBOARD STATS
|
||||
export interface DashboardStats {
|
||||
total_migrants: number;
|
||||
new_this_month: number;
|
||||
recent_additions: number;
|
||||
pending_reviews: number;
|
||||
incomplete_records: number;
|
||||
}
|
||||
|
||||
export interface DashboardResponse {
|
||||
success: boolean;
|
||||
data: DashboardStats;
|
||||
}
|
||||
export interface ActivityLog {
|
||||
log_name: string;
|
||||
description: string;
|
||||
causer_name: string;
|
||||
subject_id: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import axios from 'axios';
|
||||
|
||||
// Interfaces
|
||||
export interface SearchParams {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
ageAtMigration?: string;
|
||||
yearOfArrival?: string;
|
||||
regionOfOrigin?: string;
|
||||
settlementLocation?: string;
|
||||
query?: string;
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
}
|
||||
|
||||
// Pagination metadata structure
|
||||
|
|
|
|||
Loading…
Reference in New Issue