refactor: consolidate admin/users management under /people

This commit is contained in:
2026-06-17 09:32:26 +02:00
parent 4f370eee70
commit d6b42d78e7
31 changed files with 1928 additions and 855 deletions
@@ -1,10 +1,6 @@
import { notFound } from "next/navigation"
import { redirect } from "next/navigation"
import { getI18n } from "@/i18n/server"
import { getUserProfileById } from "@/services/user.service"
import EditUserForm from "../../_components/edit.user.form"
import ResetUserPasswordForm from "../../_components/reset.user.password.form"
import prisma from "@/lib/prisma"
export default async function EditUserPage({
params,
@@ -12,35 +8,15 @@ export default async function EditUserPage({
params: Promise<{ userId: string }>
}) {
const { userId } = await params
const user = await getUserProfileById(userId)
const { dictionary } = await getI18n()
const copy = dictionary.admin.users
if (!user) {
notFound()
const person = await prisma.person.findFirst({
where: { userId },
select: { id: true },
})
if (!person) {
redirect("/people")
}
return (
<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>
)
redirect(`/people/${person.id}/edit`)
}
+3 -109
View File
@@ -1,111 +1,5 @@
import { Pencil } from "lucide-react"
import Link from "next/link"
import { redirect } from "next/navigation"
import PageHeader from "@/components/common/pageheader"
import PaginationButtons from "@/components/common/pagination"
import { Button } from "@/components/ui/button"
import { getI18n } from "@/i18n/server"
import { getUsers } from "@/services/user.service"
import {
formatUserRole,
type UserFallbackCopy,
type UserRoleCopy,
type UserStatusCopy,
} from "./_components/user.copy"
export default async function UsersPage(props: {
searchParams?: Promise<{
page?: string
search?: string
}>
}) {
const searchParams = await props.searchParams
const currentPage = searchParams?.page ? parseInt(searchParams.page, 10) : 1
const search = searchParams?.search || ""
const { data: users, totalPages } = await getUsers({
page: currentPage,
pageSize: 10,
search,
})
const { dictionary } = await getI18n()
const copy = dictionary.admin.users
return (
<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>
)
export default function UsersPage() {
redirect("/people")
}
+1 -6
View File
@@ -4,22 +4,17 @@ import Navbar from "@/components/layout/navbar"
import AppSidebar from "@/components/layout/sidebar"
import { SidebarProvider } from "@/components/ui/sidebar"
import { getI18n } from "@/i18n/server"
import { auth } from "@/lib/auth"
export default async function LayoutDashboard({
children,
}: {
children: React.ReactNode
}) {
const session = await auth()
const { dictionary } = await getI18n()
return (
<SidebarProvider>
<AppSidebar
copy={dictionary.layout.sidebar}
userRole={session?.user.role}
/>
<AppSidebar copy={dictionary.layout.sidebar} />
<main className="w-full">
<Navbar />
<div className="flex-1 p-6">{children}</div>
@@ -1,7 +1,7 @@
import { getI18n } from "@/i18n/server"
import { PersonService } from "@/services/person.service"
import PersonForm from "../../_components/person.form"
import EditPersonForm from "../../_components/edit.person.form"
export default async function PersonEditPage({
params,
@@ -10,25 +10,27 @@ export default async function PersonEditPage({
}) {
const { personId } = await params
const { dictionary } = await getI18n()
const copy = dictionary.inventory.people
const person = await PersonService.findById(personId)
const personCopy = dictionary.inventory.people
const userCopy = dictionary.admin.users
const person = await PersonService.findByIdWithUser(personId)
if (!person) {
return <div>{copy.edit.notFound}</div>
return <div>{personCopy.edit.notFound}</div>
}
return (
<div className="flex flex-col 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>
<PersonForm
initialData={person}
mode="edit"
formCopy={copy.form}
schemaCopy={copy.schema}
departmentCopy={copy.departments}
fallbackCopy={copy.fallback}
<EditPersonForm
person={person}
formCopy={userCopy.form}
schemaCopy={{ ...userCopy.schema, ...personCopy.schema }}
roleLabels={userCopy.roles}
userFallbackCopy={userCopy.fallback}
departmentCopy={personCopy.departments}
fallbackCopy={personCopy.fallback}
submitButtonCopy={dictionary.common.submitButton}
/>
</div>
+38 -1
View File
@@ -4,6 +4,11 @@ import { AssignmentService } from "@/services/assignment.service"
import { PersonService } from "@/services/person.service"
import { formatPersonDepartment } from "../_components/person.copy"
import {
formatUserRole,
type UserFallbackCopy,
type UserRoleCopy,
} from "../_components/user.copy"
export default async function PersonInfoPage({
params,
@@ -14,7 +19,8 @@ export default async function PersonInfoPage({
const { dictionary } = await getI18n()
const copy = dictionary.inventory.people
const assignmentCopy = dictionary.inventory.assignments
const person = await PersonService.findById(personId)
const userCopy = dictionary.admin.users
const person = await PersonService.findByIdWithUser(personId)
const assignments = await AssignmentService.findAllByPerson(personId)
if (!person) {
@@ -49,6 +55,37 @@ export default async function PersonInfoPage({
)}
</span>
</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>
</CardContent>
</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>
)
}
+42 -6
View File
@@ -4,11 +4,19 @@ import Link from "next/link"
import PageHeader from "@/components/common/pageheader"
import PaginationButtons from "@/components/common/pagination"
import { Button } from "@/components/ui/button"
import type { Person } from "@/generated/prisma/client"
import { getI18n } from "@/i18n/server"
import { PersonService } from "@/services/person.service"
import { formatPersonDepartment } from "./_components/person.copy"
import {
formatPersonDepartment,
type PersonDepartmentCopy,
type PersonFallbackCopy,
} from "./_components/person.copy"
import {
formatUserRole,
type UserFallbackCopy,
type UserRoleCopy,
} from "./_components/user.copy"
export default async function PeoplePage(props: {
searchParams?: Promise<{
@@ -26,6 +34,12 @@ export default async function PeoplePage(props: {
})
const { dictionary } = await getI18n()
const copy = dictionary.inventory.people
const userCopy = dictionary.admin.users
const userStatusCopy = userCopy.status
const userRoleLabels = userCopy.roles as UserRoleCopy
const userFallbackCopy = userCopy.fallback as UserFallbackCopy
const departmentCopy = copy.departments as PersonDepartmentCopy
const personFallbackCopy = copy.fallback as PersonFallbackCopy
return (
<div className="flex flex-col gap-4">
@@ -54,13 +68,19 @@ export default async function PeoplePage(props: {
<th scope="col" className="p-4">
{copy.list.columns.department}
</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>
{people.map((person: Person) => (
{people.map((person) => (
<tr key={person.id} className="border-b">
<td className="p-4">
{`${person.firstName} ${person.lastName}`}
@@ -70,10 +90,26 @@ export default async function PeoplePage(props: {
<td className="p-4">
{formatPersonDepartment(
person.department,
copy.departments,
copy.fallback,
departmentCopy,
personFallbackCopy,
)}
</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">
<Link href={`/people/${person.id}`} passHref>
<Button
@@ -100,7 +136,7 @@ export default async function PeoplePage(props: {
</tbody>
<tfoot className="border-t">
<tr>
<td colSpan={5} className="p-4 text-center text-sm">
<td colSpan={7} className="p-4 text-center text-sm">
<PaginationButtons totalPages={totalPages} />
</td>
</tr>