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 (
+
+ )
+}
+
+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({