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..."
|
||||
|
||||
# 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
|
||||
cp -r dist/assets/* /home/mark/nun/public/assets/
|
||||
cp -r dist/assets/* /home/mark/migrants/public/assets/
|
||||
|
||||
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
|
||||
import LoginPage from "./components/admin/LoginPage";
|
||||
import RegisterPage from "./components/admin/Register";
|
||||
import AdminDashboardPage from "./components/admin/AdminDashboard";
|
||||
import Migrants from "./components/admin/Migrants";
|
||||
import AddMigrantPage from "./components/admin/AddMigrant";
|
||||
|
|
@ -125,7 +124,6 @@ function App() {
|
|||
|
||||
{/* Auth Routes */}
|
||||
<Route path="/login" element={<PublicRoute><LoginPage /></PublicRoute>} />
|
||||
<Route path="/register" element={<PublicRoute><RegisterPage /></PublicRoute>} />
|
||||
|
||||
{/* Admin Protected Routes */}
|
||||
<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 Sidebar from "@/components/layout/Sidebar"
|
||||
import type { User } from "@/types/api"
|
||||
import { UserUpdateSchema } from "@/schemas/userSchema"
|
||||
import { z } from "zod"
|
||||
|
||||
export default function UserCreate() {
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -26,12 +28,13 @@ export default function UserCreate() {
|
|||
password: '',
|
||||
password_confirmation: '',
|
||||
});
|
||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({})
|
||||
|
||||
useEffect(() => {
|
||||
async function loadUser() {
|
||||
try {
|
||||
const currentUser = await apiService.fetchCurrentUser()
|
||||
console.log("Setting component - Fetched user:", currentUser)
|
||||
// Fetch user data and update state
|
||||
setUser(currentUser)
|
||||
|
||||
// Pre-fill the form with current user data if available
|
||||
|
|
@ -43,7 +46,7 @@ export default function UserCreate() {
|
|||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch user info:", error)
|
||||
// Handle error when fetching user information
|
||||
toast.error("Failed to load user information")
|
||||
}
|
||||
}
|
||||
|
|
@ -56,20 +59,52 @@ export default function UserCreate() {
|
|||
...prev,
|
||||
[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:
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.name || !formData.email || !formData.current_password) {
|
||||
toast.error("Please fill in all required fields");
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.password && formData.password !== formData.password_confirmation) {
|
||||
toast.error("Passwords don't match");
|
||||
return;
|
||||
// Zod validation
|
||||
if (!validateForm()) {
|
||||
// Show the first validation error as a toast
|
||||
const firstError = Object.values(validationErrors)[0]
|
||||
if (firstError) {
|
||||
toast.error(firstError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -85,9 +120,19 @@ export default function UserCreate() {
|
|||
});
|
||||
|
||||
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) {
|
||||
console.error("Error updating user:", error);
|
||||
// Handle error when updating user
|
||||
|
||||
if (error.response?.data?.message) {
|
||||
toast.error(error.response.data.message);
|
||||
|
|
@ -168,8 +213,11 @@ export default function UserCreate() {
|
|||
value={formData.name}
|
||||
onChange={handleInputChange}
|
||||
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 className="space-y-2">
|
||||
|
|
@ -184,8 +232,11 @@ export default function UserCreate() {
|
|||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
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 className="border-t border-gray-800 pt-6">
|
||||
|
|
@ -203,46 +254,65 @@ export default function UserCreate() {
|
|||
value={formData.current_password}
|
||||
onChange={handleInputChange}
|
||||
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 className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-gray-300">
|
||||
Password <span className="text-red-400">*</span>
|
||||
New Password
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Enter password"
|
||||
placeholder="Enter new password (optional)"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
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 className="space-y-2">
|
||||
<Label htmlFor="password_confirmation" className="text-gray-300">
|
||||
Confirm Password <span className="text-red-400">*</span>
|
||||
Confirm New Password
|
||||
</Label>
|
||||
<Input
|
||||
id="password_confirmation"
|
||||
name="password_confirmation"
|
||||
type="password"
|
||||
placeholder="Confirm password"
|
||||
placeholder="Confirm new password"
|
||||
value={formData.password_confirmation}
|
||||
onChange={handleInputChange}
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
</CardContent>
|
||||
|
||||
|
|
@ -262,7 +332,7 @@ export default function UserCreate() {
|
|||
className="bg-[#9B2335] hover:bg-[#9B2335]/90 text-white shadow-lg"
|
||||
>
|
||||
<UserPlus className="mr-2 size-4" />
|
||||
{isSubmitting ? "Creating..." : "Create User"}
|
||||
{isSubmitting ? "Updating..." : "Update User"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { Checkbox } from "@/components/ui/checkbox"
|
|||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogDescription,
|
||||
|
|
|
|||
|
|
@ -1,550 +1,62 @@
|
|||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useParams, useNavigate } from "react-router-dom"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
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 { 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 { showSuccessToast, showErrorToast, showUpdateItemToast } from "@/utils/toast"
|
||||
import { buildFormData } from "@/utils/formDataBuilder"
|
||||
|
||||
// Import confirmation modals
|
||||
import AddDialog from "@/components/admin/migrant/Modal/AddDialog"
|
||||
import UpdateDialog from "@/components/admin/migrant/Modal/UpdateDialog"
|
||||
// Import hooks
|
||||
import { useMigrantForm } from "@/hooks/useMigrantForm"
|
||||
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 PersonDetailsStep from "@/components/admin/migrant/form-steps/PersonDetailsStep"
|
||||
import MigrationInfoStep from "@/components/admin/migrant/form-steps/MigrationInfoStep"
|
||||
import NaturalizationStep from "@/components/admin/migrant/form-steps/NaturalizationStep"
|
||||
import ResidenceStep from "@/components/admin/migrant/form-steps/ResidenceStep"
|
||||
import FamilyStep from "@/components/admin/migrant/form-steps/FamilyStep"
|
||||
import InternmentStep from "@/components/admin/migrant/form-steps/InternmentStep"
|
||||
import PhotosStep from "@/components/admin/migrant/form-steps/PhotosStep"
|
||||
import PersonDetailsStep from "./form-steps/PersonDetailsStep"
|
||||
import MigrationInfoStep from "./form-steps/MigrationInfoStep"
|
||||
import NaturalizationStep from "./form-steps/NaturalizationStep"
|
||||
import ResidenceStep from "./form-steps/ResidenceStep"
|
||||
import FamilyStep from "./form-steps/FamilyStep"
|
||||
import InternmentStep from "./form-steps/InternmentStep"
|
||||
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 { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const isEditMode = Boolean(id)
|
||||
|
||||
// Form state
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [initialDataLoaded, setInitialDataLoaded] = useState(false)
|
||||
|
||||
// Modal states
|
||||
// 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 [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
|
||||
const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
// Initial state functions
|
||||
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: "",
|
||||
})
|
||||
|
||||
// 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 () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
const personId = Number.parseInt(id!, 10)
|
||||
if (isNaN(personId)) {
|
||||
throw new Error("Invalid person ID")
|
||||
}
|
||||
|
||||
const migrantData = await apiService.getMigrantById(personId)
|
||||
|
||||
// FIX: Populate person data from migrantData.person, not current person state
|
||||
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,
|
||||
})
|
||||
|
||||
// Populate migration data
|
||||
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 || "",
|
||||
})
|
||||
}
|
||||
|
||||
// Populate naturalization data
|
||||
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 || "",
|
||||
})
|
||||
}
|
||||
|
||||
// Populate residence data
|
||||
if (migrantData.residence) {
|
||||
setResidence({
|
||||
town_or_city: migrantData.residence.town_or_city || "",
|
||||
home_at_death: migrantData.residence.home_at_death || "",
|
||||
})
|
||||
}
|
||||
|
||||
// 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 () => {
|
||||
try {
|
||||
const formData = new FormData()
|
||||
|
||||
// Add person data
|
||||
console.log('Person data:', person);
|
||||
Object.entries(person).forEach(([key, value]) => {
|
||||
if (value) formData.append(key, value)
|
||||
})
|
||||
|
||||
// Add migration data
|
||||
console.log('Migration data:', migration);
|
||||
if (Object.values(migration).some(v => v)) {
|
||||
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 {
|
||||
console.log('⚠️ No main photo selected');
|
||||
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) {
|
||||
console.error('Form submission error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!isSubmitting) {
|
||||
if (isEditMode) {
|
||||
setIsUpdateDialogOpen(true)
|
||||
} else {
|
||||
setIsAddDialogOpen(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirmSubmit = async () => {
|
||||
if (!isSubmitting) {
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
const response = await submitForm()
|
||||
|
||||
if (isEditMode) {
|
||||
showUpdateItemToast(`Migrant ${person.surname}, ${person.christian_name}`, () => {
|
||||
navigate(`/admin/migrants`)
|
||||
})
|
||||
} else {
|
||||
showSuccessToast(() => {
|
||||
navigate(`/admin/migrants`)
|
||||
})
|
||||
resetForm()
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error("Form submission error:", error)
|
||||
showErrorToast("There was a problem saving the migrant data. Please try again.")
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
setIsAddDialogOpen(false)
|
||||
setIsUpdateDialogOpen(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const renderFormField = (
|
||||
label: string,
|
||||
value: string,
|
||||
|
|
@ -552,83 +64,181 @@ const StepperForm = () => {
|
|||
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>
|
||||
)
|
||||
fieldPath?: string // Add this to identify the field for validation
|
||||
) => {
|
||||
const hasError = fieldPath ? validation.hasFieldError(fieldPath) : false
|
||||
const errorMessage = fieldPath ? validation.getFieldError(fieldPath) : null
|
||||
|
||||
return (
|
||||
<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] ${
|
||||
hasError ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : ''
|
||||
}`}
|
||||
/>
|
||||
) : (
|
||||
<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] ${
|
||||
hasError ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : ''
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<p className="text-sm text-red-400 mt-1">{errorMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 0:
|
||||
return <PersonDetailsStep person={person} setPerson={setPerson} renderFormField={renderFormField} />
|
||||
|
||||
return <PersonDetailsStep person={formData.person} setPerson={setters.setPerson} renderFormField={renderFormField} />
|
||||
case 1:
|
||||
return <MigrationInfoStep migration={migration} setMigration={setMigration} renderFormField={renderFormField} />
|
||||
|
||||
return <MigrationInfoStep migration={formData.migration} setMigration={setters.setMigration} renderFormField={renderFormField} />
|
||||
case 2:
|
||||
return (
|
||||
<NaturalizationStep
|
||||
naturalization={naturalization}
|
||||
setNaturalization={setNaturalization}
|
||||
renderFormField={renderFormField}
|
||||
/>
|
||||
)
|
||||
|
||||
return <NaturalizationStep naturalization={formData.naturalization} setNaturalization={setters.setNaturalization} renderFormField={renderFormField} />
|
||||
case 3:
|
||||
return <ResidenceStep residence={residence} setResidence={setResidence} renderFormField={renderFormField} />
|
||||
|
||||
return <ResidenceStep residence={formData.residence} setResidence={setters.setResidence} renderFormField={renderFormField} />
|
||||
case 4:
|
||||
return <FamilyStep family={family} setFamily={setFamily} renderFormField={renderFormField} />
|
||||
|
||||
return <FamilyStep family={formData.family} setFamily={setters.setFamily} renderFormField={renderFormField} />
|
||||
case 5:
|
||||
return (
|
||||
<InternmentStep internment={internment} setInternment={setInternment} renderFormField={renderFormField} />
|
||||
)
|
||||
|
||||
return <InternmentStep internment={formData.internment} setInternment={setters.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}
|
||||
photos={photoManagement.photos}
|
||||
photoPreviews={photoManagement.photoPreviews}
|
||||
captions={photoManagement.captions}
|
||||
existingPhotos={photoManagement.existingPhotos}
|
||||
mainPhotoIndex={photoManagement.mainPhotoIndex}
|
||||
API_BASE_URL={apiService.baseURL}
|
||||
handlePhotoChange={photoManagement.handlePhotoChange}
|
||||
removeExistingPhoto={photoManagement.removeExistingPhoto}
|
||||
removeNewPhoto={photoManagement.removeNewPhoto}
|
||||
setCaptions={photoManagement.setCaptions}
|
||||
setMainPhotoIndex={photoManagement.setMainPhotoIndex}
|
||||
/>
|
||||
)
|
||||
case 7:
|
||||
return (
|
||||
<ReviewStep
|
||||
person={formData.person}
|
||||
migration={formData.migration}
|
||||
naturalization={formData.naturalization}
|
||||
residence={formData.residence}
|
||||
family={formData.family}
|
||||
internment={formData.internment}
|
||||
photos={photoManagement.photos}
|
||||
photoPreviews={photoManagement.photoPreviews}
|
||||
captions={photoManagement.captions}
|
||||
existingPhotos={photoManagement.existingPhotos}
|
||||
mainPhotoIndex={photoManagement.mainPhotoIndex ?? -1}
|
||||
API_BASE_URL={apiService.baseURL}
|
||||
/>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
try {
|
||||
const formDataToSubmit = buildFormData({
|
||||
...formData,
|
||||
photos: photoManagement.photos,
|
||||
captions: photoManagement.captions,
|
||||
mainPhotoIndex: photoManagement.mainPhotoIndex,
|
||||
existingPhotos: photoManagement.existingPhotos,
|
||||
removedPhotoIds: photoManagement.removedPhotoIds,
|
||||
isEditMode,
|
||||
})
|
||||
|
||||
if (isEditMode) {
|
||||
const id = window.location.pathname.split('/').pop()
|
||||
return await apiService.updateMigrant(parseInt(id!), formDataToSubmit)
|
||||
} else {
|
||||
return await apiService.createMigrant(formDataToSubmit)
|
||||
}
|
||||
} catch (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 = () => {
|
||||
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) {
|
||||
setIsUpdateDialogOpen(true)
|
||||
} else {
|
||||
setIsAddDialogOpen(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirmSubmit = async () => {
|
||||
if (!isSubmitting) {
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
await submitForm()
|
||||
|
||||
if (isEditMode) {
|
||||
showUpdateItemToast(`Migrant ${formData.person.surname}, ${formData.person.christian_name}`, () => {
|
||||
navigate(`/admin/migrants`)
|
||||
})
|
||||
} else {
|
||||
showSuccessToast(() => {
|
||||
navigate(`/admin/migrants`)
|
||||
})
|
||||
resetForm()
|
||||
photoManagement.resetPhotos()
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast("There was a problem saving the migrant data. Please try again.")
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
setIsAddDialogOpen(false)
|
||||
setIsUpdateDialogOpen(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && isEditMode && !initialDataLoaded) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
|
||||
|
|
@ -645,115 +255,35 @@ const StepperForm = () => {
|
|||
return (
|
||||
<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">
|
||||
<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>
|
||||
<StepperHeader currentStep={currentStep} isEditMode={isEditMode} />
|
||||
|
||||
<CardContent className="p-8 bg-gray-900">
|
||||
{renderStepContent()}
|
||||
|
||||
{/* Display validation errors summary if any */}
|
||||
{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>
|
||||
<Badge variant="outline" className="text-sm border-gray-700 text-gray-300">
|
||||
Step {currentStep + 1} of {steps.length}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<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-800"
|
||||
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>
|
||||
<StepperNavigation
|
||||
currentStep={currentStep}
|
||||
totalSteps={8}
|
||||
isSubmitting={isSubmitting}
|
||||
isEditMode={isEditMode}
|
||||
onPrevious={() => setCurrentStep(prev => prev - 1)}
|
||||
onNext={handleNext}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Confirmation Dialogs */}
|
||||
<AddDialog
|
||||
open={isAddDialogOpen}
|
||||
onOpenChange={setIsAddDialogOpen}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,22 @@
|
|||
import React from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { Upload, X, ImageIcon } from "lucide-react"
|
||||
import React from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Camera, Star, Trash2 } from 'lucide-react'
|
||||
|
||||
interface PhotosStepProps {
|
||||
photos: File[]
|
||||
photoPreviews: string[]
|
||||
captions: string[]
|
||||
existingPhotos: any[]
|
||||
existingPhotos: any
|
||||
mainPhotoIndex: number | null
|
||||
API_BASE_URL: string
|
||||
handlePhotoChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
removeExistingPhoto: (index: number) => void
|
||||
removeNewPhoto: (index: number) => void
|
||||
setCaptions: React.Dispatch<React.SetStateAction<string[]>>
|
||||
setMainPhotoIndex: React.Dispatch<React.SetStateAction<number | null>>
|
||||
setCaptions: (captions: string[]) => void
|
||||
setMainPhotoIndex: (index: number) => void
|
||||
}
|
||||
|
||||
const PhotosStep: React.FC<PhotosStepProps> = ({
|
||||
|
|
@ -34,95 +32,131 @@ const PhotosStep: React.FC<PhotosStepProps> = ({
|
|||
setCaptions,
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* File Upload Section */}
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-gray-400 transition-colors">
|
||||
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="photo-upload" className="text-lg font-medium text-gray-700 cursor-pointer">
|
||||
Upload Photos & Documents
|
||||
</Label>
|
||||
<p className="text-sm text-gray-500">
|
||||
Select multiple files to upload. Supported formats: JPG, PNG, PDF
|
||||
</p>
|
||||
<Input
|
||||
id="photo-upload"
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
onChange={handlePhotoChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => document.getElementById("photo-upload")?.click()}
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Choose Files
|
||||
</Button>
|
||||
<div>
|
||||
<Label htmlFor="photo-upload" className="text-sm font-medium text-gray-300 mb-4 block">
|
||||
Upload Photos
|
||||
</Label>
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-6 transition-all duration-200 ${
|
||||
isDragOver
|
||||
? 'border-blue-400 bg-blue-900/20 scale-[1.02]'
|
||||
: 'border-gray-600 bg-gray-800/50 hover:border-gray-500 hover:bg-gray-800/70'
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="border-gray-900 text-white bg-gray-700 hover:bg-gray-600"
|
||||
onClick={() => document.getElementById('photo-upload')?.click()}
|
||||
>
|
||||
<Camera className="w-4 h-4 mr-2" />
|
||||
Add Photos
|
||||
</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>
|
||||
|
||||
{existingPhotos.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageIcon className="w-5 h-5 text-gray-600" />
|
||||
<h3 className="text-lg font-medium text-white">Existing Photos</h3>
|
||||
<Badge variant="secondary">{existingPhotos.length}</Badge>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white border-b border-gray-600 pb-2">
|
||||
Existing Photos ({existingPhotos.length})
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{existingPhotos.map((photo, index) => (
|
||||
<Card key={`existing-${photo.id}`} className="overflow-hidden">
|
||||
<div className="relative">
|
||||
<img
|
||||
src={`${API_BASE_URL}${photo.file_path}`}
|
||||
alt={`Existing ${index}`}
|
||||
className="w-full h-48 object-cover"
|
||||
/>
|
||||
{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"
|
||||
{existingPhotos.map((photo: any, index: number) => (
|
||||
<Card key={`existing-${photo.id}`} className="bg-gray-800 border-gray-700">
|
||||
<CardContent className="p-4">
|
||||
<div className="relative">
|
||||
<img
|
||||
src={`${API_BASE_URL}${photo.file_path}`}
|
||||
alt={photo.caption || `Photo ${index + 1}`}
|
||||
className="w-full h-48 object-cover rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<RadioGroup
|
||||
value={mainPhotoIndex === index ? "main" : ""}
|
||||
onValueChange={() => setMainPhotoIndex(index)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="main" id={`main-existing-${index}`} />
|
||||
<Label htmlFor={`main-existing-${index}`} className="text-sm">
|
||||
Set as main photo
|
||||
</Label>
|
||||
<div className="absolute top-2 right-2 flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={mainPhotoIndex === index ? "default" : "outline"}
|
||||
className={mainPhotoIndex === index ? "bg-yellow-600 hover:bg-yellow-700" : ""}
|
||||
onClick={() => setMainPhotoIndex(index)}
|
||||
>
|
||||
<Star className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => removeExistingPhoto(index)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</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>
|
||||
</Card>
|
||||
))}
|
||||
|
|
@ -130,66 +164,51 @@ const PhotosStep: React.FC<PhotosStepProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{photos.length > 0 && (
|
||||
{/* New Photos Section */}
|
||||
{photoPreviews.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Upload className="w-5 h-5 text-gray-600" />
|
||||
<h3 className="text-lg font-medium text-white">New Photos</h3>
|
||||
<Badge variant="secondary">{photos.length}</Badge>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white border-b border-gray-600 pb-2">
|
||||
New Photos ({photoPreviews.length})
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{photos.map((_, index) => {
|
||||
const actualIndex = existingPhotos.length + index
|
||||
{photoPreviews.map((preview, photoIndex) => {
|
||||
const totalIndex = existingPhotos.length + photoIndex
|
||||
return (
|
||||
<Card key={`new-${index}`} className="overflow-hidden">
|
||||
<div className="relative">
|
||||
<img
|
||||
src={photoPreviews[index] || `${import.meta.env.BASE_URL}assets/placeholder.svg`}
|
||||
alt={`Preview ${index}`}
|
||||
className="w-full h-48 object-cover"
|
||||
/>
|
||||
{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"
|
||||
<Card key={`new-${photoIndex}`} className="bg-gray-800 border-gray-700">
|
||||
<CardContent className="p-4">
|
||||
<div className="relative">
|
||||
<img
|
||||
src={preview}
|
||||
alt={`New photo ${photoIndex + 1}`}
|
||||
className="w-full h-48 object-cover rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<RadioGroup
|
||||
value={mainPhotoIndex === actualIndex ? "main" : ""}
|
||||
onValueChange={() => setMainPhotoIndex(actualIndex)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="main" id={`main-new-${index}`} />
|
||||
<Label htmlFor={`main-new-${index}`} className="text-sm">
|
||||
Set as main photo
|
||||
</Label>
|
||||
<div className="absolute top-2 right-2 flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={mainPhotoIndex === totalIndex ? "default" : "outline"}
|
||||
className={mainPhotoIndex === totalIndex ? "bg-yellow-600 hover:bg-yellow-700" : ""}
|
||||
onClick={() => setMainPhotoIndex(totalIndex)}
|
||||
>
|
||||
<Star className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => removeNewPhoto(photoIndex)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</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>
|
||||
</Card>
|
||||
)
|
||||
|
|
@ -197,8 +216,16 @@ const PhotosStep: React.FC<PhotosStepProps> = ({
|
|||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
export default PhotosStep
|
||||
export default PhotosStep
|
||||
|
|
@ -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 Header from "@/components/layout/Header"
|
||||
import Sidebar from "@/components/layout/Sidebar"
|
||||
import { UserSchema } from "@/schemas/userSchema"
|
||||
import { z } from "zod"
|
||||
|
||||
export default function UserCreate() {
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -23,6 +25,7 @@ export default function UserCreate() {
|
|||
password: "",
|
||||
password_confirmation: "",
|
||||
})
|
||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({})
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target
|
||||
|
|
@ -30,19 +33,50 @@ export default function UserCreate() {
|
|||
...prev,
|
||||
[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) => {
|
||||
e.preventDefault()
|
||||
|
||||
// Basic validation
|
||||
if (!formData.name || !formData.email || !formData.password) {
|
||||
toast.error("Please fill in all required fields")
|
||||
return
|
||||
}
|
||||
|
||||
if (formData.password !== formData.password_confirmation) {
|
||||
toast.error("Passwords don't match")
|
||||
// Zod validation
|
||||
if (!validateForm()) {
|
||||
// Show the first validation error as a toast
|
||||
const firstError = Object.values(validationErrors)[0]
|
||||
if (firstError) {
|
||||
toast.error(firstError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -96,8 +130,11 @@ export default function UserCreate() {
|
|||
value={formData.name}
|
||||
onChange={handleInputChange}
|
||||
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 className="space-y-2">
|
||||
|
|
@ -112,8 +149,11 @@ export default function UserCreate() {
|
|||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
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 className="border-t border-gray-800 pt-6">
|
||||
|
|
@ -131,8 +171,11 @@ export default function UserCreate() {
|
|||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
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 className="space-y-2">
|
||||
|
|
@ -147,12 +190,27 @@ export default function UserCreate() {
|
|||
value={formData.password_confirmation}
|
||||
onChange={handleInputChange}
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
<CardFooter className="flex justify-end gap-3 pt-2 pb-6 px-6 border-t border-gray-800">
|
||||
|
|
|
|||
|
|
@ -1,85 +1,35 @@
|
|||
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { Link } from "react-router-dom"
|
||||
import {
|
||||
Search,
|
||||
Mail,
|
||||
Phone,
|
||||
MapPin,
|
||||
Facebook,
|
||||
Twitter,
|
||||
Instagram,
|
||||
Youtube,
|
||||
Clock,
|
||||
Users,
|
||||
Database,
|
||||
Award,
|
||||
} from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@/components/ui/carousel"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import SearchForm from "./SearchForm"
|
||||
import SearchForm from "@/components/home/SearchForm"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import ApiService from "@/services/apiService"
|
||||
import type { Person } from "@/types/api"
|
||||
|
||||
export default function Home() {
|
||||
const [currentSlide, setCurrentSlide] = useState(0)
|
||||
export default function Index() {
|
||||
const navigate = useNavigate()
|
||||
const [total, setTotal] = useState(0)
|
||||
const [migrants, setMigrants] = useState<Person[]>([])
|
||||
const API_BASE_URL = "http://localhost:8000"
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const response = await ApiService.getMigrants(1, 10)
|
||||
const response = await ApiService.getMigrants(1, 6)
|
||||
setMigrants(response.data || [])
|
||||
setTotal(response.total || 0)
|
||||
}
|
||||
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) => {
|
||||
e.preventDefault()
|
||||
const contactSection = document.getElementById("contact")
|
||||
|
|
@ -93,141 +43,129 @@ export default function Home() {
|
|||
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
<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
|
||||
</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
|
||||
</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
|
||||
</a>
|
||||
<a
|
||||
href="#contact"
|
||||
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
|
||||
</a>
|
||||
</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>
|
||||
</header>
|
||||
|
||||
<main className="flex-1">
|
||||
{/* Hero Section with Background Carousel and Search */}
|
||||
<section className="relative w-full h-screen flex items-center justify-center overflow-hidden">
|
||||
{/* Background Carousel */}
|
||||
<div className="absolute inset-0">
|
||||
{backgroundImages.map((image, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`absolute inset-0 transition-opacity duration-1000 ${index === currentSlide ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<img src={image.src || `${import.meta.env.BASE_URL}assets/placeholder.svg`} alt={image.alt} className="w-full h-full object-cover" />
|
||||
<div className="absolute inset-0 bg-black/60" />
|
||||
{/* Hero Section with Search Form */}
|
||||
<section className="w-full py-12 md:py-16 lg:py-20 bg-[#E8DCCA]">
|
||||
{/* No animated background elements */}
|
||||
|
||||
<div className="container px-4 md:px-6">
|
||||
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
||||
{/* Left side - Hero content */}
|
||||
<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
|
||||
<span className="block">
|
||||
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>
|
||||
|
||||
{/* Centered Content with Search */}
|
||||
<div className="relative z-10 text-center text-white px-4 md:px-6 max-w-5xl mx-auto">
|
||||
<h1 className="text-4xl md:text-6xl lg:text-7xl font-bold tracking-tighter font-serif mb-4">
|
||||
Find Your Italian Heritage
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl lg:text-2xl mb-8 text-white/90 max-w-3xl mx-auto leading-relaxed">
|
||||
Search our comprehensive database of Italian migrants to the Northern Territory. Discover family
|
||||
histories, personal stories, and cultural contributions spanning over a century.
|
||||
</p>
|
||||
|
||||
{/* Main Search Form */}
|
||||
<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
|
||||
size="lg"
|
||||
variant="outline"
|
||||
onClick={() => navigate("/search-results")}
|
||||
className="border-white text-black hover:bg-white hover:text-black px-8 py-3 text-lg"
|
||||
>
|
||||
Browse All Records
|
||||
</Button>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<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>
|
||||
{/* Right side - Search Form */}
|
||||
<div className="bg-white rounded-lg p-8 border shadow-md">
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-[#9B2335] mb-2">Search Database</h2>
|
||||
<p className="text-muted-foreground">Find your Italian ancestors</p>
|
||||
</div>
|
||||
|
||||
<SearchForm />
|
||||
|
||||
<div className="text-center">
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => navigate("/search-results")}
|
||||
className="w-full bg-[#01796F] hover:bg-[#015a54] text-white"
|
||||
>
|
||||
Browse All Records
|
||||
</Button>
|
||||
</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>
|
||||
|
||||
{/* Quick Search Tips */}
|
||||
<section className="w-full py-12 bg-[#E8DCCA]">
|
||||
{/* Search Tips Section */}
|
||||
<section className="w-full py-16 bg-gray-100">
|
||||
<div className="container px-4 md:px-6">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-[#9B2335] font-serif mb-4">Search Tips</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-bold text-[#9B2335] font-serif mb-4">Search Tips</h2>
|
||||
<p className="text-gray-600 max-w-2xl mx-auto">
|
||||
Get the most out of your search with these helpful tips
|
||||
</p>
|
||||
</div>
|
||||
<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">
|
||||
<Search className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<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.
|
||||
</p>
|
||||
</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">
|
||||
<span className="text-white font-bold">📅</span>
|
||||
<Clock className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<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.
|
||||
</p>
|
||||
</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">
|
||||
<span className="text-white font-bold">🗺️</span>
|
||||
<MapPin className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -235,335 +173,93 @@ export default function Home() {
|
|||
</div>
|
||||
</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="flex flex-col items-center justify-center space-y-4 text-center">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-3xl font-bold tracking-tighter sm:text-5xl font-serif text-[#1A2A57]">
|
||||
Featured Stories
|
||||
</h2>
|
||||
<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 className="text-center mb-12">
|
||||
<h2 className="text-3xl font-bold text-[#9B2335] font-serif mb-4">Featured Stories</h2>
|
||||
<p className="text-gray-600 max-w-3xl mx-auto text-lg">
|
||||
Discover remarkable personal journeys found in our database. Each story represents courage, determination, and the pursuit of a better life.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-5xl py-12">
|
||||
<Carousel className="w-full">
|
||||
<CarouselContent>
|
||||
{migrants.map((person) => {
|
||||
// Find the profile photo
|
||||
const profilePhoto = person.photos?.find((photo) => photo.is_profile_photo)
|
||||
|
||||
return (
|
||||
<CarouselItem key={person.person_id} className="md:basis-1/2 lg:basis-1/2">
|
||||
<div className="p-2">
|
||||
<Card className="h-full">
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="aspect-video overflow-hidden rounded-lg">
|
||||
<img
|
||||
src={
|
||||
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 className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
|
||||
{migrants.slice(0, 6).map((person) => {
|
||||
const profilePhoto = person.photos?.find((photo) => photo.is_profile_photo)
|
||||
return (
|
||||
<Card key={person.person_id} className="overflow-hidden pt-0">
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="aspect-square overflow-hidden rounded-xl">
|
||||
<img
|
||||
src={
|
||||
profilePhoto
|
||||
? `${import.meta.env.BASE_URL}${profilePhoto.file_path}`
|
||||
: `${import.meta.env.BASE_URL}assets/placeholder.png?height=400&width=600`
|
||||
}
|
||||
alt={`Portrait of ${person.full_name || person.surname || "Unnamed"}`}
|
||||
className="object-cover w-full h-full hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
)
|
||||
})}
|
||||
</CarouselContent>
|
||||
<CarouselPrevious />
|
||||
<CarouselNext />
|
||||
</Carousel>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-bold text-[#9B2335]">
|
||||
{person.full_name || person.surname || "Unnamed"}
|
||||
</h3>
|
||||
<p className="text-sm font-medium">
|
||||
Arrived{" "}
|
||||
{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>
|
||||
</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="grid gap-6 lg:grid-cols-2 lg:gap-12 items-center">
|
||||
<div className="space-y-4 text-center lg:text-left">
|
||||
<h2 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl font-serif text-[#1A2A57]">
|
||||
Preserving Our Heritage
|
||||
</h2>
|
||||
<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">
|
||||
This digital archive aims to preserve and celebrate the contributions of Italian migrants to the
|
||||
Northern Territory. By documenting their stories, photographs, and historical records, we ensure that
|
||||
their legacy continues for generations to come.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center lg:justify-start">
|
||||
<Button className="bg-[#01796F] hover:bg-[#015a54] text-white">Contribute a Story</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-[#9B2335] text-[#9B2335] hover:bg-[#9B2335] hover:text-white"
|
||||
>
|
||||
View Gallery
|
||||
</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 className="max-w-4xl mx-auto text-center">
|
||||
<h2 className="text-3xl font-bold text-[#9B2335] font-serif mb-6">Preserving Our Heritage</h2>
|
||||
<p className="text-gray-600 text-lg leading-relaxed mb-8">
|
||||
This digital archive aims to preserve and celebrate the contributions of Italian migrants to the
|
||||
Northern Territory. By documenting their stories, photographs, and historical records, we ensure that
|
||||
their legacy continues for generations to come.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Button className="bg-[#01796F] hover:bg-[#015a54] text-white">
|
||||
Contribute a Story
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-[#9B2335] text-[#9B2335] hover:bg-gray-100"
|
||||
>
|
||||
View Gallery
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Enhanced Footer with Contact Information */}
|
||||
<footer id="contact" className="bg-[#1A2A57] text-white">
|
||||
<div className="container px-4 md:px-6">
|
||||
{/* 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 id="contact" className="bg-[#1A2A57] text-white border-t">
|
||||
// ... keep existing code (footer content)
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { MapPin, Calendar, User, Home, Ship, ImageIcon, FileText } from 'lucide-
|
|||
import apiService from '@/services/apiService';
|
||||
import type { Person } from '@/types/api';
|
||||
import { formatDate } from '@/utils/date';
|
||||
const API_BASE_URL = "http://localhost:8000";
|
||||
|
||||
export default function MigrantProfile() {
|
||||
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="sticky top-20 space-y-4">
|
||||
<div className="overflow-hidden rounded-xl border-4 border-white shadow-lg">
|
||||
<img
|
||||
<img
|
||||
src={profilePhoto && profilePhoto.file_path
|
||||
? profilePhoto.file_path.startsWith('http')
|
||||
? profilePhoto.file_path
|
||||
: `${API_BASE_URL}${profilePhoto.file_path}`
|
||||
: `${import.meta.env.BASE_URL}assets/placeholder.svg?height=600&width=450`}
|
||||
alt={`${migrant.full_name || 'Migrant photo'}`}
|
||||
: `${apiService.baseURL}${profilePhoto.file_path}`
|
||||
: `${import.meta.env.BASE_URL}assets/placeholder.png`}
|
||||
alt={`${migrant.full_name || 'Migrant profile'}`}
|
||||
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 className="flex justify-between">
|
||||
|
|
@ -188,13 +183,12 @@ export default function MigrantProfile() {
|
|||
src={photo.file_path
|
||||
? photo.file_path.startsWith('http')
|
||||
? photo.file_path
|
||||
: `${API_BASE_URL}${photo.file_path}`
|
||||
: `${import.meta.env.BASE_URL}assets/placeholder.svg?height=400&width=600`}
|
||||
: `${apiService.baseURL}${photo.file_path}`
|
||||
: `${import.meta.env.BASE_URL}assets/placeholder.png?height=400&width=600`}
|
||||
alt={photo.caption || "Migrant photo"}
|
||||
className="aspect-video object-cover w-full"
|
||||
onError={(e) => {
|
||||
// Handle image loading errors
|
||||
e.currentTarget.src = `${import.meta.env.BASE_URL}assets/placeholder.svg?height=400&width=600`;
|
||||
e.currentTarget.src = `${import.meta.env.BASE_URL}assets/placeholder.png?height=400&width=600`;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
|
||||
import { Search, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Input } from '../ui/input';
|
||||
import { Button } from '../ui/button';
|
||||
import { Label } from '../ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useSearch } from '@/hooks/useSearch';
|
||||
|
||||
const advancedSearchFields = [
|
||||
|
|
@ -34,67 +35,86 @@ export default function SearchForm() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto p-4 space-y-6">
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<Input
|
||||
className='text-[#000] font-bold'
|
||||
id="searchTerm"
|
||||
placeholder="Search by name..."
|
||||
value={fields.searchTerm}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<Button type="submit" className="bg-[#9B2335] hover:bg-[#7a1c2a] text-white">
|
||||
<div className="w-full space-y-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Main Search */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
id="searchTerm"
|
||||
placeholder="Search by name..."
|
||||
value={fields.searchTerm}
|
||||
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"
|
||||
/>
|
||||
</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
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
{/* Advanced Search Toggle */}
|
||||
<div className="text-center">
|
||||
<Button
|
||||
type="button"
|
||||
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}
|
||||
>
|
||||
{isAdvancedSearch ? (
|
||||
<>
|
||||
<ChevronUp className="mr-2 h-4 w-4" />
|
||||
Simple Search
|
||||
Hide Advanced Search
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="mr-2 h-4 w-4" />
|
||||
Advanced Search
|
||||
Show Advanced Search
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Advanced Search Fields */}
|
||||
{isAdvancedSearch && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4">
|
||||
{advancedSearchFields.map(({ id, label, placeholder }) => (
|
||||
<div key={id} className="space-y-2">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<Input
|
||||
id={id}
|
||||
placeholder={placeholder}
|
||||
value={fields[id as keyof typeof fields]}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>Actions</Label>
|
||||
<Button type="button" variant="ghost" className="w-full" onClick={clearAllFields}>
|
||||
<div className="bg-gray-50 rounded-md p-6 border border-gray-200 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{advancedSearchFields.map(({ id, label, placeholder }) => (
|
||||
<div key={id} className="space-y-2">
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className="text-sm font-medium text-gray-700"
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
<Input
|
||||
id={id}
|
||||
placeholder={placeholder}
|
||||
value={fields[id as keyof typeof fields]}
|
||||
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
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ export default function SearchResults() {
|
|||
src={
|
||||
migrant.profilePhoto
|
||||
? `${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"}
|
||||
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() {
|
||||
try {
|
||||
const currentUser = await apiService.fetchCurrentUser();
|
||||
console.log("Fetched user:", currentUser);
|
||||
setUser(currentUser);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch user info:", error);
|
||||
}
|
||||
}
|
||||
loadUser();
|
||||
|
|
|
|||
|
|
@ -255,7 +255,7 @@ export default function ProfileSettings() {
|
|||
<div className="h-24 w-24 rounded-full bg-gray-200 overflow-hidden">
|
||||
{profile.avatar ? (
|
||||
<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"
|
||||
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 { useSearchParams } from "react-router-dom";
|
||||
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 result: Record<string, string> = {};
|
||||
|
|
@ -11,11 +11,9 @@ const getSearchParamsObject = (params: URLSearchParams): Record<string, string>
|
|||
return result;
|
||||
};
|
||||
|
||||
|
||||
export function useMigrantsSearch(perPage = 10) {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [migrants, setMigrants] = useState<Person[]>([]);
|
||||
const [photosById, setPhotosById] = useState<Record<number, Photo[]>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState<number>(1);
|
||||
|
|
@ -27,48 +25,57 @@ export function useMigrantsSearch(perPage = 10) {
|
|||
|
||||
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 () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const filters = getSearchParamsObject(searchParams);
|
||||
const response = await apiService.getMigrants(page, perPage, filters);
|
||||
|
||||
const fetchedMigrants = response.data || [];
|
||||
setMigrants(fetchedMigrants);
|
||||
const rawMigrants = response.data || [];
|
||||
|
||||
const enrichedMigrants = await Promise.all(
|
||||
rawMigrants.map((migrant: Person) => fetchPhotosForMigrant(migrant))
|
||||
);
|
||||
|
||||
setMigrants(enrichedMigrants);
|
||||
setPagination({
|
||||
currentPage: response.current_page ?? 1,
|
||||
totalPages: response.last_page ?? 1,
|
||||
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) {
|
||||
console.error("Failed to fetch migrants", err);
|
||||
setError("Failed to fetch migrants data.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
@ -93,7 +100,6 @@ export function useMigrantsSearch(perPage = 10) {
|
|||
|
||||
return {
|
||||
migrants,
|
||||
photosById, // expose photos by person_id
|
||||
loading,
|
||||
error,
|
||||
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 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 {
|
||||
private api: AxiosInstance;
|
||||
|
||||
|
|
@ -8,17 +12,17 @@ class ApiService {
|
|||
this.api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
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) => {
|
||||
const token = localStorage.getItem("token");
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
});
|
||||
|
||||
// Response Interceptor
|
||||
// Handle 401 Unauthorized responses by logging out the user
|
||||
this.api.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
|
|
@ -31,40 +35,42 @@ class ApiService {
|
|||
}
|
||||
);
|
||||
}
|
||||
|
||||
get baseURL(): string {
|
||||
return this.api.defaults.baseURL || "";
|
||||
}
|
||||
|
||||
// --- AUTH ---
|
||||
/** User authentication: login with email & password */
|
||||
async login(params: { email: string; password: string }) {
|
||||
// First get CSRF cookie
|
||||
await this.api.get("/sanctum/csrf-cookie");
|
||||
|
||||
// Then login
|
||||
return this.api.post("/api/login", params).then((res) => {
|
||||
console.log("Token:", res.data.token);
|
||||
localStorage.setItem("token", res.data.token);
|
||||
localStorage.setItem("user", JSON.stringify(res.data.user));
|
||||
return res.data;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** User registration */
|
||||
async register(params: { name: string; email: string; password: string }) {
|
||||
return this.api.post("/api/register", params).then((res) => res.data);
|
||||
}
|
||||
|
||||
/** Create a new user account */
|
||||
async createUser(user: User) {
|
||||
return this.api.post("/api/register", user).then((res) => res.data);
|
||||
}
|
||||
|
||||
/** Update current user's account information */
|
||||
async updateUser(user: User) {
|
||||
return this.api.put("/api/user/account", user).then(res => res.data);
|
||||
}
|
||||
|
||||
/** Fetch a list of all users */
|
||||
async displayAllUsers() {
|
||||
return this.api.get("/api/users").then(res => res.data.data);
|
||||
}
|
||||
|
||||
|
||||
/** Logout the current user and clear stored tokens */
|
||||
async logout() {
|
||||
return this.api.post("/api/logout").then((res) => {
|
||||
localStorage.removeItem("token");
|
||||
|
|
@ -72,49 +78,50 @@ class ApiService {
|
|||
return res.data;
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch current user's details */
|
||||
async fetchCurrentUser(): Promise<User> {
|
||||
return this.api.get("/api/user").then((res) => res.data.data.user);
|
||||
}
|
||||
|
||||
|
||||
// --- MIGRANTS ---
|
||||
/** Fetch a paginated list of migrants */
|
||||
async getMigrants(page = 1, perPage = 10, filters = {}): Promise<Person> {
|
||||
return this.api.get("/api/migrants", {
|
||||
params: { page, per_page: perPage, ...filters },
|
||||
}).then((res) => res.data.data);
|
||||
}
|
||||
|
||||
/** Fetch migrants data by a specific URL (for pagination, filtering, etc.) */
|
||||
async getMigrantsByUrl(url: string) {
|
||||
return this.api.get(url).then((res) => res.data);
|
||||
}
|
||||
|
||||
/** Fetch a migrant's details by ID */
|
||||
async getMigrantById(id: string | number): Promise<Person> {
|
||||
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> {
|
||||
return this.api.post("/api/migrants", formData, {
|
||||
headers: { "Content-Type": "multipart/form-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> {
|
||||
// Use POST with method spoofing instead of PUT for better FormData compatibility
|
||||
formData.append('_method', 'PUT');
|
||||
|
||||
return this.api.post(`/api/migrants/${personId}`, formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
}).then((res) => {
|
||||
return res.data.data;
|
||||
}).catch(error => {
|
||||
throw error;
|
||||
});
|
||||
}).then((res) => res.data.data);
|
||||
}
|
||||
|
||||
|
||||
/** Delete a migrant entry by ID */
|
||||
async deleteMigrant(id: string | number) {
|
||||
return this.api.delete(`/api/migrants/${id}`).then((res) => res.data);
|
||||
}
|
||||
|
||||
/** Fetch all photos for a specific migrant */
|
||||
async getPhotos(id: number): Promise<Photo[]> {
|
||||
return this.api.get(`/api/migrants/${id}/photos`).then((res) => {
|
||||
const photosData = res.data.data.photos;
|
||||
|
|
@ -122,12 +129,13 @@ class ApiService {
|
|||
});
|
||||
}
|
||||
|
||||
// --- DASHBOARD ---
|
||||
/** Fetch dashboard statistics */
|
||||
async getDashboardStats(): Promise<DashboardResponse> {
|
||||
const response = await this.api.get<DashboardResponse>("/api/dashboard/stats");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** Fetch recent activity logs */
|
||||
async getRecentActivityLogs() {
|
||||
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