mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
461 lines
20 KiB
React
461 lines
20 KiB
React
// ─── pages/users/EditUser.jsx ─────────────────────────────────────────────────
|
|
import { useEffect, useState } from "react";
|
|
import { useParams, useNavigate } from "react-router-dom";
|
|
import { useUsers } from "@/contexts/AdminUserContext";
|
|
import { toast } from "sonner";
|
|
|
|
import { useForm, Controller, useFieldArray } from "react-hook-form";
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { z } from "zod";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Spinner } from "@/components/ui/spinner";
|
|
import {
|
|
Select, SelectContent, SelectItem,
|
|
SelectTrigger, SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { ArrowLeft, Save, Plus, Trash2, UserCircle2 } from "lucide-react";
|
|
|
|
// ─── Schema ───────────────────────────────────────────────────────────────────
|
|
const addressSchema = z.object({
|
|
address_type: z.enum(["home", "work", "province", "other"]),
|
|
street: z.string().min(1, "Street is required."),
|
|
city: z.string().min(1, "City is required."),
|
|
state: z.string().min(1, "State is required."),
|
|
country: z.string().min(1, "Country is required."),
|
|
zip: z.string().min(1, "ZIP is required."),
|
|
});
|
|
|
|
const phoneSchema = z.object({
|
|
phone_type: z.enum(["mobile", "home", "work", "other"]),
|
|
country_code: z.string().min(1, "Country code is required."),
|
|
number: z.string().min(1, "Number is required."),
|
|
});
|
|
|
|
const editUserSchema = z.object({
|
|
acc_type: z.enum(["user", "staff", "admin"]),
|
|
is_active: z.boolean(),
|
|
given_name: z.string().min(1, "First name is required."),
|
|
middle_name: z.string().optional(),
|
|
last_name: z.string().min(1, "Last name is required."),
|
|
extension_name: z.string().optional(),
|
|
date_of_birth: z.string().optional(),
|
|
occupation: z.string().optional(),
|
|
addresses: z.array(addressSchema).optional(),
|
|
phone_number: z.array(phoneSchema).optional(),
|
|
});
|
|
|
|
// ─── Defaults ─────────────────────────────────────────────────────────────────
|
|
const DEFAULT_ADDRESS = {
|
|
address_type: "home", street: "", city: "",
|
|
state: "", country: "", zip: "",
|
|
};
|
|
|
|
const DEFAULT_PHONE = {
|
|
phone_type: "mobile", country_code: "63", number: "",
|
|
};
|
|
|
|
export default function EditUser() {
|
|
const { id } = useParams();
|
|
const navigate = useNavigate();
|
|
const { user, fetchUser, updateUser, loading } = useUsers();
|
|
|
|
const [avatarPreview, setAvatarPreview] = useState(null);
|
|
const [avatarFile, setAvatarFile] = useState(null);
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
control,
|
|
reset,
|
|
formState: { errors, isDirty },
|
|
} = useForm({
|
|
resolver: zodResolver(editUserSchema),
|
|
defaultValues: {
|
|
acc_type: "user", is_active: true,
|
|
given_name: "", middle_name: "", last_name: "",
|
|
extension_name: "", date_of_birth: "", occupation: "",
|
|
addresses: [], phone_number: [],
|
|
},
|
|
});
|
|
|
|
// ─── Field arrays ──────────────────────────────────────────────────────────
|
|
const {
|
|
fields: addressFields,
|
|
append: appendAddress,
|
|
remove: removeAddress,
|
|
} = useFieldArray({ control, name: "addresses" });
|
|
|
|
const {
|
|
fields: phoneFields,
|
|
append: appendPhone,
|
|
remove: removePhone,
|
|
} = useFieldArray({ control, name: "phone_number" });
|
|
|
|
// ─── Fetch user ────────────────────────────────────────────────────────────
|
|
useEffect(() => { fetchUser(id); }, [id]);
|
|
|
|
// ─── Populate form ────────────────────────────────────────────────────────
|
|
useEffect(() => {
|
|
if (!user) return;
|
|
const info = user.personal_info ?? {};
|
|
const name = info.name ?? {};
|
|
|
|
setAvatarPreview(info.avatar?.url ?? null);
|
|
|
|
reset({
|
|
acc_type: user.acc_type ?? "user",
|
|
is_active: user.is_active ?? true,
|
|
given_name: name.given_name ?? "",
|
|
middle_name: name.middle_name ?? "",
|
|
last_name: name.last_name ?? "",
|
|
extension_name: name.extension_name ?? "",
|
|
date_of_birth: info.date_of_birth ?? "",
|
|
occupation: info.occupation ?? "",
|
|
addresses: (info.addresses ?? []).map(({ full_address: _, ...rest }) => rest),
|
|
phone_number: (info.phone_number ?? []).map(({ full_number: _, ...rest }) => rest),
|
|
});
|
|
}, [user, reset]);
|
|
|
|
// ─── Avatar preview ───────────────────────────────────────────────────────
|
|
const handleAvatarChange = (e) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
setAvatarFile(file);
|
|
setAvatarPreview(URL.createObjectURL(file));
|
|
};
|
|
|
|
// ─── Submit ───────────────────────────────────────────────────────────────
|
|
const onSubmit = async (values) => {
|
|
const payload = {
|
|
acc_type: values.acc_type,
|
|
is_active: values.is_active,
|
|
personal_info: {
|
|
...user.personal_info,
|
|
date_of_birth: values.date_of_birth,
|
|
occupation: values.occupation,
|
|
name: {
|
|
...user.personal_info?.name,
|
|
given_name: values.given_name,
|
|
middle_name: values.middle_name,
|
|
last_name: values.last_name,
|
|
extension_name: values.extension_name,
|
|
},
|
|
addresses: values.addresses,
|
|
phone_number: values.phone_number,
|
|
// ─── avatar handled separately via file upload API ────────────────
|
|
},
|
|
};
|
|
|
|
const res = await updateUser(id, payload);
|
|
if (res) {
|
|
toast("User updated successfully.", {
|
|
action: {
|
|
label: "Close",
|
|
onClick: () => {}
|
|
}
|
|
});
|
|
navigate(`../view/${id}`);
|
|
} else {
|
|
toast("Failed to update user.", {
|
|
action: {
|
|
label: "Close",
|
|
onClick: () => {}
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
if (loading && !user) return (
|
|
<div className="flex items-center justify-center h-screen">
|
|
<Spinner className="size-8" />
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<form
|
|
onSubmit={handleSubmit(onSubmit)}
|
|
className="lg:container lg:mx-auto px-4 py-6 flex flex-col gap-6"
|
|
>
|
|
{/* ─── Header ──────────────────────────────────────────────────────── */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/users`)}>
|
|
<ArrowLeft className="size-4" />
|
|
</Button>
|
|
<div>
|
|
<h1 className="text-2xl font-semibold tracking-tight">Edit User</h1>
|
|
<p className="text-muted-foreground text-sm">{user?.email}</p>
|
|
</div>
|
|
</div>
|
|
<Button type="submit" disabled={loading || !isDirty}>
|
|
{loading ? <Spinner className="size-4 mr-2" /> : <Save className="size-4 mr-2" />}
|
|
Save Changes
|
|
</Button>
|
|
</div>
|
|
|
|
{/* ─── Avatar ──────────────────────────────────────────────────────── */}
|
|
<EditSection title="Profile Picture">
|
|
<div className="col-span-full flex items-center gap-6">
|
|
{/* Preview */}
|
|
<div className="size-24 rounded-full border-2 border-border overflow-hidden bg-muted flex items-center justify-center shrink-0">
|
|
{avatarPreview
|
|
? <img src={avatarPreview} alt="Avatar" className="object-cover w-full h-full" />
|
|
: <UserCircle2 className="size-12 text-muted-foreground" />
|
|
}
|
|
</div>
|
|
|
|
{/* Upload */}
|
|
<div className="flex flex-col gap-2">
|
|
<Label className="text-xs text-muted-foreground">
|
|
Upload new photo
|
|
</Label>
|
|
<Input
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={handleAvatarChange}
|
|
className="w-fit cursor-pointer text-xs"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
JPG, PNG or WEBP. Avatar upload requires a separate file upload API.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</EditSection>
|
|
|
|
{/* ─── Account Information ─────────────────────────────────────────── */}
|
|
<EditSection title="Account Information">
|
|
<EditField label="Account Type" error={errors.acc_type?.message} required>
|
|
<Controller
|
|
name="acc_type"
|
|
control={control}
|
|
render={({ field }) => (
|
|
<Select value={field.value} onValueChange={field.onChange}>
|
|
<SelectTrigger className={errors.acc_type ? "border-destructive" : ""}>
|
|
<SelectValue placeholder="Select type" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="user">User</SelectItem>
|
|
<SelectItem value="staff">Staff</SelectItem>
|
|
<SelectItem value="admin">Admin</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
/>
|
|
</EditField>
|
|
|
|
<EditField label="Status" error={errors.is_active?.message}>
|
|
<Controller
|
|
name="is_active"
|
|
control={control}
|
|
render={({ field }) => (
|
|
<Select
|
|
value={String(field.value)}
|
|
onValueChange={(v) => field.onChange(v === "true")}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select status" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="true">Active</SelectItem>
|
|
<SelectItem value="false">Inactive</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
/>
|
|
</EditField>
|
|
</EditSection>
|
|
|
|
{/* ─── Personal Information ─────────────────────────────────────────── */}
|
|
<EditSection title="Personal Information">
|
|
<EditField label="First Name" error={errors.given_name?.message} required>
|
|
<Input {...register("given_name")} className={errors.given_name ? "border-destructive" : ""} placeholder="First name" />
|
|
</EditField>
|
|
<EditField label="Middle Name" error={errors.middle_name?.message}>
|
|
<Input {...register("middle_name")} placeholder="Middle name" />
|
|
</EditField>
|
|
<EditField label="Last Name" error={errors.last_name?.message} required>
|
|
<Input {...register("last_name")} className={errors.last_name ? "border-destructive" : ""} placeholder="Last name" />
|
|
</EditField>
|
|
<EditField label="Extension Name" error={errors.extension_name?.message}>
|
|
<Input {...register("extension_name")} placeholder="e.g. Jr., Sr." />
|
|
</EditField>
|
|
<EditField label="Date of Birth" error={errors.date_of_birth?.message}>
|
|
<Input type="date" {...register("date_of_birth")} />
|
|
</EditField>
|
|
<EditField label="Occupation" error={errors.occupation?.message}>
|
|
<Input {...register("occupation")} placeholder="Occupation" />
|
|
</EditField>
|
|
</EditSection>
|
|
|
|
{/* ─── Addresses ───────────────────────────────────────────────────── */}
|
|
<div className="bg-card border rounded-lg p-6 flex flex-col gap-4">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
|
Addresses
|
|
</h2>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => appendAddress(DEFAULT_ADDRESS)}
|
|
>
|
|
<Plus className="size-3.5 mr-1" /> Add Address
|
|
</Button>
|
|
</div>
|
|
|
|
{addressFields.length === 0 && (
|
|
<p className="text-sm text-muted-foreground">No addresses added.</p>
|
|
)}
|
|
|
|
{addressFields.map((field, i) => (
|
|
<div key={field.id} className="border rounded-lg p-4 flex flex-col gap-4">
|
|
{/* ─── Address Header ──────────────────────────────────────── */}
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium">Address {i + 1}</span>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="text-destructive hover:text-destructive"
|
|
onClick={() => removeAddress(i)}
|
|
>
|
|
<Trash2 className="size-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="grid xs:grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
<EditField label="Type" error={errors.addresses?.[i]?.address_type?.message} required>
|
|
<Controller
|
|
name={`addresses.${i}.address_type`}
|
|
control={control}
|
|
render={({ field }) => (
|
|
<Select value={field.value} onValueChange={field.onChange}>
|
|
<SelectTrigger><SelectValue placeholder="Select type" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="home">Home</SelectItem>
|
|
<SelectItem value="work">Work</SelectItem>
|
|
<SelectItem value="province">Province</SelectItem>
|
|
<SelectItem value="other">Other</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
/>
|
|
</EditField>
|
|
<EditField label="Street" error={errors.addresses?.[i]?.street?.message} required>
|
|
<Input {...register(`addresses.${i}.street`)} placeholder="Street" />
|
|
</EditField>
|
|
<EditField label="City" error={errors.addresses?.[i]?.city?.message} required>
|
|
<Input {...register(`addresses.${i}.city`)} placeholder="City" />
|
|
</EditField>
|
|
<EditField label="State / Region" error={errors.addresses?.[i]?.state?.message} required>
|
|
<Input {...register(`addresses.${i}.state`)} placeholder="State / Region" />
|
|
</EditField>
|
|
<EditField label="Country" error={errors.addresses?.[i]?.country?.message} required>
|
|
<Input {...register(`addresses.${i}.country`)} placeholder="Country" />
|
|
</EditField>
|
|
<EditField label="ZIP Code" error={errors.addresses?.[i]?.zip?.message} required>
|
|
<Input {...register(`addresses.${i}.zip`)} placeholder="ZIP Code" />
|
|
</EditField>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* ─── Phone Numbers ───────────────────────────────────────────────── */}
|
|
<div className="bg-card border rounded-lg p-6 flex flex-col gap-4">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
|
Phone Numbers
|
|
</h2>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => appendPhone(DEFAULT_PHONE)}
|
|
>
|
|
<Plus className="size-3.5 mr-1" /> Add Phone
|
|
</Button>
|
|
</div>
|
|
|
|
{phoneFields.length === 0 && (
|
|
<p className="text-sm text-muted-foreground">No phone numbers added.</p>
|
|
)}
|
|
|
|
{phoneFields.map((field, i) => (
|
|
<div key={field.id} className="border rounded-lg p-4 flex flex-col gap-4">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium">Phone {i + 1}</span>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="text-destructive hover:text-destructive"
|
|
onClick={() => removePhone(i)}
|
|
>
|
|
<Trash2 className="size-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="grid xs:grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
<EditField label="Type" error={errors.phone_number?.[i]?.phone_type?.message} required>
|
|
<Controller
|
|
name={`phone_number.${i}.phone_type`}
|
|
control={control}
|
|
render={({ field }) => (
|
|
<Select value={field.value} onValueChange={field.onChange}>
|
|
<SelectTrigger><SelectValue placeholder="Select type" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="mobile">Mobile</SelectItem>
|
|
<SelectItem value="home">Home</SelectItem>
|
|
<SelectItem value="work">Work</SelectItem>
|
|
<SelectItem value="other">Other</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
/>
|
|
</EditField>
|
|
<EditField label="Country Code" error={errors.phone_number?.[i]?.country_code?.message} required>
|
|
<Input {...register(`phone_number.${i}.country_code`)} placeholder="e.g. 63" />
|
|
</EditField>
|
|
<EditField label="Number" error={errors.phone_number?.[i]?.number?.message} required>
|
|
<Input {...register(`phone_number.${i}.number`)} placeholder="e.g. 9123456789" />
|
|
</EditField>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
</form>
|
|
);
|
|
}
|
|
|
|
// ─── Reusable EditSection ─────────────────────────────────────────────────────
|
|
function EditSection({ title, children }) {
|
|
return (
|
|
<div className="bg-card border rounded-lg p-6 flex flex-col gap-4">
|
|
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
|
{title}
|
|
</h2>
|
|
<div className="grid xs:grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Reusable EditField ───────────────────────────────────────────────────────
|
|
function EditField({ label, error, required, children }) {
|
|
return (
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label className="text-xs text-muted-foreground">
|
|
{label}
|
|
{required && <span className="text-destructive ml-1">*</span>}
|
|
</Label>
|
|
{children}
|
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
</div>
|
|
);
|
|
} |