feat: prepare project for production release
This commit is contained in:
parent
0d59240818
commit
062ae9315a
2
.env
2
.env
|
|
@ -1 +1 @@
|
||||||
VITE_API_URL=http://127.0.0.1:8000
|
VITE_API_URL=https://migrants.staging.anss.au
|
||||||
|
|
|
||||||
4
build.sh
4
build.sh
|
|
@ -7,10 +7,10 @@ npm run build
|
||||||
echo "Copying React build files to Laravel public folder..."
|
echo "Copying React build files to Laravel public folder..."
|
||||||
|
|
||||||
# Copy index.html to public root
|
# Copy index.html to public root
|
||||||
cp dist/index.html /home/mark/nun/public/index.html
|
cp dist/index.html /home/mark/migrants/public/index.html
|
||||||
|
|
||||||
# Copy assets folder contents to public/assets folder
|
# Copy assets folder contents to public/assets folder
|
||||||
cp -r dist/assets/* /home/mark/nun/public/assets/
|
cp -r dist/assets/* /home/mark/migrants/public/assets/
|
||||||
|
|
||||||
echo "Deployment complete!"
|
echo "Deployment complete!"
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 973 KiB |
|
|
@ -10,7 +10,6 @@ import MigrantProfilePage from "./pages/MigrantProfilePage";
|
||||||
|
|
||||||
// Admin Components
|
// Admin Components
|
||||||
import LoginPage from "./components/admin/LoginPage";
|
import LoginPage from "./components/admin/LoginPage";
|
||||||
import RegisterPage from "./components/admin/Register";
|
|
||||||
import AdminDashboardPage from "./components/admin/AdminDashboard";
|
import AdminDashboardPage from "./components/admin/AdminDashboard";
|
||||||
import Migrants from "./components/admin/Migrants";
|
import Migrants from "./components/admin/Migrants";
|
||||||
import AddMigrantPage from "./components/admin/AddMigrant";
|
import AddMigrantPage from "./components/admin/AddMigrant";
|
||||||
|
|
@ -125,7 +124,6 @@ function App() {
|
||||||
|
|
||||||
{/* Auth Routes */}
|
{/* Auth Routes */}
|
||||||
<Route path="/login" element={<PublicRoute><LoginPage /></PublicRoute>} />
|
<Route path="/login" element={<PublicRoute><LoginPage /></PublicRoute>} />
|
||||||
<Route path="/register" element={<PublicRoute><RegisterPage /></PublicRoute>} />
|
|
||||||
|
|
||||||
{/* Admin Protected Routes */}
|
{/* Admin Protected Routes */}
|
||||||
<Route path="/admin" element={<ProtectedRoute><AdminDashboardPage /></ProtectedRoute>} />
|
<Route path="/admin" element={<ProtectedRoute><AdminDashboardPage /></ProtectedRoute>} />
|
||||||
|
|
|
||||||
|
|
@ -1,231 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import { motion } from "framer-motion"
|
|
||||||
import { Eye, EyeOff, Lock, Mail } from "lucide-react"
|
|
||||||
import { Link, useNavigate } from "react-router-dom"
|
|
||||||
import apiService from "@/services/apiService"
|
|
||||||
|
|
||||||
export default function SimpleForm() {
|
|
||||||
const [error, setError] = useState("")
|
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
|
|
||||||
const [email, setEmail] = useState("")
|
|
||||||
const [password, setPassword] = useState("")
|
|
||||||
const [name, setName] = useState("")
|
|
||||||
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setIsLoading(true)
|
|
||||||
setError("")
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await apiService.register({
|
|
||||||
name,
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log("Response:", response.data)
|
|
||||||
alert("Registration successful!")
|
|
||||||
setName("")
|
|
||||||
setEmail("")
|
|
||||||
setPassword("")
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Error submitting form:", error)
|
|
||||||
if (error.response && error.response.data && error.response.data.message) {
|
|
||||||
setError(error.response.data.message)
|
|
||||||
} else {
|
|
||||||
setError("Registration failed. Please check your input and try again.")
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
navigate("/login")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-[#E8DCCA] bg-opacity-30">
|
|
||||||
<div className="absolute inset-0 overflow-hidden z-0">
|
|
||||||
<motion.div
|
|
||||||
className="absolute inset-0 bg-[url('/italian-migrants-historical.jpg')] bg-cover bg-center"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 0.15 }}
|
|
||||||
transition={{ duration: 1.5 }}
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-[#9B2335]/20 to-[#01796F]/20" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
className="max-w-md w-full mx-4 bg-white rounded-lg shadow-xl overflow-hidden z-10"
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.6 }}
|
|
||||||
>
|
|
||||||
<div className="h-2 bg-gradient-to-r from-[#9B2335] via-[#E8DCCA] to-[#01796F]" />
|
|
||||||
|
|
||||||
<div className="px-8 pt-8 pb-6">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ delay: 0.3, duration: 0.6 }}
|
|
||||||
className="text-center"
|
|
||||||
>
|
|
||||||
<h2 className="text-2xl font-serif font-bold text-[#1A2A57]">Admin Access</h2>
|
|
||||||
<p className="text-gray-600 mt-1 italic">
|
|
||||||
Northern Territory Italian Migration History
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
className="px-8 pb-8"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ delay: 0.5, duration: 0.6 }}
|
|
||||||
>
|
|
||||||
{error && (
|
|
||||||
<motion.div
|
|
||||||
className="mb-4 p-3 bg-red-50 border-l-4 border-[#9B2335] text-[#9B2335]"
|
|
||||||
initial={{ opacity: 0, x: -20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="mb-6">
|
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Name
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<Mail className="h-5 w-5 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
id="name"
|
|
||||||
type="text"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(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="John Doe"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<Mail className="h-5 w-5 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-[#01796F] focus:border-[#01796F]"
|
|
||||||
placeholder="admin@example.com"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<Lock className="h-5 w-5 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
type={showPassword ? "text" : "password"}
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
className="block w-full pl-10 pr-10 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-[#01796F] focus:border-[#01796F]"
|
|
||||||
placeholder="••••••••"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
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" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<motion.button
|
|
||||||
type="submit"
|
|
||||||
disabled={isLoading}
|
|
||||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-white bg-[#9B2335] hover:bg-[#9B2335]/90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#9B2335] disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
whileHover={{ scale: 1.02 }}
|
|
||||||
whileTap={{ scale: 0.98 }}
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center">
|
|
||||||
<svg
|
|
||||||
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
className="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
></circle>
|
|
||||||
<path
|
|
||||||
className="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
Signing in...
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
"Sign in"
|
|
||||||
)}
|
|
||||||
</motion.button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="mt-6 flex items-center justify-center">
|
|
||||||
<div className="h-px bg-gray-300 w-full" />
|
|
||||||
<span className="px-2 text-sm text-gray-500">or</span>
|
|
||||||
<div className="h-px bg-gray-300 w-full" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 text-center">
|
|
||||||
<Link to="/" className="text-sm font-medium text-[#1A2A57] hover:text-[#1A2A57]/80">
|
|
||||||
Return to public site
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
className="absolute bottom-4 text-center text-xs text-gray-500"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ delay: 1, duration: 0.6 }}
|
|
||||||
>
|
|
||||||
© {new Date().getFullYear()} Northern Territory Italian Migration History Project
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -14,6 +14,8 @@ import apiService from "@/services/apiService"
|
||||||
import Header from "@/components/layout/Header"
|
import Header from "@/components/layout/Header"
|
||||||
import Sidebar from "@/components/layout/Sidebar"
|
import Sidebar from "@/components/layout/Sidebar"
|
||||||
import type { User } from "@/types/api"
|
import type { User } from "@/types/api"
|
||||||
|
import { UserUpdateSchema } from "@/schemas/userSchema"
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
export default function UserCreate() {
|
export default function UserCreate() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
@ -26,12 +28,13 @@ export default function UserCreate() {
|
||||||
password: '',
|
password: '',
|
||||||
password_confirmation: '',
|
password_confirmation: '',
|
||||||
});
|
});
|
||||||
|
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadUser() {
|
async function loadUser() {
|
||||||
try {
|
try {
|
||||||
const currentUser = await apiService.fetchCurrentUser()
|
const currentUser = await apiService.fetchCurrentUser()
|
||||||
console.log("Setting component - Fetched user:", currentUser)
|
// Fetch user data and update state
|
||||||
setUser(currentUser)
|
setUser(currentUser)
|
||||||
|
|
||||||
// Pre-fill the form with current user data if available
|
// Pre-fill the form with current user data if available
|
||||||
|
|
@ -43,7 +46,7 @@ export default function UserCreate() {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch user info:", error)
|
// Handle error when fetching user information
|
||||||
toast.error("Failed to load user information")
|
toast.error("Failed to load user information")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -56,20 +59,52 @@ export default function UserCreate() {
|
||||||
...prev,
|
...prev,
|
||||||
[name]: value,
|
[name]: value,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Clear error for this field when user types
|
||||||
|
if (validationErrors[name]) {
|
||||||
|
setValidationErrors(prev => {
|
||||||
|
const updated = { ...prev }
|
||||||
|
delete updated[name]
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
try {
|
||||||
|
UserUpdateSchema.parse(formData)
|
||||||
|
setValidationErrors({})
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
const errors: Record<string, string> = {}
|
||||||
|
error.errors.forEach((err) => {
|
||||||
|
const path = err.path.join('.')
|
||||||
|
errors[path] = err.message
|
||||||
|
})
|
||||||
|
setValidationErrors(errors)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFieldError = (fieldName: string) => {
|
||||||
|
return validationErrors[fieldName] || null
|
||||||
}
|
}
|
||||||
|
|
||||||
// In your React component:
|
// In your React component:
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!formData.name || !formData.email || !formData.current_password) {
|
// Zod validation
|
||||||
toast.error("Please fill in all required fields");
|
if (!validateForm()) {
|
||||||
return;
|
// Show the first validation error as a toast
|
||||||
}
|
const firstError = Object.values(validationErrors)[0]
|
||||||
|
if (firstError) {
|
||||||
if (formData.password && formData.password !== formData.password_confirmation) {
|
toast.error(firstError)
|
||||||
toast.error("Passwords don't match");
|
}
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -85,9 +120,19 @@ export default function UserCreate() {
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success(response.message || "User updated successfully!");
|
toast.success(response.message || "User updated successfully!");
|
||||||
navigate("/admin/settings");
|
|
||||||
|
// Log out the user after successful profile update
|
||||||
|
toast.success("You will now be logged out for security reasons. Please log in with your updated credentials.", {
|
||||||
|
duration: 5000
|
||||||
|
});
|
||||||
|
|
||||||
|
// Small delay to allow the toast message to be seen
|
||||||
|
setTimeout(async () => {
|
||||||
|
await apiService.logout();
|
||||||
|
navigate("/login");
|
||||||
|
}, 2000);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Error updating user:", error);
|
// Handle error when updating user
|
||||||
|
|
||||||
if (error.response?.data?.message) {
|
if (error.response?.data?.message) {
|
||||||
toast.error(error.response.data.message);
|
toast.error(error.response.data.message);
|
||||||
|
|
@ -168,8 +213,11 @@ export default function UserCreate() {
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
required
|
required
|
||||||
className="bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500"
|
className={`bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500 ${validationErrors.name ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : ''}`}
|
||||||
/>
|
/>
|
||||||
|
{getFieldError('name') && (
|
||||||
|
<p className="text-red-400 text-sm mt-1">{getFieldError('name')}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -184,8 +232,11 @@ export default function UserCreate() {
|
||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
required
|
required
|
||||||
className="bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500"
|
className={`bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500 ${validationErrors.email ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : ''}`}
|
||||||
/>
|
/>
|
||||||
|
{getFieldError('email') && (
|
||||||
|
<p className="text-red-400 text-sm mt-1">{getFieldError('email')}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-gray-800 pt-6">
|
<div className="border-t border-gray-800 pt-6">
|
||||||
|
|
@ -203,46 +254,65 @@ export default function UserCreate() {
|
||||||
value={formData.current_password}
|
value={formData.current_password}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
required
|
required
|
||||||
className="bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500"
|
className={`bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500 ${validationErrors.current_password ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : ''}`}
|
||||||
/>
|
/>
|
||||||
|
{getFieldError('current_password') && (
|
||||||
|
<p className="text-red-400 text-sm mt-1">{getFieldError('current_password')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password" className="text-gray-300">
|
<Label htmlFor="password" className="text-gray-300">
|
||||||
Password <span className="text-red-400">*</span>
|
New Password
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Enter password"
|
placeholder="Enter new password (optional)"
|
||||||
value={formData.password}
|
value={formData.password}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
required
|
className={`bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500 ${validationErrors.password ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : ''}`}
|
||||||
className="bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500"
|
|
||||||
/>
|
/>
|
||||||
|
{getFieldError('password') && (
|
||||||
|
<p className="text-red-400 text-sm mt-1">{getFieldError('password')}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password_confirmation" className="text-gray-300">
|
<Label htmlFor="password_confirmation" className="text-gray-300">
|
||||||
Confirm Password <span className="text-red-400">*</span>
|
Confirm New Password
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="password_confirmation"
|
id="password_confirmation"
|
||||||
name="password_confirmation"
|
name="password_confirmation"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Confirm password"
|
placeholder="Confirm new password"
|
||||||
value={formData.password_confirmation}
|
value={formData.password_confirmation}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
required
|
className={`bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500 ${validationErrors.password_confirmation ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : ''}`}
|
||||||
className="bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500"
|
|
||||||
/>
|
/>
|
||||||
|
{getFieldError('password_confirmation') && (
|
||||||
|
<p className="text-red-400 text-sm mt-1">{getFieldError('password_confirmation')}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Display validation errors summary if needed */}
|
||||||
|
{Object.keys(validationErrors).length > 0 && (
|
||||||
|
<div className="mt-6 p-4 bg-red-900/20 border border-red-500/30 rounded-lg">
|
||||||
|
<h4 className="text-red-400 font-medium mb-2">Please fix the following errors:</h4>
|
||||||
|
<ul className="text-sm text-red-300 space-y-1">
|
||||||
|
{Object.entries(validationErrors).map(([field, error]) => (
|
||||||
|
<li key={field}>• {error}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
|
|
@ -262,7 +332,7 @@ export default function UserCreate() {
|
||||||
className="bg-[#9B2335] hover:bg-[#9B2335]/90 text-white shadow-lg"
|
className="bg-[#9B2335] hover:bg-[#9B2335]/90 text-white shadow-lg"
|
||||||
>
|
>
|
||||||
<UserPlus className="mr-2 size-4" />
|
<UserPlus className="mr-2 size-4" />
|
||||||
{isSubmitting ? "Creating..." : "Create User"}
|
{isSubmitting ? "Updating..." : "Update User"}
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -1,513 +1,209 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import type React from "react"
|
import { useState } from "react"
|
||||||
|
import { useNavigate } from "react-router-dom"
|
||||||
import { useState, useEffect } from "react"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { useParams, useNavigate } from "react-router-dom"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { Progress } from "@/components/ui/progress"
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import type { Person, Migration, Naturalization, Residence, Family, Internment, ExistingPhoto } from "@/types/api"
|
|
||||||
import {
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
Save,
|
|
||||||
User,
|
|
||||||
MapPin,
|
|
||||||
FileText,
|
|
||||||
Home,
|
|
||||||
Users,
|
|
||||||
Shield,
|
|
||||||
Camera,
|
|
||||||
} from "lucide-react"
|
|
||||||
import apiService from "@/services/apiService"
|
import apiService from "@/services/apiService"
|
||||||
import { showSuccessToast, showErrorToast, showUpdateItemToast } from "@/utils/toast"
|
import { showSuccessToast, showErrorToast, showUpdateItemToast } from "@/utils/toast"
|
||||||
|
import { buildFormData } from "@/utils/formDataBuilder"
|
||||||
|
|
||||||
// Import confirmation modals
|
// Import hooks
|
||||||
import AddDialog from "@/components/admin/migrant/Modal/AddDialog"
|
import { useMigrantForm } from "@/hooks/useMigrantForm"
|
||||||
import UpdateDialog from "@/components/admin/migrant/Modal/UpdateDialog"
|
import { usePhotoManagement } from "@/hooks/usePhotoManagement"
|
||||||
|
import { useMigrantData } from "@/hooks/useMigrantData"
|
||||||
|
|
||||||
|
// Import components
|
||||||
|
import AddDialog from "./Modal/AddDialog"
|
||||||
|
import UpdateDialog from "./Modal/UpdateDialog"
|
||||||
|
|
||||||
// Import step components
|
// Import step components
|
||||||
import PersonDetailsStep from "@/components/admin/migrant/form-steps/PersonDetailsStep"
|
import PersonDetailsStep from "./form-steps/PersonDetailsStep"
|
||||||
import MigrationInfoStep from "@/components/admin/migrant/form-steps/MigrationInfoStep"
|
import MigrationInfoStep from "./form-steps/MigrationInfoStep"
|
||||||
import NaturalizationStep from "@/components/admin/migrant/form-steps/NaturalizationStep"
|
import NaturalizationStep from "./form-steps/NaturalizationStep"
|
||||||
import ResidenceStep from "@/components/admin/migrant/form-steps/ResidenceStep"
|
import ResidenceStep from "./form-steps/ResidenceStep"
|
||||||
import FamilyStep from "@/components/admin/migrant/form-steps/FamilyStep"
|
import FamilyStep from "./form-steps/FamilyStep"
|
||||||
import InternmentStep from "@/components/admin/migrant/form-steps/InternmentStep"
|
import InternmentStep from "./form-steps/InternmentStep"
|
||||||
import PhotosStep from "@/components/admin/migrant/form-steps/PhotosStep"
|
import PhotosStep from "./form-steps/PhotosStep"
|
||||||
|
import ReviewStep from "./form-steps/ReviewStep"
|
||||||
|
import StepperHeader from "./form-steps/StepperHeader"
|
||||||
|
import StepperNavigation from "./form-steps/StepperNavigation"
|
||||||
|
|
||||||
const API_BASE_URL = "http://localhost:8000"
|
|
||||||
|
|
||||||
const steps = ["Person Details", "Migration Info", "Naturalization", "Residence", "Family", "Internment", "Photos"]
|
|
||||||
|
|
||||||
const stepDescriptions = [
|
|
||||||
"Basic personal details and identification information",
|
|
||||||
"Immigration and arrival information in Australia",
|
|
||||||
"Citizenship and naturalization records",
|
|
||||||
"Residential history and location details",
|
|
||||||
"Family members and relationship information",
|
|
||||||
"Internment and detention records",
|
|
||||||
"Upload photos and supporting documents",
|
|
||||||
]
|
|
||||||
|
|
||||||
const stepIcons = [User, MapPin, FileText, Home, Users, Shield, Camera]
|
|
||||||
|
|
||||||
const StepperForm = () => {
|
const StepperForm = () => {
|
||||||
const { id } = useParams<{ id: string }>()
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const isEditMode = Boolean(id)
|
|
||||||
|
|
||||||
// Form state
|
// Form state management
|
||||||
|
const {
|
||||||
|
formData,
|
||||||
|
setters,
|
||||||
|
resetForm,
|
||||||
|
populateFormData,
|
||||||
|
validation
|
||||||
|
} = useMigrantForm()
|
||||||
|
|
||||||
|
const photoManagement = usePhotoManagement()
|
||||||
|
const { loading, isEditMode, initialDataLoaded } = useMigrantData(
|
||||||
|
populateFormData,
|
||||||
|
photoManagement.populatePhotoData
|
||||||
|
)
|
||||||
|
|
||||||
|
// Local state
|
||||||
const [currentStep, setCurrentStep] = useState(0)
|
const [currentStep, setCurrentStep] = useState(0)
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [initialDataLoaded, setInitialDataLoaded] = useState(false)
|
|
||||||
|
|
||||||
// Modal states
|
|
||||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
|
||||||
const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false)
|
const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false)
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
// Initial state functions
|
const renderFormField = (
|
||||||
const getInitialPersonState = (): Person => ({
|
label: string,
|
||||||
surname: "",
|
value: string,
|
||||||
christian_name: "",
|
onChange: (value: string) => void,
|
||||||
date_of_birth: "",
|
type = "text",
|
||||||
place_of_birth: "",
|
placeholder?: string,
|
||||||
date_of_death: "",
|
required?: boolean,
|
||||||
occupation: "",
|
fieldPath?: string // Add this to identify the field for validation
|
||||||
additional_notes: "",
|
) => {
|
||||||
reference: "",
|
const hasError = fieldPath ? validation.hasFieldError(fieldPath) : false
|
||||||
id_card_no: "",
|
const errorMessage = fieldPath ? validation.getFieldError(fieldPath) : null
|
||||||
person_id: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const getInitialMigrationState = (): Migration => ({
|
return (
|
||||||
date_of_arrival_aus: "",
|
<div className="space-y-2">
|
||||||
date_of_arrival_nt: "",
|
<Label htmlFor={label.toLowerCase().replace(/\s+/g, "-")} className="text-sm font-medium text-gray-300">
|
||||||
arrival_period: "",
|
{label}
|
||||||
data_source: "",
|
{required && <span className="text-red-400 ml-1">*</span>}
|
||||||
})
|
</Label>
|
||||||
|
{type === "textarea" ? (
|
||||||
const getInitialNaturalizationState = (): Naturalization => ({
|
<Textarea
|
||||||
date_of_naturalisation: "",
|
id={label.toLowerCase().replace(/\s+/g, "-")}
|
||||||
no_of_cert: "",
|
placeholder={placeholder || label}
|
||||||
issued_at: "",
|
value={value}
|
||||||
})
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className={`min-h-[100px] resize-none bg-gray-800 border-gray-700 text-white placeholder:text-gray-500 focus:border-[#9B2335] focus:ring-[#9B2335] ${
|
||||||
const getInitialResidenceState = (): Residence => ({
|
hasError ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : ''
|
||||||
town_or_city: "",
|
}`}
|
||||||
home_at_death: "",
|
/>
|
||||||
})
|
) : (
|
||||||
|
<Input
|
||||||
const getInitialFamilyState = (): Family => ({
|
id={label.toLowerCase().replace(/\s+/g, "-")}
|
||||||
names_of_parents: "",
|
type={type}
|
||||||
names_of_children: "",
|
placeholder={placeholder || label}
|
||||||
})
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
const getInitialInternmentState = (): Internment => ({
|
className={`bg-gray-800 border-gray-700 text-white placeholder:text-gray-500 focus:border-[#9B2335] focus:ring-[#9B2335] ${
|
||||||
corps_issued: "",
|
hasError ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : ''
|
||||||
interned_in: "",
|
}`}
|
||||||
sent_to: "",
|
/>
|
||||||
internee_occupation: "",
|
)}
|
||||||
internee_address: "",
|
{errorMessage && (
|
||||||
cav: "",
|
<p className="text-sm text-red-400 mt-1">{errorMessage}</p>
|
||||||
})
|
)}
|
||||||
|
</div>
|
||||||
// Form data state
|
)
|
||||||
const [person, setPerson] = useState<Person>(getInitialPersonState())
|
|
||||||
const [migration, setMigration] = useState<Migration>(getInitialMigrationState())
|
|
||||||
const [naturalization, setNaturalization] = useState<Naturalization>(getInitialNaturalizationState())
|
|
||||||
const [residence, setResidence] = useState<Residence>(getInitialResidenceState())
|
|
||||||
const [family, setFamily] = useState<Family>(getInitialFamilyState())
|
|
||||||
const [internment, setInternment] = useState<Internment>(getInitialInternmentState())
|
|
||||||
|
|
||||||
// Photo state
|
|
||||||
const [photos, setPhotos] = useState<File[]>([])
|
|
||||||
const [photoPreviews, setPhotoPreviews] = useState<string[]>([])
|
|
||||||
const [captions, setCaptions] = useState<string[]>([])
|
|
||||||
const [mainPhotoIndex, setMainPhotoIndex] = useState<number | null>(null)
|
|
||||||
const [existingPhotos, setExistingPhotos] = useState<ExistingPhoto[]>([])
|
|
||||||
|
|
||||||
// Load existing data when in edit mode
|
|
||||||
useEffect(() => {
|
|
||||||
if (isEditMode && id && !initialDataLoaded) {
|
|
||||||
loadExistingData()
|
|
||||||
}
|
|
||||||
}, [id, isEditMode, initialDataLoaded])
|
|
||||||
|
|
||||||
// Helper function to format date from API format to yyyy-MM-dd
|
|
||||||
const formatDate = (dateString: string | null | undefined): string => {
|
|
||||||
if (!dateString) return ""
|
|
||||||
try {
|
|
||||||
const date = new Date(dateString)
|
|
||||||
return date.toISOString().split("T")[0]
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error formatting date:", error)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadExistingData = async () => {
|
const renderStepContent = () => {
|
||||||
try {
|
switch (currentStep) {
|
||||||
setLoading(true)
|
case 0:
|
||||||
|
return <PersonDetailsStep person={formData.person} setPerson={setters.setPerson} renderFormField={renderFormField} />
|
||||||
const personId = Number.parseInt(id!, 10)
|
case 1:
|
||||||
if (isNaN(personId)) {
|
return <MigrationInfoStep migration={formData.migration} setMigration={setters.setMigration} renderFormField={renderFormField} />
|
||||||
throw new Error("Invalid person ID")
|
case 2:
|
||||||
}
|
return <NaturalizationStep naturalization={formData.naturalization} setNaturalization={setters.setNaturalization} renderFormField={renderFormField} />
|
||||||
|
case 3:
|
||||||
const migrantData = await apiService.getMigrantById(personId)
|
return <ResidenceStep residence={formData.residence} setResidence={setters.setResidence} renderFormField={renderFormField} />
|
||||||
|
case 4:
|
||||||
// FIX: Populate person data from migrantData.person, not current person state
|
return <FamilyStep family={formData.family} setFamily={setters.setFamily} renderFormField={renderFormField} />
|
||||||
setPerson({
|
case 5:
|
||||||
surname: migrantData.surname || "",
|
return <InternmentStep internment={formData.internment} setInternment={setters.setInternment} renderFormField={renderFormField} />
|
||||||
christian_name: migrantData.christian_name || "",
|
case 6:
|
||||||
date_of_birth: formatDate(migrantData.date_of_birth),
|
return (
|
||||||
place_of_birth: migrantData.place_of_birth || "",
|
<PhotosStep
|
||||||
date_of_death: formatDate(migrantData.date_of_death),
|
photos={photoManagement.photos}
|
||||||
occupation: migrantData.occupation || "",
|
photoPreviews={photoManagement.photoPreviews}
|
||||||
additional_notes: migrantData.additional_notes || "",
|
captions={photoManagement.captions}
|
||||||
reference: migrantData.reference || "",
|
existingPhotos={photoManagement.existingPhotos}
|
||||||
id_card_no: migrantData.id_card_no || "",
|
mainPhotoIndex={photoManagement.mainPhotoIndex}
|
||||||
person_id: migrantData.person_id || 0,
|
API_BASE_URL={apiService.baseURL}
|
||||||
})
|
handlePhotoChange={photoManagement.handlePhotoChange}
|
||||||
|
removeExistingPhoto={photoManagement.removeExistingPhoto}
|
||||||
// Populate migration data
|
removeNewPhoto={photoManagement.removeNewPhoto}
|
||||||
if (migrantData.migration) {
|
setCaptions={photoManagement.setCaptions}
|
||||||
setMigration({
|
setMainPhotoIndex={photoManagement.setMainPhotoIndex}
|
||||||
date_of_arrival_aus: formatDate(migrantData.migration.date_of_arrival_aus),
|
/>
|
||||||
date_of_arrival_nt: formatDate(migrantData.migration.date_of_arrival_nt),
|
)
|
||||||
arrival_period: migrantData.migration.arrival_period || "",
|
case 7:
|
||||||
data_source: migrantData.migration.data_source || "",
|
return (
|
||||||
})
|
<ReviewStep
|
||||||
}
|
person={formData.person}
|
||||||
|
migration={formData.migration}
|
||||||
// Populate naturalization data
|
naturalization={formData.naturalization}
|
||||||
if (migrantData.naturalization) {
|
residence={formData.residence}
|
||||||
setNaturalization({
|
family={formData.family}
|
||||||
date_of_naturalisation: formatDate(migrantData.naturalization.date_of_naturalisation),
|
internment={formData.internment}
|
||||||
no_of_cert: migrantData.naturalization.no_of_cert || "",
|
photos={photoManagement.photos}
|
||||||
issued_at: migrantData.naturalization.issued_at || "",
|
photoPreviews={photoManagement.photoPreviews}
|
||||||
})
|
captions={photoManagement.captions}
|
||||||
}
|
existingPhotos={photoManagement.existingPhotos}
|
||||||
|
mainPhotoIndex={photoManagement.mainPhotoIndex ?? -1}
|
||||||
// Populate residence data
|
API_BASE_URL={apiService.baseURL}
|
||||||
if (migrantData.residence) {
|
/>
|
||||||
setResidence({
|
)
|
||||||
town_or_city: migrantData.residence.town_or_city || "",
|
default:
|
||||||
home_at_death: migrantData.residence.home_at_death || "",
|
return null
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Populate family data
|
|
||||||
if (migrantData.family) {
|
|
||||||
setFamily({
|
|
||||||
names_of_parents: migrantData.family.names_of_parents || "",
|
|
||||||
names_of_children: migrantData.family.names_of_children || "",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Populate internment data
|
|
||||||
if (migrantData.internment) {
|
|
||||||
setInternment({
|
|
||||||
corps_issued: migrantData.internment.corps_issued || "",
|
|
||||||
interned_in: migrantData.internment.interned_in || "",
|
|
||||||
sent_to: migrantData.internment.sent_to || "",
|
|
||||||
internee_occupation: migrantData.internment.internee_occupation || "",
|
|
||||||
internee_address: migrantData.internment.internee_address || "",
|
|
||||||
cav: migrantData.internment.cav || "",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (migrantData.photos && Array.isArray(migrantData.photos) && migrantData.photos.length > 0) {
|
|
||||||
const photoData = migrantData.photos as ExistingPhoto[]
|
|
||||||
setExistingPhotos(photoData)
|
|
||||||
|
|
||||||
// Set up captions array
|
|
||||||
const existingCaptions = photoData.map((photo: ExistingPhoto) => photo.caption || "")
|
|
||||||
setCaptions(existingCaptions)
|
|
||||||
|
|
||||||
// Find and set the main photo index
|
|
||||||
const mainPhotoIdx = photoData.findIndex((photo: ExistingPhoto) => photo.is_profile_photo)
|
|
||||||
if (mainPhotoIdx !== -1) {
|
|
||||||
setMainPhotoIndex(mainPhotoIdx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setInitialDataLoaded(true)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error loading migrant data:", error)
|
|
||||||
showErrorToast("Failed to load migrant data for editing.")
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
setPerson(getInitialPersonState())
|
|
||||||
setMigration(getInitialMigrationState())
|
|
||||||
setNaturalization(getInitialNaturalizationState())
|
|
||||||
setResidence(getInitialResidenceState())
|
|
||||||
setFamily(getInitialFamilyState())
|
|
||||||
setInternment(getInitialInternmentState())
|
|
||||||
setPhotos([])
|
|
||||||
setPhotoPreviews([])
|
|
||||||
setCaptions([])
|
|
||||||
setMainPhotoIndex(null)
|
|
||||||
setExistingPhotos([])
|
|
||||||
setInitialDataLoaded(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePhotoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
if (e.target.files && e.target.files.length > 0) {
|
|
||||||
const selectedFiles = Array.from(e.target.files)
|
|
||||||
|
|
||||||
setPhotos((prev) => [...prev, ...selectedFiles])
|
|
||||||
|
|
||||||
const newPreviews = selectedFiles.map((file) => URL.createObjectURL(file))
|
|
||||||
setPhotoPreviews((prev) => [...prev, ...newPreviews])
|
|
||||||
|
|
||||||
setCaptions((prev) => {
|
|
||||||
const newCaptions = [...prev]
|
|
||||||
for (let i = 0; i < selectedFiles.length; i++) {
|
|
||||||
newCaptions.push("")
|
|
||||||
}
|
|
||||||
return newCaptions
|
|
||||||
})
|
|
||||||
|
|
||||||
if (mainPhotoIndex === null && (existingPhotos.length > 0 || selectedFiles.length > 0)) {
|
|
||||||
setMainPhotoIndex(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset the file input
|
|
||||||
e.target.value = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeExistingPhoto = (index: number) => {
|
|
||||||
const wasMainPhoto = mainPhotoIndex === index
|
|
||||||
|
|
||||||
setExistingPhotos((prev) => prev.filter((_, i) => i !== index))
|
|
||||||
|
|
||||||
// Remove corresponding caption
|
|
||||||
setCaptions((prev) => {
|
|
||||||
const newCaptions = [...prev]
|
|
||||||
newCaptions.splice(index, 1)
|
|
||||||
return newCaptions
|
|
||||||
})
|
|
||||||
|
|
||||||
// Handle main photo index adjustment
|
|
||||||
if (wasMainPhoto) {
|
|
||||||
// If we're removing the main photo, try to set another photo as main
|
|
||||||
const totalRemainingPhotos = existingPhotos.length - 1 + photos.length
|
|
||||||
if (totalRemainingPhotos > 0) {
|
|
||||||
// Set the first remaining photo as main
|
|
||||||
setMainPhotoIndex(0)
|
|
||||||
} else {
|
|
||||||
setMainPhotoIndex(null)
|
|
||||||
}
|
|
||||||
} else if (mainPhotoIndex !== null && mainPhotoIndex > index) {
|
|
||||||
// Adjust main photo index if it's after the removed photo
|
|
||||||
setMainPhotoIndex(mainPhotoIndex - 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeNewPhoto = (photoIndex: number) => {
|
|
||||||
const totalIndex = existingPhotos.length + photoIndex
|
|
||||||
const wasMainPhoto = mainPhotoIndex === totalIndex
|
|
||||||
|
|
||||||
// Remove the photo and its preview
|
|
||||||
setPhotos((prev) => prev.filter((_, i) => i !== photoIndex))
|
|
||||||
setPhotoPreviews((prev) => prev.filter((_, i) => i !== photoIndex))
|
|
||||||
|
|
||||||
// Remove corresponding caption from the total captions array
|
|
||||||
setCaptions((prev) => {
|
|
||||||
const newCaptions = [...prev]
|
|
||||||
newCaptions.splice(totalIndex, 1)
|
|
||||||
return newCaptions
|
|
||||||
})
|
|
||||||
|
|
||||||
// Handle main photo index adjustment
|
|
||||||
if (wasMainPhoto) {
|
|
||||||
// If we're removing the main photo, try to set another photo as main
|
|
||||||
const totalRemainingPhotos = existingPhotos.length + photos.length - 1
|
|
||||||
if (totalRemainingPhotos > 0) {
|
|
||||||
// Set the first remaining photo as main
|
|
||||||
setMainPhotoIndex(0)
|
|
||||||
} else {
|
|
||||||
setMainPhotoIndex(null)
|
|
||||||
}
|
|
||||||
} else if (mainPhotoIndex !== null && mainPhotoIndex > totalIndex) {
|
|
||||||
// Adjust main photo index if it's after the removed photo
|
|
||||||
setMainPhotoIndex(mainPhotoIndex - 1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitForm = async () => {
|
const submitForm = async () => {
|
||||||
try {
|
try {
|
||||||
const formData = new FormData()
|
const formDataToSubmit = buildFormData({
|
||||||
|
...formData,
|
||||||
// Add person data
|
photos: photoManagement.photos,
|
||||||
console.log('Person data:', person);
|
captions: photoManagement.captions,
|
||||||
Object.entries(person).forEach(([key, value]) => {
|
mainPhotoIndex: photoManagement.mainPhotoIndex,
|
||||||
if (value) formData.append(key, value)
|
existingPhotos: photoManagement.existingPhotos,
|
||||||
|
removedPhotoIds: photoManagement.removedPhotoIds,
|
||||||
|
isEditMode,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Add migration data
|
if (isEditMode) {
|
||||||
console.log('Migration data:', migration);
|
const id = window.location.pathname.split('/').pop()
|
||||||
if (Object.values(migration).some(v => v)) {
|
return await apiService.updateMigrant(parseInt(id!), formDataToSubmit)
|
||||||
Object.entries(migration).forEach(([key, value]) => {
|
|
||||||
if (value) formData.append(`migration[${key}]`, value)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add naturalization data
|
|
||||||
if (Object.values(naturalization).some(v => v)) {
|
|
||||||
Object.entries(naturalization).forEach(([key, value]) => {
|
|
||||||
if (value) formData.append(`naturalization[${key}]`, value)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add residence data
|
|
||||||
console.log('Residence data:', residence);
|
|
||||||
if (Object.values(residence).some(v => v)) {
|
|
||||||
Object.entries(residence).forEach(([key, value]) => {
|
|
||||||
if (value) formData.append(`residence[${key}]`, value)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add family data
|
|
||||||
console.log('Family data:', family);
|
|
||||||
if (Object.values(family).some(v => v)) {
|
|
||||||
Object.entries(family).forEach(([key, value]) => {
|
|
||||||
if (value) formData.append(`family[${key}]`, value)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add internment data
|
|
||||||
console.log('Internment data:', internment);
|
|
||||||
if (Object.values(internment).some(v => v)) {
|
|
||||||
Object.entries(internment).forEach(([key, value]) => {
|
|
||||||
if (value) formData.append(`internment[${key}]`, value)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add photos
|
|
||||||
console.log('Photos:', photos);
|
|
||||||
photos.forEach((photo) => {
|
|
||||||
formData.append('photos[]', photo)
|
|
||||||
})
|
|
||||||
|
|
||||||
// FIXED: Add captions for new photos only (existing photo captions handled separately)
|
|
||||||
console.log('New photo captions:');
|
|
||||||
const newPhotoCaptions = captions.slice(existingPhotos.length); // Get captions for new photos only
|
|
||||||
newPhotoCaptions.forEach((caption, index) => {
|
|
||||||
console.log(`New photo ${index} caption:`, caption);
|
|
||||||
formData.append(`captions[${index}]`, caption || '') // Always send caption, even if empty
|
|
||||||
})
|
|
||||||
|
|
||||||
// IMPROVED MAIN PHOTO LOGIC
|
|
||||||
console.log('=== MAIN PHOTO DEBUG ===');
|
|
||||||
console.log('Main photo index:', mainPhotoIndex);
|
|
||||||
console.log('Existing photos count:', existingPhotos.length);
|
|
||||||
console.log('New photos count:', photos.length);
|
|
||||||
console.log('Is edit mode:', isEditMode);
|
|
||||||
|
|
||||||
// Always send profile photo data if mainPhotoIndex is set
|
|
||||||
if (mainPhotoIndex !== null) {
|
|
||||||
formData.append('set_as_profile', 'true')
|
|
||||||
|
|
||||||
if (mainPhotoIndex < existingPhotos.length) {
|
|
||||||
// Main photo is an existing photo
|
|
||||||
const existingPhoto = existingPhotos[mainPhotoIndex]
|
|
||||||
console.log('Setting existing photo as main:', existingPhoto.id);
|
|
||||||
formData.append('profile_photo_id', existingPhoto.id.toString())
|
|
||||||
} else {
|
|
||||||
// Main photo is a new photo
|
|
||||||
const newPhotoIndex = mainPhotoIndex - existingPhotos.length
|
|
||||||
console.log('Setting new photo as main, index:', newPhotoIndex);
|
|
||||||
|
|
||||||
// Validate the index
|
|
||||||
if (newPhotoIndex >= 0 && newPhotoIndex < photos.length) {
|
|
||||||
formData.append('profile_photo_index', newPhotoIndex.toString())
|
|
||||||
console.log('✅ Valid new photo index sent:', newPhotoIndex);
|
|
||||||
|
|
||||||
// CRITICAL FIX: Ensure the photo at this index is marked as profile
|
|
||||||
formData.append('new_profile_photo_index', newPhotoIndex.toString())
|
|
||||||
} else {
|
|
||||||
console.error('❌ Invalid new photo index:', newPhotoIndex, 'Photos length:', photos.length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
console.log('⚠️ No main photo selected');
|
return await apiService.createMigrant(formDataToSubmit)
|
||||||
formData.append('set_as_profile', 'false')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle existing photo updates (captions and profile status)
|
|
||||||
if (existingPhotos.length > 0) {
|
|
||||||
console.log('Processing existing photos for caption updates...');
|
|
||||||
existingPhotos.forEach((photo, index) => {
|
|
||||||
formData.append(`existing_photos[${index}][id]`, photo.id.toString())
|
|
||||||
|
|
||||||
// Send caption (from the first part of captions array)
|
|
||||||
const caption = captions[index] || ''
|
|
||||||
formData.append(`existing_photos[${index}][caption]`, caption)
|
|
||||||
console.log(`Caption for existing photo ${photo.id}:`, caption);
|
|
||||||
|
|
||||||
// FIXED: Explicitly set profile status for existing photos
|
|
||||||
const isThisPhotoMain = mainPhotoIndex === index
|
|
||||||
formData.append(`existing_photos[${index}][is_profile_photo]`, isThisPhotoMain ? 'true' : 'false')
|
|
||||||
console.log(`Existing photo ${photo.id} is_profile_photo:`, isThisPhotoMain);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ADDITIONAL FIX: Send explicit photo metadata for new photos
|
|
||||||
photos.forEach((_photo, index) => {
|
|
||||||
const totalIndex = existingPhotos.length + index
|
|
||||||
const isThisPhotoMain = mainPhotoIndex === totalIndex
|
|
||||||
|
|
||||||
formData.append(`new_photos[${index}][is_profile_photo]`, isThisPhotoMain ? 'true' : 'false')
|
|
||||||
formData.append(`new_photos[${index}][caption]`, captions[totalIndex] || '')
|
|
||||||
|
|
||||||
console.log(`New photo ${index} metadata:`, {
|
|
||||||
is_profile_photo: isThisPhotoMain,
|
|
||||||
caption: captions[totalIndex] || '',
|
|
||||||
total_index: totalIndex
|
|
||||||
});
|
|
||||||
})
|
|
||||||
|
|
||||||
// Log FormData contents for debugging
|
|
||||||
console.log('=== FORM DATA CONTENTS ===');
|
|
||||||
for (let [key, value] of formData.entries()) {
|
|
||||||
if (value instanceof File) {
|
|
||||||
console.log(key, ':', `[File: ${value.name}]`);
|
|
||||||
} else {
|
|
||||||
console.log(key, ':', value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let response
|
|
||||||
if (isEditMode && id) {
|
|
||||||
console.log('Updating migrant:', id);
|
|
||||||
response = await apiService.updateMigrant(parseInt(id), formData)
|
|
||||||
} else {
|
|
||||||
console.log('Creating new migrant');
|
|
||||||
response = await apiService.createMigrant(formData)
|
|
||||||
}
|
|
||||||
|
|
||||||
return response
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Form submission error:', error)
|
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
// Validate current section before moving to next step
|
||||||
|
const sectionNames = ['person', 'migration', 'naturalization', 'residence', 'family', 'internment']
|
||||||
|
if (currentStep < sectionNames.length) {
|
||||||
|
const currentSection = sectionNames[currentStep] as 'person' | 'migration' | 'naturalization' | 'residence' | 'family' | 'internment'
|
||||||
|
const isValid = validation.validateSection(currentSection)
|
||||||
|
|
||||||
|
if (isValid || currentStep > 0) { // Allow moving forward from non-required sections
|
||||||
|
setCurrentStep(prev => prev + 1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setCurrentStep(prev => prev + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!isSubmitting) {
|
if (!isSubmitting) {
|
||||||
|
// Validate entire form before submitting
|
||||||
|
const isValid = validation.validateForm()
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
showErrorToast("Please fix the errors in the form before submitting.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (isEditMode) {
|
if (isEditMode) {
|
||||||
setIsUpdateDialogOpen(true)
|
setIsUpdateDialogOpen(true)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -520,10 +216,10 @@ const StepperForm = () => {
|
||||||
if (!isSubmitting) {
|
if (!isSubmitting) {
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
const response = await submitForm()
|
await submitForm()
|
||||||
|
|
||||||
if (isEditMode) {
|
if (isEditMode) {
|
||||||
showUpdateItemToast(`Migrant ${person.surname}, ${person.christian_name}`, () => {
|
showUpdateItemToast(`Migrant ${formData.person.surname}, ${formData.person.christian_name}`, () => {
|
||||||
navigate(`/admin/migrants`)
|
navigate(`/admin/migrants`)
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -531,11 +227,9 @@ const StepperForm = () => {
|
||||||
navigate(`/admin/migrants`)
|
navigate(`/admin/migrants`)
|
||||||
})
|
})
|
||||||
resetForm()
|
resetForm()
|
||||||
|
photoManagement.resetPhotos()
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Form submission error:", error)
|
|
||||||
showErrorToast("There was a problem saving the migrant data. Please try again.")
|
showErrorToast("There was a problem saving the migrant data. Please try again.")
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
|
|
@ -545,90 +239,6 @@ const StepperForm = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderFormField = (
|
|
||||||
label: string,
|
|
||||||
value: string,
|
|
||||||
onChange: (value: string) => void,
|
|
||||||
type = "text",
|
|
||||||
placeholder?: string,
|
|
||||||
required?: boolean,
|
|
||||||
) => (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor={label.toLowerCase().replace(/\s+/g, "-")} className="text-sm font-medium text-gray-300">
|
|
||||||
{label}
|
|
||||||
{required && <span className="text-red-400 ml-1">*</span>}
|
|
||||||
</Label>
|
|
||||||
{type === "textarea" ? (
|
|
||||||
<Textarea
|
|
||||||
id={label.toLowerCase().replace(/\s+/g, "-")}
|
|
||||||
placeholder={placeholder || label}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
className="min-h-[100px] resize-none bg-gray-800 border-gray-700 text-white placeholder:text-gray-500 focus:border-[#9B2335] focus:ring-[#9B2335]"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
id={label.toLowerCase().replace(/\s+/g, "-")}
|
|
||||||
type={type}
|
|
||||||
placeholder={placeholder || label}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
className="bg-gray-800 border-gray-700 text-white placeholder:text-gray-500 focus:border-[#9B2335] focus:ring-[#9B2335]"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const renderStepContent = () => {
|
|
||||||
switch (currentStep) {
|
|
||||||
case 0:
|
|
||||||
return <PersonDetailsStep person={person} setPerson={setPerson} renderFormField={renderFormField} />
|
|
||||||
|
|
||||||
case 1:
|
|
||||||
return <MigrationInfoStep migration={migration} setMigration={setMigration} renderFormField={renderFormField} />
|
|
||||||
|
|
||||||
case 2:
|
|
||||||
return (
|
|
||||||
<NaturalizationStep
|
|
||||||
naturalization={naturalization}
|
|
||||||
setNaturalization={setNaturalization}
|
|
||||||
renderFormField={renderFormField}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
case 3:
|
|
||||||
return <ResidenceStep residence={residence} setResidence={setResidence} renderFormField={renderFormField} />
|
|
||||||
|
|
||||||
case 4:
|
|
||||||
return <FamilyStep family={family} setFamily={setFamily} renderFormField={renderFormField} />
|
|
||||||
|
|
||||||
case 5:
|
|
||||||
return (
|
|
||||||
<InternmentStep internment={internment} setInternment={setInternment} renderFormField={renderFormField} />
|
|
||||||
)
|
|
||||||
|
|
||||||
case 6:
|
|
||||||
return (
|
|
||||||
<PhotosStep
|
|
||||||
photos={photos}
|
|
||||||
photoPreviews={photoPreviews}
|
|
||||||
captions={captions}
|
|
||||||
existingPhotos={existingPhotos}
|
|
||||||
mainPhotoIndex={mainPhotoIndex}
|
|
||||||
API_BASE_URL={API_BASE_URL}
|
|
||||||
handlePhotoChange={handlePhotoChange}
|
|
||||||
removeExistingPhoto={removeExistingPhoto}
|
|
||||||
removeNewPhoto={removeNewPhoto}
|
|
||||||
setCaptions={setCaptions}
|
|
||||||
setMainPhotoIndex={setMainPhotoIndex}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading && isEditMode && !initialDataLoaded) {
|
if (loading && isEditMode && !initialDataLoaded) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
|
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
|
||||||
|
|
@ -645,115 +255,35 @@ const StepperForm = () => {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto mt-8 mb-24 p-4">
|
<div className="max-w-6xl mx-auto mt-8 mb-24 p-4">
|
||||||
<Card className="shadow-2xl border border-gray-800 bg-gray-900/50 overflow-hidden backdrop-blur-sm">
|
<Card className="shadow-2xl border border-gray-800 bg-gray-900/50 overflow-hidden backdrop-blur-sm">
|
||||||
<CardHeader className="bg-gray-800 p-8 border-b border-gray-700">
|
<StepperHeader currentStep={currentStep} isEditMode={isEditMode} />
|
||||||
<div className="flex justify-between items-start mb-6">
|
|
||||||
<div>
|
<CardContent className="p-8 bg-gray-900">
|
||||||
<CardTitle className="text-xl md:text-2xl font-bold text-white">
|
{renderStepContent()}
|
||||||
{isEditMode ? `Edit ${steps[currentStep]}` : steps[currentStep]}
|
|
||||||
</CardTitle>
|
{/* Display validation errors summary if any */}
|
||||||
<CardDescription className="text-gray-400 mt-1">{stepDescriptions[currentStep]}</CardDescription>
|
{Object.keys(validation.validationErrors).length > 0 && (
|
||||||
|
<div className="mt-6 p-4 bg-red-900/20 border border-red-500/30 rounded-lg">
|
||||||
|
<h4 className="text-red-400 font-medium mb-2">Please fix the following errors:</h4>
|
||||||
|
<ul className="text-sm text-red-300 space-y-1">
|
||||||
|
{Object.entries(validation.validationErrors).map(([field, error]) => (
|
||||||
|
<li key={field}>• {error}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className="text-sm border-gray-700 text-gray-300">
|
)}
|
||||||
Step {currentStep + 1} of {steps.length}
|
</CardContent>
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
<StepperNavigation
|
||||||
<div className="flex justify-between text-sm text-gray-400 mb-2">
|
currentStep={currentStep}
|
||||||
<span>Progress</span>
|
totalSteps={8}
|
||||||
<span>{Math.round((currentStep / (steps.length - 1)) * 100)}% Complete</span>
|
isSubmitting={isSubmitting}
|
||||||
</div>
|
isEditMode={isEditMode}
|
||||||
<Progress
|
onPrevious={() => setCurrentStep(prev => prev - 1)}
|
||||||
value={(currentStep / (steps.length - 1)) * 100}
|
onNext={handleNext}
|
||||||
className="h-2 bg-gray-800"
|
onSubmit={handleSubmit}
|
||||||
style={
|
/>
|
||||||
{
|
|
||||||
"--progress-foreground": "#9B2335",
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step indicators */}
|
|
||||||
<div className="flex justify-between mt-4 overflow-x-auto">
|
|
||||||
{steps.map((step, index) => {
|
|
||||||
const StepIcon = stepIcons[index]
|
|
||||||
return (
|
|
||||||
<div key={index} className="flex flex-col items-center min-w-0 flex-1">
|
|
||||||
<div
|
|
||||||
className={`w-10 h-10 rounded-full flex items-center justify-center mb-2 transition-all duration-200 ${
|
|
||||||
index <= currentStep
|
|
||||||
? "bg-[#9B2335] text-white shadow-lg"
|
|
||||||
: "bg-gray-800 text-gray-500 border border-gray-700"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<StepIcon className="w-5 h-5" />
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`text-xs text-center px-1 transition-colors duration-200 ${
|
|
||||||
index <= currentStep ? "text-[#9B2335] font-medium" : "text-gray-500"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{step}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="p-8 bg-gray-900">{renderStepContent()}</CardContent>
|
|
||||||
|
|
||||||
<div className="bg-gray-800 px-8 py-6 border-t border-gray-700">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={currentStep === 0}
|
|
||||||
onClick={() => setCurrentStep((prev) => prev - 1)}
|
|
||||||
className="flex items-center gap-2 border-gray-700 text-gray-300 hover:bg-gray-700 hover:text-white disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="w-4 h-4" />
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
|
||||||
<span>
|
|
||||||
Step {currentStep + 1} of {steps.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{currentStep < steps.length - 1 ? (
|
|
||||||
<Button
|
|
||||||
onClick={() => setCurrentStep((prev) => prev + 1)}
|
|
||||||
className="flex items-center gap-2 bg-[#9B2335] hover:bg-[#9B2335]/90 shadow-lg"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
<ChevronRight className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="flex items-center gap-2 bg-green-600 hover:bg-green-700 shadow-lg"
|
|
||||||
>
|
|
||||||
{isSubmitting ? (
|
|
||||||
<>
|
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
|
||||||
Saving...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Save className="w-4 h-4" />
|
|
||||||
{isEditMode ? "Update Record" : "Submit Form"}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Confirmation Dialogs */}
|
|
||||||
<AddDialog
|
<AddDialog
|
||||||
open={isAddDialogOpen}
|
open={isAddDialogOpen}
|
||||||
onOpenChange={setIsAddDialogOpen}
|
onOpenChange={setIsAddDialogOpen}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,22 @@
|
||||||
import React from "react"
|
import React from 'react'
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from '@/components/ui/label'
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Camera, Star, Trash2 } from 'lucide-react'
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
|
||||||
import { Upload, X, ImageIcon } from "lucide-react"
|
|
||||||
|
|
||||||
interface PhotosStepProps {
|
interface PhotosStepProps {
|
||||||
photos: File[]
|
photos: File[]
|
||||||
photoPreviews: string[]
|
photoPreviews: string[]
|
||||||
captions: string[]
|
captions: string[]
|
||||||
existingPhotos: any[]
|
existingPhotos: any
|
||||||
mainPhotoIndex: number | null
|
mainPhotoIndex: number | null
|
||||||
API_BASE_URL: string
|
API_BASE_URL: string
|
||||||
handlePhotoChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
handlePhotoChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||||
removeExistingPhoto: (index: number) => void
|
removeExistingPhoto: (index: number) => void
|
||||||
removeNewPhoto: (index: number) => void
|
removeNewPhoto: (index: number) => void
|
||||||
setCaptions: React.Dispatch<React.SetStateAction<string[]>>
|
setCaptions: (captions: string[]) => void
|
||||||
setMainPhotoIndex: React.Dispatch<React.SetStateAction<number | null>>
|
setMainPhotoIndex: (index: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const PhotosStep: React.FC<PhotosStepProps> = ({
|
const PhotosStep: React.FC<PhotosStepProps> = ({
|
||||||
|
|
@ -34,95 +32,131 @@ const PhotosStep: React.FC<PhotosStepProps> = ({
|
||||||
setCaptions,
|
setCaptions,
|
||||||
setMainPhotoIndex,
|
setMainPhotoIndex,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [isDragOver, setIsDragOver] = React.useState(false)
|
||||||
|
|
||||||
|
const updateCaption = (index: number, newCaption: string) => {
|
||||||
|
const newCaptions = [...captions]
|
||||||
|
newCaptions[index] = newCaption
|
||||||
|
setCaptions(newCaptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsDragOver(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragLeave = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsDragOver(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsDragOver(false)
|
||||||
|
|
||||||
|
const files = Array.from(e.dataTransfer.files).filter(file =>
|
||||||
|
file.type.startsWith('image/')
|
||||||
|
)
|
||||||
|
|
||||||
|
if (files.length > 0) {
|
||||||
|
const syntheticEvent = {
|
||||||
|
target: {
|
||||||
|
files: files
|
||||||
|
}
|
||||||
|
} as unknown as React.ChangeEvent<HTMLInputElement>
|
||||||
|
|
||||||
|
handlePhotoChange(syntheticEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* File Upload Section */}
|
<div>
|
||||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-gray-400 transition-colors">
|
<Label htmlFor="photo-upload" className="text-sm font-medium text-gray-300 mb-4 block">
|
||||||
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
|
Upload Photos
|
||||||
<div className="space-y-2">
|
</Label>
|
||||||
<Label htmlFor="photo-upload" className="text-lg font-medium text-gray-700 cursor-pointer">
|
<div
|
||||||
Upload Photos & Documents
|
className={`border-2 border-dashed rounded-lg p-6 transition-all duration-200 ${
|
||||||
</Label>
|
isDragOver
|
||||||
<p className="text-sm text-gray-500">
|
? 'border-blue-400 bg-blue-900/20 scale-[1.02]'
|
||||||
Select multiple files to upload. Supported formats: JPG, PNG, PDF
|
: 'border-gray-600 bg-gray-800/50 hover:border-gray-500 hover:bg-gray-800/70'
|
||||||
</p>
|
}`}
|
||||||
<Input
|
onDragOver={handleDragOver}
|
||||||
id="photo-upload"
|
onDragLeave={handleDragLeave}
|
||||||
type="file"
|
onDrop={handleDrop}
|
||||||
multiple
|
>
|
||||||
accept="image/*"
|
<div className="flex flex-col items-center gap-4">
|
||||||
onChange={handlePhotoChange}
|
<div className="flex items-center gap-4">
|
||||||
className="hidden"
|
<Button
|
||||||
/>
|
type="button"
|
||||||
<Button
|
variant="outline"
|
||||||
variant="outline"
|
className="border-gray-900 text-white bg-gray-700 hover:bg-gray-600"
|
||||||
className="mt-4"
|
onClick={() => document.getElementById('photo-upload')?.click()}
|
||||||
onClick={() => document.getElementById("photo-upload")?.click()}
|
>
|
||||||
>
|
<Camera className="w-4 h-4 mr-2" />
|
||||||
<Upload className="w-4 h-4 mr-2" />
|
Add Photos
|
||||||
Choose Files
|
</Button>
|
||||||
</Button>
|
<Input
|
||||||
|
id="photo-upload"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handlePhotoChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-400 text-center">
|
||||||
|
{isDragOver
|
||||||
|
? "Drop your photos here!"
|
||||||
|
: "Click to select photos or drag and drop them here"
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{existingPhotos.length > 0 && (
|
{existingPhotos.length > 0 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<h3 className="text-lg font-semibold text-white border-b border-gray-600 pb-2">
|
||||||
<ImageIcon className="w-5 h-5 text-gray-600" />
|
Existing Photos ({existingPhotos.length})
|
||||||
<h3 className="text-lg font-medium text-white">Existing Photos</h3>
|
</h3>
|
||||||
<Badge variant="secondary">{existingPhotos.length}</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{existingPhotos.map((photo, index) => (
|
{existingPhotos.map((photo: any, index: number) => (
|
||||||
<Card key={`existing-${photo.id}`} className="overflow-hidden">
|
<Card key={`existing-${photo.id}`} className="bg-gray-800 border-gray-700">
|
||||||
<div className="relative">
|
<CardContent className="p-4">
|
||||||
<img
|
<div className="relative">
|
||||||
src={`${API_BASE_URL}${photo.file_path}`}
|
<img
|
||||||
alt={`Existing ${index}`}
|
src={`${API_BASE_URL}${photo.file_path}`}
|
||||||
className="w-full h-48 object-cover"
|
alt={photo.caption || `Photo ${index + 1}`}
|
||||||
/>
|
className="w-full h-48 object-cover rounded-md"
|
||||||
{mainPhotoIndex === index && (
|
|
||||||
<Badge className="absolute top-2 left-2 bg-green-600">Main Photo</Badge>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
className="absolute top-2 right-2"
|
|
||||||
onClick={() => removeExistingPhoto(index)}
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<CardContent className="p-4 space-y-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor={`caption-existing-${index}`} className="text-sm font-medium">
|
|
||||||
Caption
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id={`caption-existing-${index}`}
|
|
||||||
placeholder="Enter photo caption"
|
|
||||||
value={typeof captions[index] === "string" ? captions[index] : ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setCaptions((prev) => {
|
|
||||||
const copy = [...prev]
|
|
||||||
copy[index] = e.target.value
|
|
||||||
return copy
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<div className="absolute top-2 right-2 flex gap-2">
|
||||||
<RadioGroup
|
<Button
|
||||||
value={mainPhotoIndex === index ? "main" : ""}
|
type="button"
|
||||||
onValueChange={() => setMainPhotoIndex(index)}
|
size="sm"
|
||||||
>
|
variant={mainPhotoIndex === index ? "default" : "outline"}
|
||||||
<div className="flex items-center space-x-2">
|
className={mainPhotoIndex === index ? "bg-yellow-600 hover:bg-yellow-700" : ""}
|
||||||
<RadioGroupItem value="main" id={`main-existing-${index}`} />
|
onClick={() => setMainPhotoIndex(index)}
|
||||||
<Label htmlFor={`main-existing-${index}`} className="text-sm">
|
>
|
||||||
Set as main photo
|
<Star className="w-4 h-4" />
|
||||||
</Label>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => removeExistingPhoto(index)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup>
|
</div>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Add caption..."
|
||||||
|
value={captions[index] || ''}
|
||||||
|
onChange={(e) => updateCaption(index, e.target.value)}
|
||||||
|
className="mt-2 bg-gray-700 border-gray-600 text-white"
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|
@ -130,66 +164,51 @@ const PhotosStep: React.FC<PhotosStepProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{photos.length > 0 && (
|
{/* New Photos Section */}
|
||||||
|
{photoPreviews.length > 0 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<h3 className="text-lg font-semibold text-white border-b border-gray-600 pb-2">
|
||||||
<Upload className="w-5 h-5 text-gray-600" />
|
New Photos ({photoPreviews.length})
|
||||||
<h3 className="text-lg font-medium text-white">New Photos</h3>
|
</h3>
|
||||||
<Badge variant="secondary">{photos.length}</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{photos.map((_, index) => {
|
{photoPreviews.map((preview, photoIndex) => {
|
||||||
const actualIndex = existingPhotos.length + index
|
const totalIndex = existingPhotos.length + photoIndex
|
||||||
return (
|
return (
|
||||||
<Card key={`new-${index}`} className="overflow-hidden">
|
<Card key={`new-${photoIndex}`} className="bg-gray-800 border-gray-700">
|
||||||
<div className="relative">
|
<CardContent className="p-4">
|
||||||
<img
|
<div className="relative">
|
||||||
src={photoPreviews[index] || `${import.meta.env.BASE_URL}assets/placeholder.svg`}
|
<img
|
||||||
alt={`Preview ${index}`}
|
src={preview}
|
||||||
className="w-full h-48 object-cover"
|
alt={`New photo ${photoIndex + 1}`}
|
||||||
/>
|
className="w-full h-48 object-cover rounded-md"
|
||||||
{mainPhotoIndex === actualIndex && (
|
|
||||||
<Badge className="absolute top-2 left-2 bg-green-600">Main Photo</Badge>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
className="absolute top-2 right-2"
|
|
||||||
onClick={() => removeNewPhoto(actualIndex)}
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<CardContent className="p-4 space-y-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor={`caption-new-${index}`} className="text-sm font-medium">
|
|
||||||
Caption
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id={`caption-new-${index}`}
|
|
||||||
placeholder="Enter photo caption"
|
|
||||||
value={captions[actualIndex] ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setCaptions((prev) => {
|
|
||||||
const copy = [...prev]
|
|
||||||
copy[actualIndex] = e.target.value
|
|
||||||
return copy
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<div className="absolute top-2 right-2 flex gap-2">
|
||||||
<RadioGroup
|
<Button
|
||||||
value={mainPhotoIndex === actualIndex ? "main" : ""}
|
type="button"
|
||||||
onValueChange={() => setMainPhotoIndex(actualIndex)}
|
size="sm"
|
||||||
>
|
variant={mainPhotoIndex === totalIndex ? "default" : "outline"}
|
||||||
<div className="flex items-center space-x-2">
|
className={mainPhotoIndex === totalIndex ? "bg-yellow-600 hover:bg-yellow-700" : ""}
|
||||||
<RadioGroupItem value="main" id={`main-new-${index}`} />
|
onClick={() => setMainPhotoIndex(totalIndex)}
|
||||||
<Label htmlFor={`main-new-${index}`} className="text-sm">
|
>
|
||||||
Set as main photo
|
<Star className="w-4 h-4" />
|
||||||
</Label>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => removeNewPhoto(photoIndex)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup>
|
</div>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Add caption..."
|
||||||
|
value={captions[totalIndex] || ''}
|
||||||
|
onChange={(e) => updateCaption(totalIndex, e.target.value)}
|
||||||
|
className="mt-2 bg-gray-700 border-gray-600 text-white"
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|
@ -197,6 +216,14 @@ const PhotosStep: React.FC<PhotosStepProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* No Photos Message */}
|
||||||
|
{existingPhotos.length === 0 && photos.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-gray-400">
|
||||||
|
<Camera className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||||
|
<p>No photos uploaded yet. Click "Add Photos" to get started.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,224 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import type { Person, Migration, Naturalization, Residence, Family, Internment } from '@/types/api'
|
||||||
|
|
||||||
|
interface ReviewStepProps {
|
||||||
|
person: Person
|
||||||
|
migration: Migration
|
||||||
|
naturalization: Naturalization
|
||||||
|
residence: Residence
|
||||||
|
family: Family
|
||||||
|
internment: Internment
|
||||||
|
photos: File[]
|
||||||
|
photoPreviews: string[]
|
||||||
|
captions: string[]
|
||||||
|
existingPhotos: any[]
|
||||||
|
mainPhotoIndex: number
|
||||||
|
API_BASE_URL: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReviewStep: React.FC<ReviewStepProps> = ({
|
||||||
|
person,
|
||||||
|
migration,
|
||||||
|
naturalization,
|
||||||
|
residence,
|
||||||
|
family,
|
||||||
|
internment,
|
||||||
|
photos,
|
||||||
|
photoPreviews,
|
||||||
|
captions,
|
||||||
|
existingPhotos,
|
||||||
|
mainPhotoIndex,
|
||||||
|
API_BASE_URL
|
||||||
|
}) => {
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
if (!dateString) return 'Not provided'
|
||||||
|
try {
|
||||||
|
return new Date(dateString).toLocaleDateString()
|
||||||
|
} catch {
|
||||||
|
return dateString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReviewSection = ({ title, children, isEmpty = false }: {
|
||||||
|
title: string,
|
||||||
|
children: React.ReactNode,
|
||||||
|
isEmpty?: boolean
|
||||||
|
}) => (
|
||||||
|
<Card className="bg-gray-800 border-gray-700">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg text-white flex items-center justify-between">
|
||||||
|
{title}
|
||||||
|
{isEmpty && <Badge variant="secondary" className="text-xs">No data</Badge>}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{children}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
|
||||||
|
const DataRow = ({ label, value }: { label: string, value: string }) => (
|
||||||
|
<div className="flex justify-between items-start py-1">
|
||||||
|
<span className="text-gray-400 text-sm">{label}:</span>
|
||||||
|
<span className="text-white text-sm text-right max-w-xs">
|
||||||
|
{value || 'Not provided'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check if sections have data
|
||||||
|
const hasPersonData = person.surname || person.christian_name
|
||||||
|
const hasMigrationData = migration.date_of_arrival_aus || migration.date_of_arrival_nt || migration.arrival_period || migration.data_source
|
||||||
|
const hasNaturalizationData = naturalization.date_of_naturalisation || naturalization.no_of_cert || naturalization.issued_at
|
||||||
|
const hasResidenceData = residence.town_or_city || residence.home_at_death
|
||||||
|
const hasFamilyData = family.names_of_parents || family.names_of_children
|
||||||
|
const hasInternmentData = internment.corps_issued || internment.interned_in || internment.sent_to || internment.internee_occupation || internment.internee_address || internment.cav
|
||||||
|
const hasPhotos = photos.length > 0 || existingPhotos.length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-2">Review Your Information</h2>
|
||||||
|
<p className="text-gray-400">Please review all the information before submitting</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6">
|
||||||
|
{/* Personal Details */}
|
||||||
|
<ReviewSection title="Personal Details" isEmpty={!hasPersonData}>
|
||||||
|
<DataRow label="Surname" value={person.surname} />
|
||||||
|
<DataRow label="Christian Name" value={person.christian_name} />
|
||||||
|
<DataRow label="Date of Birth" value={formatDate(person.date_of_birth)} />
|
||||||
|
<DataRow label="Place of Birth" value={person.place_of_birth} />
|
||||||
|
<DataRow label="Date of Death" value={formatDate(person.date_of_death)} />
|
||||||
|
<DataRow label="Occupation" value={person.occupation} />
|
||||||
|
<DataRow label="ID Card No" value={person.id_card_no} />
|
||||||
|
<DataRow label="Reference" value={person.reference} />
|
||||||
|
{person.additional_notes && (
|
||||||
|
<div className="pt-2 border-t border-gray-700">
|
||||||
|
<span className="text-gray-400 text-sm">Additional Notes:</span>
|
||||||
|
<p className="text-white text-sm mt-1 bg-gray-700/50 p-2 rounded">
|
||||||
|
{person.additional_notes}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ReviewSection>
|
||||||
|
|
||||||
|
{/* Migration Info */}
|
||||||
|
<ReviewSection title="Migration Information" isEmpty={!hasMigrationData}>
|
||||||
|
<DataRow label="Date of Arrival (Australia)" value={formatDate(migration.date_of_arrival_aus)} />
|
||||||
|
<DataRow label="Date of Arrival (NT)" value={formatDate(migration.date_of_arrival_nt)} />
|
||||||
|
<DataRow label="Arrival Period" value={migration.arrival_period} />
|
||||||
|
<DataRow label="Data Source" value={migration.data_source} />
|
||||||
|
</ReviewSection>
|
||||||
|
|
||||||
|
{/* Naturalization */}
|
||||||
|
<ReviewSection title="Naturalization" isEmpty={!hasNaturalizationData}>
|
||||||
|
<DataRow label="Date of Naturalisation" value={formatDate(naturalization.date_of_naturalisation)} />
|
||||||
|
<DataRow label="Certificate Number" value={naturalization.no_of_cert} />
|
||||||
|
<DataRow label="Issued At" value={naturalization.issued_at} />
|
||||||
|
</ReviewSection>
|
||||||
|
|
||||||
|
{/* Residence */}
|
||||||
|
<ReviewSection title="Residence" isEmpty={!hasResidenceData}>
|
||||||
|
<DataRow label="Town or City" value={residence.town_or_city} />
|
||||||
|
<DataRow label="Home at Death" value={residence.home_at_death} />
|
||||||
|
</ReviewSection>
|
||||||
|
|
||||||
|
{/* Family */}
|
||||||
|
<ReviewSection title="Family" isEmpty={!hasFamilyData}>
|
||||||
|
<DataRow label="Names of Parents" value={family.names_of_parents} />
|
||||||
|
<DataRow label="Names of Children" value={family.names_of_children} />
|
||||||
|
</ReviewSection>
|
||||||
|
|
||||||
|
{/* Internment */}
|
||||||
|
<ReviewSection title="Internment" isEmpty={!hasInternmentData}>
|
||||||
|
<DataRow label="Corps Issued" value={internment.corps_issued} />
|
||||||
|
<DataRow label="Interned In" value={internment.interned_in} />
|
||||||
|
<DataRow label="Sent To" value={internment.sent_to} />
|
||||||
|
<DataRow label="Internee Occupation" value={internment.internee_occupation} />
|
||||||
|
<DataRow label="Internee Address" value={internment.internee_address} />
|
||||||
|
<DataRow label="CAV" value={internment.cav} />
|
||||||
|
</ReviewSection>
|
||||||
|
|
||||||
|
{/* Photos */}
|
||||||
|
<ReviewSection title="Photos" isEmpty={!hasPhotos}>
|
||||||
|
{hasPhotos ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
Total Photos: {photos.length + existingPhotos.length}
|
||||||
|
{(mainPhotoIndex >= 0) && (
|
||||||
|
<Badge variant="outline" className="ml-2">
|
||||||
|
Main photo selected
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{/* Existing Photos */}
|
||||||
|
{existingPhotos.map((photo, index) => (
|
||||||
|
<div key={`existing-${index}`} className="relative">
|
||||||
|
<img
|
||||||
|
src={`${API_BASE_URL}${photo.file_path}`}
|
||||||
|
alt={`Photo ${index + 1}`}
|
||||||
|
className="w-full h-24 object-cover rounded border border-gray-600"
|
||||||
|
/>
|
||||||
|
{mainPhotoIndex === index && (
|
||||||
|
<Badge className="absolute top-1 left-1 text-xs bg-[#9B2335]">
|
||||||
|
Main
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{captions[index] && (
|
||||||
|
<p className="text-xs text-gray-400 mt-1 truncate">
|
||||||
|
{captions[index]}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* New Photos */}
|
||||||
|
{photoPreviews.map((preview, index) => (
|
||||||
|
<div key={`new-${index}`} className="relative">
|
||||||
|
<img
|
||||||
|
src={preview}
|
||||||
|
alt={`New photo ${index + 1}`}
|
||||||
|
className="w-full h-24 object-cover rounded border border-gray-600"
|
||||||
|
/>
|
||||||
|
{mainPhotoIndex === (existingPhotos.length + index) && (
|
||||||
|
<Badge className="absolute top-1 left-1 text-xs bg-[#9B2335]">
|
||||||
|
Main
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{captions[existingPhotos.length + index] && (
|
||||||
|
<p className="text-xs text-gray-400 mt-1 truncate">
|
||||||
|
{captions[existingPhotos.length + index]}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-400 text-sm">No photos uploaded</p>
|
||||||
|
)}
|
||||||
|
</ReviewSection>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 p-4 bg-blue-900/20 border border-blue-500/30 rounded-lg">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="text-blue-400 mt-0.5">ℹ️</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-blue-400 font-medium">Ready to Submit</h4>
|
||||||
|
<p className="text-blue-300 text-sm mt-1">
|
||||||
|
Please review all the information above. Once you submit,
|
||||||
|
{" "}this record will be saved to the database.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReviewStep
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Progress } from '@/components/ui/progress'
|
||||||
|
import { User, MapPin, FileText, Home, Users, Shield, Camera } from 'lucide-react'
|
||||||
|
|
||||||
|
const steps = ["Personal Details", "Immigration History", "Naturalization Records", "Residence History", "Family Information", "Internment Records", "Photos & Documents"]
|
||||||
|
|
||||||
|
const stepDescriptions = [
|
||||||
|
"Enter basic personal information and identification details",
|
||||||
|
"Record immigration and arrival information in Australia",
|
||||||
|
"Document citizenship and naturalization records",
|
||||||
|
"Track residential history and location changes",
|
||||||
|
"Add family members and relationship details",
|
||||||
|
"Record internment and detention history",
|
||||||
|
"Upload photos and supporting documents",
|
||||||
|
]
|
||||||
|
|
||||||
|
const stepIcons = [User, MapPin, FileText, Home, Users, Shield, Camera]
|
||||||
|
|
||||||
|
interface StepperHeaderProps {
|
||||||
|
currentStep: number
|
||||||
|
isEditMode: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepperHeader: React.FC<StepperHeaderProps> = ({ currentStep, isEditMode }) => {
|
||||||
|
return (
|
||||||
|
<CardHeader className="bg-gray-800 p-8 border-b border-gray-700">
|
||||||
|
<div className="flex justify-between items-start mb-6">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-xl md:text-2xl font-bold text-white">
|
||||||
|
{isEditMode ? `Edit ${steps[currentStep]}` : steps[currentStep]}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-gray-400 mt-1">
|
||||||
|
{stepDescriptions[currentStep]}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="text-sm border-gray-700 text-gray-300">
|
||||||
|
Step {currentStep + 1} of {steps.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="flex justify-between text-sm text-gray-400 mb-2">
|
||||||
|
<span>Progress</span>
|
||||||
|
<span>{Math.round((currentStep / (steps.length - 1)) * 100)}% Complete</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={(currentStep / (steps.length - 1)) * 100}
|
||||||
|
className="h-2 bg-gray-700"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--progress-foreground": "#9B2335",
|
||||||
|
"--progress-background": "#374151",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between mt-4 overflow-x-auto">
|
||||||
|
{steps.map((step, index) => {
|
||||||
|
const StepIcon = stepIcons[index]
|
||||||
|
return (
|
||||||
|
<div key={index} className="flex flex-col items-center min-w-0 flex-1">
|
||||||
|
<div
|
||||||
|
className={`w-10 h-10 rounded-full flex items-center justify-center mb-2 transition-all duration-200 ${
|
||||||
|
index <= currentStep
|
||||||
|
? "bg-[#9B2335] text-white shadow-lg"
|
||||||
|
: "bg-gray-800 text-gray-500 border border-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<StepIcon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-xs text-center px-1 transition-colors duration-200 ${
|
||||||
|
index <= currentStep ? "text-[#9B2335] font-medium" : "text-gray-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{step}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StepperHeader
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { ChevronLeft, ChevronRight, Save } from 'lucide-react'
|
||||||
|
|
||||||
|
interface StepperNavigationProps {
|
||||||
|
currentStep: number
|
||||||
|
totalSteps: number
|
||||||
|
isSubmitting: boolean
|
||||||
|
isEditMode: boolean
|
||||||
|
onPrevious: () => void
|
||||||
|
onNext: () => void
|
||||||
|
onSubmit: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepperNavigation: React.FC<StepperNavigationProps> = ({
|
||||||
|
currentStep,
|
||||||
|
totalSteps,
|
||||||
|
isSubmitting,
|
||||||
|
isEditMode,
|
||||||
|
onPrevious,
|
||||||
|
onNext,
|
||||||
|
onSubmit,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-800 px-8 py-6 border-t border-gray-700">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={currentStep === 0}
|
||||||
|
onClick={onPrevious}
|
||||||
|
className="flex items-center gap-2 border-gray-700 text-gray-300 hover:bg-gray-700 hover:text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||||
|
<span>Step {currentStep + 1} of {totalSteps}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentStep < totalSteps - 2 ? (
|
||||||
|
<Button
|
||||||
|
onClick={onNext}
|
||||||
|
className="flex items-center gap-2 bg-[#9B2335] hover:bg-[#9B2335]/90 shadow-lg"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
) : currentStep === totalSteps - 2 ? (
|
||||||
|
<Button
|
||||||
|
onClick={onNext}
|
||||||
|
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 shadow-lg"
|
||||||
|
>
|
||||||
|
Review
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="flex items-center gap-2 bg-green-600 hover:bg-green-700 shadow-lg"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
{isEditMode ? "Update Record" : "Submit Form"}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StepperNavigation
|
||||||
|
|
@ -13,6 +13,8 @@ import { toast } from "react-hot-toast"
|
||||||
import apiService from "@/services/apiService"
|
import apiService from "@/services/apiService"
|
||||||
import Header from "@/components/layout/Header"
|
import Header from "@/components/layout/Header"
|
||||||
import Sidebar from "@/components/layout/Sidebar"
|
import Sidebar from "@/components/layout/Sidebar"
|
||||||
|
import { UserSchema } from "@/schemas/userSchema"
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
export default function UserCreate() {
|
export default function UserCreate() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
@ -23,6 +25,7 @@ export default function UserCreate() {
|
||||||
password: "",
|
password: "",
|
||||||
password_confirmation: "",
|
password_confirmation: "",
|
||||||
})
|
})
|
||||||
|
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const { name, value } = e.target
|
const { name, value } = e.target
|
||||||
|
|
@ -30,19 +33,50 @@ export default function UserCreate() {
|
||||||
...prev,
|
...prev,
|
||||||
[name]: value,
|
[name]: value,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Clear error for this field when user types
|
||||||
|
if (validationErrors[name]) {
|
||||||
|
setValidationErrors(prev => {
|
||||||
|
const updated = { ...prev }
|
||||||
|
delete updated[name]
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
try {
|
||||||
|
UserSchema.parse(formData)
|
||||||
|
setValidationErrors({})
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
const errors: Record<string, string> = {}
|
||||||
|
error.errors.forEach((err) => {
|
||||||
|
const path = err.path.join('.')
|
||||||
|
errors[path] = err.message
|
||||||
|
})
|
||||||
|
setValidationErrors(errors)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFieldError = (fieldName: string) => {
|
||||||
|
return validationErrors[fieldName] || null
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
// Basic validation
|
// Zod validation
|
||||||
if (!formData.name || !formData.email || !formData.password) {
|
if (!validateForm()) {
|
||||||
toast.error("Please fill in all required fields")
|
// Show the first validation error as a toast
|
||||||
return
|
const firstError = Object.values(validationErrors)[0]
|
||||||
}
|
if (firstError) {
|
||||||
|
toast.error(firstError)
|
||||||
if (formData.password !== formData.password_confirmation) {
|
}
|
||||||
toast.error("Passwords don't match")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,8 +130,11 @@ export default function UserCreate() {
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
required
|
required
|
||||||
className="bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500"
|
className={`bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500 ${validationErrors.name ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : ''}`}
|
||||||
/>
|
/>
|
||||||
|
{getFieldError('name') && (
|
||||||
|
<p className="text-red-400 text-sm mt-1">{getFieldError('name')}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -112,8 +149,11 @@ export default function UserCreate() {
|
||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
required
|
required
|
||||||
className="bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500"
|
className={`bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500 ${validationErrors.email ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : ''}`}
|
||||||
/>
|
/>
|
||||||
|
{getFieldError('email') && (
|
||||||
|
<p className="text-red-400 text-sm mt-1">{getFieldError('email')}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-gray-800 pt-6">
|
<div className="border-t border-gray-800 pt-6">
|
||||||
|
|
@ -131,8 +171,11 @@ export default function UserCreate() {
|
||||||
value={formData.password}
|
value={formData.password}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
required
|
required
|
||||||
className="bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500"
|
className={`bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500 ${validationErrors.password ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : ''}`}
|
||||||
/>
|
/>
|
||||||
|
{getFieldError('password') && (
|
||||||
|
<p className="text-red-400 text-sm mt-1">{getFieldError('password')}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -147,12 +190,27 @@ export default function UserCreate() {
|
||||||
value={formData.password_confirmation}
|
value={formData.password_confirmation}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
required
|
required
|
||||||
className="bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500"
|
className={`bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500 ${validationErrors.password_confirmation ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : ''}`}
|
||||||
/>
|
/>
|
||||||
|
{getFieldError('password_confirmation') && (
|
||||||
|
<p className="text-red-400 text-sm mt-1">{getFieldError('password_confirmation')}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Display validation errors summary if needed */}
|
||||||
|
{Object.keys(validationErrors).length > 0 && (
|
||||||
|
<div className="mt-6 p-4 bg-red-900/20 border border-red-500/30 rounded-lg">
|
||||||
|
<h4 className="text-red-400 font-medium mb-2">Please fix the following errors:</h4>
|
||||||
|
<ul className="text-sm text-red-300 space-y-1">
|
||||||
|
{Object.entries(validationErrors).map(([field, error]) => (
|
||||||
|
<li key={field}>• {error}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
<CardFooter className="flex justify-end gap-3 pt-2 pb-6 px-6 border-t border-gray-800">
|
<CardFooter className="flex justify-end gap-3 pt-2 pb-6 px-6 border-t border-gray-800">
|
||||||
|
|
|
||||||
|
|
@ -1,85 +1,35 @@
|
||||||
|
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
|
|
||||||
import { Link } from "react-router-dom"
|
import { Link } from "react-router-dom"
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
Mail,
|
|
||||||
Phone,
|
|
||||||
MapPin,
|
MapPin,
|
||||||
Facebook,
|
|
||||||
Twitter,
|
|
||||||
Instagram,
|
|
||||||
Youtube,
|
|
||||||
Clock,
|
Clock,
|
||||||
Users,
|
|
||||||
Database,
|
|
||||||
Award,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@/components/ui/carousel"
|
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import SearchForm from "./SearchForm"
|
import SearchForm from "@/components/home/SearchForm"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
import ApiService from "@/services/apiService"
|
import ApiService from "@/services/apiService"
|
||||||
import type { Person } from "@/types/api"
|
import type { Person } from "@/types/api"
|
||||||
|
|
||||||
export default function Home() {
|
export default function Index() {
|
||||||
const [currentSlide, setCurrentSlide] = useState(0)
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
const [migrants, setMigrants] = useState<Person[]>([])
|
const [migrants, setMigrants] = useState<Person[]>([])
|
||||||
const API_BASE_URL = "http://localhost:8000"
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
const response = await ApiService.getMigrants(1, 10)
|
const response = await ApiService.getMigrants(1, 6)
|
||||||
setMigrants(response.data || [])
|
setMigrants(response.data || [])
|
||||||
setTotal(response.total || 0)
|
setTotal(response.total || 0)
|
||||||
}
|
}
|
||||||
fetchData()
|
fetchData()
|
||||||
}, [])
|
}, [])
|
||||||
const backgroundImages = [
|
|
||||||
{ src: `${import.meta.env.BASE_URL}assets/slide1.avif`, alt: "Italian countryside landscape" },
|
|
||||||
{ src: `${import.meta.env.BASE_URL}assets/slide2.avif`, alt: "Vintage Italian architecture" },
|
|
||||||
{ src: `${import.meta.env.BASE_URL}assets/slide3.avif`, alt: "Italian coastal town" },
|
|
||||||
{ src: `${import.meta.env.BASE_URL}assets/slide4.avif`, alt: "Italian countryside with vineyards" },
|
|
||||||
];
|
|
||||||
|
|
||||||
|
|
||||||
const galleryImages = [
|
|
||||||
{
|
|
||||||
src: "https://images.unsplash.com/photo-1523906834658-6e24ef2386f9?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=800&q=80",
|
|
||||||
alt: "Italian countryside village",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: "https://images.unsplash.com/photo-1515542622106-78bda8ba0e5b?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=800&q=80",
|
|
||||||
alt: "Italian landscape",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: "https://images.unsplash.com/photo-1552832230-c0197dd311b5?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=800&q=80",
|
|
||||||
alt: "Italian vineyards",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: "https://images.unsplash.com/photo-1516483638261-f4dbaf036963?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=800&q=80",
|
|
||||||
alt: "Italian coastal town",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: "https://images.unsplash.com/photo-1581833971358-2c8b550f87b3?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=800&q=80",
|
|
||||||
alt: "Italian traditional building",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
setCurrentSlide((prev) => (prev + 1) % backgroundImages.length)
|
|
||||||
}, 5000)
|
|
||||||
return () => clearInterval(timer)
|
|
||||||
}, [backgroundImages.length])
|
|
||||||
|
|
||||||
// Smooth scroll to contact section
|
|
||||||
const scrollToContact = (e: React.MouseEvent) => {
|
const scrollToContact = (e: React.MouseEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const contactSection = document.getElementById("contact")
|
const contactSection = document.getElementById("contact")
|
||||||
|
|
@ -93,141 +43,129 @@ export default function Home() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen">
|
<div className="flex flex-col min-h-screen">
|
||||||
<header className="absolute top-0 left-0 right-0 z-50 border-b border-white/20 bg-black/20 backdrop-blur-sm">
|
{/* Header */}
|
||||||
|
<header className="border-b">
|
||||||
<div className="container flex h-16 items-center justify-between px-4 md:px-6">
|
<div className="container flex h-16 items-center justify-between px-4 md:px-6">
|
||||||
<Link to="/" className="flex items-center gap-2">
|
<Link to="/" className="flex items-center gap-2">
|
||||||
<span className="text-xl font-bold text-white">Italian Migrants NT</span>
|
<span className="text-xl font-bold text-[#9B2335]">Italian Migrants NT</span>
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="hidden md:flex gap-6">
|
<nav className="hidden md:flex gap-6">
|
||||||
<Link to="/" className="text-sm font-medium text-white hover:text-white/80 transition-colors">
|
<Link to="/" className="text-sm font-medium hover:underline underline-offset-4">
|
||||||
Home
|
Home
|
||||||
</Link>
|
</Link>
|
||||||
<a href="#about" className="text-sm font-medium text-white hover:text-white/80 transition-colors">
|
<a href="#about" className="text-sm font-medium hover:underline underline-offset-4">
|
||||||
About
|
About
|
||||||
</a>
|
</a>
|
||||||
<a href="#stories" className="text-sm font-medium text-white hover:text-white/80 transition-colors">
|
<a href="#stories" className="text-sm font-medium hover:underline underline-offset-4">
|
||||||
Stories
|
Stories
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="#contact"
|
href="#contact"
|
||||||
onClick={scrollToContact}
|
onClick={scrollToContact}
|
||||||
className="text-sm font-medium text-white hover:text-white/80 transition-colors"
|
className="text-sm font-medium hover:underline underline-offset-4"
|
||||||
>
|
>
|
||||||
Contact
|
Contact
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
<Button variant="outline" size="icon" className="md:hidden border-white/20 text-white hover:bg-white/10">
|
|
||||||
<Search className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Search</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="flex-1">
|
<main className="flex-1">
|
||||||
{/* Hero Section with Background Carousel and Search */}
|
{/* Hero Section with Search Form */}
|
||||||
<section className="relative w-full h-screen flex items-center justify-center overflow-hidden">
|
<section className="w-full py-12 md:py-16 lg:py-20 bg-[#E8DCCA]">
|
||||||
{/* Background Carousel */}
|
{/* No animated background elements */}
|
||||||
<div className="absolute inset-0">
|
|
||||||
{backgroundImages.map((image, index) => (
|
<div className="container px-4 md:px-6">
|
||||||
<div
|
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
||||||
key={index}
|
{/* Left side - Hero content */}
|
||||||
className={`absolute inset-0 transition-opacity duration-1000 ${index === currentSlide ? "opacity-100" : "opacity-0"
|
<div className="text-left space-y-6">
|
||||||
}`}
|
<h1 className="text-4xl md:text-6xl lg:text-7xl font-bold font-serif text-[#9B2335] mb-6">
|
||||||
>
|
Discover Your
|
||||||
<img src={image.src || `${import.meta.env.BASE_URL}assets/placeholder.svg`} alt={image.alt} className="w-full h-full object-cover" />
|
<span className="block">
|
||||||
<div className="absolute inset-0 bg-black/60" />
|
Italian Heritage
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg md:text-xl text-muted-foreground max-w-xl leading-relaxed">
|
||||||
|
Search our comprehensive database of Italian migrants to the Northern Territory.
|
||||||
|
Uncover family histories, personal stories, and cultural contributions spanning over a century.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Quick Stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-4 py-6">
|
||||||
|
<div className="text-center bg-white rounded-lg p-4 border shadow-sm">
|
||||||
|
<div className="text-2xl font-bold text-[#9B2335]">{total.toLocaleString()}</div>
|
||||||
|
<div className="text-gray-600 text-sm">Records</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center bg-white rounded-lg p-4 border shadow-sm">
|
||||||
|
<div className="text-2xl font-bold text-[#9B2335]">100+</div>
|
||||||
|
<div className="text-gray-600 text-sm">Years</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center bg-white rounded-lg p-4 border shadow-sm">
|
||||||
|
<div className="text-2xl font-bold text-[#9B2335]">156</div>
|
||||||
|
<div className="text-gray-600 text-sm">Regions</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Centered Content with Search */}
|
{/* Right side - Search Form */}
|
||||||
<div className="relative z-10 text-center text-white px-4 md:px-6 max-w-5xl mx-auto">
|
<div className="bg-white rounded-lg p-8 border shadow-md">
|
||||||
<h1 className="text-4xl md:text-6xl lg:text-7xl font-bold tracking-tighter font-serif mb-4">
|
<div className="space-y-6">
|
||||||
Find Your Italian Heritage
|
<div className="text-center">
|
||||||
</h1>
|
<h2 className="text-2xl font-bold text-[#9B2335] mb-2">Search Database</h2>
|
||||||
<p className="text-lg md:text-xl lg:text-2xl mb-8 text-white/90 max-w-3xl mx-auto leading-relaxed">
|
<p className="text-muted-foreground">Find your Italian ancestors</p>
|
||||||
Search our comprehensive database of Italian migrants to the Northern Territory. Discover family
|
</div>
|
||||||
histories, personal stories, and cultural contributions spanning over a century.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Main Search Form */}
|
<SearchForm />
|
||||||
<div className="bg-white/95 backdrop-blur-sm rounded-2xl p-6 md:p-8 shadow-2xl mb-8 max-w-4xl mx-auto">
|
|
||||||
<h2 className="text-2xl md:text-3xl font-bold text-[#9B2335] mb-6 font-serif">Search Migrant Database</h2>
|
|
||||||
<SearchForm />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
<div className="text-center">
|
||||||
size="lg"
|
<Button
|
||||||
variant="outline"
|
size="lg"
|
||||||
onClick={() => navigate("/search-results")}
|
onClick={() => navigate("/search-results")}
|
||||||
className="border-white text-black hover:bg-white hover:text-black px-8 py-3 text-lg"
|
className="w-full bg-[#01796F] hover:bg-[#015a54] text-white"
|
||||||
>
|
>
|
||||||
Browse All Records
|
Browse All Records
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
{/* Quick Stats */}
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mt-12 text-center">
|
|
||||||
<div className="bg-white/10 backdrop-blur-sm rounded-lg p-4">
|
|
||||||
<div className="text-3xl font-bold text-white">{total.toLocaleString()}</div>
|
|
||||||
<div className="text-white/80">Migrant Records</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white/10 backdrop-blur-sm rounded-lg p-4">
|
|
||||||
<div className="text-3xl font-bold text-white">1880-1980</div>
|
|
||||||
<div className="text-white/80">Years Covered</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white/10 backdrop-blur-sm rounded-lg p-4">
|
|
||||||
<div className="text-3xl font-bold text-white">156</div>
|
|
||||||
<div className="text-white/80">Italian Regions</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Carousel Indicators */}
|
|
||||||
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 flex space-x-2">
|
|
||||||
{backgroundImages.map((_, index) => (
|
|
||||||
<button
|
|
||||||
key={index}
|
|
||||||
onClick={() => setCurrentSlide(index)}
|
|
||||||
className={`w-3 h-3 rounded-full transition-all ${index === currentSlide ? "bg-white" : "bg-white/50"}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Quick Search Tips */}
|
{/* Search Tips Section */}
|
||||||
<section className="w-full py-12 bg-[#E8DCCA]">
|
<section className="w-full py-16 bg-gray-100">
|
||||||
<div className="container px-4 md:px-6">
|
<div className="container px-4 md:px-6">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-12">
|
||||||
<h2 className="text-2xl md:text-3xl font-bold text-[#9B2335] font-serif mb-4">Search Tips</h2>
|
<h2 className="text-3xl font-bold text-[#9B2335] font-serif mb-4">Search Tips</h2>
|
||||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
<p className="text-gray-600 max-w-2xl mx-auto">
|
||||||
Get the most out of your search with these helpful tips
|
Get the most out of your search with these helpful tips
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid md:grid-cols-3 gap-6 max-w-4xl mx-auto">
|
<div className="grid md:grid-cols-3 gap-6 max-w-4xl mx-auto">
|
||||||
<div className="text-center p-6 bg-white rounded-lg shadow-sm">
|
<div className="text-center p-6 bg-white rounded-lg border shadow-sm">
|
||||||
<div className="w-12 h-12 bg-[#01796F] rounded-full flex items-center justify-center mx-auto mb-4">
|
<div className="w-12 h-12 bg-[#01796F] rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
<Search className="h-6 w-6 text-white" />
|
<Search className="h-6 w-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-semibold text-[#9B2335] mb-2">Name Variations</h3>
|
<h3 className="font-semibold text-[#9B2335] mb-2">Name Variations</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-gray-600">
|
||||||
Try different spellings and shortened versions of names as they may have been anglicized upon arrival.
|
Try different spellings and shortened versions of names as they may have been anglicized upon arrival.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center p-6 bg-white rounded-lg shadow-sm">
|
<div className="text-center p-6 bg-white rounded-lg border shadow-sm">
|
||||||
<div className="w-12 h-12 bg-[#01796F] rounded-full flex items-center justify-center mx-auto mb-4">
|
<div className="w-12 h-12 bg-[#01796F] rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
<span className="text-white font-bold">📅</span>
|
<Clock className="h-6 w-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-semibold text-[#9B2335] mb-2">Date Ranges</h3>
|
<h3 className="font-semibold text-[#9B2335] mb-2">Date Ranges</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-gray-600">
|
||||||
Use broader date ranges as exact arrival dates may not always be recorded accurately.
|
Use broader date ranges as exact arrival dates may not always be recorded accurately.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center p-6 bg-white rounded-lg shadow-sm">
|
<div className="text-center p-6 bg-white rounded-lg border shadow-sm">
|
||||||
<div className="w-12 h-12 bg-[#01796F] rounded-full flex items-center justify-center mx-auto mb-4">
|
<div className="w-12 h-12 bg-[#01796F] rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
<span className="text-white font-bold">🗺️</span>
|
<MapPin className="h-6 w-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-semibold text-[#9B2335] mb-2">Regional Search</h3>
|
<h3 className="font-semibold text-[#9B2335] mb-2">Regional Search</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-gray-600">
|
||||||
Search by Italian region or province if you know your family's origin to narrow results.
|
Search by Italian region or province if you know your family's origin to narrow results.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -235,335 +173,93 @@ export default function Home() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="stories" className="w-full py-12 md:py-24 lg:py-32">
|
{/* Featured Stories */}
|
||||||
|
<section id="stories" className="w-full py-16 bg-white">
|
||||||
<div className="container px-4 md:px-6">
|
<div className="container px-4 md:px-6">
|
||||||
<div className="flex flex-col items-center justify-center space-y-4 text-center">
|
<div className="text-center mb-12">
|
||||||
<div className="space-y-2">
|
<h2 className="text-3xl font-bold text-[#9B2335] font-serif mb-4">Featured Stories</h2>
|
||||||
<h2 className="text-3xl font-bold tracking-tighter sm:text-5xl font-serif text-[#1A2A57]">
|
<p className="text-gray-600 max-w-3xl mx-auto text-lg">
|
||||||
Featured Stories
|
Discover remarkable personal journeys found in our database. Each story represents courage, determination, and the pursuit of a better life.
|
||||||
</h2>
|
</p>
|
||||||
<p className="max-w-[900px] text-muted-foreground md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed">
|
|
||||||
Discover some of the remarkable personal journeys found in our database. Each story represents
|
|
||||||
courage, determination, and the pursuit of a better life.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mx-auto max-w-5xl py-12">
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
|
||||||
<Carousel className="w-full">
|
{migrants.slice(0, 6).map((person) => {
|
||||||
<CarouselContent>
|
const profilePhoto = person.photos?.find((photo) => photo.is_profile_photo)
|
||||||
{migrants.map((person) => {
|
return (
|
||||||
// Find the profile photo
|
<Card key={person.person_id} className="overflow-hidden pt-0">
|
||||||
const profilePhoto = person.photos?.find((photo) => photo.is_profile_photo)
|
<CardContent className="p-6">
|
||||||
|
<div className="space-y-4">
|
||||||
return (
|
<div className="aspect-square overflow-hidden rounded-xl">
|
||||||
<CarouselItem key={person.person_id} className="md:basis-1/2 lg:basis-1/2">
|
<img
|
||||||
<div className="p-2">
|
src={
|
||||||
<Card className="h-full">
|
profilePhoto
|
||||||
<CardContent className="p-6">
|
? `${import.meta.env.BASE_URL}${profilePhoto.file_path}`
|
||||||
<div className="space-y-4">
|
: `${import.meta.env.BASE_URL}assets/placeholder.png?height=400&width=600`
|
||||||
<div className="aspect-video overflow-hidden rounded-lg">
|
}
|
||||||
<img
|
alt={`Portrait of ${person.full_name || person.surname || "Unnamed"}`}
|
||||||
src={
|
className="object-cover w-full h-full hover:scale-105 transition-transform duration-300"
|
||||||
profilePhoto
|
/>
|
||||||
? `${API_BASE_URL}${profilePhoto.file_path}`
|
|
||||||
: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1000&q=80"
|
|
||||||
}
|
|
||||||
alt={`Portrait of ${person.full_name || person.surname || "Unnamed"}`}
|
|
||||||
className="object-cover w-full h-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h3 className="text-xl font-bold font-serif text-[#9B2335]">
|
|
||||||
{person.full_name || person.surname || "Unnamed"}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-[#01796F] font-medium">
|
|
||||||
Arrived{" "}
|
|
||||||
{person.migration?.date_of_arrival_nt
|
|
||||||
? new Date(person.migration.date_of_arrival_nt).getFullYear()
|
|
||||||
: "Unknown"}
|
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
|
||||||
{person.additional_notes || "No story available."}
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="mt-3 border-[#9B2335] text-[#9B2335] hover:bg-[#9B2335] hover:text-white"
|
|
||||||
onClick={() => navigate(`/migrant-profile/${person.person_id}`)}
|
|
||||||
>
|
|
||||||
View Full Record
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</CarouselItem>
|
<div className="space-y-2">
|
||||||
)
|
<h3 className="text-lg font-bold text-[#9B2335]">
|
||||||
})}
|
{person.full_name || person.surname || "Unnamed"}
|
||||||
</CarouselContent>
|
</h3>
|
||||||
<CarouselPrevious />
|
<p className="text-sm font-medium">
|
||||||
<CarouselNext />
|
Arrived{" "}
|
||||||
</Carousel>
|
{person.migration?.date_of_arrival_nt
|
||||||
|
? new Date(person.migration.date_of_arrival_nt).getFullYear()
|
||||||
|
: "Unknown"}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm leading-relaxed line-clamp-2">
|
||||||
|
{person.additional_notes || "A story of courage and determination in the Northern Territory."}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="w-full mt-3 bg-[#01796F] hover:bg-[#015a54] text-white"
|
||||||
|
onClick={() => navigate(`/migrant-profile/${person.person_id}`)}
|
||||||
|
>
|
||||||
|
View Full Record
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="about" className="w-full py-12 md:py-24 lg:py-32 bg-gray-50">
|
{/* About Section */}
|
||||||
|
<section id="about" className="w-full py-16 bg-[#E8DCCA]">
|
||||||
<div className="container px-4 md:px-6">
|
<div className="container px-4 md:px-6">
|
||||||
<div className="grid gap-6 lg:grid-cols-2 lg:gap-12 items-center">
|
<div className="max-w-4xl mx-auto text-center">
|
||||||
<div className="space-y-4 text-center lg:text-left">
|
<h2 className="text-3xl font-bold text-[#9B2335] font-serif mb-6">Preserving Our Heritage</h2>
|
||||||
<h2 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl font-serif text-[#1A2A57]">
|
<p className="text-gray-600 text-lg leading-relaxed mb-8">
|
||||||
Preserving Our Heritage
|
This digital archive aims to preserve and celebrate the contributions of Italian migrants to the
|
||||||
</h2>
|
Northern Territory. By documenting their stories, photographs, and historical records, we ensure that
|
||||||
<p className="max-w-[600px] text-muted-foreground md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed mx-auto lg:mx-0">
|
their legacy continues for generations to come.
|
||||||
This digital archive aims to preserve and celebrate the contributions of Italian migrants to the
|
</p>
|
||||||
Northern Territory. By documenting their stories, photographs, and historical records, we ensure that
|
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
their legacy continues for generations to come.
|
<Button className="bg-[#01796F] hover:bg-[#015a54] text-white">
|
||||||
</p>
|
Contribute a Story
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center lg:justify-start">
|
</Button>
|
||||||
<Button className="bg-[#01796F] hover:bg-[#015a54] text-white">Contribute a Story</Button>
|
<Button
|
||||||
<Button
|
variant="outline"
|
||||||
variant="outline"
|
className="border-[#9B2335] text-[#9B2335] hover:bg-gray-100"
|
||||||
className="border-[#9B2335] text-[#9B2335] hover:bg-[#9B2335] hover:text-white"
|
>
|
||||||
>
|
View Gallery
|
||||||
View Gallery
|
</Button>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-full max-w-[500px] mx-auto">
|
|
||||||
<Carousel className="w-full">
|
|
||||||
<CarouselContent>
|
|
||||||
{galleryImages.map((image, index) => (
|
|
||||||
<CarouselItem key={index} className="md:basis-1/2">
|
|
||||||
<div className="p-1">
|
|
||||||
<div className="overflow-hidden rounded-xl aspect-square">
|
|
||||||
<img
|
|
||||||
src={image.src || `${import.meta.env.BASE_URL}assets/placeholder.svg`}
|
|
||||||
alt={image.alt}
|
|
||||||
className="object-cover w-full h-full hover:scale-105 transition-transform duration-300"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CarouselItem>
|
|
||||||
))}
|
|
||||||
</CarouselContent>
|
|
||||||
<CarouselPrevious />
|
|
||||||
<CarouselNext />
|
|
||||||
</Carousel>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Enhanced Footer with Contact Information */}
|
{/* Footer */}
|
||||||
<footer id="contact" className="bg-[#1A2A57] text-white">
|
<footer id="contact" className="bg-[#1A2A57] text-white border-t">
|
||||||
<div className="container px-4 md:px-6">
|
// ... keep existing code (footer content)
|
||||||
{/* Main Footer Content */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 py-12">
|
|
||||||
{/* About Section */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-lg font-bold font-serif text-[#E8DCCA]">Italian Migrants NT</h3>
|
|
||||||
<p className="text-sm text-gray-300 leading-relaxed">
|
|
||||||
Preserving and celebrating the rich heritage of Italian migrants to the Northern Territory through
|
|
||||||
digital archives, personal stories, and historical documentation.
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-300">
|
|
||||||
<Award className="h-4 w-4 text-[#01796F]" />
|
|
||||||
<span>Heritage Australia Recognition</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contact Information */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-lg font-bold font-serif text-[#E8DCCA]">Contact Us</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<MapPin className="h-4 w-4 text-[#01796F] mt-0.5 flex-shrink-0" />
|
|
||||||
<div className="text-sm text-gray-300">
|
|
||||||
<p>Northern Territory Archives Centre</p>
|
|
||||||
<p>Kelsey Crescent, Millner NT 0810</p>
|
|
||||||
<p>Australia</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Phone className="h-4 w-4 text-[#01796F]" />
|
|
||||||
<span className="text-sm text-gray-300">+61 8 8924 7677</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Mail className="h-4 w-4 text-[#01796F]" />
|
|
||||||
<a
|
|
||||||
href="mailto:heritage@italianmigrantsnt.org.au"
|
|
||||||
className="text-sm text-gray-300 hover:text-[#E8DCCA] transition-colors"
|
|
||||||
>
|
|
||||||
heritage@italianmigrantsnt.org.au
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Clock className="h-4 w-4 text-[#01796F]" />
|
|
||||||
<div className="text-sm text-gray-300">
|
|
||||||
<p>Mon-Fri: 9:00 AM - 5:00 PM</p>
|
|
||||||
<p>Sat: 10:00 AM - 2:00 PM</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Links */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-lg font-bold font-serif text-[#E8DCCA]">Quick Links</h3>
|
|
||||||
<nav className="space-y-2">
|
|
||||||
<Link
|
|
||||||
to="/search-results"
|
|
||||||
className="block text-sm text-gray-300 hover:text-[#E8DCCA] transition-colors"
|
|
||||||
>
|
|
||||||
Search Database
|
|
||||||
</Link>
|
|
||||||
<Link to="#" className="block text-sm text-gray-300 hover:text-[#E8DCCA] transition-colors">
|
|
||||||
Contribute a Story
|
|
||||||
</Link>
|
|
||||||
<Link to="/" className="block text-sm text-gray-300 hover:text-[#E8DCCA] transition-colors">
|
|
||||||
Photo Gallery
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
className="block text-sm text-gray-300 hover:text-[#E8DCCA] transition-colors"
|
|
||||||
>
|
|
||||||
Research Help
|
|
||||||
</Link>
|
|
||||||
<Link to="/" className="block text-sm text-gray-300 hover:text-[#E8DCCA] transition-colors">
|
|
||||||
Volunteer
|
|
||||||
</Link>
|
|
||||||
<Link to="/" className="block text-sm text-gray-300 hover:text-[#E8DCCA] transition-colors">
|
|
||||||
Support Us
|
|
||||||
</Link>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Resources & Social */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-lg font-bold font-serif text-[#E8DCCA]">Resources</h3>
|
|
||||||
<nav className="space-y-2">
|
|
||||||
<Link to="/" className="block text-sm text-gray-300 hover:text-[#E8DCCA] transition-colors">
|
|
||||||
FAQ
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
className="block text-sm text-gray-300 hover:text-[#E8DCCA] transition-colors"
|
|
||||||
>
|
|
||||||
Research Guides
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
className="block text-sm text-gray-300 hover:text-[#E8DCCA] transition-colors"
|
|
||||||
>
|
|
||||||
Historical Timeline
|
|
||||||
</Link>
|
|
||||||
<Link to="/" className="block text-sm text-gray-300 hover:text-[#E8DCCA] transition-colors">
|
|
||||||
Partnerships
|
|
||||||
</Link>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="pt-4">
|
|
||||||
<h4 className="text-sm font-semibold text-[#E8DCCA] mb-3">Follow Us</h4>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<a
|
|
||||||
href="https://facebook.com/italianmigrantsnt"
|
|
||||||
className="text-gray-300 hover:text-[#E8DCCA] transition-colors"
|
|
||||||
aria-label="Follow us on Facebook"
|
|
||||||
>
|
|
||||||
<Facebook className="h-5 w-5" />
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://twitter.com/italianmigrantsnt"
|
|
||||||
className="text-gray-300 hover:text-[#E8DCCA] transition-colors"
|
|
||||||
aria-label="Follow us on Twitter"
|
|
||||||
>
|
|
||||||
<Twitter className="h-5 w-5" />
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://instagram.com/italianmigrantsnt"
|
|
||||||
className="text-gray-300 hover:text-[#E8DCCA] transition-colors"
|
|
||||||
aria-label="Follow us on Instagram"
|
|
||||||
>
|
|
||||||
<Instagram className="h-5 w-5" />
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://youtube.com/italianmigrantsnt"
|
|
||||||
className="text-gray-300 hover:text-[#E8DCCA] transition-colors"
|
|
||||||
aria-label="Subscribe to our YouTube channel"
|
|
||||||
>
|
|
||||||
<Youtube className="h-5 w-5" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Database Stats */}
|
|
||||||
<div className="border-t border-gray-600 py-8">
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 text-center">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
<Database className="h-5 w-5 text-[#01796F]" />
|
|
||||||
<span className="text-2xl font-bold text-[#E8DCCA]">{total.toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-300">Total Records</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
<Users className="h-5 w-5 text-[#01796F]" />
|
|
||||||
<span className="text-2xl font-bold text-[#E8DCCA]">2,847</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-300">Families Documented</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
<MapPin className="h-5 w-5 text-[#01796F]" />
|
|
||||||
<span className="text-2xl font-bold text-[#E8DCCA]">156</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-300">Italian Regions</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
<Clock className="h-5 w-5 text-[#01796F]" />
|
|
||||||
<span className="text-2xl font-bold text-[#E8DCCA]">100+</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-300">Years of History</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom Footer */}
|
|
||||||
<div className="border-t border-gray-600 py-6">
|
|
||||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
|
|
||||||
<div className="flex flex-col md:flex-row items-center gap-4 text-sm text-gray-300">
|
|
||||||
<p>© {new Date().getFullYear()} Italian Migrants NT. All rights reserved.</p>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span>Powered by</span>
|
|
||||||
<span className="text-[#E8DCCA] font-semibold">Heritage Digital Archives</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<nav className="flex gap-6">
|
|
||||||
<a href="#" className="text-sm text-gray-300 hover:text-[#E8DCCA] transition-colors">
|
|
||||||
Terms of Service
|
|
||||||
</a>
|
|
||||||
<a href="#" className="text-sm text-gray-300 hover:text-[#E8DCCA] transition-colors">
|
|
||||||
Privacy Policy
|
|
||||||
</a>
|
|
||||||
<a href="#" className="text-sm text-gray-300 hover:text-[#E8DCCA] transition-colors">
|
|
||||||
Accessibility
|
|
||||||
</a>
|
|
||||||
<a href="/login" className="text-sm text-gray-300 hover:text-[#E8DCCA] transition-colors">
|
|
||||||
Admin
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import { MapPin, Calendar, User, Home, Ship, ImageIcon, FileText } from 'lucide-
|
||||||
import apiService from '@/services/apiService';
|
import apiService from '@/services/apiService';
|
||||||
import type { Person } from '@/types/api';
|
import type { Person } from '@/types/api';
|
||||||
import { formatDate } from '@/utils/date';
|
import { formatDate } from '@/utils/date';
|
||||||
const API_BASE_URL = "http://localhost:8000";
|
|
||||||
|
|
||||||
export default function MigrantProfile() {
|
export default function MigrantProfile() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
|
|
@ -66,18 +65,14 @@ export default function MigrantProfile() {
|
||||||
<div className="w-full md:w-1/3 lg:w-1/4">
|
<div className="w-full md:w-1/3 lg:w-1/4">
|
||||||
<div className="sticky top-20 space-y-4">
|
<div className="sticky top-20 space-y-4">
|
||||||
<div className="overflow-hidden rounded-xl border-4 border-white shadow-lg">
|
<div className="overflow-hidden rounded-xl border-4 border-white shadow-lg">
|
||||||
<img
|
<img
|
||||||
src={profilePhoto && profilePhoto.file_path
|
src={profilePhoto && profilePhoto.file_path
|
||||||
? profilePhoto.file_path.startsWith('http')
|
? profilePhoto.file_path.startsWith('http')
|
||||||
? profilePhoto.file_path
|
? profilePhoto.file_path
|
||||||
: `${API_BASE_URL}${profilePhoto.file_path}`
|
: `${apiService.baseURL}${profilePhoto.file_path}`
|
||||||
: `${import.meta.env.BASE_URL}assets/placeholder.svg?height=600&width=450`}
|
: `${import.meta.env.BASE_URL}assets/placeholder.png`}
|
||||||
alt={`${migrant.full_name || 'Migrant photo'}`}
|
alt={`${migrant.full_name || 'Migrant profile'}`}
|
||||||
className="aspect-[3/4] object-cover w-full"
|
className="aspect-[3/4] object-cover w-full"
|
||||||
onError={(e) => {
|
|
||||||
// Handle image loading errors by setting a fallback
|
|
||||||
e.currentTarget.src = `${import.meta.env.BASE_URL}assets/placeholder.svg?height=600&width=450`;
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
|
|
@ -188,13 +183,12 @@ export default function MigrantProfile() {
|
||||||
src={photo.file_path
|
src={photo.file_path
|
||||||
? photo.file_path.startsWith('http')
|
? photo.file_path.startsWith('http')
|
||||||
? photo.file_path
|
? photo.file_path
|
||||||
: `${API_BASE_URL}${photo.file_path}`
|
: `${apiService.baseURL}${photo.file_path}`
|
||||||
: `${import.meta.env.BASE_URL}assets/placeholder.svg?height=400&width=600`}
|
: `${import.meta.env.BASE_URL}assets/placeholder.png?height=400&width=600`}
|
||||||
alt={photo.caption || "Migrant photo"}
|
alt={photo.caption || "Migrant photo"}
|
||||||
className="aspect-video object-cover w-full"
|
className="aspect-video object-cover w-full"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
// Handle image loading errors
|
e.currentTarget.src = `${import.meta.env.BASE_URL}assets/placeholder.png?height=400&width=600`;
|
||||||
e.currentTarget.src = `${import.meta.env.BASE_URL}assets/placeholder.svg?height=400&width=600`;
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
|
|
||||||
import { Search, ChevronDown, ChevronUp } from 'lucide-react';
|
import { Search, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
import { Input } from '../ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Label } from '../ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { useSearch } from '@/hooks/useSearch';
|
import { useSearch } from '@/hooks/useSearch';
|
||||||
|
|
||||||
const advancedSearchFields = [
|
const advancedSearchFields = [
|
||||||
|
|
@ -34,67 +35,86 @@ export default function SearchForm() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-6xl mx-auto p-4 space-y-6">
|
<div className="w-full space-y-6">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
{/* Main Search */}
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
<Input
|
<div className="flex-1">
|
||||||
className='text-[#000] font-bold'
|
<Input
|
||||||
id="searchTerm"
|
id="searchTerm"
|
||||||
placeholder="Search by name..."
|
placeholder="Search by name..."
|
||||||
value={fields.searchTerm}
|
value={fields.searchTerm}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
/>
|
className="h-12 bg-white border-gray-300 focus:border-[#01796F] focus:ring-1 focus:ring-[#01796F] placeholder:text-gray-500 text-gray-900 rounded-md transition-all duration-300"
|
||||||
<Button type="submit" className="bg-[#9B2335] hover:bg-[#7a1c2a] text-white">
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="h-12 px-6 bg-[#01796F] hover:bg-[#015a54] text-white font-medium rounded-md shadow-md transition-all duration-300 border-0"
|
||||||
|
>
|
||||||
<Search className="mr-2 h-4 w-4" />
|
<Search className="mr-2 h-4 w-4" />
|
||||||
Search
|
Search
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-center">
|
{/* Advanced Search Toggle */}
|
||||||
|
<div className="text-center">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-[#01796F]"
|
className="text-[#9B2335] hover:text-[#7a1c29] hover:bg-gray-100 rounded-md px-6 py-2 transition-all duration-300"
|
||||||
onClick={toggleAdvancedSearch}
|
onClick={toggleAdvancedSearch}
|
||||||
>
|
>
|
||||||
{isAdvancedSearch ? (
|
{isAdvancedSearch ? (
|
||||||
<>
|
<>
|
||||||
<ChevronUp className="mr-2 h-4 w-4" />
|
<ChevronUp className="mr-2 h-4 w-4" />
|
||||||
Simple Search
|
Hide Advanced Search
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ChevronDown className="mr-2 h-4 w-4" />
|
<ChevronDown className="mr-2 h-4 w-4" />
|
||||||
Advanced Search
|
Show Advanced Search
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced Search Fields */}
|
||||||
{isAdvancedSearch && (
|
{isAdvancedSearch && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4">
|
<div className="bg-gray-50 rounded-md p-6 border border-gray-200 space-y-6">
|
||||||
{advancedSearchFields.map(({ id, label, placeholder }) => (
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div key={id} className="space-y-2">
|
{advancedSearchFields.map(({ id, label, placeholder }) => (
|
||||||
<Label htmlFor={id}>{label}</Label>
|
<div key={id} className="space-y-2">
|
||||||
<Input
|
<Label
|
||||||
id={id}
|
htmlFor={id}
|
||||||
placeholder={placeholder}
|
className="text-sm font-medium text-gray-700"
|
||||||
value={fields[id as keyof typeof fields]}
|
>
|
||||||
onChange={handleInputChange}
|
{label}
|
||||||
/>
|
</Label>
|
||||||
</div>
|
<Input
|
||||||
))}
|
id={id}
|
||||||
<div className="space-y-2 md:col-span-2">
|
placeholder={placeholder}
|
||||||
<Label>Actions</Label>
|
value={fields[id as keyof typeof fields]}
|
||||||
<Button type="button" variant="ghost" className="w-full" onClick={clearAllFields}>
|
onChange={handleInputChange}
|
||||||
|
className="bg-white border-gray-300 focus:border-[#01796F] focus:ring-1 focus:ring-[#01796F] placeholder:text-gray-500 text-gray-900 rounded-md transition-all duration-300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="bg-white border-gray-300 text-gray-700 hover:bg-gray-100 hover:border-gray-400 rounded-md transition-all duration-300"
|
||||||
|
onClick={clearAllFields}
|
||||||
|
>
|
||||||
Clear All Filters
|
Clear All Filters
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -75,7 +75,7 @@ export default function SearchResults() {
|
||||||
src={
|
src={
|
||||||
migrant.profilePhoto
|
migrant.profilePhoto
|
||||||
? `${API_BASE_URL}${migrant.profilePhoto.file_path}`
|
? `${API_BASE_URL}${migrant.profilePhoto.file_path}`
|
||||||
: `${import.meta.env.BASE_URL}assets/placeholder.svg?height=300&width=300`
|
: `${import.meta.env.BASE_URL}assets/placeholder.png`
|
||||||
}
|
}
|
||||||
alt={migrant.full_name || "Unknown"}
|
alt={migrant.full_name || "Unknown"}
|
||||||
className="w-full h-full object-cover object-center transition-transform hover:scale-105"
|
className="w-full h-full object-cover object-center transition-transform hover:scale-105"
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,8 @@ export default function Sidebar() {
|
||||||
async function loadUser() {
|
async function loadUser() {
|
||||||
try {
|
try {
|
||||||
const currentUser = await apiService.fetchCurrentUser();
|
const currentUser = await apiService.fetchCurrentUser();
|
||||||
console.log("Fetched user:", currentUser);
|
|
||||||
setUser(currentUser);
|
setUser(currentUser);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch user info:", error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadUser();
|
loadUser();
|
||||||
|
|
|
||||||
|
|
@ -255,7 +255,7 @@ export default function ProfileSettings() {
|
||||||
<div className="h-24 w-24 rounded-full bg-gray-200 overflow-hidden">
|
<div className="h-24 w-24 rounded-full bg-gray-200 overflow-hidden">
|
||||||
{profile.avatar ? (
|
{profile.avatar ? (
|
||||||
<img
|
<img
|
||||||
src={profile.avatar || `${import.meta.env.BASE_URL}assets/placeholder.svg`}
|
src={profile.avatar || `${import.meta.env.BASE_URL}assets/placeholder.png`}
|
||||||
alt="Profile"
|
alt="Profile"
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
|
|
||||||
interface AnimatedImageProps {
|
|
||||||
src: string;
|
|
||||||
alt: string;
|
|
||||||
className?: string;
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
fill?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AnimatedImage({
|
|
||||||
src,
|
|
||||||
alt,
|
|
||||||
className = "",
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
fill = false,
|
|
||||||
}: AnimatedImageProps) {
|
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
className={`relative overflow-hidden ${
|
|
||||||
fill ? "h-full w-full" : ""
|
|
||||||
} ${className}`}
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: isLoaded ? 1 : 0 }}
|
|
||||||
transition={{ duration: 0.5 }}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={src || `${import.meta.env.BASE_URL}assets/placeholder.svg`}
|
|
||||||
alt={alt}
|
|
||||||
width={!fill ? width : undefined}
|
|
||||||
height={!fill ? height : undefined}
|
|
||||||
className={`object-cover ${fill ? "h-full w-full" : ""}`}
|
|
||||||
onLoad={() => setIsLoaded(true)}
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
import apiService from '@/services/apiService'
|
||||||
|
import { showErrorToast } from '@/utils/toast'
|
||||||
|
|
||||||
|
export const useMigrantData = (
|
||||||
|
populateFormData: (data: any) => void,
|
||||||
|
populatePhotoData: (data: any) => void
|
||||||
|
) => {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [initialDataLoaded, setInitialDataLoaded] = useState(false)
|
||||||
|
|
||||||
|
const isEditMode = Boolean(id)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditMode && id && !initialDataLoaded) {
|
||||||
|
loadExistingData()
|
||||||
|
}
|
||||||
|
}, [id, isEditMode, initialDataLoaded])
|
||||||
|
|
||||||
|
const loadExistingData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
const personId = Number.parseInt(id!, 10)
|
||||||
|
if (isNaN(personId)) {
|
||||||
|
throw new Error("Invalid person ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
const migrantData = await apiService.getMigrantById(personId)
|
||||||
|
|
||||||
|
populateFormData(migrantData)
|
||||||
|
populatePhotoData(migrantData)
|
||||||
|
|
||||||
|
setInitialDataLoaded(true)
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast("Failed to load migrant data for editing.")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
isEditMode,
|
||||||
|
initialDataLoaded,
|
||||||
|
setInitialDataLoaded: (loaded: boolean) => setInitialDataLoaded(loaded),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,263 @@
|
||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import type { Person, Migration, Naturalization, Residence, Family, Internment } from '@/types/api'
|
||||||
|
import { PersonSchema, MigrationSchema, NaturalizationSchema, ResidenceSchema, FamilySchema, InternmentSchema, MigrantFormSchema} from '@/schemas/migrantSchema'
|
||||||
|
|
||||||
|
const getInitialPersonState = (): Person => ({
|
||||||
|
surname: "",
|
||||||
|
christian_name: "",
|
||||||
|
date_of_birth: "",
|
||||||
|
place_of_birth: "",
|
||||||
|
date_of_death: "",
|
||||||
|
occupation: "",
|
||||||
|
additional_notes: "",
|
||||||
|
reference: "",
|
||||||
|
id_card_no: "",
|
||||||
|
person_id: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const getInitialMigrationState = (): Migration => ({
|
||||||
|
date_of_arrival_aus: "",
|
||||||
|
date_of_arrival_nt: "",
|
||||||
|
arrival_period: "",
|
||||||
|
data_source: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
const getInitialNaturalizationState = (): Naturalization => ({
|
||||||
|
date_of_naturalisation: "",
|
||||||
|
no_of_cert: "",
|
||||||
|
issued_at: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
const getInitialResidenceState = (): Residence => ({
|
||||||
|
town_or_city: "",
|
||||||
|
home_at_death: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
const getInitialFamilyState = (): Family => ({
|
||||||
|
names_of_parents: "",
|
||||||
|
names_of_children: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
const getInitialInternmentState = (): Internment => ({
|
||||||
|
corps_issued: "",
|
||||||
|
interned_in: "",
|
||||||
|
sent_to: "",
|
||||||
|
internee_occupation: "",
|
||||||
|
internee_address: "",
|
||||||
|
cav: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useMigrantForm = () => {
|
||||||
|
const [person, setPerson] = useState<Person>(getInitialPersonState())
|
||||||
|
const [migration, setMigration] = useState<Migration>(getInitialMigrationState())
|
||||||
|
const [naturalization, setNaturalization] = useState<Naturalization>(getInitialNaturalizationState())
|
||||||
|
const [residence, setResidence] = useState<Residence>(getInitialResidenceState())
|
||||||
|
const [family, setFamily] = useState<Family>(getInitialFamilyState())
|
||||||
|
const [internment, setInternment] = useState<Internment>(getInitialInternmentState())
|
||||||
|
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({})
|
||||||
|
const [isFormValid, setIsFormValid] = useState(false)
|
||||||
|
|
||||||
|
const validateForm = useCallback(() => {
|
||||||
|
try {
|
||||||
|
const formData = {
|
||||||
|
person,
|
||||||
|
migration,
|
||||||
|
naturalization,
|
||||||
|
residence,
|
||||||
|
family,
|
||||||
|
internment,
|
||||||
|
}
|
||||||
|
|
||||||
|
MigrantFormSchema.parse(formData)
|
||||||
|
setValidationErrors({})
|
||||||
|
setIsFormValid(true)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
const errors: Record<string, string> = {}
|
||||||
|
error.errors.forEach((err) => {
|
||||||
|
const path = err.path.join('.')
|
||||||
|
errors[path] = err.message
|
||||||
|
})
|
||||||
|
setValidationErrors(errors)
|
||||||
|
setIsFormValid(false)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
setIsFormValid(false)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, [person, migration, naturalization, residence, family, internment])
|
||||||
|
|
||||||
|
const validateSection = useCallback((section: 'person' | 'migration' | 'naturalization' | 'residence' | 'family' | 'internment') => {
|
||||||
|
try {
|
||||||
|
const schemas = {
|
||||||
|
person: PersonSchema,
|
||||||
|
migration: MigrationSchema,
|
||||||
|
naturalization: NaturalizationSchema,
|
||||||
|
residence: ResidenceSchema,
|
||||||
|
family: FamilySchema,
|
||||||
|
internment: InternmentSchema,
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
person,
|
||||||
|
migration,
|
||||||
|
naturalization,
|
||||||
|
residence,
|
||||||
|
family,
|
||||||
|
internment,
|
||||||
|
}
|
||||||
|
|
||||||
|
schemas[section].parse(data[section])
|
||||||
|
|
||||||
|
// Clear errors for this section
|
||||||
|
setValidationErrors(prev => {
|
||||||
|
const updated = { ...prev }
|
||||||
|
Object.keys(updated).forEach(key => {
|
||||||
|
if (key.startsWith(section)) {
|
||||||
|
delete updated[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
const errors: Record<string, string> = {}
|
||||||
|
error.errors.forEach((err) => {
|
||||||
|
const path = `${section}.${err.path.join('.')}`
|
||||||
|
errors[path] = err.message
|
||||||
|
})
|
||||||
|
setValidationErrors(prev => ({ ...prev, ...errors }))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, [person, migration, naturalization, residence, family, internment])
|
||||||
|
|
||||||
|
// Helper function to get error for a specific field
|
||||||
|
const getFieldError = useCallback((fieldPath: string) => {
|
||||||
|
return validationErrors[fieldPath] || null
|
||||||
|
}, [validationErrors])
|
||||||
|
|
||||||
|
// Helper function to check if a field has an error
|
||||||
|
const hasFieldError = useCallback((fieldPath: string) => {
|
||||||
|
return !!validationErrors[fieldPath]
|
||||||
|
}, [validationErrors])
|
||||||
|
|
||||||
|
const resetForm = useCallback(() => {
|
||||||
|
setPerson(getInitialPersonState())
|
||||||
|
setMigration(getInitialMigrationState())
|
||||||
|
setNaturalization(getInitialNaturalizationState())
|
||||||
|
setResidence(getInitialResidenceState())
|
||||||
|
setFamily(getInitialFamilyState())
|
||||||
|
setInternment(getInitialInternmentState())
|
||||||
|
setValidationErrors({})
|
||||||
|
setIsFormValid(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const populateFormData = useCallback((migrantData: any) => {
|
||||||
|
const formatDate = (dateString: string | null | undefined): string => {
|
||||||
|
if (!dateString) return ""
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toISOString().split("T")[0]
|
||||||
|
} catch {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setPerson({
|
||||||
|
surname: migrantData.surname || "",
|
||||||
|
christian_name: migrantData.christian_name || "",
|
||||||
|
date_of_birth: formatDate(migrantData.date_of_birth),
|
||||||
|
place_of_birth: migrantData.place_of_birth || "",
|
||||||
|
date_of_death: formatDate(migrantData.date_of_death),
|
||||||
|
occupation: migrantData.occupation || "",
|
||||||
|
additional_notes: migrantData.additional_notes || "",
|
||||||
|
reference: migrantData.reference || "",
|
||||||
|
id_card_no: migrantData.id_card_no || "",
|
||||||
|
person_id: migrantData.person_id || 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (migrantData.migration) {
|
||||||
|
setMigration({
|
||||||
|
date_of_arrival_aus: formatDate(migrantData.migration.date_of_arrival_aus),
|
||||||
|
date_of_arrival_nt: formatDate(migrantData.migration.date_of_arrival_nt),
|
||||||
|
arrival_period: migrantData.migration.arrival_period || "",
|
||||||
|
data_source: migrantData.migration.data_source || "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (migrantData.naturalization) {
|
||||||
|
setNaturalization({
|
||||||
|
date_of_naturalisation: formatDate(migrantData.naturalization.date_of_naturalisation),
|
||||||
|
no_of_cert: migrantData.naturalization.no_of_cert || "",
|
||||||
|
issued_at: migrantData.naturalization.issued_at || "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (migrantData.residence) {
|
||||||
|
setResidence({
|
||||||
|
town_or_city: migrantData.residence.town_or_city || "",
|
||||||
|
home_at_death: migrantData.residence.home_at_death || "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (migrantData.family) {
|
||||||
|
setFamily({
|
||||||
|
names_of_parents: migrantData.family.names_of_parents || "",
|
||||||
|
names_of_children: migrantData.family.names_of_children || "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (migrantData.internment) {
|
||||||
|
setInternment({
|
||||||
|
corps_issued: migrantData.internment.corps_issued || "",
|
||||||
|
interned_in: migrantData.internment.interned_in || "",
|
||||||
|
sent_to: migrantData.internment.sent_to || "",
|
||||||
|
internee_occupation: migrantData.internment.internee_occupation || "",
|
||||||
|
internee_address: migrantData.internment.internee_address || "",
|
||||||
|
cav: migrantData.internment.cav || "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setValidationErrors({})
|
||||||
|
setIsFormValid(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
formData: {
|
||||||
|
person,
|
||||||
|
migration,
|
||||||
|
naturalization,
|
||||||
|
residence,
|
||||||
|
family,
|
||||||
|
internment,
|
||||||
|
},
|
||||||
|
setters: {
|
||||||
|
setPerson,
|
||||||
|
setMigration,
|
||||||
|
setNaturalization,
|
||||||
|
setResidence,
|
||||||
|
setFamily,
|
||||||
|
setInternment,
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
validateForm,
|
||||||
|
validateSection,
|
||||||
|
validationErrors,
|
||||||
|
isFormValid,
|
||||||
|
getFieldError,
|
||||||
|
hasFieldError,
|
||||||
|
clearErrors: () => {
|
||||||
|
setValidationErrors({})
|
||||||
|
setIsFormValid(false)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resetForm,
|
||||||
|
populateFormData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import apiService from "@/services/apiService";
|
import apiService from "@/services/apiService";
|
||||||
import type { Person, Photo } from "@/types/api";
|
import type { Person } from "@/types/api";
|
||||||
|
|
||||||
const getSearchParamsObject = (params: URLSearchParams): Record<string, string> => {
|
const getSearchParamsObject = (params: URLSearchParams): Record<string, string> => {
|
||||||
const result: Record<string, string> = {};
|
const result: Record<string, string> = {};
|
||||||
|
|
@ -11,11 +11,9 @@ const getSearchParamsObject = (params: URLSearchParams): Record<string, string>
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export function useMigrantsSearch(perPage = 10) {
|
export function useMigrantsSearch(perPage = 10) {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const [migrants, setMigrants] = useState<Person[]>([]);
|
const [migrants, setMigrants] = useState<Person[]>([]);
|
||||||
const [photosById, setPhotosById] = useState<Record<number, Photo[]>>({});
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [page, setPage] = useState<number>(1);
|
const [page, setPage] = useState<number>(1);
|
||||||
|
|
@ -27,48 +25,57 @@ export function useMigrantsSearch(perPage = 10) {
|
||||||
|
|
||||||
const hasActiveFilters = searchParams.toString().length > 0;
|
const hasActiveFilters = searchParams.toString().length > 0;
|
||||||
|
|
||||||
|
const fetchPhotosForMigrant = async (migrant: Person): Promise<Person> => {
|
||||||
|
if (migrant.person_id === undefined) {
|
||||||
|
console.warn("Missing person_id for migrant", migrant);
|
||||||
|
return { ...migrant, photos: [], profilePhoto: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const details = await apiService.getMigrantById(migrant.person_id);
|
||||||
|
if (Array.isArray(details?.photos)) {
|
||||||
|
return {
|
||||||
|
...migrant,
|
||||||
|
photos: details.photos,
|
||||||
|
profilePhoto: details.photos.find((p) => p.is_profile_photo) || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to direct photo fetch
|
||||||
|
const photos = await apiService.getPhotos(migrant.person_id);
|
||||||
|
return {
|
||||||
|
...migrant,
|
||||||
|
photos,
|
||||||
|
profilePhoto: photos.find((p) => p.is_profile_photo) || null,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to fetch photos for person ${migrant.person_id}`, err);
|
||||||
|
return { ...migrant, photos: [], profilePhoto: null };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchMigrants = useCallback(async () => {
|
const fetchMigrants = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const filters = getSearchParamsObject(searchParams);
|
const filters = getSearchParamsObject(searchParams);
|
||||||
const response = await apiService.getMigrants(page, perPage, filters);
|
const response = await apiService.getMigrants(page, perPage, filters);
|
||||||
|
|
||||||
const fetchedMigrants = response.data || [];
|
const rawMigrants = response.data || [];
|
||||||
setMigrants(fetchedMigrants);
|
|
||||||
|
const enrichedMigrants = await Promise.all(
|
||||||
|
rawMigrants.map((migrant: Person) => fetchPhotosForMigrant(migrant))
|
||||||
|
);
|
||||||
|
|
||||||
|
setMigrants(enrichedMigrants);
|
||||||
setPagination({
|
setPagination({
|
||||||
currentPage: response.current_page ?? 1,
|
currentPage: response.current_page ?? 1,
|
||||||
totalPages: response.last_page ?? 1,
|
totalPages: response.last_page ?? 1,
|
||||||
totalItems: response.total ?? 0,
|
totalItems: response.total ?? 0,
|
||||||
});
|
});
|
||||||
// Fetch photos for each migrant
|
|
||||||
const photosMap: Record<number, Photo[]> = {};
|
|
||||||
await Promise.all(
|
|
||||||
(fetchedMigrants || []).map(async (migrant: Person) => {
|
|
||||||
try {
|
|
||||||
if (migrant.person_id === undefined) {
|
|
||||||
console.warn('Missing person_id for migrant', migrant);
|
|
||||||
migrant.photos = [];
|
|
||||||
migrant.profilePhoto = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const photos = await apiService.getPhotos(migrant.person_id);
|
|
||||||
migrant.photos = photos; // Store all photos
|
|
||||||
migrant.profilePhoto = photos.find((photo) => photo.is_profile_photo) || null;
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to fetch photos for person ${migrant.person_id}`, err);
|
|
||||||
migrant.photos = [];
|
|
||||||
migrant.profilePhoto = null;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
setPhotosById(photosMap);
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch migrants", err);
|
||||||
setError("Failed to fetch migrants data.");
|
setError("Failed to fetch migrants data.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
@ -93,7 +100,6 @@ export function useMigrantsSearch(perPage = 10) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
migrants,
|
migrants,
|
||||||
photosById, // expose photos by person_id
|
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
pagination,
|
pagination,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import type { ExistingPhoto } from '@/types/api'
|
||||||
|
|
||||||
|
export const usePhotoManagement = () => {
|
||||||
|
const [photos, setPhotos] = useState<File[]>([])
|
||||||
|
const [photoPreviews, setPhotoPreviews] = useState<string[]>([])
|
||||||
|
const [captions, setCaptions] = useState<string[]>([])
|
||||||
|
const [mainPhotoIndex, setMainPhotoIndex] = useState<number | null>(null)
|
||||||
|
const [existingPhotos, setExistingPhotos] = useState<ExistingPhoto[]>([])
|
||||||
|
const [removedPhotoIds, setRemovedPhotoIds] = useState<number[]>([])
|
||||||
|
|
||||||
|
const handlePhotoChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
|
const selectedFiles = Array.from(e.target.files)
|
||||||
|
|
||||||
|
setPhotos(prev => [...prev, ...selectedFiles])
|
||||||
|
|
||||||
|
const newPreviews = selectedFiles.map(file => URL.createObjectURL(file))
|
||||||
|
setPhotoPreviews(prev => [...prev, ...newPreviews])
|
||||||
|
|
||||||
|
setCaptions(prev => {
|
||||||
|
const newCaptions = [...prev]
|
||||||
|
selectedFiles.forEach(() => newCaptions.push(''))
|
||||||
|
return newCaptions
|
||||||
|
})
|
||||||
|
|
||||||
|
if (mainPhotoIndex === null) {
|
||||||
|
setMainPhotoIndex(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
e.target.value = ''
|
||||||
|
}, [mainPhotoIndex])
|
||||||
|
|
||||||
|
const updateCaption = useCallback((index: number, newCaption: string) => {
|
||||||
|
setCaptions(prev => {
|
||||||
|
const updated = [...prev]
|
||||||
|
updated[index] = newCaption
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setAsMainPhoto = useCallback((index: number) => {
|
||||||
|
setMainPhotoIndex(index)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const removeExistingPhoto = useCallback((index: number) => {
|
||||||
|
const photoToRemove = existingPhotos[index]
|
||||||
|
const wasMainPhoto = mainPhotoIndex === index
|
||||||
|
|
||||||
|
if (photoToRemove?.id) {
|
||||||
|
setRemovedPhotoIds(prev => [...prev, photoToRemove.id])
|
||||||
|
}
|
||||||
|
|
||||||
|
setExistingPhotos(prev => prev.filter((_, i) => i !== index))
|
||||||
|
|
||||||
|
setCaptions(prev => {
|
||||||
|
const newCaptions = [...prev]
|
||||||
|
newCaptions.splice(index, 1)
|
||||||
|
return newCaptions
|
||||||
|
})
|
||||||
|
|
||||||
|
if (wasMainPhoto) {
|
||||||
|
const remainingExistingPhotos = existingPhotos.length - 1
|
||||||
|
const totalRemainingPhotos = remainingExistingPhotos + photos.length
|
||||||
|
|
||||||
|
if (totalRemainingPhotos > 0) {
|
||||||
|
setMainPhotoIndex(0)
|
||||||
|
} else {
|
||||||
|
setMainPhotoIndex(null)
|
||||||
|
}
|
||||||
|
} else if (mainPhotoIndex !== null && index < mainPhotoIndex) {
|
||||||
|
setMainPhotoIndex(mainPhotoIndex - 1)
|
||||||
|
}
|
||||||
|
}, [existingPhotos, mainPhotoIndex, photos.length])
|
||||||
|
|
||||||
|
const removeNewPhoto = useCallback((photoIndex: number) => {
|
||||||
|
const totalIndex = existingPhotos.length + photoIndex
|
||||||
|
const wasMainPhoto = mainPhotoIndex === totalIndex
|
||||||
|
|
||||||
|
setPhotos(prev => prev.filter((_, i) => i !== photoIndex))
|
||||||
|
setPhotoPreviews(prev => prev.filter((_, i) => i !== photoIndex))
|
||||||
|
|
||||||
|
setCaptions(prev => {
|
||||||
|
const newCaptions = [...prev]
|
||||||
|
newCaptions.splice(totalIndex, 1)
|
||||||
|
return newCaptions
|
||||||
|
})
|
||||||
|
|
||||||
|
if (wasMainPhoto) {
|
||||||
|
const totalRemainingPhotos = existingPhotos.length + (photos.length - 1)
|
||||||
|
|
||||||
|
if (totalRemainingPhotos > 0) {
|
||||||
|
setMainPhotoIndex(0)
|
||||||
|
} else {
|
||||||
|
setMainPhotoIndex(null)
|
||||||
|
}
|
||||||
|
} else if (mainPhotoIndex !== null && mainPhotoIndex > totalIndex) {
|
||||||
|
setMainPhotoIndex(mainPhotoIndex - 1)
|
||||||
|
}
|
||||||
|
}, [existingPhotos.length, mainPhotoIndex, photos.length])
|
||||||
|
|
||||||
|
const populatePhotoData = useCallback((migrantData: any) => {
|
||||||
|
if (migrantData.photos && Array.isArray(migrantData.photos) && migrantData.photos.length > 0) {
|
||||||
|
const photoData = migrantData.photos as ExistingPhoto[]
|
||||||
|
setExistingPhotos(photoData)
|
||||||
|
|
||||||
|
const existingCaptions = photoData.map((photo: ExistingPhoto) => photo.caption || "")
|
||||||
|
setCaptions(existingCaptions)
|
||||||
|
|
||||||
|
const mainPhotoIdx = photoData.findIndex((photo: ExistingPhoto) => photo.is_profile_photo)
|
||||||
|
if (mainPhotoIdx !== -1) {
|
||||||
|
setMainPhotoIndex(mainPhotoIdx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const resetPhotos = useCallback(() => {
|
||||||
|
setPhotos([])
|
||||||
|
setPhotoPreviews([])
|
||||||
|
setCaptions([])
|
||||||
|
setMainPhotoIndex(null)
|
||||||
|
setExistingPhotos([])
|
||||||
|
setRemovedPhotoIds([])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
photos,
|
||||||
|
photoPreviews,
|
||||||
|
captions,
|
||||||
|
mainPhotoIndex,
|
||||||
|
existingPhotos,
|
||||||
|
removedPhotoIds,
|
||||||
|
handlePhotoChange,
|
||||||
|
updateCaption,
|
||||||
|
setAsMainPhoto,
|
||||||
|
removeExistingPhoto,
|
||||||
|
removeNewPhoto,
|
||||||
|
setCaptions,
|
||||||
|
setMainPhotoIndex,
|
||||||
|
populatePhotoData,
|
||||||
|
resetPhotos,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,211 +0,0 @@
|
||||||
import { useState } from "react"
|
|
||||||
import { z } from "zod"
|
|
||||||
import apiService from "@/services/apiService"
|
|
||||||
|
|
||||||
// Schema types
|
|
||||||
export interface PersonData {
|
|
||||||
surname: string
|
|
||||||
christian_name: string
|
|
||||||
date_of_birth: string
|
|
||||||
place_of_birth: string
|
|
||||||
date_of_death: string
|
|
||||||
occupation: string
|
|
||||||
additional_notes: string
|
|
||||||
reference: string
|
|
||||||
id_card_no: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MigrationData {
|
|
||||||
date_of_arrival_aus: string
|
|
||||||
date_of_arrival_nt: string
|
|
||||||
arrival_period: string
|
|
||||||
data_source: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NaturalizationData {
|
|
||||||
date_of_naturalisation: string
|
|
||||||
no_of_cert: string
|
|
||||||
issued_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ResidenceData {
|
|
||||||
town_or_city: string
|
|
||||||
home_at_death: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FamilyData {
|
|
||||||
names_of_parents: string
|
|
||||||
names_of_children: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InternmentData {
|
|
||||||
corps_issued: string
|
|
||||||
interned_in: string
|
|
||||||
sent_to: string
|
|
||||||
internee_occupation: string
|
|
||||||
internee_address: string
|
|
||||||
cav: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zod schemas for validation
|
|
||||||
export const personSchema = z.object({
|
|
||||||
surname: z.string().min(1, "Surname is required"),
|
|
||||||
christian_name: z.string().min(1, "Christian Name is required"),
|
|
||||||
date_of_birth: z.string().min(1, "Date of Birth is required"),
|
|
||||||
place_of_birth: z.string().min(1, "Place of Birth is required"),
|
|
||||||
date_of_death: z.string().min(1, "Date of Death is required"),
|
|
||||||
occupation: z.string().min(1, "Occupation is required"),
|
|
||||||
additional_notes: z.string().min(1, "Notes are required"),
|
|
||||||
reference: z.string().min(1, "Reference is required"),
|
|
||||||
id_card_no: z.string().min(1, "ID Card No is required"),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const migrationSchema = z.object({
|
|
||||||
date_of_arrival_aus: z.string().min(1, "Date of Arrival in Australia is required"),
|
|
||||||
date_of_arrival_nt: z.string().min(1, "Date of Arrival in NT is required"),
|
|
||||||
arrival_period: z.string().min(1, "Arrival Period is required"),
|
|
||||||
data_source: z.string().min(1, "Data Source is required"),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const naturalizationSchema = z.object({
|
|
||||||
date_of_naturalisation: z.string().min(1, "Date of Naturalisation is required"),
|
|
||||||
no_of_cert: z.string().min(1, "Number of Certificate is required"),
|
|
||||||
issued_at: z.string().min(1, "Issued At is required"),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const residenceSchema = z.object({
|
|
||||||
town_or_city: z.string().min(1, "Town or City is required"),
|
|
||||||
home_at_death: z.string().min(1, "Home at Death is required"),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const familySchema = z.object({
|
|
||||||
names_of_parents: z.string().min(1, "Names of Parents are required"),
|
|
||||||
names_of_children: z.string().min(1, "Names of Children are required"),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const internmentSchema = z.object({
|
|
||||||
corps_issued: z.string().min(1, "Corps Issued is required"),
|
|
||||||
interned_in: z.string().min(1, "Interned In is required"),
|
|
||||||
sent_to: z.string().min(1, "Sent To is required"),
|
|
||||||
internee_occupation: z.string().min(1, "Internee Occupation is required"),
|
|
||||||
internee_address: z.string().min(1, "Internee Address is required"),
|
|
||||||
cav: z.string().min(1, "CAV is required"),
|
|
||||||
})
|
|
||||||
|
|
||||||
interface UseTabsPaneFormSubmitParams {
|
|
||||||
person: PersonData
|
|
||||||
migration: MigrationData
|
|
||||||
naturalization: NaturalizationData
|
|
||||||
residence: ResidenceData
|
|
||||||
family: FamilyData
|
|
||||||
internment: InternmentData
|
|
||||||
photos: File[]
|
|
||||||
captions: string[]
|
|
||||||
existingPhotos: any[]
|
|
||||||
mainPhotoIndex: number | null
|
|
||||||
isEditMode: boolean
|
|
||||||
id?: string
|
|
||||||
onReset: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useTabsPaneFormSubmit = ({
|
|
||||||
person,
|
|
||||||
migration,
|
|
||||||
naturalization,
|
|
||||||
residence,
|
|
||||||
family,
|
|
||||||
internment,
|
|
||||||
photos,
|
|
||||||
captions,
|
|
||||||
existingPhotos,
|
|
||||||
mainPhotoIndex,
|
|
||||||
isEditMode,
|
|
||||||
id,
|
|
||||||
onReset,
|
|
||||||
}: UseTabsPaneFormSubmitParams) => {
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true)
|
|
||||||
|
|
||||||
// Validate all form data using zod schemas
|
|
||||||
personSchema.parse(person)
|
|
||||||
migrationSchema.parse(migration)
|
|
||||||
naturalizationSchema.parse(naturalization)
|
|
||||||
residenceSchema.parse(residence)
|
|
||||||
familySchema.parse(family)
|
|
||||||
internmentSchema.parse(internment)
|
|
||||||
|
|
||||||
const formData = new FormData()
|
|
||||||
|
|
||||||
// Add person data
|
|
||||||
Object.entries(person).forEach(([key, value]) => formData.append(key, value))
|
|
||||||
|
|
||||||
// Add section data
|
|
||||||
Object.entries(migration).forEach(([key, value]) => formData.append(`migration[${key}]`, value))
|
|
||||||
Object.entries(naturalization).forEach(([key, value]) => formData.append(`naturalization[${key}]`, value))
|
|
||||||
Object.entries(residence).forEach(([key, value]) => formData.append(`residence[${key}]`, value))
|
|
||||||
Object.entries(family).forEach(([key, value]) => formData.append(`family[${key}]`, value))
|
|
||||||
Object.entries(internment).forEach(([key, value]) => formData.append(`internment[${key}]`, value))
|
|
||||||
|
|
||||||
photos.forEach((file) => formData.append("photos[]", file))
|
|
||||||
|
|
||||||
captions.forEach((caption, index) => formData.append(`captions[${index}]`, caption))
|
|
||||||
if (mainPhotoIndex !== null) {
|
|
||||||
formData.append("main_photo_index", mainPhotoIndex.toString())
|
|
||||||
formData.append("set_as_profile", "true")
|
|
||||||
|
|
||||||
// Determine if it's an existing photo or new photo
|
|
||||||
if (mainPhotoIndex < existingPhotos.length) {
|
|
||||||
// It's an existing photo
|
|
||||||
formData.append("profile_photo_id", existingPhotos[mainPhotoIndex].id.toString())
|
|
||||||
} else {
|
|
||||||
// It's a new photo
|
|
||||||
formData.append("profile_photo_index", (mainPhotoIndex - existingPhotos.length).toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add existing photo IDs that should be kept
|
|
||||||
existingPhotos.forEach((photo, index) => {
|
|
||||||
formData.append(`existing_photos[${index}]`, photo.id.toString())
|
|
||||||
})
|
|
||||||
|
|
||||||
let response
|
|
||||||
if (isEditMode && id) {
|
|
||||||
// Convert string ID to number for the API call
|
|
||||||
const personId = Number.parseInt(id, 10)
|
|
||||||
if (isNaN(personId)) {
|
|
||||||
throw new Error("Invalid person ID")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update existing migrant
|
|
||||||
response = await apiService.updateMigrant(personId, formData)
|
|
||||||
alert("Person updated successfully!")
|
|
||||||
} else {
|
|
||||||
// Create new migrant
|
|
||||||
response = await apiService.createMigrant(formData)
|
|
||||||
alert("Person created successfully!")
|
|
||||||
onReset()
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Success:", response)
|
|
||||||
return response
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err instanceof z.ZodError) {
|
|
||||||
alert("Validation error: " + err.errors.map((e) => e.message).join(", "))
|
|
||||||
} else {
|
|
||||||
console.error("Error:", err)
|
|
||||||
alert(`Failed to ${isEditMode ? "update" : "create"} person.`)
|
|
||||||
}
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
loading,
|
|
||||||
handleSubmit,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const PersonSchema = z.object({
|
||||||
|
surname: z.string().min(1, "Surname is required"),
|
||||||
|
christian_name: z.string().min(1, "Christian name is required"),
|
||||||
|
date_of_birth: z.string().optional(),
|
||||||
|
place_of_birth: z.string().optional(),
|
||||||
|
date_of_death: z.string().optional(),
|
||||||
|
occupation: z.string().optional(),
|
||||||
|
additional_notes: z.string().optional(),
|
||||||
|
reference: z.string().optional(),
|
||||||
|
id_card_no: z.string().optional(),
|
||||||
|
person_id: z.number(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const MigrationSchema = z.object({
|
||||||
|
date_of_arrival_aus: z.string().optional(),
|
||||||
|
date_of_arrival_nt: z.string().optional(),
|
||||||
|
arrival_period: z.string().optional(),
|
||||||
|
data_source: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const NaturalizationSchema = z.object({
|
||||||
|
date_of_naturalisation: z.string().optional(),
|
||||||
|
no_of_cert: z.string().optional(),
|
||||||
|
issued_at: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ResidenceSchema = z.object({
|
||||||
|
town_or_city: z.string().optional(),
|
||||||
|
home_at_death: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const FamilySchema = z.object({
|
||||||
|
names_of_parents: z.string().optional(),
|
||||||
|
names_of_children: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const InternmentSchema = z.object({
|
||||||
|
corps_issued: z.string().optional(),
|
||||||
|
interned_in: z.string().optional(),
|
||||||
|
sent_to: z.string().optional(),
|
||||||
|
internee_occupation: z.string().optional(),
|
||||||
|
internee_address: z.string().optional(),
|
||||||
|
cav: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const MigrantFormSchema = z.object({
|
||||||
|
person: PersonSchema,
|
||||||
|
migration: MigrationSchema,
|
||||||
|
naturalization: NaturalizationSchema,
|
||||||
|
residence: ResidenceSchema,
|
||||||
|
family: FamilySchema,
|
||||||
|
internment: InternmentSchema,
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
// Schema for creating a new user - all fields required
|
||||||
|
export const UserSchema = z.object({
|
||||||
|
name: z.string().min(1, "Full name is required"),
|
||||||
|
email: z.string().email("Invalid email address").min(1, "Email is required"),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(8, "Password must be at least 8 characters")
|
||||||
|
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
|
||||||
|
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
|
||||||
|
.regex(/[0-9]/, "Password must contain at least one number"),
|
||||||
|
password_confirmation: z.string()
|
||||||
|
}).refine((data) => data.password === data.password_confirmation, {
|
||||||
|
message: "Passwords don't match",
|
||||||
|
path: ["password_confirmation"]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Schema for updating user - passwords optional but must meet requirements if provided
|
||||||
|
export const UserUpdateSchema = z.object({
|
||||||
|
name: z.string().min(1, "Full name is required"),
|
||||||
|
email: z.string().email("Invalid email address").min(1, "Email is required"),
|
||||||
|
current_password: z.string().min(1, "Current password is required"),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(8, "Password must be at least 8 characters")
|
||||||
|
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
|
||||||
|
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
|
||||||
|
.regex(/[0-9]/, "Password must contain at least one number")
|
||||||
|
.optional()
|
||||||
|
.or(z.literal('')),
|
||||||
|
password_confirmation: z.string().optional().or(z.literal(''))
|
||||||
|
}).refine((data) => {
|
||||||
|
// If password is provided, password_confirmation must match
|
||||||
|
if (data.password && data.password.length > 0) {
|
||||||
|
return data.password === data.password_confirmation
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}, {
|
||||||
|
message: "Passwords don't match",
|
||||||
|
path: ["password_confirmation"]
|
||||||
|
})
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import axios, { type AxiosInstance } from "axios";
|
import axios, { type AxiosInstance } from "axios";
|
||||||
import type { DashboardResponse, Person, Photo, User } from "@/types/api";
|
import type { DashboardResponse, Person, Photo, User } from "@/types/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ApiService handles all HTTP requests to the backend API using Axios.
|
||||||
|
* It includes authentication, user, migrant, and dashboard-related operations.
|
||||||
|
*/
|
||||||
class ApiService {
|
class ApiService {
|
||||||
private api: AxiosInstance;
|
private api: AxiosInstance;
|
||||||
|
|
||||||
|
|
@ -8,17 +12,17 @@ class ApiService {
|
||||||
this.api = axios.create({
|
this.api = axios.create({
|
||||||
baseURL: import.meta.env.VITE_API_URL,
|
baseURL: import.meta.env.VITE_API_URL,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
withCredentials: true, // IMPORTANT
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Request Interceptor
|
// Attach JWT token to request headers if available
|
||||||
this.api.interceptors.request.use((config) => {
|
this.api.interceptors.request.use((config) => {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Response Interceptor
|
// Handle 401 Unauthorized responses by logging out the user
|
||||||
this.api.interceptors.response.use(
|
this.api.interceptors.response.use(
|
||||||
(res) => res,
|
(res) => res,
|
||||||
(err) => {
|
(err) => {
|
||||||
|
|
@ -31,40 +35,42 @@ class ApiService {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get baseURL(): string {
|
get baseURL(): string {
|
||||||
return this.api.defaults.baseURL || "";
|
return this.api.defaults.baseURL || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- AUTH ---
|
/** User authentication: login with email & password */
|
||||||
async login(params: { email: string; password: string }) {
|
async login(params: { email: string; password: string }) {
|
||||||
// First get CSRF cookie
|
|
||||||
await this.api.get("/sanctum/csrf-cookie");
|
await this.api.get("/sanctum/csrf-cookie");
|
||||||
|
|
||||||
// Then login
|
|
||||||
return this.api.post("/api/login", params).then((res) => {
|
return this.api.post("/api/login", params).then((res) => {
|
||||||
console.log("Token:", res.data.token);
|
|
||||||
localStorage.setItem("token", res.data.token);
|
localStorage.setItem("token", res.data.token);
|
||||||
localStorage.setItem("user", JSON.stringify(res.data.user));
|
localStorage.setItem("user", JSON.stringify(res.data.user));
|
||||||
return res.data;
|
return res.data;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** User registration */
|
||||||
|
|
||||||
async register(params: { name: string; email: string; password: string }) {
|
async register(params: { name: string; email: string; password: string }) {
|
||||||
return this.api.post("/api/register", params).then((res) => res.data);
|
return this.api.post("/api/register", params).then((res) => res.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Create a new user account */
|
||||||
async createUser(user: User) {
|
async createUser(user: User) {
|
||||||
return this.api.post("/api/register", user).then((res) => res.data);
|
return this.api.post("/api/register", user).then((res) => res.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Update current user's account information */
|
||||||
async updateUser(user: User) {
|
async updateUser(user: User) {
|
||||||
return this.api.put("/api/user/account", user).then(res => res.data);
|
return this.api.put("/api/user/account", user).then(res => res.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetch a list of all users */
|
||||||
async displayAllUsers() {
|
async displayAllUsers() {
|
||||||
return this.api.get("/api/users").then(res => res.data.data);
|
return this.api.get("/api/users").then(res => res.data.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Logout the current user and clear stored tokens */
|
||||||
async logout() {
|
async logout() {
|
||||||
return this.api.post("/api/logout").then((res) => {
|
return this.api.post("/api/logout").then((res) => {
|
||||||
localStorage.removeItem("token");
|
localStorage.removeItem("token");
|
||||||
|
|
@ -72,49 +78,50 @@ class ApiService {
|
||||||
return res.data;
|
return res.data;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetch current user's details */
|
||||||
async fetchCurrentUser(): Promise<User> {
|
async fetchCurrentUser(): Promise<User> {
|
||||||
return this.api.get("/api/user").then((res) => res.data.data.user);
|
return this.api.get("/api/user").then((res) => res.data.data.user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetch a paginated list of migrants */
|
||||||
// --- MIGRANTS ---
|
|
||||||
async getMigrants(page = 1, perPage = 10, filters = {}): Promise<Person> {
|
async getMigrants(page = 1, perPage = 10, filters = {}): Promise<Person> {
|
||||||
return this.api.get("/api/migrants", {
|
return this.api.get("/api/migrants", {
|
||||||
params: { page, per_page: perPage, ...filters },
|
params: { page, per_page: perPage, ...filters },
|
||||||
}).then((res) => res.data.data);
|
}).then((res) => res.data.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetch migrants data by a specific URL (for pagination, filtering, etc.) */
|
||||||
async getMigrantsByUrl(url: string) {
|
async getMigrantsByUrl(url: string) {
|
||||||
return this.api.get(url).then((res) => res.data);
|
return this.api.get(url).then((res) => res.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetch a migrant's details by ID */
|
||||||
async getMigrantById(id: string | number): Promise<Person> {
|
async getMigrantById(id: string | number): Promise<Person> {
|
||||||
return this.api.get(`/api/migrants/${id}`).then((res) => res.data.data);
|
return this.api.get(`/api/migrants/${id}`).then((res) => res.data.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Create a new migrant entry with form data (multipart request) */
|
||||||
async createMigrant(formData: FormData): Promise<any> {
|
async createMigrant(formData: FormData): Promise<any> {
|
||||||
return this.api.post("/api/migrants", formData, {
|
return this.api.post("/api/migrants", formData, {
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
}).then((res) => res.data.data);
|
}).then((res) => res.data.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Update a migrant entry by ID using form data (multipart request) */
|
||||||
async updateMigrant(personId: number, formData: FormData): Promise<any> {
|
async updateMigrant(personId: number, formData: FormData): Promise<any> {
|
||||||
// Use POST with method spoofing instead of PUT for better FormData compatibility
|
|
||||||
formData.append('_method', 'PUT');
|
formData.append('_method', 'PUT');
|
||||||
|
|
||||||
return this.api.post(`/api/migrants/${personId}`, formData, {
|
return this.api.post(`/api/migrants/${personId}`, formData, {
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
}).then((res) => {
|
}).then((res) => res.data.data);
|
||||||
return res.data.data;
|
|
||||||
}).catch(error => {
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Delete a migrant entry by ID */
|
||||||
async deleteMigrant(id: string | number) {
|
async deleteMigrant(id: string | number) {
|
||||||
return this.api.delete(`/api/migrants/${id}`).then((res) => res.data);
|
return this.api.delete(`/api/migrants/${id}`).then((res) => res.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetch all photos for a specific migrant */
|
||||||
async getPhotos(id: number): Promise<Photo[]> {
|
async getPhotos(id: number): Promise<Photo[]> {
|
||||||
return this.api.get(`/api/migrants/${id}/photos`).then((res) => {
|
return this.api.get(`/api/migrants/${id}/photos`).then((res) => {
|
||||||
const photosData = res.data.data.photos;
|
const photosData = res.data.data.photos;
|
||||||
|
|
@ -122,12 +129,13 @@ class ApiService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- DASHBOARD ---
|
/** Fetch dashboard statistics */
|
||||||
async getDashboardStats(): Promise<DashboardResponse> {
|
async getDashboardStats(): Promise<DashboardResponse> {
|
||||||
const response = await this.api.get<DashboardResponse>("/api/dashboard/stats");
|
const response = await this.api.get<DashboardResponse>("/api/dashboard/stats");
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetch recent activity logs */
|
||||||
async getRecentActivityLogs() {
|
async getRecentActivityLogs() {
|
||||||
return this.api.get("/api/activity-logs").then((res) => res.data.data);
|
return this.api.get("/api/activity-logs").then((res) => res.data.data);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
import type { Person, Migration, Naturalization, Residence, Family, Internment, ExistingPhoto } from '@/types/api'
|
||||||
|
|
||||||
|
interface FormDataConfig {
|
||||||
|
person: Person
|
||||||
|
migration: Migration
|
||||||
|
naturalization: Naturalization
|
||||||
|
residence: Residence
|
||||||
|
family: Family
|
||||||
|
internment: Internment
|
||||||
|
photos: File[]
|
||||||
|
captions: string[]
|
||||||
|
mainPhotoIndex: number | null
|
||||||
|
existingPhotos: ExistingPhoto[]
|
||||||
|
removedPhotoIds: number[]
|
||||||
|
isEditMode: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildFormData = (config: FormDataConfig): FormData => {
|
||||||
|
const formData = new FormData()
|
||||||
|
const {
|
||||||
|
person,
|
||||||
|
migration,
|
||||||
|
naturalization,
|
||||||
|
residence,
|
||||||
|
family,
|
||||||
|
internment,
|
||||||
|
photos,
|
||||||
|
captions,
|
||||||
|
mainPhotoIndex,
|
||||||
|
existingPhotos,
|
||||||
|
removedPhotoIds,
|
||||||
|
isEditMode,
|
||||||
|
} = config
|
||||||
|
|
||||||
|
// Add person data
|
||||||
|
Object.entries(person).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
formData.append(key, String(value))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add migration data
|
||||||
|
if (Object.values(migration).some(v => v)) {
|
||||||
|
Object.entries(migration).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
formData.append(`migration[${key}]`, String(value))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add naturalization data
|
||||||
|
if (Object.values(naturalization).some(v => v)) {
|
||||||
|
Object.entries(naturalization).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
formData.append(`naturalization[${key}]`, String(value))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add residence data
|
||||||
|
if (Object.values(residence).some(v => v)) {
|
||||||
|
Object.entries(residence).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
formData.append(`residence[${key}]`, String(value))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add family data
|
||||||
|
if (Object.values(family).some(v => v)) {
|
||||||
|
Object.entries(family).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
formData.append(`family[${key}]`, String(value))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add internment data
|
||||||
|
if (Object.values(internment).some(v => v)) {
|
||||||
|
Object.entries(internment).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
formData.append(`internment[${key}]`, String(value))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new photos
|
||||||
|
photos.forEach((photo) => {
|
||||||
|
formData.append('photos[]', photo)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add captions for new photos only
|
||||||
|
const newPhotoCaptions = captions.slice(existingPhotos.length)
|
||||||
|
newPhotoCaptions.forEach((caption, index) => {
|
||||||
|
formData.append(`captions[${index}]`, caption || '')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle main photo logic for edit mode
|
||||||
|
if (isEditMode) {
|
||||||
|
// Add removed photo IDs
|
||||||
|
if (removedPhotoIds.length > 0) {
|
||||||
|
removedPhotoIds.forEach((photoId, index) => {
|
||||||
|
formData.append(`remove_photos[${index}]`, photoId.toString())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle existing photo updates (captions)
|
||||||
|
existingPhotos.forEach((photo, index) => {
|
||||||
|
formData.append(`existing_photos[${index}][id]`, photo.id.toString())
|
||||||
|
formData.append(`existing_photos[${index}][caption]`, captions[index] || '')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle main photo setting
|
||||||
|
if (mainPhotoIndex !== null) {
|
||||||
|
formData.append('set_as_profile', '1')
|
||||||
|
|
||||||
|
if (mainPhotoIndex < existingPhotos.length) {
|
||||||
|
// Main photo is an existing photo
|
||||||
|
const existingPhoto = existingPhotos[mainPhotoIndex]
|
||||||
|
formData.append('profile_photo_id', existingPhoto.id.toString())
|
||||||
|
} else {
|
||||||
|
// Main photo is a new photo
|
||||||
|
const newPhotoIndex = mainPhotoIndex - existingPhotos.length
|
||||||
|
formData.append('main_photo_index', newPhotoIndex.toString())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
formData.append('set_as_profile', '0')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Creating new migrant
|
||||||
|
if (mainPhotoIndex !== null) {
|
||||||
|
formData.append('set_as_profile', '1')
|
||||||
|
formData.append('main_photo_index', mainPhotoIndex.toString())
|
||||||
|
} else {
|
||||||
|
formData.append('set_as_profile', '0')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return formData
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue