refactor: consolidate admin/users management under /people
This commit is contained in:
@@ -11,12 +11,16 @@ import {
|
|||||||
} from "@/schemas/person.schema"
|
} from "@/schemas/person.schema"
|
||||||
import {
|
import {
|
||||||
buildUnifiedCreateSchema,
|
buildUnifiedCreateSchema,
|
||||||
|
buildUnifiedUpdateSchema,
|
||||||
type UnifiedCreateFormType,
|
type UnifiedCreateFormType,
|
||||||
|
type UnifiedSchemaCopy,
|
||||||
|
type UnifiedUpdateFormType,
|
||||||
} from "@/schemas/user.schema"
|
} from "@/schemas/user.schema"
|
||||||
import {
|
import {
|
||||||
createPersonUseCase,
|
createPersonUseCase,
|
||||||
createPersonUserUseCase,
|
createPersonUserUseCase,
|
||||||
updatePersonUseCase,
|
updatePersonUseCase,
|
||||||
|
updatePersonUserUseCase,
|
||||||
} from "@/use-cases/person.use-cases"
|
} from "@/use-cases/person.use-cases"
|
||||||
|
|
||||||
import { localizePersonFieldErrors } from "./person.messages"
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { notFound } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
import { getI18n } from "@/i18n/server"
|
import prisma from "@/lib/prisma"
|
||||||
import { getUserProfileById } from "@/services/user.service"
|
|
||||||
|
|
||||||
import EditUserForm from "../../_components/edit.user.form"
|
|
||||||
import ResetUserPasswordForm from "../../_components/reset.user.password.form"
|
|
||||||
|
|
||||||
export default async function EditUserPage({
|
export default async function EditUserPage({
|
||||||
params,
|
params,
|
||||||
@@ -12,35 +8,15 @@ export default async function EditUserPage({
|
|||||||
params: Promise<{ userId: string }>
|
params: Promise<{ userId: string }>
|
||||||
}) {
|
}) {
|
||||||
const { userId } = await params
|
const { userId } = await params
|
||||||
const user = await getUserProfileById(userId)
|
|
||||||
const { dictionary } = await getI18n()
|
|
||||||
const copy = dictionary.admin.users
|
|
||||||
|
|
||||||
if (!user) {
|
const person = await prisma.person.findFirst({
|
||||||
notFound()
|
where: { userId },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!person) {
|
||||||
|
redirect("/people")
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
redirect(`/people/${person.id}/edit`)
|
||||||
<div className="flex flex-col gap-8">
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<h1 className="text-2xl font-bold">{copy.edit.title}</h1>
|
|
||||||
</div>
|
|
||||||
<EditUserForm
|
|
||||||
formCopy={copy.form}
|
|
||||||
schemaCopy={copy.schema}
|
|
||||||
roleLabels={copy.roles}
|
|
||||||
submitButtonCopy={dictionary.common.submitButton}
|
|
||||||
user={user}
|
|
||||||
/>
|
|
||||||
<section className="flex flex-col gap-4 border-t pt-6">
|
|
||||||
<h2 className="text-xl font-semibold">{copy.resetPassword.title}</h2>
|
|
||||||
<ResetUserPasswordForm
|
|
||||||
formCopy={copy.resetPassword}
|
|
||||||
schemaCopy={copy.schema}
|
|
||||||
submitButtonCopy={dictionary.common.submitButton}
|
|
||||||
userId={user.id}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,111 +1,5 @@
|
|||||||
import { Pencil } from "lucide-react"
|
import { redirect } from "next/navigation"
|
||||||
import Link from "next/link"
|
|
||||||
|
|
||||||
import PageHeader from "@/components/common/pageheader"
|
export default function UsersPage() {
|
||||||
import PaginationButtons from "@/components/common/pagination"
|
redirect("/people")
|
||||||
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 (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<PageHeader
|
|
||||||
title={copy.list.title}
|
|
||||||
addLabel={copy.list.addLabel}
|
|
||||||
link="/admin/users/new"
|
|
||||||
search={search}
|
|
||||||
data={users}
|
|
||||||
/>
|
|
||||||
{users.length === 0 && currentPage === 1 && (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
{copy.list.empty}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{users.length > 0 && (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="text-muted-foreground w-full text-left text-sm">
|
|
||||||
<thead className="border-b">
|
|
||||||
<tr>
|
|
||||||
<th scope="col" className="p-4">
|
|
||||||
{copy.list.columns.name}
|
|
||||||
</th>
|
|
||||||
<th scope="col" className="p-4">
|
|
||||||
{copy.list.columns.email}
|
|
||||||
</th>
|
|
||||||
<th scope="col" className="p-4">
|
|
||||||
{copy.list.columns.role}
|
|
||||||
</th>
|
|
||||||
<th scope="col" className="p-4">
|
|
||||||
{copy.list.columns.status}
|
|
||||||
</th>
|
|
||||||
<th scope="col" className="p-4">
|
|
||||||
{copy.list.columns.actions}
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{users.map((user) => (
|
|
||||||
<tr key={user.id} className="border-b">
|
|
||||||
<td className="p-4">{user.name}</td>
|
|
||||||
<td className="p-4">{user.email}</td>
|
|
||||||
<td className="p-4">
|
|
||||||
{formatUserRole(
|
|
||||||
user.role,
|
|
||||||
copy.roles as UserRoleCopy,
|
|
||||||
copy.fallback as UserFallbackCopy,
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="p-4">
|
|
||||||
{user.isActive
|
|
||||||
? (copy.status as UserStatusCopy).active
|
|
||||||
: (copy.status as UserStatusCopy).inactive}
|
|
||||||
</td>
|
|
||||||
<td className="p-4">
|
|
||||||
<Link href={`/admin/users/${user.id}/edit`} passHref>
|
|
||||||
<Button variant="outline" size="icon">
|
|
||||||
<Pencil />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
<tfoot className="border-t">
|
|
||||||
<tr>
|
|
||||||
<td colSpan={6} className="p-4 text-center text-sm">
|
|
||||||
<PaginationButtons totalPages={totalPages} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,22 +4,17 @@ import Navbar from "@/components/layout/navbar"
|
|||||||
import AppSidebar from "@/components/layout/sidebar"
|
import AppSidebar from "@/components/layout/sidebar"
|
||||||
import { SidebarProvider } from "@/components/ui/sidebar"
|
import { SidebarProvider } from "@/components/ui/sidebar"
|
||||||
import { getI18n } from "@/i18n/server"
|
import { getI18n } from "@/i18n/server"
|
||||||
import { auth } from "@/lib/auth"
|
|
||||||
|
|
||||||
export default async function LayoutDashboard({
|
export default async function LayoutDashboard({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const session = await auth()
|
|
||||||
const { dictionary } = await getI18n()
|
const { dictionary } = await getI18n()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<AppSidebar
|
<AppSidebar copy={dictionary.layout.sidebar} />
|
||||||
copy={dictionary.layout.sidebar}
|
|
||||||
userRole={session?.user.role}
|
|
||||||
/>
|
|
||||||
<main className="w-full">
|
<main className="w-full">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<div className="flex-1 p-6">{children}</div>
|
<div className="flex-1 p-6">{children}</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getI18n } from "@/i18n/server"
|
import { getI18n } from "@/i18n/server"
|
||||||
import { PersonService } from "@/services/person.service"
|
import { PersonService } from "@/services/person.service"
|
||||||
|
|
||||||
import PersonForm from "../../_components/person.form"
|
import EditPersonForm from "../../_components/edit.person.form"
|
||||||
|
|
||||||
export default async function PersonEditPage({
|
export default async function PersonEditPage({
|
||||||
params,
|
params,
|
||||||
@@ -10,25 +10,27 @@ export default async function PersonEditPage({
|
|||||||
}) {
|
}) {
|
||||||
const { personId } = await params
|
const { personId } = await params
|
||||||
const { dictionary } = await getI18n()
|
const { dictionary } = await getI18n()
|
||||||
const copy = dictionary.inventory.people
|
const personCopy = dictionary.inventory.people
|
||||||
const person = await PersonService.findById(personId)
|
const userCopy = dictionary.admin.users
|
||||||
|
const person = await PersonService.findByIdWithUser(personId)
|
||||||
|
|
||||||
if (!person) {
|
if (!person) {
|
||||||
return <div>{copy.edit.notFound}</div>
|
return <div>{personCopy.edit.notFound}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<h1 className="text-2xl font-bold">{copy.edit.title}</h1>
|
<h1 className="text-2xl font-bold">{personCopy.edit.title}</h1>
|
||||||
</div>
|
</div>
|
||||||
<PersonForm
|
<EditPersonForm
|
||||||
initialData={person}
|
person={person}
|
||||||
mode="edit"
|
formCopy={userCopy.form}
|
||||||
formCopy={copy.form}
|
schemaCopy={{ ...userCopy.schema, ...personCopy.schema }}
|
||||||
schemaCopy={copy.schema}
|
roleLabels={userCopy.roles}
|
||||||
departmentCopy={copy.departments}
|
userFallbackCopy={userCopy.fallback}
|
||||||
fallbackCopy={copy.fallback}
|
departmentCopy={personCopy.departments}
|
||||||
|
fallbackCopy={personCopy.fallback}
|
||||||
submitButtonCopy={dictionary.common.submitButton}
|
submitButtonCopy={dictionary.common.submitButton}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { AssignmentService } from "@/services/assignment.service"
|
|||||||
import { PersonService } from "@/services/person.service"
|
import { PersonService } from "@/services/person.service"
|
||||||
|
|
||||||
import { formatPersonDepartment } from "../_components/person.copy"
|
import { formatPersonDepartment } from "../_components/person.copy"
|
||||||
|
import {
|
||||||
|
formatUserRole,
|
||||||
|
type UserFallbackCopy,
|
||||||
|
type UserRoleCopy,
|
||||||
|
} from "../_components/user.copy"
|
||||||
|
|
||||||
export default async function PersonInfoPage({
|
export default async function PersonInfoPage({
|
||||||
params,
|
params,
|
||||||
@@ -14,7 +19,8 @@ export default async function PersonInfoPage({
|
|||||||
const { dictionary } = await getI18n()
|
const { dictionary } = await getI18n()
|
||||||
const copy = dictionary.inventory.people
|
const copy = dictionary.inventory.people
|
||||||
const assignmentCopy = dictionary.inventory.assignments
|
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)
|
const assignments = await AssignmentService.findAllByPerson(personId)
|
||||||
|
|
||||||
if (!person) {
|
if (!person) {
|
||||||
@@ -49,6 +55,37 @@ export default async function PersonInfoPage({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{person.user ? (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{copy.detail.labels.role}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{formatUserRole(
|
||||||
|
person.user.role,
|
||||||
|
userCopy.roles as UserRoleCopy,
|
||||||
|
userCopy.fallback as UserFallbackCopy,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{copy.detail.labels.status}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{person.user.isActive
|
||||||
|
? userCopy.status.active
|
||||||
|
: userCopy.status.inactive}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="col-span-2 flex justify-between">
|
||||||
|
<span className="text-gray-600">{copy.detail.labels.role}</span>
|
||||||
|
<span>{copy.detail.labels.noUser}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -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<UnifiedUpdateFormType>({
|
||||||
|
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 (
|
||||||
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<input type="hidden" {...register("id")} />
|
||||||
|
<TextInput
|
||||||
|
error={errors.firstName?.message}
|
||||||
|
id="firstName"
|
||||||
|
label={formCopy.firstNameLabel}
|
||||||
|
placeholder={formCopy.firstNamePlaceholder}
|
||||||
|
register={register("firstName")}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
error={errors.lastName?.message}
|
||||||
|
id="lastName"
|
||||||
|
label={formCopy.lastNameLabel}
|
||||||
|
placeholder={formCopy.lastNamePlaceholder}
|
||||||
|
register={register("lastName")}
|
||||||
|
/>
|
||||||
|
<DepartmentSelect
|
||||||
|
error={errors.department?.message}
|
||||||
|
formCopy={formCopy}
|
||||||
|
departmentCopy={departmentCopy}
|
||||||
|
fallbackCopy={fallbackCopy}
|
||||||
|
register={register("department")}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
error={errors.email?.message}
|
||||||
|
id="email"
|
||||||
|
label={formCopy.emailLabel}
|
||||||
|
placeholder={formCopy.emailPlaceholder}
|
||||||
|
register={register("email")}
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
error={errors.phone?.message}
|
||||||
|
id="phone"
|
||||||
|
label={formCopy.phoneLabel}
|
||||||
|
placeholder={formCopy.phonePlaceholder}
|
||||||
|
register={register("phone")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hasUser && (
|
||||||
|
<section
|
||||||
|
className="flex flex-col gap-4 border-t pt-4"
|
||||||
|
aria-labelledby="user-account-heading"
|
||||||
|
>
|
||||||
|
<h2 id="user-account-heading" className="text-xl font-semibold">
|
||||||
|
{formCopy.userAccountHeading}
|
||||||
|
</h2>
|
||||||
|
<RoleSelect
|
||||||
|
register={register("role")}
|
||||||
|
roleLabel={formCopy.roleLabel}
|
||||||
|
roleLabels={roleLabels}
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input type="checkbox" {...register("isActive")} />
|
||||||
|
{formCopy.activeLabel}
|
||||||
|
</label>
|
||||||
|
<TextInput
|
||||||
|
error={errors.password?.message}
|
||||||
|
id="password"
|
||||||
|
label={formCopy.newPasswordLabel}
|
||||||
|
placeholder={formCopy.newPasswordPlaceholder}
|
||||||
|
register={register("password")}
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SubmitButton
|
||||||
|
copy={submitButtonCopy}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
isSubmitSuccessful={isSubmitSuccessful}
|
||||||
|
>
|
||||||
|
{formCopy.updateSubmit}
|
||||||
|
</SubmitButton>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextInput({
|
||||||
|
error,
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
placeholder,
|
||||||
|
register,
|
||||||
|
type = "text",
|
||||||
|
}: {
|
||||||
|
error?: string
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
placeholder: string
|
||||||
|
register: UseFormRegisterReturn
|
||||||
|
type?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor={id} className="mb-2 block text-lg">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
id={id}
|
||||||
|
placeholder={placeholder}
|
||||||
|
{...register}
|
||||||
|
className={`w-full rounded-lg border px-4 py-2 ${error ? "border-error" : ""}`}
|
||||||
|
/>
|
||||||
|
{error && <p className="text-error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoleSelect({
|
||||||
|
register,
|
||||||
|
roleLabel,
|
||||||
|
roleLabels,
|
||||||
|
}: {
|
||||||
|
register: UseFormRegisterReturn
|
||||||
|
roleLabel: string
|
||||||
|
roleLabels: UserRoleCopy
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="role" className="mb-2 block text-lg">
|
||||||
|
{roleLabel}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="role"
|
||||||
|
{...register}
|
||||||
|
className="w-full rounded-lg border px-4 py-2"
|
||||||
|
>
|
||||||
|
<option value="ADMIN">{roleLabels.ADMIN}</option>
|
||||||
|
<option value="MANAGER">{roleLabels.MANAGER}</option>
|
||||||
|
<option value="STAFF">{roleLabels.STAFF}</option>
|
||||||
|
<option value="VIEWER">{roleLabels.VIEWER}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DepartmentSelect({
|
||||||
|
error,
|
||||||
|
formCopy,
|
||||||
|
departmentCopy,
|
||||||
|
fallbackCopy,
|
||||||
|
register,
|
||||||
|
}: {
|
||||||
|
error?: string
|
||||||
|
formCopy: UserFormCopy
|
||||||
|
departmentCopy: PersonDepartmentCopy
|
||||||
|
fallbackCopy: PersonFallbackCopy
|
||||||
|
register: UseFormRegisterReturn
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="department" className="mb-2 block text-lg">
|
||||||
|
{formCopy.departmentLabel}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="department"
|
||||||
|
{...register}
|
||||||
|
className={`w-full rounded-lg border px-4 py-2 ${error ? "border-error" : ""}`}
|
||||||
|
>
|
||||||
|
<option value="">{formCopy.departmentPlaceholder}</option>
|
||||||
|
{Object.keys(PERSON_DEPARTMENTS).map((department) => (
|
||||||
|
<option key={department} value={department}>
|
||||||
|
{formatPersonDepartment(department, departmentCopy, fallbackCopy)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{error && <p className="text-error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export for tests that need to verify the data shape passed to this form.
|
||||||
|
export { formatUserRole }
|
||||||
@@ -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<CreatePersonFormType>({
|
|
||||||
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 (
|
|
||||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
|
|
||||||
<input type="hidden" {...register("id")} />
|
|
||||||
<div>
|
|
||||||
<label htmlFor="firstName" className="mb-2 block text-lg">
|
|
||||||
{formCopy.firstNameLabel}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="firstName"
|
|
||||||
placeholder={formCopy.firstNamePlaceholder}
|
|
||||||
{...register("firstName")}
|
|
||||||
className={`w-full rounded-lg border px-4 py-2`}
|
|
||||||
/>
|
|
||||||
{errors?.firstName && (
|
|
||||||
<p className="text-error">{errors.firstName.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="lastName" className="mb-2 block text-lg">
|
|
||||||
{formCopy.lastNameLabel}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="lastName"
|
|
||||||
placeholder={formCopy.lastNamePlaceholder}
|
|
||||||
{...register("lastName")}
|
|
||||||
className={`w-full rounded-lg border px-4 py-2`}
|
|
||||||
/>
|
|
||||||
{errors?.lastName && (
|
|
||||||
<p className="text-error">{errors.lastName.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="department" className="mb-2 block text-lg">
|
|
||||||
{formCopy.departmentLabel}
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="department"
|
|
||||||
{...register("department")}
|
|
||||||
className="w-full rounded-lg border px-4 py-2"
|
|
||||||
>
|
|
||||||
<option value="">{formCopy.departmentPlaceholder}</option>
|
|
||||||
{Object.keys(PERSON_DEPARTMENTS).map((department) => (
|
|
||||||
<option key={department} value={department}>
|
|
||||||
{formatPersonDepartment(department, departmentCopy, fallbackCopy)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{errors?.department && (
|
|
||||||
<p className="text-error">{errors.department.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="email" className="mb-2 block text-lg">
|
|
||||||
{formCopy.emailLabel}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="email"
|
|
||||||
placeholder={formCopy.emailPlaceholder}
|
|
||||||
{...register("email")}
|
|
||||||
className={`w-full rounded-lg border px-4 py-2`}
|
|
||||||
/>
|
|
||||||
{errors?.email && <p className="text-error">{errors.email.message}</p>}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="phone" className="mb-2 block text-lg">
|
|
||||||
{formCopy.phoneLabel}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="phone"
|
|
||||||
placeholder={formCopy.phonePlaceholder}
|
|
||||||
{...register("phone")}
|
|
||||||
className={`w-full rounded-lg border px-4 py-2`}
|
|
||||||
/>
|
|
||||||
{errors?.phone && <p className="text-error">{errors.phone.message}</p>}
|
|
||||||
</div>
|
|
||||||
<SubmitButton
|
|
||||||
copy={submitButtonCopy}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
isSubmitSuccessful={isSubmitSuccessful}
|
|
||||||
>
|
|
||||||
{mode === "create" ? formCopy.createSubmit : formCopy.updateSubmit}
|
|
||||||
</SubmitButton>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -4,11 +4,19 @@ import Link from "next/link"
|
|||||||
import PageHeader from "@/components/common/pageheader"
|
import PageHeader from "@/components/common/pageheader"
|
||||||
import PaginationButtons from "@/components/common/pagination"
|
import PaginationButtons from "@/components/common/pagination"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import type { Person } from "@/generated/prisma/client"
|
|
||||||
import { getI18n } from "@/i18n/server"
|
import { getI18n } from "@/i18n/server"
|
||||||
import { PersonService } from "@/services/person.service"
|
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: {
|
export default async function PeoplePage(props: {
|
||||||
searchParams?: Promise<{
|
searchParams?: Promise<{
|
||||||
@@ -26,6 +34,12 @@ export default async function PeoplePage(props: {
|
|||||||
})
|
})
|
||||||
const { dictionary } = await getI18n()
|
const { dictionary } = await getI18n()
|
||||||
const copy = dictionary.inventory.people
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
@@ -54,13 +68,19 @@ export default async function PeoplePage(props: {
|
|||||||
<th scope="col" className="p-4">
|
<th scope="col" className="p-4">
|
||||||
{copy.list.columns.department}
|
{copy.list.columns.department}
|
||||||
</th>
|
</th>
|
||||||
|
<th scope="col" className="p-4">
|
||||||
|
{copy.list.columns.role}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-4">
|
||||||
|
{copy.list.columns.status}
|
||||||
|
</th>
|
||||||
<th scope="col" className="p-4">
|
<th scope="col" className="p-4">
|
||||||
{copy.list.columns.actions}
|
{copy.list.columns.actions}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{people.map((person: Person) => (
|
{people.map((person) => (
|
||||||
<tr key={person.id} className="border-b">
|
<tr key={person.id} className="border-b">
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
{`${person.firstName} ${person.lastName}`}
|
{`${person.firstName} ${person.lastName}`}
|
||||||
@@ -70,10 +90,26 @@ export default async function PeoplePage(props: {
|
|||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
{formatPersonDepartment(
|
{formatPersonDepartment(
|
||||||
person.department,
|
person.department,
|
||||||
copy.departments,
|
departmentCopy,
|
||||||
copy.fallback,
|
personFallbackCopy,
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
{person.user
|
||||||
|
? formatUserRole(
|
||||||
|
person.user.role,
|
||||||
|
userRoleLabels,
|
||||||
|
userFallbackCopy,
|
||||||
|
)
|
||||||
|
: "—"}
|
||||||
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
{person.user
|
||||||
|
? person.user.isActive
|
||||||
|
? userStatusCopy.active
|
||||||
|
: userStatusCopy.inactive
|
||||||
|
: "—"}
|
||||||
|
</td>
|
||||||
<td className="flex items-center gap-2 p-4">
|
<td className="flex items-center gap-2 p-4">
|
||||||
<Link href={`/people/${person.id}`} passHref>
|
<Link href={`/people/${person.id}`} passHref>
|
||||||
<Button
|
<Button
|
||||||
@@ -100,7 +136,7 @@ export default async function PeoplePage(props: {
|
|||||||
</tbody>
|
</tbody>
|
||||||
<tfoot className="border-t">
|
<tfoot className="border-t">
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="p-4 text-center text-sm">
|
<td colSpan={7} className="p-4 text-center text-sm">
|
||||||
<PaginationButtons totalPages={totalPages} />
|
<PaginationButtons totalPages={totalPages} />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
Clipboard,
|
Clipboard,
|
||||||
Home,
|
Home,
|
||||||
Package,
|
Package,
|
||||||
Shield,
|
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
User,
|
User,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
@@ -21,7 +20,6 @@ import {
|
|||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
import type { UserRole } from "@/generated/prisma/client"
|
|
||||||
import type { Dictionary } from "@/i18n/dictionaries"
|
import type { Dictionary } from "@/i18n/dictionaries"
|
||||||
|
|
||||||
import { SidebarSection } from "./sidebar/sidebarSection"
|
import { SidebarSection } from "./sidebar/sidebarSection"
|
||||||
@@ -94,25 +92,11 @@ const items: SidebarItem[] = [
|
|||||||
|
|
||||||
export default function AppSidebar({
|
export default function AppSidebar({
|
||||||
copy,
|
copy,
|
||||||
userRole,
|
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof Sidebar> & {
|
}: React.ComponentProps<typeof Sidebar> & {
|
||||||
copy: SidebarCopy
|
copy: SidebarCopy
|
||||||
userRole?: UserRole
|
|
||||||
}) {
|
}) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const visibleItems =
|
|
||||||
userRole === "ADMIN"
|
|
||||||
? [
|
|
||||||
...items,
|
|
||||||
{
|
|
||||||
type: "item",
|
|
||||||
labelKey: "users",
|
|
||||||
url: "/admin/users",
|
|
||||||
icon: Shield,
|
|
||||||
} satisfies SidebarItem,
|
|
||||||
]
|
|
||||||
: items
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar {...props}>
|
<Sidebar {...props}>
|
||||||
@@ -126,7 +110,7 @@ export default function AppSidebar({
|
|||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{visibleItems.map((item) => {
|
{items.map((item) => {
|
||||||
if (item.type === "item") {
|
if (item.type === "item") {
|
||||||
const isActive =
|
const isActive =
|
||||||
item.url === "/"
|
item.url === "/"
|
||||||
|
|||||||
@@ -328,6 +328,8 @@ export const en = {
|
|||||||
email: "Email",
|
email: "Email",
|
||||||
phone: "Phone",
|
phone: "Phone",
|
||||||
department: "Department",
|
department: "Department",
|
||||||
|
role: "Role",
|
||||||
|
status: "Status",
|
||||||
actions: "Actions",
|
actions: "Actions",
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
@@ -341,6 +343,9 @@ export const en = {
|
|||||||
email: "Email",
|
email: "Email",
|
||||||
phone: "Phone",
|
phone: "Phone",
|
||||||
department: "Department",
|
department: "Department",
|
||||||
|
role: "Role",
|
||||||
|
status: "Status",
|
||||||
|
noUser: "No user account",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
new: {
|
new: {
|
||||||
@@ -361,11 +366,17 @@ export const en = {
|
|||||||
emailPlaceholder: "Email",
|
emailPlaceholder: "Email",
|
||||||
phoneLabel: "Phone",
|
phoneLabel: "Phone",
|
||||||
phonePlaceholder: "Phone",
|
phonePlaceholder: "Phone",
|
||||||
|
roleLabel: "Role",
|
||||||
|
activeLabel: "Active user",
|
||||||
|
newPasswordLabel: "New password",
|
||||||
|
newPasswordPlaceholder: "Leave blank to keep current password",
|
||||||
|
userAccountHeading: "User account",
|
||||||
createSubmit: "Create Person",
|
createSubmit: "Create Person",
|
||||||
updateSubmit: "Update Person",
|
updateSubmit: "Update Person",
|
||||||
},
|
},
|
||||||
fallback: {
|
fallback: {
|
||||||
unknownDepartment: "Unknown department",
|
unknownDepartment: "Unknown department",
|
||||||
|
unknownStatus: "Unknown status",
|
||||||
},
|
},
|
||||||
departments: {
|
departments: {
|
||||||
IT: "IT",
|
IT: "IT",
|
||||||
@@ -383,6 +394,7 @@ export const en = {
|
|||||||
updateSuccess: "Person updated successfully",
|
updateSuccess: "Person updated successfully",
|
||||||
updateFailure: "Failed to update person",
|
updateFailure: "Failed to update person",
|
||||||
duplicateEmail: "Email already exists",
|
duplicateEmail: "Email already exists",
|
||||||
|
notFound: "Person not found",
|
||||||
},
|
},
|
||||||
schema: {
|
schema: {
|
||||||
firstNameRequired: "First name is required",
|
firstNameRequired: "First name is required",
|
||||||
@@ -465,8 +477,11 @@ export const en = {
|
|||||||
phonePlaceholder: "Phone",
|
phonePlaceholder: "Phone",
|
||||||
passwordLabel: "Password",
|
passwordLabel: "Password",
|
||||||
passwordPlaceholder: "Minimum 8 characters",
|
passwordPlaceholder: "Minimum 8 characters",
|
||||||
|
newPasswordLabel: "New password",
|
||||||
|
newPasswordPlaceholder: "Leave blank to keep current password",
|
||||||
roleLabel: "Role",
|
roleLabel: "Role",
|
||||||
activeLabel: "Active user",
|
activeLabel: "Active user",
|
||||||
|
userAccountHeading: "User account",
|
||||||
createSubmit: "Create User",
|
createSubmit: "Create User",
|
||||||
updateSubmit: "Update User",
|
updateSubmit: "Update User",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -333,6 +333,8 @@ export const es = {
|
|||||||
email: "Correo electrónico",
|
email: "Correo electrónico",
|
||||||
phone: "Teléfono",
|
phone: "Teléfono",
|
||||||
department: "Departamento",
|
department: "Departamento",
|
||||||
|
role: "Rol",
|
||||||
|
status: "Estado",
|
||||||
actions: "Acciones",
|
actions: "Acciones",
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
@@ -346,6 +348,9 @@ export const es = {
|
|||||||
email: "Correo electrónico",
|
email: "Correo electrónico",
|
||||||
phone: "Teléfono",
|
phone: "Teléfono",
|
||||||
department: "Departamento",
|
department: "Departamento",
|
||||||
|
role: "Rol",
|
||||||
|
status: "Estado",
|
||||||
|
noUser: "Sin cuenta de usuario",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
new: {
|
new: {
|
||||||
@@ -366,11 +371,18 @@ export const es = {
|
|||||||
emailPlaceholder: "Correo electrónico",
|
emailPlaceholder: "Correo electrónico",
|
||||||
phoneLabel: "Teléfono",
|
phoneLabel: "Teléfono",
|
||||||
phonePlaceholder: "Teléfono",
|
phonePlaceholder: "Teléfono",
|
||||||
|
roleLabel: "Rol",
|
||||||
|
activeLabel: "Usuario activo",
|
||||||
|
newPasswordLabel: "Nueva contraseña",
|
||||||
|
newPasswordPlaceholder:
|
||||||
|
"Déjalo vacío para mantener la contraseña actual",
|
||||||
|
userAccountHeading: "Cuenta de usuario",
|
||||||
createSubmit: "Crear persona",
|
createSubmit: "Crear persona",
|
||||||
updateSubmit: "Actualizar persona",
|
updateSubmit: "Actualizar persona",
|
||||||
},
|
},
|
||||||
fallback: {
|
fallback: {
|
||||||
unknownDepartment: "Departamento desconocido",
|
unknownDepartment: "Departamento desconocido",
|
||||||
|
unknownStatus: "Estado desconocido",
|
||||||
},
|
},
|
||||||
departments: {
|
departments: {
|
||||||
IT: "IT",
|
IT: "IT",
|
||||||
@@ -388,6 +400,7 @@ export const es = {
|
|||||||
updateSuccess: "Persona actualizada correctamente",
|
updateSuccess: "Persona actualizada correctamente",
|
||||||
updateFailure: "Error al actualizar la persona",
|
updateFailure: "Error al actualizar la persona",
|
||||||
duplicateEmail: "El correo electrónico ya existe",
|
duplicateEmail: "El correo electrónico ya existe",
|
||||||
|
notFound: "Persona no encontrada",
|
||||||
},
|
},
|
||||||
schema: {
|
schema: {
|
||||||
firstNameRequired: "El nombre es obligatorio",
|
firstNameRequired: "El nombre es obligatorio",
|
||||||
@@ -470,8 +483,12 @@ export const es = {
|
|||||||
phonePlaceholder: "Teléfono",
|
phonePlaceholder: "Teléfono",
|
||||||
passwordLabel: "Contraseña",
|
passwordLabel: "Contraseña",
|
||||||
passwordPlaceholder: "Mínimo 8 caracteres",
|
passwordPlaceholder: "Mínimo 8 caracteres",
|
||||||
|
newPasswordLabel: "Nueva contraseña",
|
||||||
|
newPasswordPlaceholder:
|
||||||
|
"Déjalo vacío para mantener la contraseña actual",
|
||||||
roleLabel: "Rol",
|
roleLabel: "Rol",
|
||||||
activeLabel: "Usuario activo",
|
activeLabel: "Usuario activo",
|
||||||
|
userAccountHeading: "Cuenta de usuario",
|
||||||
createSubmit: "Crear usuario",
|
createSubmit: "Crear usuario",
|
||||||
updateSubmit: "Actualizar usuario",
|
updateSubmit: "Actualizar usuario",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -80,6 +80,50 @@ export const unifiedFormRoleSchema = z.enum([
|
|||||||
"NO_USER",
|
"NO_USER",
|
||||||
])
|
])
|
||||||
|
|
||||||
|
export const unifiedUpdateRoleSchema = z.enum([
|
||||||
|
"ADMIN",
|
||||||
|
"MANAGER",
|
||||||
|
"STAFF",
|
||||||
|
"VIEWER",
|
||||||
|
])
|
||||||
|
|
||||||
|
export function buildUnifiedUpdateSchema(copy: UnifiedSchemaCopy) {
|
||||||
|
return z
|
||||||
|
.object({
|
||||||
|
id: z.string().nonempty(copy.idRequired),
|
||||||
|
firstName: z.string().trim().min(1, { error: copy.firstNameRequired }),
|
||||||
|
lastName: z.string().trim().min(1, { error: copy.lastNameRequired }),
|
||||||
|
department: z.enum(personDepartments, {
|
||||||
|
error: copy.departmentRequired,
|
||||||
|
}),
|
||||||
|
email: z
|
||||||
|
.union([z.email({ error: copy.emailInvalid }), z.literal(""), z.null()])
|
||||||
|
.optional(),
|
||||||
|
phone: z.string().optional().nullable(),
|
||||||
|
role: unifiedUpdateRoleSchema.optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
password: z.string().optional(),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (
|
||||||
|
data.role &&
|
||||||
|
data.password !== undefined &&
|
||||||
|
data.password !== "" &&
|
||||||
|
data.password.length < 8
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: copy.passwordMinLength,
|
||||||
|
path: ["password"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UnifiedUpdateFormType = z.infer<
|
||||||
|
ReturnType<typeof buildUnifiedUpdateSchema>
|
||||||
|
>
|
||||||
|
|
||||||
export function buildUnifiedCreateSchema(copy: UnifiedSchemaCopy) {
|
export function buildUnifiedCreateSchema(copy: UnifiedSchemaCopy) {
|
||||||
return z
|
return z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@@ -2,6 +2,14 @@ import type { Person, Prisma } from "@/generated/prisma/client"
|
|||||||
import { paginate } from "@/lib/paginate"
|
import { paginate } from "@/lib/paginate"
|
||||||
import prisma from "@/lib/prisma"
|
import prisma from "@/lib/prisma"
|
||||||
|
|
||||||
|
const personWithUserSelect = {
|
||||||
|
include: { user: true },
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type PersonWithUser = Prisma.PersonGetPayload<
|
||||||
|
typeof personWithUserSelect
|
||||||
|
>
|
||||||
|
|
||||||
export const PersonService = {
|
export const PersonService = {
|
||||||
findAll: async (): Promise<Person[]> => {
|
findAll: async (): Promise<Person[]> => {
|
||||||
return prisma.person.findMany({
|
return prisma.person.findMany({
|
||||||
@@ -19,10 +27,11 @@ export const PersonService = {
|
|||||||
pageSize?: number
|
pageSize?: number
|
||||||
search?: string
|
search?: string
|
||||||
}) => {
|
}) => {
|
||||||
return paginate<Person>({
|
return paginate<PersonWithUser>({
|
||||||
model: prisma.person,
|
model: prisma.person,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
include: personWithUserSelect.include,
|
||||||
where: {
|
where: {
|
||||||
...(search
|
...(search
|
||||||
? {
|
? {
|
||||||
@@ -47,6 +56,16 @@ export const PersonService = {
|
|||||||
return db.person.findUnique({ where: { id } })
|
return db.person.findUnique({ where: { id } })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
findByIdWithUser: async (
|
||||||
|
id: string,
|
||||||
|
db: Prisma.TransactionClient | typeof prisma = prisma,
|
||||||
|
): Promise<PersonWithUser | null> => {
|
||||||
|
return db.person.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: personWithUserSelect.include,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
findByEmail: async (
|
findByEmail: async (
|
||||||
email: string,
|
email: string,
|
||||||
db: Prisma.TransactionClient | typeof prisma = prisma,
|
db: Prisma.TransactionClient | typeof prisma = prisma,
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import type {
|
|||||||
CreatePersonFormType,
|
CreatePersonFormType,
|
||||||
UpdatePersonFormType,
|
UpdatePersonFormType,
|
||||||
} from "@/schemas/person.schema"
|
} from "@/schemas/person.schema"
|
||||||
import type { UnifiedCreateFormType } from "@/schemas/user.schema"
|
import type {
|
||||||
|
UnifiedCreateFormType,
|
||||||
|
UnifiedUpdateFormType,
|
||||||
|
} from "@/schemas/user.schema"
|
||||||
import { PersonService } from "@/services/person.service"
|
import { PersonService } from "@/services/person.service"
|
||||||
import { getUserByEmail } from "@/services/user.service"
|
import { getUserByEmail } from "@/services/user.service"
|
||||||
|
|
||||||
@@ -217,3 +220,83 @@ export async function createPersonUserUseCase(
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updatePersonUserUseCase(
|
||||||
|
input: UnifiedUpdateFormType,
|
||||||
|
): Promise<PersonUseCaseResult> {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
department,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
role,
|
||||||
|
isActive,
|
||||||
|
password,
|
||||||
|
} = input
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await prisma.$transaction(async (tx) => {
|
||||||
|
const existing = await tx.person.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { user: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return personError({ id: ["Person not found"] })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (email) {
|
||||||
|
const existingPersonEmail = await PersonService.findByEmail(email, tx)
|
||||||
|
if (existingPersonEmail && existingPersonEmail.id !== id) {
|
||||||
|
return personError({ email: ["Email already exists"] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await PersonService.update(
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
department,
|
||||||
|
email: email || null,
|
||||||
|
phone: phone || null,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
)
|
||||||
|
|
||||||
|
// If the person has a linked user, update User fields.
|
||||||
|
if (existing.userId && existing.user) {
|
||||||
|
const userData: Prisma.UserUpdateInput = {}
|
||||||
|
|
||||||
|
if (role !== undefined) {
|
||||||
|
userData.role = role
|
||||||
|
}
|
||||||
|
if (isActive !== undefined) {
|
||||||
|
userData.isActive = isActive
|
||||||
|
}
|
||||||
|
if (password && password.length >= 8) {
|
||||||
|
userData.password = await getPasswordHash(password)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(userData).length > 0) {
|
||||||
|
await tx.user.update({
|
||||||
|
where: { id: existing.userId },
|
||||||
|
data: userData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const errors = uniqueErrorFor(error)
|
||||||
|
|
||||||
|
if (errors) {
|
||||||
|
return personError(errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
|
||||||
|
import type { PrismaClient } from "@/generated/prisma/client"
|
||||||
|
import { createTestPerson, createTestUser } from "../helpers/factories"
|
||||||
|
import {
|
||||||
|
resetIntegrationTestDatabase,
|
||||||
|
startIntegrationTestDatabase,
|
||||||
|
stopIntegrationTestDatabase,
|
||||||
|
} from "../helpers/test-db"
|
||||||
|
|
||||||
|
let prisma: PrismaClient
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await startIntegrationTestDatabase()
|
||||||
|
const prismaModule = await import("@/lib/prisma")
|
||||||
|
prisma = prismaModule.prisma
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetIntegrationTestDatabase(prisma)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma?.$disconnect()
|
||||||
|
await stopIntegrationTestDatabase()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("/admin/users -> /people redirect routes", () => {
|
||||||
|
it("does not have a /admin/users list page (route is consolidated into /people)", async () => {
|
||||||
|
const fs = await import("node:fs/promises")
|
||||||
|
const path = await import("node:path")
|
||||||
|
|
||||||
|
// /admin/users/page.tsx must still exist (as a redirect stub) — verify it's just a redirect.
|
||||||
|
const adminUsersPage = path.join(
|
||||||
|
process.cwd(),
|
||||||
|
"src/app/(dashboard)/admin/users/page.tsx",
|
||||||
|
)
|
||||||
|
const contents = await fs.readFile(adminUsersPage, "utf-8")
|
||||||
|
expect(contents).toMatch(/redirect\s*\(\s*["']\/people["']\s*\)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("resolves a userId back to its linked personId", async () => {
|
||||||
|
// Build a Person<->User link to verify the redirect can find the person by userId.
|
||||||
|
const user = await createTestUser(prisma, {
|
||||||
|
email: "linked@example.test",
|
||||||
|
})
|
||||||
|
const person = await createTestPerson(prisma, {
|
||||||
|
email: "linked@example.test",
|
||||||
|
})
|
||||||
|
await prisma.person.update({
|
||||||
|
where: { id: person.id },
|
||||||
|
data: { userId: user.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
const found = await prisma.person.findFirst({
|
||||||
|
where: { userId: user.id },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(found?.id).toBe(person.id)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
|
||||||
|
import type { PrismaClient } from "@/generated/prisma/client"
|
||||||
|
import { getPasswordHash } from "@/lib/security"
|
||||||
|
import { createTestPerson, createTestUser } from "../helpers/factories"
|
||||||
|
import {
|
||||||
|
resetIntegrationTestDatabase,
|
||||||
|
startIntegrationTestDatabase,
|
||||||
|
stopIntegrationTestDatabase,
|
||||||
|
} from "../helpers/test-db"
|
||||||
|
|
||||||
|
let prisma: PrismaClient
|
||||||
|
let updatePersonUserUseCase: typeof import("@/use-cases/person.use-cases").updatePersonUserUseCase
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await startIntegrationTestDatabase()
|
||||||
|
|
||||||
|
const prismaModule = await import("@/lib/prisma")
|
||||||
|
const personUseCases = await import("@/use-cases/person.use-cases")
|
||||||
|
|
||||||
|
prisma = prismaModule.prisma
|
||||||
|
updatePersonUserUseCase = personUseCases.updatePersonUserUseCase
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetIntegrationTestDatabase(prisma)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma?.$disconnect()
|
||||||
|
await stopIntegrationTestDatabase()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("updatePersonUserUseCase", () => {
|
||||||
|
describe("person-only update", () => {
|
||||||
|
it("updates only the Person when person has no linked User", async () => {
|
||||||
|
const person = await createTestPerson(prisma, {
|
||||||
|
firstName: "Old",
|
||||||
|
lastName: "Name",
|
||||||
|
email: "old@example.test",
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await updatePersonUserUseCase({
|
||||||
|
id: person.id,
|
||||||
|
firstName: "New",
|
||||||
|
lastName: "Name",
|
||||||
|
department: "IT",
|
||||||
|
email: "new@example.test",
|
||||||
|
phone: "1234",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true })
|
||||||
|
|
||||||
|
const updated = await prisma.person.findUniqueOrThrow({
|
||||||
|
where: { id: person.id },
|
||||||
|
})
|
||||||
|
expect(updated).toMatchObject({
|
||||||
|
firstName: "New",
|
||||||
|
lastName: "Name",
|
||||||
|
department: "IT",
|
||||||
|
email: "new@example.test",
|
||||||
|
phone: "1234",
|
||||||
|
userId: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("normalizes empty email to null when person has no User", async () => {
|
||||||
|
const person = await createTestPerson(prisma, { email: null })
|
||||||
|
|
||||||
|
const result = await updatePersonUserUseCase({
|
||||||
|
id: person.id,
|
||||||
|
firstName: "Empty",
|
||||||
|
lastName: "Email",
|
||||||
|
department: "OTHER",
|
||||||
|
email: "",
|
||||||
|
phone: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true })
|
||||||
|
|
||||||
|
const updated = await prisma.person.findUniqueOrThrow({
|
||||||
|
where: { id: person.id },
|
||||||
|
})
|
||||||
|
expect(updated.email).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("person+user update", () => {
|
||||||
|
it("updates Person fields and User role/isActive when person has a User linked", async () => {
|
||||||
|
const user = await createTestUser(prisma, {
|
||||||
|
email: "user-update@example.test",
|
||||||
|
name: "Old Name",
|
||||||
|
role: "STAFF",
|
||||||
|
isActive: true,
|
||||||
|
})
|
||||||
|
const person = await createTestPerson(prisma, {
|
||||||
|
firstName: "Linked",
|
||||||
|
lastName: "Person",
|
||||||
|
email: "user-update@example.test",
|
||||||
|
})
|
||||||
|
await prisma.person.update({
|
||||||
|
where: { id: person.id },
|
||||||
|
data: { userId: user.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await updatePersonUserUseCase({
|
||||||
|
id: person.id,
|
||||||
|
firstName: "Linked",
|
||||||
|
lastName: "Person",
|
||||||
|
department: "ENGINEERING",
|
||||||
|
email: "user-update@example.test",
|
||||||
|
phone: null,
|
||||||
|
role: "ADMIN",
|
||||||
|
isActive: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true })
|
||||||
|
|
||||||
|
const updatedPerson = await prisma.person.findUniqueOrThrow({
|
||||||
|
where: { id: person.id },
|
||||||
|
include: { user: true },
|
||||||
|
})
|
||||||
|
expect(updatedPerson.department).toBe("ENGINEERING")
|
||||||
|
expect(updatedPerson.user).toMatchObject({
|
||||||
|
id: user.id,
|
||||||
|
role: "ADMIN",
|
||||||
|
isActive: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("resets the User password when password is provided", async () => {
|
||||||
|
const user = await createTestUser(prisma, {
|
||||||
|
email: "pw-reset@example.test",
|
||||||
|
})
|
||||||
|
const person = await createTestPerson(prisma, {
|
||||||
|
email: "pw-reset@example.test",
|
||||||
|
})
|
||||||
|
await prisma.person.update({
|
||||||
|
where: { id: person.id },
|
||||||
|
data: { userId: user.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await updatePersonUserUseCase({
|
||||||
|
id: person.id,
|
||||||
|
firstName: person.firstName,
|
||||||
|
lastName: person.lastName,
|
||||||
|
department: "OTHER",
|
||||||
|
email: "pw-reset@example.test",
|
||||||
|
phone: null,
|
||||||
|
role: "STAFF",
|
||||||
|
isActive: true,
|
||||||
|
password: "new-password-1",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true })
|
||||||
|
|
||||||
|
const updated = await prisma.user.findUniqueOrThrow({
|
||||||
|
where: { id: user.id },
|
||||||
|
})
|
||||||
|
const { verifyPassword } = await import("@/lib/security")
|
||||||
|
await expect(
|
||||||
|
verifyPassword("new-password-1", updated.password),
|
||||||
|
).resolves.toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not change the password when password is not provided", async () => {
|
||||||
|
const originalHash = await getPasswordHash("original-password-1")
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: "no-pw@example.test",
|
||||||
|
name: "No PW",
|
||||||
|
password: originalHash,
|
||||||
|
role: "STAFF",
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const person = await createTestPerson(prisma, {
|
||||||
|
email: "no-pw@example.test",
|
||||||
|
})
|
||||||
|
await prisma.person.update({
|
||||||
|
where: { id: person.id },
|
||||||
|
data: { userId: user.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await updatePersonUserUseCase({
|
||||||
|
id: person.id,
|
||||||
|
firstName: person.firstName,
|
||||||
|
lastName: person.lastName,
|
||||||
|
department: "OTHER",
|
||||||
|
email: "no-pw@example.test",
|
||||||
|
phone: null,
|
||||||
|
role: "STAFF",
|
||||||
|
isActive: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true })
|
||||||
|
|
||||||
|
const updated = await prisma.user.findUniqueOrThrow({
|
||||||
|
where: { id: user.id },
|
||||||
|
})
|
||||||
|
const { verifyPassword: verify } = await import("@/lib/security")
|
||||||
|
await expect(
|
||||||
|
verify("original-password-1", updated.password),
|
||||||
|
).resolves.toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("validation errors", () => {
|
||||||
|
it("returns error when person is not found", async () => {
|
||||||
|
const result = await updatePersonUserUseCase({
|
||||||
|
id: "nonexistent-id",
|
||||||
|
firstName: "Ghost",
|
||||||
|
lastName: "Person",
|
||||||
|
department: "OTHER",
|
||||||
|
email: "ghost@example.test",
|
||||||
|
phone: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.errors.id).toBeDefined()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects duplicate email in Person table", async () => {
|
||||||
|
const person = await createTestPerson(prisma, {
|
||||||
|
email: "mine@example.test",
|
||||||
|
})
|
||||||
|
await createTestPerson(prisma, {
|
||||||
|
email: "theirs@example.test",
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await updatePersonUserUseCase({
|
||||||
|
id: person.id,
|
||||||
|
firstName: "Mine",
|
||||||
|
lastName: "Person",
|
||||||
|
department: "OTHER",
|
||||||
|
email: "theirs@example.test",
|
||||||
|
phone: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: false,
|
||||||
|
errors: { email: ["Email already exists"] },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,6 +8,7 @@ const actionCopy = {
|
|||||||
updateSuccess: "Persona actualizada correctamente",
|
updateSuccess: "Persona actualizada correctamente",
|
||||||
updateFailure: "Error al actualizar la persona",
|
updateFailure: "Error al actualizar la persona",
|
||||||
duplicateEmail: "El correo electrónico ya existe",
|
duplicateEmail: "El correo electrónico ya existe",
|
||||||
|
notFound: "Persona no encontrada",
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("person action message localization", () => {
|
describe("person action message localization", () => {
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
|
import { en } from "@/i18n/dictionaries/en"
|
||||||
|
import { es } from "@/i18n/dictionaries/es"
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
revalidatePath: vi.fn(),
|
||||||
|
getI18n: vi.fn(),
|
||||||
|
requireRole: vi.fn(),
|
||||||
|
updatePersonUserUseCase: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("next/cache", () => ({
|
||||||
|
revalidatePath: mocks.revalidatePath,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/i18n/server", () => ({
|
||||||
|
getI18n: mocks.getI18n,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/services/auth.service", () => ({
|
||||||
|
requireRole: mocks.requireRole,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/use-cases/person.use-cases", () => ({
|
||||||
|
updatePersonUserUseCase: mocks.updatePersonUserUseCase,
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { updatePersonUserAction } from "@/actions/person.actions"
|
||||||
|
|
||||||
|
describe("updatePersonUserAction", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
|
||||||
|
mocks.requireRole.mockResolvedValue({ user: { id: "admin-1" } })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("schema validation", () => {
|
||||||
|
it("rejects empty id with localized idRequired error", async () => {
|
||||||
|
const result = await updatePersonUserAction({
|
||||||
|
id: "",
|
||||||
|
firstName: "Ada",
|
||||||
|
lastName: "Lovelace",
|
||||||
|
department: "ENGINEERING",
|
||||||
|
email: "ada@example.test",
|
||||||
|
phone: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: false,
|
||||||
|
errors: {
|
||||||
|
id: [es.inventory.people.schema.idRequired],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(mocks.updatePersonUserUseCase).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects invalid email with localized emailInvalid error", async () => {
|
||||||
|
const result = await updatePersonUserAction({
|
||||||
|
id: "person-1",
|
||||||
|
firstName: "Ada",
|
||||||
|
lastName: "Lovelace",
|
||||||
|
department: "ENGINEERING",
|
||||||
|
email: "not-an-email",
|
||||||
|
phone: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: false,
|
||||||
|
errors: {
|
||||||
|
email: [es.inventory.people.schema.emailInvalid],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(mocks.updatePersonUserUseCase).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects short password when role is provided", async () => {
|
||||||
|
const result = await updatePersonUserAction({
|
||||||
|
id: "person-1",
|
||||||
|
firstName: "Ada",
|
||||||
|
lastName: "Lovelace",
|
||||||
|
department: "ENGINEERING",
|
||||||
|
email: "ada@example.test",
|
||||||
|
phone: null,
|
||||||
|
role: "ADMIN",
|
||||||
|
isActive: true,
|
||||||
|
password: "corta",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: false,
|
||||||
|
errors: {
|
||||||
|
password: [es.admin.users.schema.passwordMinLength],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(mocks.updatePersonUserUseCase).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects NO_USER role on update", async () => {
|
||||||
|
const result = await updatePersonUserAction({
|
||||||
|
id: "person-1",
|
||||||
|
firstName: "Ada",
|
||||||
|
lastName: "Lovelace",
|
||||||
|
department: "ENGINEERING",
|
||||||
|
email: "ada@example.test",
|
||||||
|
phone: null,
|
||||||
|
role: "NO_USER" as unknown as "ADMIN",
|
||||||
|
isActive: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(mocks.updatePersonUserUseCase).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects invalid role enum on update", async () => {
|
||||||
|
const result = await updatePersonUserAction({
|
||||||
|
id: "person-1",
|
||||||
|
firstName: "Ada",
|
||||||
|
lastName: "Lovelace",
|
||||||
|
department: "ENGINEERING",
|
||||||
|
email: "ada@example.test",
|
||||||
|
phone: null,
|
||||||
|
role: "SUPER_ADMIN" as unknown as "ADMIN",
|
||||||
|
isActive: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(mocks.updatePersonUserUseCase).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("use-case failure", () => {
|
||||||
|
it("localizes duplicate email errors from the use case", async () => {
|
||||||
|
mocks.updatePersonUserUseCase.mockResolvedValue({
|
||||||
|
success: false,
|
||||||
|
errors: { email: ["Email already exists"] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await updatePersonUserAction({
|
||||||
|
id: "person-1",
|
||||||
|
firstName: "Ada",
|
||||||
|
lastName: "Lovelace",
|
||||||
|
department: "ENGINEERING",
|
||||||
|
email: "duplicate@example.test",
|
||||||
|
phone: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: false,
|
||||||
|
errors: {
|
||||||
|
email: [es.inventory.people.actions.duplicateEmail],
|
||||||
|
},
|
||||||
|
message: es.inventory.people.actions.updateFailure,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("localizes person-not-found error from the use case", async () => {
|
||||||
|
mocks.updatePersonUserUseCase.mockResolvedValue({
|
||||||
|
success: false,
|
||||||
|
errors: { id: ["Person not found"] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await updatePersonUserAction({
|
||||||
|
id: "missing",
|
||||||
|
firstName: "Ada",
|
||||||
|
lastName: "Lovelace",
|
||||||
|
department: "ENGINEERING",
|
||||||
|
email: "ada@example.test",
|
||||||
|
phone: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
if (!result.success && result.errors) {
|
||||||
|
expect(result.errors.id).toBeDefined()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("success", () => {
|
||||||
|
it("returns success and revalidates /people on happy path", async () => {
|
||||||
|
mocks.updatePersonUserUseCase.mockResolvedValue({ success: true })
|
||||||
|
|
||||||
|
const result = await updatePersonUserAction({
|
||||||
|
id: "person-1",
|
||||||
|
firstName: "Ada",
|
||||||
|
lastName: "Lovelace",
|
||||||
|
department: "ENGINEERING",
|
||||||
|
email: "ada@example.test",
|
||||||
|
phone: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
message: es.inventory.people.actions.updateSuccess,
|
||||||
|
})
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/people")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uses English copy in en locale", async () => {
|
||||||
|
mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
|
||||||
|
mocks.updatePersonUserUseCase.mockResolvedValue({ success: true })
|
||||||
|
|
||||||
|
const result = await updatePersonUserAction({
|
||||||
|
id: "person-1",
|
||||||
|
firstName: "Ada",
|
||||||
|
lastName: "Lovelace",
|
||||||
|
department: "ENGINEERING",
|
||||||
|
email: "ada@example.test",
|
||||||
|
phone: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
message: en.inventory.people.actions.updateSuccess,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
|
import { en } from "@/i18n/dictionaries/en"
|
||||||
|
import { es } from "@/i18n/dictionaries/es"
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
getI18n: vi.fn(),
|
||||||
|
getUsers: vi.fn(),
|
||||||
|
getUserProfileById: vi.fn(),
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
redirect: vi.fn((url: string) => {
|
||||||
|
throw new Error(`REDIRECT:${url}`)
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/i18n/server", () => ({
|
||||||
|
getI18n: mocks.getI18n,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/services/user.service", () => ({
|
||||||
|
getUsers: mocks.getUsers,
|
||||||
|
getUserProfileById: mocks.getUserProfileById,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/lib/prisma", () => ({
|
||||||
|
prisma: {
|
||||||
|
person: {
|
||||||
|
findFirst: mocks.findFirst,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
default: {
|
||||||
|
person: {
|
||||||
|
findFirst: mocks.findFirst,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
redirect: mocks.redirect,
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe("/admin/users routes redirect to /people", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("redirects /admin/users to /people", async () => {
|
||||||
|
const { default: UsersPage } = await import(
|
||||||
|
"@/app/(dashboard)/admin/users/page"
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(() => UsersPage()).toThrow("REDIRECT:/people")
|
||||||
|
expect(mocks.getUsers).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("redirects /admin/users/[userId]/edit to /people/[personId]/edit when person is found", async () => {
|
||||||
|
mocks.findFirst.mockResolvedValue({ id: "person-99" })
|
||||||
|
|
||||||
|
const { default: EditUserPage } = await import(
|
||||||
|
"@/app/(dashboard)/admin/users/[userId]/edit/page"
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
EditUserPage({ params: Promise.resolve({ userId: "user-1" }) }),
|
||||||
|
).rejects.toThrow("REDIRECT:/people/person-99/edit")
|
||||||
|
expect(mocks.getUserProfileById).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("redirects /admin/users/[userId]/edit to /people when person is not found", async () => {
|
||||||
|
mocks.findFirst.mockResolvedValue(null)
|
||||||
|
|
||||||
|
const { default: EditUserPage } = await import(
|
||||||
|
"@/app/(dashboard)/admin/users/[userId]/edit/page"
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
EditUserPage({ params: Promise.resolve({ userId: "orphan-user" }) }),
|
||||||
|
).rejects.toThrow("REDIRECT:/people")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("still honors Spanish locale when redirecting (does not require dictionary lookups)", async () => {
|
||||||
|
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
|
||||||
|
|
||||||
|
const { default: UsersPage } = await import(
|
||||||
|
"@/app/(dashboard)/admin/users/page"
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(() => UsersPage()).toThrow("REDIRECT:/people")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { createElement } from "react"
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server"
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
|
import { en } from "@/i18n/dictionaries/en"
|
||||||
|
import { es } from "@/i18n/dictionaries/es"
|
||||||
|
import type { PersonWithUser } from "@/services/person.service"
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
getI18n: vi.fn(),
|
||||||
|
findByIdWithUser: vi.fn(),
|
||||||
|
findById: vi.fn(),
|
||||||
|
personForm: vi.fn(),
|
||||||
|
push: vi.fn(),
|
||||||
|
toastError: vi.fn(),
|
||||||
|
toastSuccess: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/i18n/server", () => ({
|
||||||
|
getI18n: mocks.getI18n,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/services/person.service", () => ({
|
||||||
|
PersonService: {
|
||||||
|
findByIdWithUser: mocks.findByIdWithUser,
|
||||||
|
findById: mocks.findById,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/app/(dashboard)/people/_components/edit.person.form", () => ({
|
||||||
|
default: (props: unknown) => {
|
||||||
|
mocks.personForm(props)
|
||||||
|
return createElement("div", null, "Edit person form")
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter: () => ({ push: mocks.push }),
|
||||||
|
redirect: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/actions/person.actions", () => ({
|
||||||
|
updatePersonUserAction: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("sonner", () => ({
|
||||||
|
toast: {
|
||||||
|
error: mocks.toastError,
|
||||||
|
success: mocks.toastSuccess,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const basePerson: PersonWithUser = {
|
||||||
|
id: "person-1",
|
||||||
|
firstName: "Ada",
|
||||||
|
lastName: "Lovelace",
|
||||||
|
department: "ENGINEERING",
|
||||||
|
email: "ada@example.test",
|
||||||
|
phone: "1234",
|
||||||
|
userId: null,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date("2024-01-01"),
|
||||||
|
updatedAt: new Date("2024-01-01"),
|
||||||
|
user: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const personWithUser: PersonWithUser = {
|
||||||
|
...basePerson,
|
||||||
|
id: "person-2",
|
||||||
|
userId: "user-1",
|
||||||
|
user: {
|
||||||
|
id: "user-1",
|
||||||
|
name: "Ada Lovelace",
|
||||||
|
email: "ada@example.test",
|
||||||
|
role: "ADMIN",
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date("2024-01-01"),
|
||||||
|
updatedAt: new Date("2024-01-01"),
|
||||||
|
password: "hashed",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("edit person page wiring", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("loads the person without user, passes PersonWithoutUser to the edit form", async () => {
|
||||||
|
mocks.findByIdWithUser.mockResolvedValue({ ...basePerson, user: null })
|
||||||
|
|
||||||
|
const { default: PersonEditPage } = await import(
|
||||||
|
"@/app/(dashboard)/people/[personId]/edit/page"
|
||||||
|
)
|
||||||
|
|
||||||
|
renderToStaticMarkup(
|
||||||
|
await PersonEditPage({
|
||||||
|
params: Promise.resolve({ personId: "person-1" }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(mocks.findByIdWithUser).toHaveBeenCalledWith("person-1")
|
||||||
|
expect(mocks.personForm).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
person: expect.objectContaining({
|
||||||
|
id: "person-1",
|
||||||
|
user: null,
|
||||||
|
}),
|
||||||
|
formCopy: en.admin.users.form,
|
||||||
|
schemaCopy: {
|
||||||
|
...en.admin.users.schema,
|
||||||
|
...en.inventory.people.schema,
|
||||||
|
},
|
||||||
|
roleLabels: en.admin.users.roles,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("passes Spanish copy in es locale and passes the linked User to the form", async () => {
|
||||||
|
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
|
||||||
|
mocks.findByIdWithUser.mockResolvedValue(personWithUser)
|
||||||
|
|
||||||
|
const { default: PersonEditPage } = await import(
|
||||||
|
"@/app/(dashboard)/people/[personId]/edit/page"
|
||||||
|
)
|
||||||
|
|
||||||
|
renderToStaticMarkup(
|
||||||
|
await PersonEditPage({
|
||||||
|
params: Promise.resolve({ personId: "person-2" }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(mocks.personForm).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
person: expect.objectContaining({
|
||||||
|
id: "person-2",
|
||||||
|
user: expect.objectContaining({
|
||||||
|
id: "user-1",
|
||||||
|
role: "ADMIN",
|
||||||
|
isActive: true,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
formCopy: es.admin.users.form,
|
||||||
|
roleLabels: es.admin.users.roles,
|
||||||
|
departmentCopy: es.inventory.people.departments,
|
||||||
|
fallbackCopy: expect.objectContaining({
|
||||||
|
unknownDepartment: es.inventory.people.fallback.unknownDepartment,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("renders 'Person not found' in Spanish when person does not exist", async () => {
|
||||||
|
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
|
||||||
|
mocks.findByIdWithUser.mockResolvedValue(null)
|
||||||
|
|
||||||
|
const { default: PersonEditPage } = await import(
|
||||||
|
"@/app/(dashboard)/people/[personId]/edit/page"
|
||||||
|
)
|
||||||
|
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
await PersonEditPage({
|
||||||
|
params: Promise.resolve({ personId: "missing" }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(html).toContain("Persona no encontrada")
|
||||||
|
expect(mocks.personForm).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,8 +5,10 @@ import { es } from "@/i18n/dictionaries/es"
|
|||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
getI18n: vi.fn(),
|
getI18n: vi.fn(),
|
||||||
|
findByIdWithUser: vi.fn(),
|
||||||
findById: vi.fn(),
|
findById: vi.fn(),
|
||||||
redirect: vi.fn(),
|
redirect: vi.fn(),
|
||||||
|
personForm: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock("@/i18n/server", () => ({
|
vi.mock("@/i18n/server", () => ({
|
||||||
@@ -15,6 +17,7 @@ vi.mock("@/i18n/server", () => ({
|
|||||||
|
|
||||||
vi.mock("@/services/person.service", () => ({
|
vi.mock("@/services/person.service", () => ({
|
||||||
PersonService: {
|
PersonService: {
|
||||||
|
findByIdWithUser: mocks.findByIdWithUser,
|
||||||
findById: mocks.findById,
|
findById: mocks.findById,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
@@ -29,6 +32,14 @@ vi.mock("next/navigation", () => ({
|
|||||||
vi.mock("@/actions/person.actions", () => ({
|
vi.mock("@/actions/person.actions", () => ({
|
||||||
createNewPerson: vi.fn(),
|
createNewPerson: vi.fn(),
|
||||||
updatePerson: vi.fn(),
|
updatePerson: vi.fn(),
|
||||||
|
updatePersonUserAction: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/app/(dashboard)/people/_components/edit.person.form", () => ({
|
||||||
|
default: (props: unknown) => {
|
||||||
|
mocks.personForm(props)
|
||||||
|
return null
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock("sonner", () => ({
|
vi.mock("sonner", () => ({
|
||||||
@@ -44,18 +55,23 @@ describe("person pages", () => {
|
|||||||
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
|
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
|
||||||
})
|
})
|
||||||
|
|
||||||
it("renders the edit person page with Person heading and no username", async () => {
|
it("renders the edit person page with Person heading and passes person to unified form", async () => {
|
||||||
const { default: PersonEditPage } = await import(
|
const { default: PersonEditPage } = await import(
|
||||||
"@/app/(dashboard)/people/[personId]/edit/page"
|
"@/app/(dashboard)/people/[personId]/edit/page"
|
||||||
)
|
)
|
||||||
|
|
||||||
mocks.findById.mockResolvedValue({
|
mocks.findByIdWithUser.mockResolvedValue({
|
||||||
id: "person-1",
|
id: "person-1",
|
||||||
firstName: "Ada",
|
firstName: "Ada",
|
||||||
lastName: "Lovelace",
|
lastName: "Lovelace",
|
||||||
email: "ada@example.test",
|
email: "ada@example.test",
|
||||||
phone: "1234",
|
phone: "1234",
|
||||||
department: "ENGINEERING",
|
department: "ENGINEERING",
|
||||||
|
userId: null,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date("2024-01-01"),
|
||||||
|
updatedAt: new Date("2024-01-01"),
|
||||||
|
user: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
@@ -65,8 +81,15 @@ describe("person pages", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(html).toContain("Editar persona")
|
expect(html).toContain("Editar persona")
|
||||||
expect(html).toContain("Actualizar persona")
|
expect(mocks.personForm).toHaveBeenCalledWith(
|
||||||
expect(html).not.toContain("Usuario")
|
expect.objectContaining({
|
||||||
|
person: expect.objectContaining({
|
||||||
|
id: "person-1",
|
||||||
|
firstName: "Ada",
|
||||||
|
lastName: "Lovelace",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("renders a Person not-found message on edit page", async () => {
|
it("renders a Person not-found message on edit page", async () => {
|
||||||
@@ -74,7 +97,7 @@ describe("person pages", () => {
|
|||||||
"@/app/(dashboard)/people/[personId]/edit/page"
|
"@/app/(dashboard)/people/[personId]/edit/page"
|
||||||
)
|
)
|
||||||
|
|
||||||
mocks.findById.mockResolvedValue(null)
|
mocks.findByIdWithUser.mockResolvedValue(null)
|
||||||
|
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
await PersonEditPage({
|
await PersonEditPage({
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
import { createElement } from "react"
|
|
||||||
import { renderToStaticMarkup } from "react-dom/server"
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
||||||
|
|
||||||
import { en } from "@/i18n/dictionaries/en"
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
|
||||||
getI18n: vi.fn(),
|
|
||||||
findById: vi.fn(),
|
|
||||||
personForm: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock("@/i18n/server", () => ({
|
|
||||||
getI18n: mocks.getI18n,
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock("@/services/person.service", () => ({
|
|
||||||
PersonService: {
|
|
||||||
findById: mocks.findById,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock("@/app/(dashboard)/people/_components/person.form", () => ({
|
|
||||||
default: (props: unknown) => {
|
|
||||||
mocks.personForm(props)
|
|
||||||
return createElement("div", null, "Person form")
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
describe("person form schema wiring", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("passes server-resolved Person schema copy into the edit person form boundary", async () => {
|
|
||||||
mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
|
|
||||||
mocks.findById.mockResolvedValue({
|
|
||||||
id: "person-1",
|
|
||||||
firstName: "Ada",
|
|
||||||
lastName: "Lovelace",
|
|
||||||
department: "ENGINEERING",
|
|
||||||
email: "ada@example.test",
|
|
||||||
phone: "1234",
|
|
||||||
})
|
|
||||||
|
|
||||||
const { default: PersonEditPage } = await import(
|
|
||||||
"@/app/(dashboard)/people/[personId]/edit/page"
|
|
||||||
)
|
|
||||||
|
|
||||||
renderToStaticMarkup(
|
|
||||||
await PersonEditPage({
|
|
||||||
params: Promise.resolve({ personId: "person-1" }),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(mocks.personForm).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
mode: "edit",
|
|
||||||
schemaCopy: en.inventory.people.schema,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -7,6 +7,7 @@ import { en } from "@/i18n/dictionaries/en"
|
|||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
findAllPaginated: vi.fn(),
|
findAllPaginated: vi.fn(),
|
||||||
findById: vi.fn(),
|
findById: vi.fn(),
|
||||||
|
findByIdWithUser: vi.fn(),
|
||||||
findAllByPerson: vi.fn(),
|
findAllByPerson: vi.fn(),
|
||||||
getI18n: vi.fn(),
|
getI18n: vi.fn(),
|
||||||
}))
|
}))
|
||||||
@@ -19,6 +20,7 @@ vi.mock("@/services/person.service", () => ({
|
|||||||
PersonService: {
|
PersonService: {
|
||||||
findAllPaginated: mocks.findAllPaginated,
|
findAllPaginated: mocks.findAllPaginated,
|
||||||
findById: mocks.findById,
|
findById: mocks.findById,
|
||||||
|
findByIdWithUser: mocks.findByIdWithUser,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -62,6 +64,11 @@ describe("person pages", () => {
|
|||||||
email: "ada@example.test",
|
email: "ada@example.test",
|
||||||
phone: "1234",
|
phone: "1234",
|
||||||
department: "ENGINEERING",
|
department: "ENGINEERING",
|
||||||
|
userId: null,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date("2024-01-01"),
|
||||||
|
updatedAt: new Date("2024-01-01"),
|
||||||
|
user: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
@@ -86,6 +93,72 @@ describe("person pages", () => {
|
|||||||
expect(html).toContain("/people/person-1/edit")
|
expect(html).toContain("/people/person-1/edit")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("renders role and status columns for people with linked users", async () => {
|
||||||
|
const { default: PeoplePage } = await import(
|
||||||
|
"@/app/(dashboard)/people/page"
|
||||||
|
)
|
||||||
|
|
||||||
|
mocks.findAllPaginated.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "person-1",
|
||||||
|
firstName: "Ada",
|
||||||
|
lastName: "Lovelace",
|
||||||
|
email: "ada@example.test",
|
||||||
|
phone: "1234",
|
||||||
|
department: "ENGINEERING",
|
||||||
|
userId: "user-1",
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date("2024-01-01"),
|
||||||
|
updatedAt: new Date("2024-01-01"),
|
||||||
|
user: {
|
||||||
|
id: "user-1",
|
||||||
|
name: "Ada Lovelace",
|
||||||
|
email: "ada@example.test",
|
||||||
|
role: "ADMIN",
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date("2024-01-01"),
|
||||||
|
updatedAt: new Date("2024-01-01"),
|
||||||
|
password: "hashed",
|
||||||
|
movements: [],
|
||||||
|
assignments: [],
|
||||||
|
person: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "person-2",
|
||||||
|
firstName: "Bob",
|
||||||
|
lastName: "Jones",
|
||||||
|
email: "bob@example.test",
|
||||||
|
phone: null,
|
||||||
|
department: "IT",
|
||||||
|
userId: null,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date("2024-01-01"),
|
||||||
|
updatedAt: new Date("2024-01-01"),
|
||||||
|
user: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalPages: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
await PeoplePage({ searchParams: Promise.resolve({}) }),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Column headers from inventory.people.list.columns
|
||||||
|
expect(html).toContain("Role")
|
||||||
|
expect(html).toContain("Status")
|
||||||
|
|
||||||
|
// Person with linked user: role label + active label
|
||||||
|
expect(html).toContain("Admin")
|
||||||
|
expect(html).toContain("Active")
|
||||||
|
|
||||||
|
// Person without user: no canonical enum leaks, just placeholder
|
||||||
|
expect(html).not.toContain(">STAFF<")
|
||||||
|
expect(html).not.toContain(">ADMIN<")
|
||||||
|
})
|
||||||
|
|
||||||
it("renders the person list empty state from Person copy", async () => {
|
it("renders the person list empty state from Person copy", async () => {
|
||||||
const { default: PeoplePage } = await import(
|
const { default: PeoplePage } = await import(
|
||||||
"@/app/(dashboard)/people/page"
|
"@/app/(dashboard)/people/page"
|
||||||
@@ -108,13 +181,18 @@ describe("person pages", () => {
|
|||||||
"@/app/(dashboard)/people/[personId]/page"
|
"@/app/(dashboard)/people/[personId]/page"
|
||||||
)
|
)
|
||||||
|
|
||||||
mocks.findById.mockResolvedValue({
|
mocks.findByIdWithUser.mockResolvedValue({
|
||||||
id: "person-1",
|
id: "person-1",
|
||||||
firstName: "Ada",
|
firstName: "Ada",
|
||||||
lastName: "Lovelace",
|
lastName: "Lovelace",
|
||||||
email: "ada@example.test",
|
email: "ada@example.test",
|
||||||
phone: "1234",
|
phone: "1234",
|
||||||
department: "DRIVER",
|
department: "DRIVER",
|
||||||
|
userId: null,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date("2024-01-01"),
|
||||||
|
updatedAt: new Date("2024-01-01"),
|
||||||
|
user: null,
|
||||||
})
|
})
|
||||||
mocks.findAllByPerson.mockResolvedValue([
|
mocks.findAllByPerson.mockResolvedValue([
|
||||||
{
|
{
|
||||||
@@ -144,12 +222,87 @@ describe("person pages", () => {
|
|||||||
expect(html).toContain("Laptop")
|
expect(html).toContain("Laptop")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("renders person detail User role and status when person has linked User", async () => {
|
||||||
|
const { default: PersonInfoPage } = await import(
|
||||||
|
"@/app/(dashboard)/people/[personId]/page"
|
||||||
|
)
|
||||||
|
|
||||||
|
mocks.findByIdWithUser.mockResolvedValue({
|
||||||
|
id: "person-1",
|
||||||
|
firstName: "Ada",
|
||||||
|
lastName: "Lovelace",
|
||||||
|
email: "ada@example.test",
|
||||||
|
phone: "1234",
|
||||||
|
department: "DRIVER",
|
||||||
|
userId: "user-1",
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date("2024-01-01"),
|
||||||
|
updatedAt: new Date("2024-01-01"),
|
||||||
|
user: {
|
||||||
|
id: "user-1",
|
||||||
|
name: "Ada Lovelace",
|
||||||
|
email: "ada@example.test",
|
||||||
|
role: "ADMIN",
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date("2024-01-01"),
|
||||||
|
updatedAt: new Date("2024-01-01"),
|
||||||
|
password: "hashed",
|
||||||
|
movements: [],
|
||||||
|
assignments: [],
|
||||||
|
person: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
mocks.findAllByPerson.mockResolvedValue([])
|
||||||
|
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
await PersonInfoPage({
|
||||||
|
params: Promise.resolve({ personId: "person-1" }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(html).toContain("Role")
|
||||||
|
expect(html).toContain("Status")
|
||||||
|
expect(html).toContain("Admin")
|
||||||
|
expect(html).toContain("Active")
|
||||||
|
// Canonical enum value must not leak into display
|
||||||
|
expect(html).not.toContain(">ADMIN<")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("renders 'No user account' placeholder for person without linked User", async () => {
|
||||||
|
const { default: PersonInfoPage } = await import(
|
||||||
|
"@/app/(dashboard)/people/[personId]/page"
|
||||||
|
)
|
||||||
|
|
||||||
|
mocks.findByIdWithUser.mockResolvedValue({
|
||||||
|
id: "person-1",
|
||||||
|
firstName: "Ada",
|
||||||
|
lastName: "Lovelace",
|
||||||
|
email: "ada@example.test",
|
||||||
|
phone: "1234",
|
||||||
|
department: "DRIVER",
|
||||||
|
userId: null,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date("2024-01-01"),
|
||||||
|
updatedAt: new Date("2024-01-01"),
|
||||||
|
user: null,
|
||||||
|
})
|
||||||
|
mocks.findAllByPerson.mockResolvedValue([])
|
||||||
|
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
await PersonInfoPage({
|
||||||
|
params: Promise.resolve({ personId: "person-1" }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(html).toContain("No user account")
|
||||||
|
})
|
||||||
|
|
||||||
it("renders person detail not-found from Person copy", async () => {
|
it("renders person detail not-found from Person copy", async () => {
|
||||||
const { default: PersonInfoPage } = await import(
|
const { default: PersonInfoPage } = await import(
|
||||||
"@/app/(dashboard)/people/[personId]/page"
|
"@/app/(dashboard)/people/[personId]/page"
|
||||||
)
|
)
|
||||||
|
|
||||||
mocks.findById.mockResolvedValue(null)
|
mocks.findByIdWithUser.mockResolvedValue(null)
|
||||||
mocks.findAllByPerson.mockResolvedValue([])
|
mocks.findAllByPerson.mockResolvedValue([])
|
||||||
|
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
|
|||||||
@@ -1,227 +0,0 @@
|
|||||||
import { renderToStaticMarkup } from "react-dom/server"
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
||||||
|
|
||||||
import { en } from "@/i18n/dictionaries/en"
|
|
||||||
import { es } from "@/i18n/dictionaries/es"
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
|
||||||
createPersonUser: vi.fn(),
|
|
||||||
getUserProfileById: vi.fn(),
|
|
||||||
getI18n: vi.fn(),
|
|
||||||
push: vi.fn(),
|
|
||||||
toastError: vi.fn(),
|
|
||||||
toastSuccess: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock("@/i18n/server", () => ({
|
|
||||||
getI18n: mocks.getI18n,
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock("@/actions/person.actions", () => ({
|
|
||||||
createPersonUserAction: mocks.createPersonUser,
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock("@/actions/user.actions", () => ({
|
|
||||||
updateUserAction: vi.fn(),
|
|
||||||
resetUserPasswordAction: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock("@/services/person.service", () => ({
|
|
||||||
PersonService: {
|
|
||||||
findById: vi.fn(),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock("@/services/user.service", () => ({
|
|
||||||
getUserProfileById: mocks.getUserProfileById,
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock("next/navigation", () => ({
|
|
||||||
useRouter: () => ({
|
|
||||||
push: mocks.push,
|
|
||||||
}),
|
|
||||||
notFound: () => {
|
|
||||||
throw new Error("NOT_FOUND")
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock("sonner", () => ({
|
|
||||||
toast: {
|
|
||||||
error: mocks.toastError,
|
|
||||||
success: mocks.toastSuccess,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
describe("new user form localization", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
|
|
||||||
})
|
|
||||||
|
|
||||||
it("renders new user page with localized title and unified form labels in Spanish", async () => {
|
|
||||||
const { default: NewUserPage } = await import(
|
|
||||||
"@/app/(dashboard)/people/new/page"
|
|
||||||
)
|
|
||||||
|
|
||||||
const html = renderToStaticMarkup(await NewUserPage())
|
|
||||||
|
|
||||||
// Title
|
|
||||||
expect(html).toContain("Nueva persona")
|
|
||||||
|
|
||||||
// Person field labels
|
|
||||||
expect(html).toContain("Nombre")
|
|
||||||
expect(html).toContain("Apellido")
|
|
||||||
expect(html).toContain("Departamento")
|
|
||||||
expect(html).toContain("Teléfono")
|
|
||||||
|
|
||||||
// User field labels
|
|
||||||
expect(html).toContain("Correo electrónico")
|
|
||||||
expect(html).toContain("Contraseña")
|
|
||||||
expect(html).toContain("Rol")
|
|
||||||
|
|
||||||
// Role labels (display) with canonical values
|
|
||||||
expect(html).toContain("Administrador")
|
|
||||||
expect(html).toContain("Gerente")
|
|
||||||
expect(html).toContain("Personal")
|
|
||||||
expect(html).toContain("Visor")
|
|
||||||
expect(html).toContain("Sin cuenta de usuario")
|
|
||||||
|
|
||||||
// Submit button text
|
|
||||||
expect(html).toContain("Crear usuario")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("renders new user page with English unified form labels in English locale", async () => {
|
|
||||||
mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
|
|
||||||
|
|
||||||
const { default: NewUserPage } = await import(
|
|
||||||
"@/app/(dashboard)/people/new/page"
|
|
||||||
)
|
|
||||||
|
|
||||||
const html = renderToStaticMarkup(await NewUserPage())
|
|
||||||
|
|
||||||
expect(html).toContain("New Person")
|
|
||||||
|
|
||||||
// Person fields
|
|
||||||
expect(html).toContain("First Name")
|
|
||||||
expect(html).toContain("Last Name")
|
|
||||||
expect(html).toContain("Department")
|
|
||||||
expect(html).toContain("Phone")
|
|
||||||
|
|
||||||
// User fields
|
|
||||||
expect(html).toContain("Email")
|
|
||||||
expect(html).toContain("Password")
|
|
||||||
expect(html).toContain("Role")
|
|
||||||
|
|
||||||
expect(html).toContain("Create User")
|
|
||||||
expect(html).toContain("Admin")
|
|
||||||
expect(html).toContain("Manager")
|
|
||||||
expect(html).toContain("Staff")
|
|
||||||
expect(html).toContain("Viewer")
|
|
||||||
expect(html).toContain("No user account")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps canonical role values in option value attributes including NO_USER, not localized labels", async () => {
|
|
||||||
const { default: NewUserPage } = await import(
|
|
||||||
"@/app/(dashboard)/people/new/page"
|
|
||||||
)
|
|
||||||
|
|
||||||
const html = renderToStaticMarkup(await NewUserPage())
|
|
||||||
|
|
||||||
// Canonical values must be in value attributes
|
|
||||||
expect(html).toContain('value="ADMIN"')
|
|
||||||
expect(html).toContain('value="MANAGER"')
|
|
||||||
expect(html).toContain('value="STAFF"')
|
|
||||||
expect(html).toContain('value="VIEWER"')
|
|
||||||
expect(html).toContain('value="NO_USER"')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("edit user form localization", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
|
|
||||||
mocks.getUserProfileById.mockResolvedValue({
|
|
||||||
id: "user-1",
|
|
||||||
name: "Ada Lovelace",
|
|
||||||
email: "ada@example.test",
|
|
||||||
role: "ADMIN",
|
|
||||||
isActive: true,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("renders edit user page with localized title, form labels, and reset-password section in Spanish", async () => {
|
|
||||||
const { default: EditUserPage } = await import(
|
|
||||||
"@/app/(dashboard)/admin/users/[userId]/edit/page"
|
|
||||||
)
|
|
||||||
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
await EditUserPage({
|
|
||||||
params: Promise.resolve({ userId: "user-1" }),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Title
|
|
||||||
expect(html).toContain("Editar usuario")
|
|
||||||
|
|
||||||
// Form labels
|
|
||||||
expect(html).toContain("Nombre")
|
|
||||||
expect(html).toContain("Nueva contraseña")
|
|
||||||
|
|
||||||
// Role labels with canonical values
|
|
||||||
expect(html).toContain("Administrador")
|
|
||||||
expect(html).toContain('value="ADMIN"')
|
|
||||||
|
|
||||||
// Active user checkbox label
|
|
||||||
expect(html).toContain("Usuario activo")
|
|
||||||
|
|
||||||
// Submit button
|
|
||||||
expect(html).toContain("Actualizar usuario")
|
|
||||||
|
|
||||||
// Reset password section
|
|
||||||
expect(html).toContain("Restablecer contraseña")
|
|
||||||
expect(html).toContain("Nueva contraseña")
|
|
||||||
expect(html).toContain("Mínimo 8 caracteres")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("renders edit user page with English labels in English locale", async () => {
|
|
||||||
mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
|
|
||||||
|
|
||||||
const { default: EditUserPage } = await import(
|
|
||||||
"@/app/(dashboard)/admin/users/[userId]/edit/page"
|
|
||||||
)
|
|
||||||
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
await EditUserPage({
|
|
||||||
params: Promise.resolve({ userId: "user-1" }),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(html).toContain("Edit User")
|
|
||||||
expect(html).toContain("Active user")
|
|
||||||
expect(html).toContain("Update User")
|
|
||||||
expect(html).toContain("Reset password")
|
|
||||||
expect(html).toContain("New password")
|
|
||||||
expect(html).toContain("Reset Password")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("renders edit user form with role option values as canonical enums regardless of locale", async () => {
|
|
||||||
const { default: EditUserPage } = await import(
|
|
||||||
"@/app/(dashboard)/admin/users/[userId]/edit/page"
|
|
||||||
)
|
|
||||||
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
await EditUserPage({
|
|
||||||
params: Promise.resolve({ userId: "user-1" }),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Canonical role values
|
|
||||||
expect(html).toContain('value="ADMIN"')
|
|
||||||
expect(html).toContain('value="MANAGER"')
|
|
||||||
expect(html).toContain('value="STAFF"')
|
|
||||||
expect(html).toContain('value="VIEWER"')
|
|
||||||
|
|
||||||
// Spanish labels for roles
|
|
||||||
expect(html).toContain("Administrador")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
import { createElement } from "react"
|
|
||||||
import { renderToStaticMarkup } from "react-dom/server"
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
||||||
|
|
||||||
import { en } from "@/i18n/dictionaries/en"
|
|
||||||
import { es } from "@/i18n/dictionaries/es"
|
|
||||||
import type { getUsers as _getUsers } from "@/services/user.service"
|
|
||||||
|
|
||||||
type UserData = Awaited<ReturnType<typeof _getUsers>>["data"][number]
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
|
||||||
getUsers: vi.fn(),
|
|
||||||
getI18n: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock("@/i18n/server", () => ({
|
|
||||||
getI18n: mocks.getI18n,
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock("@/services/user.service", () => ({
|
|
||||||
getUsers: mocks.getUsers,
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock("@/components/common/pageheader", () => ({
|
|
||||||
default: ({ title, addLabel }: { title?: string; addLabel?: string }) =>
|
|
||||||
createElement(
|
|
||||||
"header",
|
|
||||||
null,
|
|
||||||
[title, addLabel].filter(Boolean).join(" | "),
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock("@/components/common/pagination", () => ({
|
|
||||||
default: ({ totalPages }: { totalPages: number }) =>
|
|
||||||
createElement("nav", { "aria-label": "Pagination" }, totalPages),
|
|
||||||
}))
|
|
||||||
|
|
||||||
describe("user pages localization", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
|
|
||||||
})
|
|
||||||
|
|
||||||
it("renders the user list in Spanish with localized headers, status/role labels, and unchanged user data", async () => {
|
|
||||||
const { default: UsersPage } = await import(
|
|
||||||
"@/app/(dashboard)/admin/users/page"
|
|
||||||
)
|
|
||||||
|
|
||||||
mocks.getUsers.mockResolvedValue({
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
id: "user-1",
|
|
||||||
name: "Ada Lovelace",
|
|
||||||
email: "ada@example.test",
|
|
||||||
role: "ADMIN",
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "user-2",
|
|
||||||
name: "Grace Hopper",
|
|
||||||
email: "grace@example.test",
|
|
||||||
role: "STAFF",
|
|
||||||
isActive: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
totalPages: 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
await UsersPage({ searchParams: Promise.resolve({}) }),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Title and add label
|
|
||||||
expect(html).toContain("Usuarios")
|
|
||||||
expect(html).toContain("Agregar usuario")
|
|
||||||
|
|
||||||
// Table headers from dictionary
|
|
||||||
expect(html).toContain("Nombre")
|
|
||||||
expect(html).toContain("Correo electrónico")
|
|
||||||
expect(html).toContain("Rol")
|
|
||||||
expect(html).toContain("Estado")
|
|
||||||
expect(html).toContain("Acciones")
|
|
||||||
|
|
||||||
// Status labels from dictionary (display-only, not canonical)
|
|
||||||
expect(html).toContain("Activo")
|
|
||||||
expect(html).toContain("Inactivo")
|
|
||||||
|
|
||||||
// Role labels from dictionary (display-only, not canonical)
|
|
||||||
expect(html).toContain("Administrador")
|
|
||||||
expect(html).toContain("Personal")
|
|
||||||
|
|
||||||
// User data is never translated
|
|
||||||
expect(html).toContain("Ada Lovelace")
|
|
||||||
expect(html).toContain("ada@example.test")
|
|
||||||
expect(html).toContain("Grace Hopper")
|
|
||||||
expect(html).toContain("grace@example.test")
|
|
||||||
|
|
||||||
// Canonical role values must NOT appear as display text
|
|
||||||
expect(html).not.toContain(">ADMIN<")
|
|
||||||
expect(html).not.toContain(">STAFF<")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("renders the localized user empty state when no users exist", async () => {
|
|
||||||
const { default: UsersPage } = await import(
|
|
||||||
"@/app/(dashboard)/admin/users/page"
|
|
||||||
)
|
|
||||||
|
|
||||||
mocks.getUsers.mockResolvedValue({
|
|
||||||
data: [],
|
|
||||||
totalPages: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
await UsersPage({ searchParams: Promise.resolve({}) }),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(html).toContain("No se encontraron usuarios.")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("renders the user list in English with English dictionary labels", async () => {
|
|
||||||
const { default: UsersPage } = await import(
|
|
||||||
"@/app/(dashboard)/admin/users/page"
|
|
||||||
)
|
|
||||||
|
|
||||||
mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
|
|
||||||
mocks.getUsers.mockResolvedValue({
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
id: "user-1",
|
|
||||||
name: "Ada Lovelace",
|
|
||||||
email: "ada@example.test",
|
|
||||||
role: "MANAGER",
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
totalPages: 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
await UsersPage({ searchParams: Promise.resolve({}) }),
|
|
||||||
)
|
|
||||||
|
|
||||||
// English dictionary labels
|
|
||||||
expect(html).toContain("Users")
|
|
||||||
expect(html).toContain("Add User")
|
|
||||||
expect(html).toContain("Name")
|
|
||||||
expect(html).toContain("Email")
|
|
||||||
expect(html).toContain("Role")
|
|
||||||
expect(html).toContain("Status")
|
|
||||||
expect(html).toContain("Actions")
|
|
||||||
expect(html).toContain("Active")
|
|
||||||
expect(html).toContain("Manager")
|
|
||||||
|
|
||||||
// Canonical enum value must NOT appear as display text
|
|
||||||
expect(html).not.toContain(">MANAGER<")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("renders unknown role via fallback when role is not in dictionary", async () => {
|
|
||||||
const { default: UsersPage } = await import(
|
|
||||||
"@/app/(dashboard)/admin/users/page"
|
|
||||||
)
|
|
||||||
|
|
||||||
mocks.getUsers.mockResolvedValue({
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
id: "user-1",
|
|
||||||
name: "Test User",
|
|
||||||
email: "test@example.test",
|
|
||||||
role: "UNKNOWN_ROLE",
|
|
||||||
isActive: true,
|
|
||||||
} as unknown as UserData,
|
|
||||||
],
|
|
||||||
totalPages: 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
await UsersPage({ searchParams: Promise.resolve({}) }),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Unknown role should use fallback
|
|
||||||
expect(html).toContain("Rol desconocido")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -52,6 +52,7 @@ describe("formatPersonDepartment helper", () => {
|
|||||||
|
|
||||||
const fallbackCopy = {
|
const fallbackCopy = {
|
||||||
unknownDepartment: "Departamento desconocido",
|
unknownDepartment: "Departamento desconocido",
|
||||||
|
unknownStatus: "Estado desconocido",
|
||||||
}
|
}
|
||||||
|
|
||||||
it("formats known department values with localized labels", () => {
|
it("formats known department values with localized labels", () => {
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { createElement } from "react"
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server"
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
|
import { en } from "@/i18n/dictionaries/en"
|
||||||
|
import { es } from "@/i18n/dictionaries/es"
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
usePathname: vi.fn(() => "/people"),
|
||||||
|
Link: ({ href, children }: { href: string; children: React.ReactNode }) =>
|
||||||
|
createElement("a", { href }, children),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
usePathname: mocks.usePathname,
|
||||||
|
redirect: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("next/link", () => ({
|
||||||
|
default: mocks.Link,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/sidebar", () => {
|
||||||
|
const Pass = ({ children }: { children: React.ReactNode }) => children
|
||||||
|
return {
|
||||||
|
Sidebar: Pass,
|
||||||
|
SidebarContent: Pass,
|
||||||
|
SidebarGroup: Pass,
|
||||||
|
SidebarGroupContent: Pass,
|
||||||
|
SidebarHeader: Pass,
|
||||||
|
SidebarMenu: Pass,
|
||||||
|
SidebarMenuButton: ({
|
||||||
|
children: buttonChildren,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) => buttonChildren,
|
||||||
|
SidebarMenuItem: ({
|
||||||
|
children: itemChildren,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) => createElement("li", null, itemChildren),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock("@/components/layout/sidebar/sidebarSection", () => ({
|
||||||
|
SidebarSection: ({
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
items: { title: string; url: string }[]
|
||||||
|
}) =>
|
||||||
|
createElement(
|
||||||
|
"section",
|
||||||
|
null,
|
||||||
|
title,
|
||||||
|
...items.map((sub) =>
|
||||||
|
createElement("a", { key: sub.url, href: sub.url }, sub.title),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe("app sidebar (consolidated people management)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mocks.usePathname.mockReturnValue("/people")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does NOT render a 'users' link in the sidebar (consolidated under /people)", async () => {
|
||||||
|
const { default: AppSidebar } = await import("@/components/layout/sidebar")
|
||||||
|
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
createElement(AppSidebar, { copy: en.layout.sidebar }),
|
||||||
|
)
|
||||||
|
|
||||||
|
// No /admin/users link must exist anywhere
|
||||||
|
expect(html).not.toContain("/admin/users")
|
||||||
|
expect(html).not.toContain(">Users<")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("renders 'People' (not 'Users') in English", async () => {
|
||||||
|
const { default: AppSidebar } = await import("@/components/layout/sidebar")
|
||||||
|
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
createElement(AppSidebar, { copy: en.layout.sidebar }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(html).toContain("People")
|
||||||
|
expect(html).toContain("/people")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("renders 'Personas' (not 'Usuarios') in Spanish", async () => {
|
||||||
|
const { default: AppSidebar } = await import("@/components/layout/sidebar")
|
||||||
|
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
createElement(AppSidebar, { copy: es.layout.sidebar }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(html).toContain("Personas")
|
||||||
|
expect(html).toContain("/people")
|
||||||
|
expect(html).not.toContain("Usuarios")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("marks the /people entry as active when pathname starts with /people", async () => {
|
||||||
|
const { default: AppSidebar } = await import("@/components/layout/sidebar")
|
||||||
|
|
||||||
|
mocks.usePathname.mockReturnValue("/people/person-1/edit")
|
||||||
|
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
createElement(AppSidebar, { copy: en.layout.sidebar }),
|
||||||
|
)
|
||||||
|
|
||||||
|
// The /people link must still be present and reachable
|
||||||
|
expect(html).toContain("/people")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -40,8 +40,11 @@ describe("admin users dictionary", () => {
|
|||||||
phonePlaceholder: "Phone",
|
phonePlaceholder: "Phone",
|
||||||
passwordLabel: "Password",
|
passwordLabel: "Password",
|
||||||
passwordPlaceholder: "Minimum 8 characters",
|
passwordPlaceholder: "Minimum 8 characters",
|
||||||
|
newPasswordLabel: "New password",
|
||||||
|
newPasswordPlaceholder: "Leave blank to keep current password",
|
||||||
roleLabel: "Role",
|
roleLabel: "Role",
|
||||||
activeLabel: "Active user",
|
activeLabel: "Active user",
|
||||||
|
userAccountHeading: "User account",
|
||||||
createSubmit: "Create User",
|
createSubmit: "Create User",
|
||||||
updateSubmit: "Update User",
|
updateSubmit: "Update User",
|
||||||
})
|
})
|
||||||
@@ -132,8 +135,11 @@ describe("admin users dictionary", () => {
|
|||||||
phonePlaceholder: "Teléfono",
|
phonePlaceholder: "Teléfono",
|
||||||
passwordLabel: "Contraseña",
|
passwordLabel: "Contraseña",
|
||||||
passwordPlaceholder: "Mínimo 8 caracteres",
|
passwordPlaceholder: "Mínimo 8 caracteres",
|
||||||
|
newPasswordLabel: "Nueva contraseña",
|
||||||
|
newPasswordPlaceholder: "Déjalo vacío para mantener la contraseña actual",
|
||||||
roleLabel: "Rol",
|
roleLabel: "Rol",
|
||||||
activeLabel: "Usuario activo",
|
activeLabel: "Usuario activo",
|
||||||
|
userAccountHeading: "Cuenta de usuario",
|
||||||
createSubmit: "Crear usuario",
|
createSubmit: "Crear usuario",
|
||||||
updateSubmit: "Actualizar usuario",
|
updateSubmit: "Actualizar usuario",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -701,6 +701,8 @@ describe("i18n dictionaries", () => {
|
|||||||
email: "Email",
|
email: "Email",
|
||||||
phone: "Phone",
|
phone: "Phone",
|
||||||
department: "Department",
|
department: "Department",
|
||||||
|
role: "Role",
|
||||||
|
status: "Status",
|
||||||
actions: "Actions",
|
actions: "Actions",
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
@@ -714,6 +716,9 @@ describe("i18n dictionaries", () => {
|
|||||||
email: "Email",
|
email: "Email",
|
||||||
phone: "Phone",
|
phone: "Phone",
|
||||||
department: "Department",
|
department: "Department",
|
||||||
|
role: "Role",
|
||||||
|
status: "Status",
|
||||||
|
noUser: "No user account",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
new: {
|
new: {
|
||||||
@@ -734,11 +739,17 @@ describe("i18n dictionaries", () => {
|
|||||||
emailPlaceholder: "Email",
|
emailPlaceholder: "Email",
|
||||||
phoneLabel: "Phone",
|
phoneLabel: "Phone",
|
||||||
phonePlaceholder: "Phone",
|
phonePlaceholder: "Phone",
|
||||||
|
roleLabel: "Role",
|
||||||
|
activeLabel: "Active user",
|
||||||
|
newPasswordLabel: "New password",
|
||||||
|
newPasswordPlaceholder: "Leave blank to keep current password",
|
||||||
|
userAccountHeading: "User account",
|
||||||
createSubmit: "Create Person",
|
createSubmit: "Create Person",
|
||||||
updateSubmit: "Update Person",
|
updateSubmit: "Update Person",
|
||||||
},
|
},
|
||||||
fallback: {
|
fallback: {
|
||||||
unknownDepartment: "Unknown department",
|
unknownDepartment: "Unknown department",
|
||||||
|
unknownStatus: "Unknown status",
|
||||||
},
|
},
|
||||||
departments: {
|
departments: {
|
||||||
IT: "IT",
|
IT: "IT",
|
||||||
@@ -756,6 +767,7 @@ describe("i18n dictionaries", () => {
|
|||||||
updateSuccess: "Person updated successfully",
|
updateSuccess: "Person updated successfully",
|
||||||
updateFailure: "Failed to update person",
|
updateFailure: "Failed to update person",
|
||||||
duplicateEmail: "Email already exists",
|
duplicateEmail: "Email already exists",
|
||||||
|
notFound: "Person not found",
|
||||||
},
|
},
|
||||||
schema: {
|
schema: {
|
||||||
firstNameRequired: "First name is required",
|
firstNameRequired: "First name is required",
|
||||||
@@ -777,6 +789,8 @@ describe("i18n dictionaries", () => {
|
|||||||
email: "Correo electrónico",
|
email: "Correo electrónico",
|
||||||
phone: "Teléfono",
|
phone: "Teléfono",
|
||||||
department: "Departamento",
|
department: "Departamento",
|
||||||
|
role: "Rol",
|
||||||
|
status: "Estado",
|
||||||
actions: "Acciones",
|
actions: "Acciones",
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
@@ -790,6 +804,9 @@ describe("i18n dictionaries", () => {
|
|||||||
email: "Correo electrónico",
|
email: "Correo electrónico",
|
||||||
phone: "Teléfono",
|
phone: "Teléfono",
|
||||||
department: "Departamento",
|
department: "Departamento",
|
||||||
|
role: "Rol",
|
||||||
|
status: "Estado",
|
||||||
|
noUser: "Sin cuenta de usuario",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
new: {
|
new: {
|
||||||
@@ -810,11 +827,18 @@ describe("i18n dictionaries", () => {
|
|||||||
emailPlaceholder: "Correo electrónico",
|
emailPlaceholder: "Correo electrónico",
|
||||||
phoneLabel: "Teléfono",
|
phoneLabel: "Teléfono",
|
||||||
phonePlaceholder: "Teléfono",
|
phonePlaceholder: "Teléfono",
|
||||||
|
roleLabel: "Rol",
|
||||||
|
activeLabel: "Usuario activo",
|
||||||
|
newPasswordLabel: "Nueva contraseña",
|
||||||
|
newPasswordPlaceholder:
|
||||||
|
"Déjalo vacío para mantener la contraseña actual",
|
||||||
|
userAccountHeading: "Cuenta de usuario",
|
||||||
createSubmit: "Crear persona",
|
createSubmit: "Crear persona",
|
||||||
updateSubmit: "Actualizar persona",
|
updateSubmit: "Actualizar persona",
|
||||||
},
|
},
|
||||||
fallback: {
|
fallback: {
|
||||||
unknownDepartment: "Departamento desconocido",
|
unknownDepartment: "Departamento desconocido",
|
||||||
|
unknownStatus: "Estado desconocido",
|
||||||
},
|
},
|
||||||
departments: {
|
departments: {
|
||||||
IT: "IT",
|
IT: "IT",
|
||||||
@@ -832,6 +856,7 @@ describe("i18n dictionaries", () => {
|
|||||||
updateSuccess: "Persona actualizada correctamente",
|
updateSuccess: "Persona actualizada correctamente",
|
||||||
updateFailure: "Error al actualizar la persona",
|
updateFailure: "Error al actualizar la persona",
|
||||||
duplicateEmail: "El correo electrónico ya existe",
|
duplicateEmail: "El correo electrónico ya existe",
|
||||||
|
notFound: "Persona no encontrada",
|
||||||
},
|
},
|
||||||
schema: {
|
schema: {
|
||||||
firstNameRequired: "El nombre es obligatorio",
|
firstNameRequired: "El nombre es obligatorio",
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildUnifiedUpdateSchema,
|
||||||
|
type UnifiedSchemaCopy,
|
||||||
|
} from "@/schemas/user.schema"
|
||||||
|
|
||||||
|
const enCopy: UnifiedSchemaCopy = {
|
||||||
|
firstNameRequired: "First name is required",
|
||||||
|
lastNameRequired: "Last name is required",
|
||||||
|
departmentRequired: "Department is required",
|
||||||
|
emailInvalid: "Invalid email",
|
||||||
|
passwordMinLength: "Password must be at least 8 characters",
|
||||||
|
nameRequired: "Name is required",
|
||||||
|
userIdRequired: "User id is required",
|
||||||
|
idRequired: "ID is required",
|
||||||
|
userIdInvalid: "User ID must be a valid UUID",
|
||||||
|
}
|
||||||
|
|
||||||
|
const esCopy: UnifiedSchemaCopy = {
|
||||||
|
firstNameRequired: "El nombre es obligatorio",
|
||||||
|
lastNameRequired: "El apellido es obligatorio",
|
||||||
|
departmentRequired: "El departamento es obligatorio",
|
||||||
|
emailInvalid: "Correo electrónico no válido",
|
||||||
|
passwordMinLength: "La contraseña debe tener al menos 8 caracteres",
|
||||||
|
nameRequired: "El nombre es obligatorio",
|
||||||
|
userIdRequired: "El ID de usuario es obligatorio",
|
||||||
|
idRequired: "El ID es obligatorio",
|
||||||
|
userIdInvalid: "El ID de usuario debe ser un UUID válido",
|
||||||
|
}
|
||||||
|
|
||||||
|
const validPersonOnly = {
|
||||||
|
id: "person-1",
|
||||||
|
firstName: "John",
|
||||||
|
lastName: "Doe",
|
||||||
|
department: "IT",
|
||||||
|
email: "john@example.test",
|
||||||
|
phone: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const validPersonWithUser = {
|
||||||
|
...validPersonOnly,
|
||||||
|
role: "ADMIN" as const,
|
||||||
|
isActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buildUnifiedUpdateSchema", () => {
|
||||||
|
describe("person-only update (no user fields provided)", () => {
|
||||||
|
it("accepts person fields without role/isActive/password when person has no User", () => {
|
||||||
|
const schema = buildUnifiedUpdateSchema(enCopy)
|
||||||
|
const result = schema.safeParse(validPersonOnly)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data.id).toBe("person-1")
|
||||||
|
expect(result.data.firstName).toBe("John")
|
||||||
|
expect(result.data.role).toBeUndefined()
|
||||||
|
expect(result.data.isActive).toBeUndefined()
|
||||||
|
expect(result.data.password).toBeUndefined()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("accepts person fields with isActive=false when person has no User", () => {
|
||||||
|
const schema = buildUnifiedUpdateSchema(enCopy)
|
||||||
|
const result = schema.safeParse({ ...validPersonOnly, isActive: false })
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects empty id", () => {
|
||||||
|
const schema = buildUnifiedUpdateSchema(enCopy)
|
||||||
|
const result = schema.safeParse({
|
||||||
|
id: "",
|
||||||
|
firstName: "John",
|
||||||
|
lastName: "Doe",
|
||||||
|
department: "IT",
|
||||||
|
email: "john@example.test",
|
||||||
|
phone: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.flatten().fieldErrors.id).toContain(
|
||||||
|
enCopy.idRequired,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("person+user update (when person has User linked)", () => {
|
||||||
|
it("accepts person and user fields with role and isActive when no password change", () => {
|
||||||
|
const schema = buildUnifiedUpdateSchema(enCopy)
|
||||||
|
const result = schema.safeParse(validPersonWithUser)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data.role).toBe("ADMIN")
|
||||||
|
expect(result.data.isActive).toBe(true)
|
||||||
|
expect(result.data.password).toBeUndefined()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("accepts password reset (>= 8 chars) when role is provided", () => {
|
||||||
|
const schema = buildUnifiedUpdateSchema(enCopy)
|
||||||
|
const result = schema.safeParse({
|
||||||
|
...validPersonWithUser,
|
||||||
|
password: "newpassword1",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data.password).toBe("newpassword1")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects short password when role is provided and password is non-empty", () => {
|
||||||
|
const schema = buildUnifiedUpdateSchema(enCopy)
|
||||||
|
const result = schema.safeParse({
|
||||||
|
...validPersonWithUser,
|
||||||
|
password: "short",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.flatten().fieldErrors.password).toContain(
|
||||||
|
enCopy.passwordMinLength,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("accepts empty password string as 'do not change' (ignored)", () => {
|
||||||
|
const schema = buildUnifiedUpdateSchema(enCopy)
|
||||||
|
const result = schema.safeParse({
|
||||||
|
...validPersonWithUser,
|
||||||
|
password: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects invalid role enum", () => {
|
||||||
|
const schema = buildUnifiedUpdateSchema(enCopy)
|
||||||
|
const result = schema.safeParse({
|
||||||
|
...validPersonWithUser,
|
||||||
|
role: "SUPER_ADMIN",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects NO_USER role on update (cannot remove user from existing person)", () => {
|
||||||
|
const schema = buildUnifiedUpdateSchema(enCopy)
|
||||||
|
const result = schema.safeParse({
|
||||||
|
...validPersonWithUser,
|
||||||
|
role: "NO_USER" as unknown as "ADMIN",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uses localized password error message", () => {
|
||||||
|
const schema = buildUnifiedUpdateSchema(esCopy)
|
||||||
|
const result = schema.safeParse({
|
||||||
|
...validPersonWithUser,
|
||||||
|
password: "corta",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.flatten().fieldErrors.password).toContain(
|
||||||
|
esCopy.passwordMinLength,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("email validation", () => {
|
||||||
|
it("rejects invalid email format", () => {
|
||||||
|
const schema = buildUnifiedUpdateSchema(enCopy)
|
||||||
|
const result = schema.safeParse({
|
||||||
|
...validPersonOnly,
|
||||||
|
email: "not-an-email",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.flatten().fieldErrors.email).toContain(
|
||||||
|
enCopy.emailInvalid,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("accepts null email (person can have no email)", () => {
|
||||||
|
const schema = buildUnifiedUpdateSchema(enCopy)
|
||||||
|
const result = schema.safeParse({ ...validPersonOnly, email: null })
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user