This commit is contained in:
rgrgogu
2026-05-07 23:35:31 +08:00
parent 42b842c480
commit ca6eae1d56
35 changed files with 1637 additions and 207 deletions
@@ -0,0 +1,26 @@
import { House } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import ArchiveGroupTable from "../../components/user_groups/ArchiveGroupTable";
export default function ArchivedGroupList() {
const items = [
{ label: "Home", icon: <House className="size-4" />, to: `/admin/users` },
{ label: "User Group", to: `/admin/users/groups` },
{ label: "Archived" },
]
return (
<section className="bg-muted/60 h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6 ">
<AppBreadcrumb items={items} />
</div>
<div className="w-full">
<ArchiveGroupTable />
</div>
</div>
</section>
)
}
@@ -1,11 +1,25 @@
import React from 'react'
import { House } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import GroupTable from "../../components/user_groups/GroupTable";
export default function GroupList() {
const items = [
{ label: "Home", icon: <House className="size-4" />, to: `/admin/users` },
{ label: "User Groups" },
]
const GroupList = () => {
return (
<div>
GroupList
</div>
)
}
<section className="bg-muted/60 h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6 ">
<AppBreadcrumb items={items} />
</div>
export default GroupList
<div className="w-full">
<GroupTable />
</div>
</div>
</section>
)
}
@@ -0,0 +1,484 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useForm, useFieldArray, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { ChevronRight, ChevronLeft, Check, User, FileText } from "lucide-react";
import { useUsers } from "@/contexts/AdminUserContext";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
// ─── Zod schema ───────────────────────────────────────────────────────────────
const phoneSchema = z.object({
phone_type: z.enum(["mobile", "home", "work"]),
country_code: z.string().min(1, "Required"),
number: z.string().min(1, "Number is required").regex(/^\d+$/, "Digits only"),
});
const addressSchema = z.object({
address_type: z.enum(["home", "province", "work"]),
street: z.string().min(1, "Street is required"),
city: z.string().min(1, "City is required"),
state: z.string().min(1, "State is required"),
zip: z.string().min(1, "ZIP is required"),
country: z.string().min(1, "Country is required"),
});
const staffUserSchema = z.object({
email: z.string().min(1, "Email is required").email("Invalid email format"),
last_name: z.string().min(1, "Last name is required"),
given_name: z.string().min(1, "Given name is required"),
middle_name: z.string().optional(),
extension_name: z.string().optional(),
date_of_birth: z.string().optional(),
occupation: z.string().optional(),
phones: z.array(phoneSchema).min(1, "At least one phone number is required"),
addresses: z.array(addressSchema).min(1, "At least one address is required"),
});
// ─── Steps ────────────────────────────────────────────────────────────────────
const STEPS = [
{ id: 0, label: "Personal Information", icon: User },
{ id: 1, label: "Summary", icon: FileText },
];
// ─── Default values ───────────────────────────────────────────────────────────
const DEFAULT_VALUES = {
email: "",
last_name: "",
given_name: "",
middle_name: "",
extension_name: "",
date_of_birth: "",
occupation: "",
phones: [{ phone_type: "mobile", country_code: "63", number: "" }],
addresses: [{ address_type: "home", street: "", city: "", state: "", zip: "", country: "Philippines" }],
};
// ─── Field wrapper ────────────────────────────────────────────────────────────
function Field({ label, required, error, children }) {
return (
<div className="space-y-1.5">
<Label className="text-sm font-medium">
{label}{required && <span className="text-destructive ml-0.5">*</span>}
</Label>
{children}
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
}
// ─── Step 1 — Personal Information ───────────────────────────────────────────
function StepPersonal({ control, register, errors }) {
const {
fields: phoneFields,
append: appendPhone,
remove: removePhone,
} = useFieldArray({ control, name: "phones" });
const {
fields: addressFields,
append: appendAddress,
remove: removeAddress,
} = useFieldArray({ control, name: "addresses" });
return (
<div className="space-y-6">
{/* ── Name ── */}
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">Name</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Field label="Last Name" required error={errors.last_name?.message}>
<Input {...register("last_name")} placeholder="Dela Cruz" />
</Field>
<Field label="Given Name" required error={errors.given_name?.message}>
<Input {...register("given_name")} placeholder="Juan" />
</Field>
<Field label="Middle Name" error={errors.middle_name?.message}>
<Input {...register("middle_name")} placeholder="Santos" />
</Field>
<Field label="Extension Name" error={errors.extension_name?.message}>
<Input {...register("extension_name")} placeholder="Jr., Sr., III" />
</Field>
<Field label="Date of Birth" error={errors.date_of_birth?.message}>
<Input type="date" {...register("date_of_birth")} />
</Field>
<Field label="Occupation" error={errors.occupation?.message}>
<Input {...register("occupation")} placeholder="Sales Agent" />
</Field>
</div>
</div>
<Separator />
{/* ── Credentials ── */}
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">Account Credentials</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Field label="Email" required error={errors.email?.message}>
<Input type="email" {...register("email")} placeholder="juan@example.com" />
</Field>
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground sm:col-span-2">
<span>🔐</span>
<span>A temporary password will be auto-generated and sent to the provided email address.</span>
</div>
</div>
</div>
<Separator />
{/* ── Phone Numbers ── */}
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">Phone Numbers</p>
<div className="space-y-3">
{phoneFields.map((field, i) => (
<div key={field.id} className="border border-border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Phone {i + 1}</span>
{phoneFields.length > 1 && (
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive h-7 px-2"
onClick={() => removePhone(i)}
>
Remove
</Button>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Field label="Type" error={errors.phones?.[i]?.phone_type?.message}>
<Controller
control={control}
name={`phones.${i}.phone_type`}
render={({ field: f }) => (
<Select value={f.value} onValueChange={f.onChange}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="mobile">Mobile</SelectItem>
<SelectItem value="home">Home</SelectItem>
<SelectItem value="work">Work</SelectItem>
</SelectContent>
</Select>
)}
/>
</Field>
<Field label="Country Code" error={errors.phones?.[i]?.country_code?.message}>
<Controller
control={control}
name={`phones.${i}.country_code`}
render={({ field: f }) => (
<Select value={f.value} onValueChange={f.onChange}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="63">+63 (PH)</SelectItem>
<SelectItem value="1">+1 (US)</SelectItem>
<SelectItem value="44">+44 (UK)</SelectItem>
</SelectContent>
</Select>
)}
/>
</Field>
<Field label="Number" required error={errors.phones?.[i]?.number?.message}>
<Input {...register(`phones.${i}.number`)} placeholder="9123456789" />
</Field>
</div>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={() => appendPhone({ phone_type: "mobile", country_code: "63", number: "" })}
>
+ Add Phone Number
</Button>
{errors.phones?.root?.message && (
<p className="text-xs text-destructive">{errors.phones.root.message}</p>
)}
</div>
</div>
<Separator />
{/* ── Addresses ── */}
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">Addresses</p>
<div className="space-y-3">
{addressFields.map((field, i) => (
<div key={field.id} className="border border-border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Address {i + 1}</span>
{addressFields.length > 1 && (
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive h-7 px-2"
onClick={() => removeAddress(i)}
>
Remove
</Button>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Field label="Address Type" error={errors.addresses?.[i]?.address_type?.message}>
<Controller
control={control}
name={`addresses.${i}.address_type`}
render={({ field: f }) => (
<Select value={f.value} onValueChange={f.onChange}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="home">Home</SelectItem>
<SelectItem value="province">Province</SelectItem>
<SelectItem value="work">Work</SelectItem>
</SelectContent>
</Select>
)}
/>
</Field>
<Field label="Street" required error={errors.addresses?.[i]?.street?.message}>
<Input {...register(`addresses.${i}.street`)} placeholder="123 Mabini St" />
</Field>
<Field label="City" required error={errors.addresses?.[i]?.city?.message}>
<Input {...register(`addresses.${i}.city`)} placeholder="Manila" />
</Field>
<Field label="State / Region" required error={errors.addresses?.[i]?.state?.message}>
<Input {...register(`addresses.${i}.state`)} placeholder="NCR" />
</Field>
<Field label="ZIP Code" required error={errors.addresses?.[i]?.zip?.message}>
<Input {...register(`addresses.${i}.zip`)} placeholder="1000" />
</Field>
<Field label="Country" error={errors.addresses?.[i]?.country?.message}>
<Input {...register(`addresses.${i}.country`)} placeholder="Philippines" />
</Field>
</div>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={() => appendAddress({ address_type: "province", street: "", city: "", state: "", zip: "", country: "Philippines" })}
>
+ Add Address
</Button>
{errors.addresses?.root?.message && (
<p className="text-xs text-destructive">{errors.addresses.root.message}</p>
)}
</div>
</div>
</div>
);
}
// ─── Step 2 — Summary ─────────────────────────────────────────────────────────
function SummaryRow({ label, value }) {
if (!value) return null;
return (
<div className="flex justify-between py-1.5 text-sm">
<span className="text-muted-foreground min-w-[140px]">{label}</span>
<span className="text-foreground text-right">{value}</span>
</div>
);
}
function StepSummary({ data }) {
const fullName =
[data.last_name, [data.given_name, data.middle_name].filter(Boolean).join(" ")]
.filter(Boolean)
.join(", ") + (data.extension_name ? ` ${data.extension_name}` : "");
return (
<div className="space-y-4">
<div className="border border-border rounded-lg p-4 space-y-1">
<div className="flex items-center gap-2 mb-3">
<User className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Personal Information</span>
<Badge variant="secondary" className="ml-auto text-xs">Staff</Badge>
</div>
<SummaryRow label="Full Name" value={fullName || "—"} />
<SummaryRow label="Email" value={data.email} />
<SummaryRow label="Date of Birth" value={data.date_of_birth} />
<SummaryRow label="Occupation" value={data.occupation} />
</div>
{data.phones?.some((p) => p.number) && (
<div className="border border-border rounded-lg p-4 space-y-1">
<p className="text-sm font-medium mb-2">Phone Numbers</p>
{data.phones.filter((p) => p.number).map((p, i) => (
<SummaryRow
key={i}
label={p.phone_type.charAt(0).toUpperCase() + p.phone_type.slice(1)}
value={`+${p.country_code} ${p.number}`}
/>
))}
</div>
)}
{data.addresses?.some((a) => a.street) && (
<div className="border border-border rounded-lg p-4 space-y-1">
<p className="text-sm font-medium mb-2">Addresses</p>
{data.addresses.filter((a) => a.street).map((a, i) => (
<SummaryRow
key={i}
label={a.address_type.charAt(0).toUpperCase() + a.address_type.slice(1)}
value={[a.street, a.city, a.state, a.country, a.zip].filter(Boolean).join(", ")}
/>
))}
</div>
)}
</div>
);
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function AddStaffUserPage() {
const navigate = useNavigate();
const { addStaffUser, loading } = useUsers();
const [step, setStep] = useState(0);
const {
register,
control,
trigger,
getValues,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(staffUserSchema),
defaultValues: DEFAULT_VALUES,
mode: "onTouched",
});
// Validate all fields then advance to summary
const handleNext = async () => {
const valid = await trigger();
if (valid) setStep(1);
};
// Called manually — no <form> tag so no accidental submit
const handleCreate = handleSubmit(async (data) => {
const payload = {
email: data.email,
personal_info: {
name: {
given_name: data.given_name,
last_name: data.last_name,
middle_name: data.middle_name ?? "",
extension_name: data.extension_name ?? "",
},
date_of_birth: data.date_of_birth || null,
occupation: data.occupation ?? "",
phone_number: data.phones,
addresses: data.addresses,
},
};
const res = await addStaffUser(payload);
if (res) navigate("/admin/users/all");
});
return (
// ← plain div, no <form> — prevents any accidental submit on button clicks
<div className="max-w-4xl mx-auto px-4 py-8 space-y-6">
{/* Header */}
<div>
<h1 className="text-xl font-semibold tracking-tight">Add Staff User</h1>
<p className="text-sm text-muted-foreground mt-1">
Creates a new account with staff access level.
</p>
</div>
{/* Stepper */}
<div className="flex items-center gap-0">
{STEPS.map((s, i) => {
const Icon = s.icon;
const isActive = step === i;
const isDone = step > i;
return (
<div key={s.id} className="flex items-center flex-1 last:flex-none">
<div className="flex flex-col items-center gap-1">
<div className={cn(
"h-8 w-8 rounded-full flex items-center justify-center border transition-colors",
isDone && "bg-emerald-600 border-emerald-600 text-white",
isActive && "border-primary bg-primary text-primary-foreground",
!isActive && !isDone && "border-border bg-background text-muted-foreground"
)}>
{isDone ? <Check className="h-4 w-4" /> : <Icon className="h-4 w-4" />}
</div>
<span className={cn(
"text-[11px] font-medium whitespace-nowrap hidden sm:block",
isActive ? "text-foreground" : "text-muted-foreground",
isDone ? "text-emerald-600" : ""
)}>
{s.label}
</span>
</div>
{i < STEPS.length - 1 && (
<div className={cn(
"flex-1 h-px mx-2 mb-4 transition-colors",
step > i ? "bg-emerald-600" : "bg-border"
)} />
)}
</div>
);
})}
</div>
{/* Step content */}
<div className="border border-border rounded-xl p-5 bg-card min-h-[320px]">
<h2 className="text-base font-medium mb-4">{STEPS[step].label}</h2>
{step === 0 && (
<StepPersonal control={control} register={register} errors={errors} />
)}
{step === 1 && (
<StepSummary data={getValues()} />
)}
</div>
{/* Navigation */}
<div className="flex items-center justify-between">
<Button
type="button"
variant="outline"
onClick={step === 0 ? () => navigate(-1) : () => setStep(0)}
>
<ChevronLeft className="h-4 w-4 mr-1" />
{step === 0 ? "Cancel" : "Back"}
</Button>
{step === 0 ? (
<Button type="button" onClick={handleNext}>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
) : (
<Button
type="button" // ← type="button", not "submit"
disabled={loading}
onClick={handleCreate} // ← called manually
className="bg-emerald-600 hover:bg-emerald-700 text-white"
>
{loading ? "Creating..." : "Create Staff User"}
</Button>
)}
</div>
</div>
);
}
-11
View File
@@ -1,11 +0,0 @@
import React from 'react'
const AddUser = () => {
return (
<div>
AddUser
</div>
)
}
export default AddUser
@@ -1,7 +1,7 @@
import { House } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import ArchiveUserTable from "../../components/ArchiveUserTable";
import ArchiveUserTable from "../../components/users/ArchiveUserTable";
export default function ArchivedUserList() {
const items = [
+1 -1
View File
@@ -1,7 +1,7 @@
import { House } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import UsersTable from "../../components/UserTable";
import UsersTable from "../../components/users/UserTable";
export default function UserList() {
const items = [