diff --git a/src/app/(dashboard)/admin/users/new/page.tsx b/src/app/(dashboard)/admin/users/new/page.tsx index 20f77f0..676a555 100644 --- a/src/app/(dashboard)/admin/users/new/page.tsx +++ b/src/app/(dashboard)/admin/users/new/page.tsx @@ -1,24 +1,5 @@ -import { getI18n } from "@/i18n/server" +import { redirect } from "next/navigation" -import NewUserForm from "../_components/new.user.form" - -export default async function NewUserPage() { - const { dictionary } = await getI18n() - const copy = dictionary.admin.users - - return ( -
-
-

{copy.new.title}

-
- -
- ) +export default function NewUserPage() { + redirect("/people/new") } diff --git a/src/app/(dashboard)/people/_components/new.person.form.tsx b/src/app/(dashboard)/people/_components/new.person.form.tsx new file mode 100644 index 0000000..4bcd7b0 --- /dev/null +++ b/src/app/(dashboard)/people/_components/new.person.form.tsx @@ -0,0 +1,246 @@ +"use client" + +import { zodResolver } from "@hookform/resolvers/zod" +import { useRouter } from "next/navigation" +import { useMemo } from "react" +import type { UseFormRegisterReturn } from "react-hook-form" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { createPersonUserAction } from "@/actions/person.actions" +import { + SubmitButton, + type SubmitButtonCopy, +} from "@/components/forms/submitButton" +import { PERSON_DEPARTMENTS } from "@/lib/constants" +import { + buildUnifiedCreateSchema, + type UnifiedCreateFormType, + type UnifiedSchemaCopy, +} from "@/schemas/user.schema" + +import { + formatPersonDepartment, + type PersonDepartmentCopy, + type PersonFallbackCopy, + type UserFormCopy, + type UserRoleCopy, +} from "./user.copy" + +export default function NewUserForm({ + formCopy, + schemaCopy, + roleLabels, + departmentCopy, + fallbackCopy, + submitButtonCopy, +}: { + formCopy: UserFormCopy + schemaCopy: UnifiedSchemaCopy + roleLabels: UserRoleCopy + departmentCopy: PersonDepartmentCopy + fallbackCopy: PersonFallbackCopy + submitButtonCopy: SubmitButtonCopy +}) { + const router = useRouter() + const schema = useMemo( + () => buildUnifiedCreateSchema(schemaCopy), + [schemaCopy], + ) + const { + register, + handleSubmit, + watch, + setError, + formState: { errors, isSubmitting, isSubmitSuccessful }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + role: "STAFF", + isActive: true, + }, + }) + + const selectedRole = watch("role") + const showPassword = selectedRole !== "NO_USER" + + const onSubmit = async (formData: UnifiedCreateFormType) => { + const response = await createPersonUserAction(formData) + + if (response?.errors) { + Object.entries(response.errors).forEach(([fieldName, messages]) => { + messages.forEach((message: string) => { + setError(fieldName as keyof UnifiedCreateFormType, { + type: "server", + message, + }) + toast.error(message) + }) + }) + return + } + + if (response?.success) { + toast.success(response.message) + router.push("/people") + } + } + + return ( +
+ + + + + + + {showPassword && ( + + )} + + {formCopy.createSubmit} + + + ) +} + +function UserTextInput({ + error, + id, + label, + placeholder, + register, + type = "text", +}: { + error?: string + id: string + label: string + placeholder: string + register: UseFormRegisterReturn + type?: string +}) { + return ( +
+ + + {error &&

{error}

} +
+ ) +} + +function RoleSelect({ + register, + roleLabel, + roleLabels, +}: { + register: UseFormRegisterReturn + roleLabel: string + roleLabels: UserRoleCopy +}) { + return ( +
+ + +
+ ) +} + +function DepartmentSelect({ + error, + formCopy, + departmentCopy, + fallbackCopy, + register, +}: { + error?: string + formCopy: UserFormCopy + departmentCopy: PersonDepartmentCopy + fallbackCopy: PersonFallbackCopy + register: UseFormRegisterReturn +}) { + return ( +
+ + + {error &&

{error}

} +
+ ) +} diff --git a/src/app/(dashboard)/people/_components/user.copy.ts b/src/app/(dashboard)/people/_components/user.copy.ts new file mode 100644 index 0000000..b4a28c6 --- /dev/null +++ b/src/app/(dashboard)/people/_components/user.copy.ts @@ -0,0 +1,35 @@ +import type { Dictionary } from "@/i18n/dictionaries" + +export type UserFormCopy = Dictionary["admin"]["users"]["form"] +export type UserRoleCopy = Dictionary["admin"]["users"]["roles"] +export type UserStatusCopy = Dictionary["admin"]["users"]["status"] +export type UserFallbackCopy = Dictionary["admin"]["users"]["fallback"] +export type UserResetPasswordCopy = + Dictionary["admin"]["users"]["resetPassword"] +export type PersonDepartmentCopy = + Dictionary["inventory"]["people"]["departments"] +export type PersonFallbackCopy = Dictionary["inventory"]["people"]["fallback"] + +export function formatUserRole( + role: string, + roleCopy: UserRoleCopy, + fallbackCopy: UserFallbackCopy, +): string { + return role in roleCopy + ? roleCopy[role as keyof UserRoleCopy] + : fallbackCopy.unknownRole +} + +export function formatPersonDepartment( + department: string | null | undefined, + departmentCopy: PersonDepartmentCopy, + fallbackCopy: PersonFallbackCopy, +): string { + if (!department) { + return fallbackCopy.unknownDepartment + } + + return department in departmentCopy + ? departmentCopy[department as keyof PersonDepartmentCopy] + : fallbackCopy.unknownDepartment +} diff --git a/src/app/(dashboard)/people/layout.tsx b/src/app/(dashboard)/people/layout.tsx new file mode 100644 index 0000000..d9a4329 --- /dev/null +++ b/src/app/(dashboard)/people/layout.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from "react" + +import { requireRole } from "@/services/auth.service" + +export default async function PeopleLayout({ + children, +}: { + children: ReactNode +}) { + await requireRole("ADMIN") + + return children +} diff --git a/src/app/(dashboard)/people/new/page.tsx b/src/app/(dashboard)/people/new/page.tsx index a11cbc0..05b0333 100644 --- a/src/app/(dashboard)/people/new/page.tsx +++ b/src/app/(dashboard)/people/new/page.tsx @@ -1,5 +1,24 @@ -import { redirect } from "next/navigation" +import { getI18n } from "@/i18n/server" -export default function NewPersonPage() { - redirect("/admin/users/new") +import NewPersonForm from "../_components/new.person.form" + +export default async function NewUserPage() { + const { dictionary } = await getI18n() + const copy = dictionary.admin.users + + return ( +
+
+

{copy.new.title}

+
+ +
+ ) } diff --git a/src/components/layout/addMenu.tsx b/src/components/layout/addMenu.tsx index fda30e7..9891838 100644 --- a/src/components/layout/addMenu.tsx +++ b/src/components/layout/addMenu.tsx @@ -38,7 +38,7 @@ const items: { key: keyof AddMenuCopy; href: string }[] = [ }, { key: "person", - href: "/admin/users/new", + href: "/people/new", }, { key: "assignment", diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index c769219..284376f 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -445,7 +445,7 @@ export const en = { }, }, new: { - title: "New User", + title: "New Person", }, edit: { title: "Edit User", diff --git a/src/i18n/dictionaries/es.ts b/src/i18n/dictionaries/es.ts index 53f899c..1aba2fe 100644 --- a/src/i18n/dictionaries/es.ts +++ b/src/i18n/dictionaries/es.ts @@ -450,7 +450,7 @@ export const es = { }, }, new: { - title: "Nuevo usuario", + title: "Nueva persona", }, edit: { title: "Editar usuario", diff --git a/tests/unit/app/people/person-form-pages.test.ts b/tests/unit/app/people/person-form-pages.test.ts index dffab9e..48cd289 100644 --- a/tests/unit/app/people/person-form-pages.test.ts +++ b/tests/unit/app/people/person-form-pages.test.ts @@ -44,16 +44,6 @@ describe("person pages", () => { mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" }) }) - it("redirects /people/new to /admin/users/new", async () => { - const { default: NewPersonPage } = await import( - "@/app/(dashboard)/people/new/page" - ) - - await NewPersonPage() - - expect(mocks.redirect).toHaveBeenCalledWith("/admin/users/new") - }) - it("renders the edit person page with Person heading and no username", async () => { const { default: PersonEditPage } = await import( "@/app/(dashboard)/people/[personId]/edit/page" diff --git a/tests/unit/app/users/unified-form-pages.test.ts b/tests/unit/app/users/unified-form-pages.test.ts index ecc636b..c63e43c 100644 --- a/tests/unit/app/users/unified-form-pages.test.ts +++ b/tests/unit/app/users/unified-form-pages.test.ts @@ -47,7 +47,7 @@ describe("unified creation form page", () => { it("renders unified form with Person fields, email, password, role, and NO_USER option in Spanish", async () => { const { default: NewUserPage } = await import( - "@/app/(dashboard)/admin/users/new/page" + "@/app/(dashboard)/people/new/page" ) const html = renderToStaticMarkup(await NewUserPage()) @@ -78,7 +78,7 @@ describe("unified creation form page", () => { mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" }) const { default: NewUserPage } = await import( - "@/app/(dashboard)/admin/users/new/page" + "@/app/(dashboard)/people/new/page" ) const html = renderToStaticMarkup(await NewUserPage()) @@ -100,7 +100,7 @@ describe("unified creation form page", () => { it("renders Person field placeholders from the unified form dictionary", async () => { const { default: NewUserPage } = await import( - "@/app/(dashboard)/admin/users/new/page" + "@/app/(dashboard)/people/new/page" ) const html = renderToStaticMarkup(await NewUserPage()) @@ -114,7 +114,7 @@ describe("unified creation form page", () => { it("renders department select with all PERSON_DEPARTMENTS values", async () => { const { default: NewUserPage } = await import( - "@/app/(dashboard)/admin/users/new/page" + "@/app/(dashboard)/people/new/page" ) const html = renderToStaticMarkup(await NewUserPage()) diff --git a/tests/unit/app/users/user-form-pages.test.ts b/tests/unit/app/users/user-form-pages.test.ts index 9d98813..9d27526 100644 --- a/tests/unit/app/users/user-form-pages.test.ts +++ b/tests/unit/app/users/user-form-pages.test.ts @@ -60,13 +60,13 @@ describe("new user form localization", () => { it("renders new user page with localized title and unified form labels in Spanish", async () => { const { default: NewUserPage } = await import( - "@/app/(dashboard)/admin/users/new/page" + "@/app/(dashboard)/people/new/page" ) const html = renderToStaticMarkup(await NewUserPage()) // Title - expect(html).toContain("Nuevo usuario") + expect(html).toContain("Nueva persona") // Person field labels expect(html).toContain("Nombre") @@ -94,12 +94,12 @@ describe("new user form localization", () => { mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" }) const { default: NewUserPage } = await import( - "@/app/(dashboard)/admin/users/new/page" + "@/app/(dashboard)/people/new/page" ) const html = renderToStaticMarkup(await NewUserPage()) - expect(html).toContain("New User") + expect(html).toContain("New Person") // Person fields expect(html).toContain("First Name") @@ -122,7 +122,7 @@ describe("new user form localization", () => { it("keeps canonical role values in option value attributes including NO_USER, not localized labels", async () => { const { default: NewUserPage } = await import( - "@/app/(dashboard)/admin/users/new/page" + "@/app/(dashboard)/people/new/page" ) const html = renderToStaticMarkup(await NewUserPage()) diff --git a/tests/unit/i18n/admin-users-dictionary.test.ts b/tests/unit/i18n/admin-users-dictionary.test.ts index 510baff..e51748a 100644 --- a/tests/unit/i18n/admin-users-dictionary.test.ts +++ b/tests/unit/i18n/admin-users-dictionary.test.ts @@ -22,7 +22,7 @@ describe("admin users dictionary", () => { }, }) - expect(users.new).toEqual({ title: "New User" }) + expect(users.new).toEqual({ title: "New Person" }) expect(users.edit).toEqual({ title: "Edit User" }) expect(users.form).toEqual({ @@ -114,7 +114,7 @@ describe("admin users dictionary", () => { }, }) - expect(users.new).toEqual({ title: "Nuevo usuario" }) + expect(users.new).toEqual({ title: "Nueva persona" }) expect(users.edit).toEqual({ title: "Editar usuario" }) expect(users.form).toEqual({