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