From d6b42d78e7d8f5cf08960e7faab80b356b5f7df7 Mon Sep 17 00:00:00 2001 From: Asis Ferrer Date: Wed, 17 Jun 2026 09:32:26 +0200 Subject: [PATCH] refactor: consolidate admin/users management under /people --- src/actions/person.actions.ts | 46 +++ .../admin/users/[userId]/edit/page.tsx | 44 +-- src/app/(dashboard)/admin/users/page.tsx | 112 +------ src/app/(dashboard)/layout.tsx | 7 +- .../people/[personId]/edit/page.tsx | 26 +- .../(dashboard)/people/[personId]/page.tsx | 39 ++- .../people/_components/edit.person.form.tsx | 274 ++++++++++++++++++ .../people/_components/person.form.tsx | 188 ------------ src/app/(dashboard)/people/page.tsx | 48 ++- src/components/layout/sidebar.tsx | 18 +- src/i18n/dictionaries/en.ts | 15 + src/i18n/dictionaries/es.ts | 17 ++ src/schemas/user.schema.ts | 44 +++ src/services/person.service.ts | 21 +- src/use-cases/person.use-cases.ts | 85 +++++- .../use-cases/admin-users-redirect.test.ts | 61 ++++ .../update-person-user.use-cases.test.ts | 247 ++++++++++++++++ tests/unit/actions/person.messages.test.ts | 1 + .../actions/update-person-user.action.test.ts | 218 ++++++++++++++ .../admin-users/admin-users-redirect.test.ts | 91 ++++++ .../people/edit-person-form-wiring.test.ts | 170 +++++++++++ .../unit/app/people/person-form-pages.test.ts | 33 ++- .../app/people/person-form-wiring.test.ts | 63 ---- tests/unit/app/people/person-pages.test.ts | 157 +++++++++- tests/unit/app/users/user-form-pages.test.ts | 227 --------------- tests/unit/app/users/user-pages.test.ts | 183 ------------ tests/unit/app/users/user.copy.test.ts | 1 + tests/unit/components/layout/sidebar.test.tsx | 116 ++++++++ .../unit/i18n/admin-users-dictionary.test.ts | 6 + tests/unit/i18n/dictionaries.test.ts | 25 ++ .../schemas/unified-update.schema.test.ts | 200 +++++++++++++ 31 files changed, 1928 insertions(+), 855 deletions(-) create mode 100644 src/app/(dashboard)/people/_components/edit.person.form.tsx delete mode 100644 src/app/(dashboard)/people/_components/person.form.tsx create mode 100644 tests/integration/use-cases/admin-users-redirect.test.ts create mode 100644 tests/integration/use-cases/update-person-user.use-cases.test.ts create mode 100644 tests/unit/actions/update-person-user.action.test.ts create mode 100644 tests/unit/app/admin-users/admin-users-redirect.test.ts create mode 100644 tests/unit/app/people/edit-person-form-wiring.test.ts delete mode 100644 tests/unit/app/people/person-form-wiring.test.ts delete mode 100644 tests/unit/app/users/user-form-pages.test.ts delete mode 100644 tests/unit/app/users/user-pages.test.ts create mode 100644 tests/unit/components/layout/sidebar.test.tsx create mode 100644 tests/unit/schemas/unified-update.schema.test.ts diff --git a/src/actions/person.actions.ts b/src/actions/person.actions.ts index 3fd499e..7add59d 100644 --- a/src/actions/person.actions.ts +++ b/src/actions/person.actions.ts @@ -11,12 +11,16 @@ import { } from "@/schemas/person.schema" import { buildUnifiedCreateSchema, + buildUnifiedUpdateSchema, type UnifiedCreateFormType, + type UnifiedSchemaCopy, + type UnifiedUpdateFormType, } from "@/schemas/user.schema" import { createPersonUseCase, createPersonUserUseCase, updatePersonUseCase, + updatePersonUserUseCase, } from "@/use-cases/person.use-cases" import { localizePersonFieldErrors } from "./person.messages" @@ -144,3 +148,45 @@ export async function updatePerson(formData: UpdatePersonFormType) { } } } + +export async function updatePersonUserAction(formData: UnifiedUpdateFormType) { + const { dictionary } = await getI18n() + const userCopy = dictionary.admin.users + const personCopy = dictionary.inventory.people + const schemaCopy: UnifiedSchemaCopy = { + ...userCopy.schema, + ...personCopy.schema, + } + const validatedFields = + buildUnifiedUpdateSchema(schemaCopy).safeParse(formData) + + if (!validatedFields.success) { + return { + success: false, + errors: flattenError(validatedFields.error).fieldErrors, + } + } + + try { + const result = await updatePersonUserUseCase(validatedFields.data) + + if (!result.success) { + return { + ...result, + errors: localizeUnifiedCreateFieldErrors( + result.errors, + userCopy.actions, + schemaCopy, + ), + message: personCopy.actions.updateFailure, + } + } + + revalidatePath("/people") + + return { success: true, message: personCopy.actions.updateSuccess } + } catch (error) { + console.error("Database error:", error) + return { success: false, message: personCopy.actions.updateFailure } + } +} diff --git a/src/app/(dashboard)/admin/users/[userId]/edit/page.tsx b/src/app/(dashboard)/admin/users/[userId]/edit/page.tsx index a9b0e41..3048447 100644 --- a/src/app/(dashboard)/admin/users/[userId]/edit/page.tsx +++ b/src/app/(dashboard)/admin/users/[userId]/edit/page.tsx @@ -1,10 +1,6 @@ -import { notFound } from "next/navigation" +import { redirect } from "next/navigation" -import { getI18n } from "@/i18n/server" -import { getUserProfileById } from "@/services/user.service" - -import EditUserForm from "../../_components/edit.user.form" -import ResetUserPasswordForm from "../../_components/reset.user.password.form" +import prisma from "@/lib/prisma" export default async function EditUserPage({ params, @@ -12,35 +8,15 @@ export default async function EditUserPage({ params: Promise<{ userId: string }> }) { const { userId } = await params - const user = await getUserProfileById(userId) - const { dictionary } = await getI18n() - const copy = dictionary.admin.users - if (!user) { - notFound() + const person = await prisma.person.findFirst({ + where: { userId }, + select: { id: true }, + }) + + if (!person) { + redirect("/people") } - return ( -
-
-

{copy.edit.title}

-
- -
-

{copy.resetPassword.title}

- -
-
- ) + redirect(`/people/${person.id}/edit`) } diff --git a/src/app/(dashboard)/admin/users/page.tsx b/src/app/(dashboard)/admin/users/page.tsx index 55d9b43..69a304f 100644 --- a/src/app/(dashboard)/admin/users/page.tsx +++ b/src/app/(dashboard)/admin/users/page.tsx @@ -1,111 +1,5 @@ -import { Pencil } from "lucide-react" -import Link from "next/link" +import { redirect } from "next/navigation" -import PageHeader from "@/components/common/pageheader" -import PaginationButtons from "@/components/common/pagination" -import { Button } from "@/components/ui/button" -import { getI18n } from "@/i18n/server" -import { getUsers } from "@/services/user.service" - -import { - formatUserRole, - type UserFallbackCopy, - type UserRoleCopy, - type UserStatusCopy, -} from "./_components/user.copy" - -export default async function UsersPage(props: { - searchParams?: Promise<{ - page?: string - search?: string - }> -}) { - const searchParams = await props.searchParams - const currentPage = searchParams?.page ? parseInt(searchParams.page, 10) : 1 - const search = searchParams?.search || "" - const { data: users, totalPages } = await getUsers({ - page: currentPage, - pageSize: 10, - search, - }) - const { dictionary } = await getI18n() - const copy = dictionary.admin.users - - return ( -
- - {users.length === 0 && currentPage === 1 && ( -
-
- {copy.list.empty} -
-
- )} - {users.length > 0 && ( -
- - - - - - - - - - - - {users.map((user) => ( - - - - - - - - ))} - - - - - - -
- {copy.list.columns.name} - - {copy.list.columns.email} - - {copy.list.columns.role} - - {copy.list.columns.status} - - {copy.list.columns.actions} -
{user.name}{user.email} - {formatUserRole( - user.role, - copy.roles as UserRoleCopy, - copy.fallback as UserFallbackCopy, - )} - - {user.isActive - ? (copy.status as UserStatusCopy).active - : (copy.status as UserStatusCopy).inactive} - - - - -
- -
-
- )} -
- ) +export default function UsersPage() { + redirect("/people") } diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index a21ac47..b53a73c 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -4,22 +4,17 @@ import Navbar from "@/components/layout/navbar" import AppSidebar from "@/components/layout/sidebar" import { SidebarProvider } from "@/components/ui/sidebar" import { getI18n } from "@/i18n/server" -import { auth } from "@/lib/auth" export default async function LayoutDashboard({ children, }: { children: React.ReactNode }) { - const session = await auth() const { dictionary } = await getI18n() return ( - +
{children}
diff --git a/src/app/(dashboard)/people/[personId]/edit/page.tsx b/src/app/(dashboard)/people/[personId]/edit/page.tsx index 7b17513..99c94f6 100644 --- a/src/app/(dashboard)/people/[personId]/edit/page.tsx +++ b/src/app/(dashboard)/people/[personId]/edit/page.tsx @@ -1,7 +1,7 @@ import { getI18n } from "@/i18n/server" import { PersonService } from "@/services/person.service" -import PersonForm from "../../_components/person.form" +import EditPersonForm from "../../_components/edit.person.form" export default async function PersonEditPage({ params, @@ -10,25 +10,27 @@ export default async function PersonEditPage({ }) { const { personId } = await params const { dictionary } = await getI18n() - const copy = dictionary.inventory.people - const person = await PersonService.findById(personId) + const personCopy = dictionary.inventory.people + const userCopy = dictionary.admin.users + const person = await PersonService.findByIdWithUser(personId) if (!person) { - return
{copy.edit.notFound}
+ return
{personCopy.edit.notFound}
} return (
-

{copy.edit.title}

+

{personCopy.edit.title}

-
diff --git a/src/app/(dashboard)/people/[personId]/page.tsx b/src/app/(dashboard)/people/[personId]/page.tsx index 85e2886..5949ee3 100644 --- a/src/app/(dashboard)/people/[personId]/page.tsx +++ b/src/app/(dashboard)/people/[personId]/page.tsx @@ -4,6 +4,11 @@ import { AssignmentService } from "@/services/assignment.service" import { PersonService } from "@/services/person.service" import { formatPersonDepartment } from "../_components/person.copy" +import { + formatUserRole, + type UserFallbackCopy, + type UserRoleCopy, +} from "../_components/user.copy" export default async function PersonInfoPage({ params, @@ -14,7 +19,8 @@ export default async function PersonInfoPage({ const { dictionary } = await getI18n() const copy = dictionary.inventory.people const assignmentCopy = dictionary.inventory.assignments - const person = await PersonService.findById(personId) + const userCopy = dictionary.admin.users + const person = await PersonService.findByIdWithUser(personId) const assignments = await AssignmentService.findAllByPerson(personId) if (!person) { @@ -49,6 +55,37 @@ export default async function PersonInfoPage({ )} + {person.user ? ( + <> +
+ + {copy.detail.labels.role} + + + {formatUserRole( + person.user.role, + userCopy.roles as UserRoleCopy, + userCopy.fallback as UserFallbackCopy, + )} + +
+
+ + {copy.detail.labels.status} + + + {person.user.isActive + ? userCopy.status.active + : userCopy.status.inactive} + +
+ + ) : ( +
+ {copy.detail.labels.role} + {copy.detail.labels.noUser} +
+ )} diff --git a/src/app/(dashboard)/people/_components/edit.person.form.tsx b/src/app/(dashboard)/people/_components/edit.person.form.tsx new file mode 100644 index 0000000..10b24e4 --- /dev/null +++ b/src/app/(dashboard)/people/_components/edit.person.form.tsx @@ -0,0 +1,274 @@ +"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 { updatePersonUserAction } from "@/actions/person.actions" +import { + SubmitButton, + type SubmitButtonCopy, +} from "@/components/forms/submitButton" +import { PERSON_DEPARTMENTS } from "@/lib/constants" +import { + buildUnifiedUpdateSchema, + type UnifiedSchemaCopy, + type UnifiedUpdateFormType, +} from "@/schemas/user.schema" +import type { PersonWithUser } from "@/services/person.service" + +import { + formatPersonDepartment, + formatUserRole, + type PersonDepartmentCopy, + type PersonFallbackCopy, + type UserFallbackCopy, + type UserFormCopy, + type UserRoleCopy, +} from "./user.copy" + +export default function EditPersonForm({ + person, + formCopy, + schemaCopy, + roleLabels, + userFallbackCopy, + departmentCopy, + fallbackCopy, + submitButtonCopy, +}: { + person: PersonWithUser + formCopy: UserFormCopy + schemaCopy: UnifiedSchemaCopy + roleLabels: UserRoleCopy + userFallbackCopy: UserFallbackCopy + departmentCopy: PersonDepartmentCopy + fallbackCopy: PersonFallbackCopy + submitButtonCopy: SubmitButtonCopy +}) { + const router = useRouter() + const schema = useMemo( + () => buildUnifiedUpdateSchema(schemaCopy), + [schemaCopy], + ) + const hasUser = Boolean(person.userId && person.user) + const user = person.user + + const { + register, + handleSubmit, + setError, + formState: { errors, isSubmitting, isSubmitSuccessful }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + id: person.id, + firstName: person.firstName, + lastName: person.lastName, + department: person.department ?? "OTHER", + email: person.email ?? "", + phone: person.phone ?? "", + ...(hasUser && user ? { role: user.role, isActive: user.isActive } : {}), + }, + }) + + const onSubmit = async (formData: UnifiedUpdateFormType) => { + const response = await updatePersonUserAction(formData) + + if (response?.errors) { + Object.entries(response.errors).forEach(([fieldName, messages]) => { + messages.forEach((message: string) => { + setError(fieldName as keyof UnifiedUpdateFormType, { + type: "server", + message, + }) + toast.error(message) + }) + }) + return + } + + if (response?.success) { + toast.success(response.message) + router.push("/people") + } + } + + return ( +
+ + + + + + + + {hasUser && ( +
+

+ {formCopy.userAccountHeading} +

+ + + +
+ )} + + + {formCopy.updateSubmit} + + + ) +} + +function TextInput({ + 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}

} +
+ ) +} + +// Re-export for tests that need to verify the data shape passed to this form. +export { formatUserRole } diff --git a/src/app/(dashboard)/people/_components/person.form.tsx b/src/app/(dashboard)/people/_components/person.form.tsx deleted file mode 100644 index 2a450c8..0000000 --- a/src/app/(dashboard)/people/_components/person.form.tsx +++ /dev/null @@ -1,188 +0,0 @@ -"use client" - -import { zodResolver } from "@hookform/resolvers/zod" -import { useRouter } from "next/navigation" -import { useMemo } from "react" -import { useForm } from "react-hook-form" -import { toast } from "sonner" -import { createNewPerson, updatePerson } from "@/actions/person.actions" -import { - SubmitButton, - type SubmitButtonCopy, -} from "@/components/forms/submitButton" -import { PERSON_DEPARTMENTS } from "@/lib/constants" -import { - buildCreatePersonSchema, - buildUpdatePersonSchema, - type CreatePersonFormType, - type PersonSchemaCopy, - type UpdatePersonFormType, -} from "@/schemas/person.schema" -import type { Person } from "@/types" - -import { - formatPersonDepartment, - type PersonDepartmentCopy, - type PersonFallbackCopy, - type PersonFormCopy, -} from "./person.copy" - -interface PersonFormProps { - initialData?: Person - mode?: "create" | "edit" - formCopy: PersonFormCopy - schemaCopy: PersonSchemaCopy - departmentCopy: PersonDepartmentCopy - fallbackCopy: PersonFallbackCopy - submitButtonCopy: SubmitButtonCopy -} - -export default function PersonForm({ - initialData, - mode = "create", - formCopy, - schemaCopy, - departmentCopy, - fallbackCopy, - submitButtonCopy, -}: PersonFormProps) { - const router = useRouter() - const schema = useMemo( - () => - mode === "create" - ? buildCreatePersonSchema(schemaCopy) - : buildUpdatePersonSchema(schemaCopy), - [mode, schemaCopy], - ) - - const { - register, - handleSubmit, - setError, - formState: { errors, isSubmitting, isSubmitSuccessful }, - } = useForm({ - resolver: zodResolver(schema), - defaultValues: { - id: initialData?.id || "", - firstName: initialData?.firstName || "", - lastName: initialData?.lastName || "", - department: initialData?.department || "OTHER", - email: initialData?.email || "", - phone: initialData?.phone || "", - }, - }) - - const onSubmit = async (formData: CreatePersonFormType) => { - const response = - mode === "create" - ? await createNewPerson(formData) - : await updatePerson(formData as UpdatePersonFormType) - - if (response?.errors) { - Object.entries(response.errors).forEach(([fieldName, messages]) => { - messages.forEach((msg: string) => { - setError(fieldName as keyof CreatePersonFormType, { - type: "server", - message: msg, - }) - toast.error(msg) - }) - }) - return - } - - if (response?.success) { - toast.success(response.message) - router.push("/people") - } - } - - return ( -
- -
- - - {errors?.firstName && ( -

{errors.firstName.message}

- )} -
-
- - - {errors?.lastName && ( -

{errors.lastName.message}

- )} -
-
- - - {errors?.department && ( -

{errors.department.message}

- )} -
-
- - - {errors?.email &&

{errors.email.message}

} -
-
- - - {errors?.phone &&

{errors.phone.message}

} -
- - {mode === "create" ? formCopy.createSubmit : formCopy.updateSubmit} - -
- ) -} diff --git a/src/app/(dashboard)/people/page.tsx b/src/app/(dashboard)/people/page.tsx index 7b063ab..96c3552 100644 --- a/src/app/(dashboard)/people/page.tsx +++ b/src/app/(dashboard)/people/page.tsx @@ -4,11 +4,19 @@ import Link from "next/link" import PageHeader from "@/components/common/pageheader" import PaginationButtons from "@/components/common/pagination" import { Button } from "@/components/ui/button" -import type { Person } from "@/generated/prisma/client" import { getI18n } from "@/i18n/server" import { PersonService } from "@/services/person.service" -import { formatPersonDepartment } from "./_components/person.copy" +import { + formatPersonDepartment, + type PersonDepartmentCopy, + type PersonFallbackCopy, +} from "./_components/person.copy" +import { + formatUserRole, + type UserFallbackCopy, + type UserRoleCopy, +} from "./_components/user.copy" export default async function PeoplePage(props: { searchParams?: Promise<{ @@ -26,6 +34,12 @@ export default async function PeoplePage(props: { }) const { dictionary } = await getI18n() const copy = dictionary.inventory.people + const userCopy = dictionary.admin.users + const userStatusCopy = userCopy.status + const userRoleLabels = userCopy.roles as UserRoleCopy + const userFallbackCopy = userCopy.fallback as UserFallbackCopy + const departmentCopy = copy.departments as PersonDepartmentCopy + const personFallbackCopy = copy.fallback as PersonFallbackCopy return (
@@ -54,13 +68,19 @@ export default async function PeoplePage(props: { {copy.list.columns.department} + + {copy.list.columns.role} + + + {copy.list.columns.status} + {copy.list.columns.actions} - {people.map((person: Person) => ( + {people.map((person) => ( {`${person.firstName} ${person.lastName}`} @@ -70,10 +90,26 @@ export default async function PeoplePage(props: { {formatPersonDepartment( person.department, - copy.departments, - copy.fallback, + departmentCopy, + personFallbackCopy, )} + + {person.user + ? formatUserRole( + person.user.role, + userRoleLabels, + userFallbackCopy, + ) + : "—"} + + + {person.user + ? person.user.isActive + ? userStatusCopy.active + : userStatusCopy.inactive + : "—"} +