Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd38621f8b | |||
| a0dcf85f5c | |||
| d3114326bb | |||
| 575900427a | |||
| 455e267999 | |||
| 7f607be01b | |||
| 5d303bdb7e | |||
| f88d831f4c | |||
| 65f9a6d6a4 | |||
| 8f6d2882f8 |
@@ -0,0 +1,24 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Team" (
|
||||
"id" UUID NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Team_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "team_name_lower_unique" ON "Team" (lower("name"));
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Person" ADD COLUMN "teamId" UUID;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Person_teamId_deletedAt_idx" ON "Person"("teamId", "deletedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Person_teamId_idx" ON "Person"("teamId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Person" ADD CONSTRAINT "Person_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -130,6 +130,9 @@ model Person {
|
||||
email String?
|
||||
phone String?
|
||||
|
||||
teamId String? @db.Uuid
|
||||
team Team? @relation(fields: [teamId], references: [id], onDelete: SetNull, onUpdate: Cascade)
|
||||
|
||||
userId String? @unique @db.Uuid
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull, onUpdate: Cascade)
|
||||
|
||||
@@ -141,9 +144,20 @@ model Person {
|
||||
|
||||
@@index([lastName, firstName])
|
||||
@@index([department, deletedAt])
|
||||
@@index([teamId, deletedAt])
|
||||
@@index([teamId])
|
||||
@@index([deletedAt])
|
||||
}
|
||||
|
||||
model Team {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
people Person[]
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// CATALOG
|
||||
// ======================================================
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { flattenError } from "zod"
|
||||
import { getI18n } from "@/i18n/server"
|
||||
import {
|
||||
buildCreateTeamSchema,
|
||||
buildUpdateTeamSchema,
|
||||
type CreateTeamFormType,
|
||||
type UpdateTeamFormType,
|
||||
} from "@/schemas/team.schema"
|
||||
import { getAuthenticatedSession, requireRole } from "@/services/auth.service"
|
||||
import {
|
||||
createTeamUseCase,
|
||||
deleteTeamUseCase,
|
||||
listTeamsUseCase,
|
||||
updateTeamUseCase,
|
||||
} from "@/use-cases/team.use-cases"
|
||||
|
||||
import { localizeTeamFieldErrors } from "./team.messages"
|
||||
|
||||
export async function createTeamAction(formData: CreateTeamFormType) {
|
||||
await requireRole("ADMIN")
|
||||
|
||||
const { dictionary } = await getI18n()
|
||||
const copy = dictionary.inventory.teams
|
||||
const validatedFields = buildCreateTeamSchema(copy.schema).safeParse(formData)
|
||||
|
||||
if (!validatedFields.success) {
|
||||
return {
|
||||
success: false,
|
||||
errors: flattenError(validatedFields.error).fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createTeamUseCase(validatedFields.data)
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
...result,
|
||||
errors: localizeTeamFieldErrors(result.errors, copy.actions),
|
||||
message: copy.actions.createFailure,
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath("/people")
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: copy.actions.createSuccess,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Database error:", error)
|
||||
return {
|
||||
success: false,
|
||||
message: copy.actions.createFailure,
|
||||
errors: {
|
||||
name: [copy.actions.duplicateName],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTeamAction(formData: UpdateTeamFormType) {
|
||||
await requireRole("ADMIN")
|
||||
|
||||
const { dictionary } = await getI18n()
|
||||
const copy = dictionary.inventory.teams
|
||||
const validatedFields = buildUpdateTeamSchema(copy.schema).safeParse(formData)
|
||||
|
||||
if (!validatedFields.success) {
|
||||
return {
|
||||
success: false,
|
||||
errors: flattenError(validatedFields.error).fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await updateTeamUseCase(validatedFields.data)
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
...result,
|
||||
errors: localizeTeamFieldErrors(result.errors, copy.actions),
|
||||
message: copy.actions.updateFailure,
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath("/people")
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: copy.actions.updateSuccess,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Database error:", error)
|
||||
return {
|
||||
success: false,
|
||||
message: copy.actions.updateFailure,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTeamAction(formData: FormData) {
|
||||
await requireRole("ADMIN")
|
||||
|
||||
const { dictionary } = await getI18n()
|
||||
const copy = dictionary.inventory.teams
|
||||
const { id } = Object.fromEntries(formData) as { id: string }
|
||||
|
||||
try {
|
||||
const result = await deleteTeamUseCase(id)
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
...result,
|
||||
errors: localizeTeamFieldErrors(result.errors, copy.actions),
|
||||
message: copy.actions.deleteFailure,
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath("/people")
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
message: copy.actions.deleteSuccess,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Database error:", error)
|
||||
return {
|
||||
success: false as const,
|
||||
message: copy.actions.deleteFailure,
|
||||
errors: {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function listTeamsAction() {
|
||||
await getAuthenticatedSession()
|
||||
|
||||
return listTeamsUseCase()
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Dictionary } from "@/i18n/dictionaries"
|
||||
|
||||
type TeamActionCopy = Dictionary["inventory"]["teams"]["actions"]
|
||||
|
||||
type FieldErrors = Record<string, string[]>
|
||||
|
||||
const teamErrorMessageKeys = {
|
||||
"Team already exists": "duplicateName",
|
||||
"Team name is the same": "unchangedName",
|
||||
"Team name unchanged": "unchangedName",
|
||||
"Team not found": "notFound",
|
||||
} as const satisfies Record<string, keyof TeamActionCopy>
|
||||
|
||||
function isTeamErrorMessage(
|
||||
message: string,
|
||||
): message is keyof typeof teamErrorMessageKeys {
|
||||
return message in teamErrorMessageKeys
|
||||
}
|
||||
|
||||
function localizeTeamMessage(message: string, copy: TeamActionCopy): string {
|
||||
if (!isTeamErrorMessage(message)) return message
|
||||
|
||||
return copy[teamErrorMessageKeys[message]]
|
||||
}
|
||||
|
||||
export function localizeTeamFieldErrors(
|
||||
errors: FieldErrors | undefined,
|
||||
copy: TeamActionCopy,
|
||||
): FieldErrors | undefined {
|
||||
if (!errors) return undefined
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(errors).map(([field, messages]) => [
|
||||
field,
|
||||
messages.map((message) => localizeTeamMessage(message, copy)),
|
||||
]),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"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 { createTeamAction } from "@/actions/team.actions"
|
||||
import {
|
||||
SubmitButton,
|
||||
type SubmitButtonCopy,
|
||||
} from "@/components/forms/submitButton"
|
||||
import type { Dictionary } from "@/i18n/dictionaries"
|
||||
import {
|
||||
buildCreateTeamSchema,
|
||||
type CreateTeamFormType,
|
||||
} from "@/schemas/team.schema"
|
||||
|
||||
type TeamFormCopy = Dictionary["inventory"]["teams"]["form"]
|
||||
type TeamSchemaCopy = Dictionary["inventory"]["teams"]["schema"]
|
||||
|
||||
export default function TeamCreateForm({
|
||||
formCopy,
|
||||
schemaCopy,
|
||||
submitButtonCopy,
|
||||
}: {
|
||||
formCopy: TeamFormCopy
|
||||
schemaCopy: TeamSchemaCopy
|
||||
submitButtonCopy: SubmitButtonCopy
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const schema = useMemo(() => buildCreateTeamSchema(schemaCopy), [schemaCopy])
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setError,
|
||||
formState: { errors, isSubmitting, isSubmitSuccessful },
|
||||
} = useForm<CreateTeamFormType>({
|
||||
resolver: zodResolver(schema),
|
||||
})
|
||||
|
||||
const onSubmit = async (formData: CreateTeamFormType) => {
|
||||
const response = await createTeamAction(formData)
|
||||
|
||||
if (response?.errors) {
|
||||
Object.entries(response.errors).forEach(([fieldName, messages]) => {
|
||||
messages.forEach((msg: string) => {
|
||||
setError(fieldName as keyof CreateTeamFormType, {
|
||||
type: "server",
|
||||
message: msg,
|
||||
})
|
||||
toast.error(msg)
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (response?.success) {
|
||||
toast.success(response.message)
|
||||
reset()
|
||||
router.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-4 rounded-lg border p-4"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="team-name" className="mb-2 block text-lg">
|
||||
{formCopy.nameLabel}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="team-name"
|
||||
placeholder={formCopy.namePlaceholder}
|
||||
{...register("name")}
|
||||
className={`w-full rounded-lg border px-4 py-2 ${errors.name ? "border-error" : ""}`}
|
||||
/>
|
||||
{errors.name && <p className="text-error">{errors.name.message}</p>}
|
||||
</div>
|
||||
<SubmitButton
|
||||
copy={submitButtonCopy}
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitSuccessful={isSubmitSuccessful}
|
||||
>
|
||||
{formCopy.createSubmit}
|
||||
</SubmitButton>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Pencil } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import { updateTeamAction } from "@/actions/team.actions"
|
||||
import {
|
||||
SubmitButton,
|
||||
type SubmitButtonCopy,
|
||||
} from "@/components/forms/submitButton"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import type { Dictionary } from "@/i18n/dictionaries"
|
||||
import {
|
||||
buildUpdateTeamSchema,
|
||||
type UpdateTeamFormType,
|
||||
} from "@/schemas/team.schema"
|
||||
import type { TeamSummary } from "@/types"
|
||||
|
||||
type TeamFormCopy = Dictionary["inventory"]["teams"]["form"]
|
||||
type TeamSchemaCopy = Dictionary["inventory"]["teams"]["schema"]
|
||||
type TeamListCopy = Dictionary["inventory"]["teams"]["list"]
|
||||
|
||||
export default function TeamEditForm({
|
||||
team,
|
||||
formCopy,
|
||||
schemaCopy,
|
||||
listCopy,
|
||||
submitButtonCopy,
|
||||
}: {
|
||||
team: TeamSummary
|
||||
formCopy: TeamFormCopy
|
||||
schemaCopy: TeamSchemaCopy
|
||||
listCopy: TeamListCopy
|
||||
submitButtonCopy: SubmitButtonCopy
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [open, setOpen] = useState(false)
|
||||
const schema = useMemo(() => buildUpdateTeamSchema(schemaCopy), [schemaCopy])
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting, isSubmitSuccessful },
|
||||
} = useForm<UpdateTeamFormType>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async (formData: UpdateTeamFormType) => {
|
||||
const response = await updateTeamAction(formData)
|
||||
|
||||
if (response?.errors) {
|
||||
Object.entries(response.errors).forEach(([fieldName, messages]) => {
|
||||
messages.forEach((msg: string) => {
|
||||
setError(fieldName as keyof UpdateTeamFormType, {
|
||||
type: "server",
|
||||
message: msg,
|
||||
})
|
||||
toast.error(msg)
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (response?.success) {
|
||||
toast.success(response.message)
|
||||
setOpen(false)
|
||||
router.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label={listCopy.actions.edit}
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{formCopy.updateSubmit}</DialogTitle>
|
||||
<DialogDescription>{team.name}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<input type="hidden" {...register("id")} />
|
||||
<div className="flex flex-col gap-2">
|
||||
<label
|
||||
htmlFor={`team-name-${team.id}`}
|
||||
className="mb-2 block text-lg"
|
||||
>
|
||||
{formCopy.nameLabel}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id={`team-name-${team.id}`}
|
||||
placeholder={formCopy.namePlaceholder}
|
||||
{...register("name")}
|
||||
className={`w-full rounded-lg border px-4 py-2 ${errors.name ? "border-error" : ""}`}
|
||||
/>
|
||||
{errors.name && <p className="text-error">{errors.name.message}</p>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">
|
||||
{formCopy.cancel}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<SubmitButton
|
||||
copy={submitButtonCopy}
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitSuccessful={isSubmitSuccessful}
|
||||
>
|
||||
{formCopy.updateSubmit}
|
||||
</SubmitButton>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
"use client"
|
||||
|
||||
import { Trash } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTransition } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { deleteTeamAction } from "@/actions/team.actions"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import type { Dictionary } from "@/i18n/dictionaries"
|
||||
import type { TeamSummary } from "@/types"
|
||||
|
||||
import TeamEditForm from "./team.edit.form"
|
||||
|
||||
type TeamFormCopy = Dictionary["inventory"]["teams"]["form"]
|
||||
type TeamSchemaCopy = Dictionary["inventory"]["teams"]["schema"]
|
||||
type TeamListCopy = Dictionary["inventory"]["teams"]["list"]
|
||||
type SubmitButtonCopy = Dictionary["common"]["submitButton"]
|
||||
|
||||
function DeleteTeamButton({
|
||||
team,
|
||||
copy,
|
||||
}: {
|
||||
team: TeamSummary
|
||||
copy: TeamListCopy
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const handleDelete = (formData: FormData) => {
|
||||
startTransition(async () => {
|
||||
const response = await deleteTeamAction(formData)
|
||||
|
||||
if (!response.success && response.errors?.id) {
|
||||
toast.error(response.errors.id[0])
|
||||
return
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
toast.success(response.message)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(response.message ?? copy.actions.delete)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={handleDelete}>
|
||||
<input type="hidden" name="id" value={team.id} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={isPending}
|
||||
aria-label={copy.actions.delete}
|
||||
>
|
||||
<Trash />
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TeamListTable({
|
||||
teams,
|
||||
formCopy,
|
||||
schemaCopy,
|
||||
listCopy,
|
||||
submitButtonCopy,
|
||||
}: {
|
||||
teams: TeamSummary[]
|
||||
formCopy: TeamFormCopy
|
||||
schemaCopy: TeamSchemaCopy
|
||||
listCopy: TeamListCopy
|
||||
submitButtonCopy: SubmitButtonCopy
|
||||
}) {
|
||||
if (teams.length === 0) {
|
||||
return <div>{listCopy.empty}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
{listCopy.columns.name}
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
{listCopy.columns.actions}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{teams.map((team) => (
|
||||
<tr key={team.id} className="border-b">
|
||||
<td className="p-4">{team.name}</td>
|
||||
<td className="flex items-center gap-2 p-4">
|
||||
<TeamEditForm
|
||||
team={team}
|
||||
formCopy={formCopy}
|
||||
schemaCopy={schemaCopy}
|
||||
listCopy={listCopy}
|
||||
submitButtonCopy={submitButtonCopy}
|
||||
/>
|
||||
<DeleteTeamButton team={team} copy={listCopy} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import PageHeader from "@/components/common/pageheader"
|
||||
import { getI18n } from "@/i18n/server"
|
||||
import { listTeamsUseCase } from "@/use-cases/team.use-cases"
|
||||
|
||||
import TeamCreateForm from "./team.create.form"
|
||||
import TeamListTable from "./team.list.table"
|
||||
|
||||
export default async function TeamsTab() {
|
||||
const teams = await listTeamsUseCase()
|
||||
const { dictionary } = await getI18n()
|
||||
const copy = dictionary.inventory.teams
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PageHeader
|
||||
title={copy.list.title}
|
||||
addLabel={copy.list.addLabel}
|
||||
data={teams}
|
||||
/>
|
||||
<TeamCreateForm
|
||||
formCopy={copy.form}
|
||||
schemaCopy={copy.schema}
|
||||
submitButtonCopy={dictionary.common.submitButton}
|
||||
/>
|
||||
<TeamListTable
|
||||
teams={teams}
|
||||
formCopy={copy.form}
|
||||
schemaCopy={copy.schema}
|
||||
listCopy={copy.list}
|
||||
submitButtonCopy={dictionary.common.submitButton}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,21 +13,36 @@ import {
|
||||
type PersonDepartmentCopy,
|
||||
type PersonFallbackCopy,
|
||||
} from "./_components/person.copy"
|
||||
import TeamsTab from "./_components/teams.tab"
|
||||
import {
|
||||
formatUserRole,
|
||||
type UserFallbackCopy,
|
||||
type UserRoleCopy,
|
||||
} from "./_components/user.copy"
|
||||
|
||||
const VALID_TABS = ["people", "teams"] as const
|
||||
|
||||
type Tab = (typeof VALID_TABS)[number]
|
||||
|
||||
function resolveTab(raw: string | undefined): Tab {
|
||||
if (raw && VALID_TABS.includes(raw as Tab)) {
|
||||
return raw as Tab
|
||||
}
|
||||
|
||||
return "people"
|
||||
}
|
||||
|
||||
export default async function PeoplePage(props: {
|
||||
searchParams?: Promise<{
|
||||
page?: string
|
||||
search?: string
|
||||
tab?: string
|
||||
}>
|
||||
}) {
|
||||
const searchParams = await props.searchParams
|
||||
const currentPage = searchParams?.page ? parseInt(searchParams.page, 10) : 1
|
||||
const search = searchParams?.search || ""
|
||||
const activeTab = resolveTab(searchParams?.tab)
|
||||
const { data: people, totalPages } = await PersonService.findAllPaginated({
|
||||
page: currentPage,
|
||||
pageSize: 10,
|
||||
@@ -35,6 +50,7 @@ export default async function PeoplePage(props: {
|
||||
})
|
||||
const { dictionary } = await getI18n()
|
||||
const copy = dictionary.inventory.people
|
||||
const teamCopy = dictionary.inventory.teams
|
||||
const userCopy = dictionary.admin.users
|
||||
const userStatusCopy = userCopy.status
|
||||
const userRoleLabels = userCopy.roles as UserRoleCopy
|
||||
@@ -42,7 +58,7 @@ export default async function PeoplePage(props: {
|
||||
const departmentCopy = copy.departments as PersonDepartmentCopy
|
||||
const personFallbackCopy = copy.fallback as PersonFallbackCopy
|
||||
|
||||
return (
|
||||
const peopleList = (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PageHeader
|
||||
title={copy.list.title}
|
||||
@@ -160,4 +176,33 @@ export default async function PeoplePage(props: {
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<nav aria-label="People sections">
|
||||
<ul className="flex gap-4 border-b">
|
||||
<li>
|
||||
<Link
|
||||
href="/people?tab=people"
|
||||
aria-current={activeTab === "people" ? "page" : undefined}
|
||||
className={`inline-block px-4 py-2 ${activeTab === "people" ? "border-b-2 border-primary font-semibold" : ""}`}
|
||||
>
|
||||
{copy.list.title}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/people?tab=teams"
|
||||
aria-current={activeTab === "teams" ? "page" : undefined}
|
||||
className={`inline-block px-4 py-2 ${activeTab === "teams" ? "border-b-2 border-primary font-semibold" : ""}`}
|
||||
>
|
||||
{teamCopy.list.title}
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
{activeTab === "teams" && <TeamsTab />}
|
||||
{activeTab === "people" && peopleList}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -384,6 +384,44 @@ export const en = {
|
||||
value: "{remaining} of {total}",
|
||||
},
|
||||
},
|
||||
teams: {
|
||||
list: {
|
||||
title: "Teams",
|
||||
addLabel: "Add Team",
|
||||
empty: "No teams found.",
|
||||
columns: {
|
||||
name: "Name",
|
||||
actions: "Actions",
|
||||
},
|
||||
actions: {
|
||||
edit: "Edit team",
|
||||
delete: "Delete team",
|
||||
},
|
||||
},
|
||||
form: {
|
||||
nameLabel: "Team name",
|
||||
namePlaceholder: "Team name",
|
||||
createSubmit: "Create Team",
|
||||
updateSubmit: "Update Team",
|
||||
cancel: "Cancel",
|
||||
},
|
||||
actions: {
|
||||
createSuccess: "Team created successfully",
|
||||
createFailure: "Failed to create team",
|
||||
updateSuccess: "Team updated successfully",
|
||||
updateFailure: "Failed to update team",
|
||||
deleteSuccess: "Team deleted successfully",
|
||||
deleteFailure: "Failed to delete team",
|
||||
duplicateName: "Team already exists",
|
||||
unchangedName: "Team name unchanged",
|
||||
notFound: "Team not found",
|
||||
},
|
||||
schema: {
|
||||
nameRequired: "Team name is required",
|
||||
nameMaxLength: "Team name must be at most 80 characters",
|
||||
idRequired: "Team ID is required",
|
||||
},
|
||||
},
|
||||
people: {
|
||||
list: {
|
||||
title: "People",
|
||||
|
||||
@@ -389,6 +389,45 @@ export const es = {
|
||||
value: "{remaining} de {total}",
|
||||
},
|
||||
},
|
||||
teams: {
|
||||
list: {
|
||||
title: "Equipos",
|
||||
addLabel: "Agregar equipo",
|
||||
empty: "No se encontraron equipos.",
|
||||
columns: {
|
||||
name: "Nombre",
|
||||
actions: "Acciones",
|
||||
},
|
||||
actions: {
|
||||
edit: "Editar equipo",
|
||||
delete: "Eliminar equipo",
|
||||
},
|
||||
},
|
||||
form: {
|
||||
nameLabel: "Nombre del equipo",
|
||||
namePlaceholder: "Nombre del equipo",
|
||||
createSubmit: "Crear equipo",
|
||||
updateSubmit: "Actualizar equipo",
|
||||
cancel: "Cancelar",
|
||||
},
|
||||
actions: {
|
||||
createSuccess: "Equipo creado correctamente",
|
||||
createFailure: "Error al crear el equipo",
|
||||
updateSuccess: "Equipo actualizado correctamente",
|
||||
updateFailure: "Error al actualizar el equipo",
|
||||
deleteSuccess: "Equipo eliminado correctamente",
|
||||
deleteFailure: "Error al eliminar el equipo",
|
||||
duplicateName: "El equipo ya existe",
|
||||
unchangedName: "El nombre del equipo no cambió",
|
||||
notFound: "Equipo no encontrado",
|
||||
},
|
||||
schema: {
|
||||
nameRequired: "El nombre del equipo es obligatorio",
|
||||
nameMaxLength:
|
||||
"El nombre del equipo no puede superar los 80 caracteres",
|
||||
idRequired: "El ID del equipo es obligatorio",
|
||||
},
|
||||
},
|
||||
people: {
|
||||
list: {
|
||||
title: "Personas",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import type { Dictionary } from "@/i18n/dictionaries"
|
||||
|
||||
export type TeamSchemaCopy = Dictionary["inventory"]["teams"]["schema"]
|
||||
|
||||
const defaultTeamSchemaCopy: TeamSchemaCopy = {
|
||||
nameRequired: "Name is required",
|
||||
nameMaxLength: "Name must be at most 80 characters",
|
||||
idRequired: "ID is required",
|
||||
}
|
||||
|
||||
export function buildCreateTeamSchema(copy: TeamSchemaCopy) {
|
||||
return z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, { error: copy.nameRequired })
|
||||
.max(80, { error: copy.nameMaxLength }),
|
||||
})
|
||||
}
|
||||
|
||||
export function buildUpdateTeamSchema(copy: TeamSchemaCopy) {
|
||||
return buildCreateTeamSchema(copy).extend({
|
||||
id: z.string().nonempty(copy.idRequired),
|
||||
})
|
||||
}
|
||||
|
||||
export const createTeamSchema = buildCreateTeamSchema(defaultTeamSchemaCopy)
|
||||
|
||||
export type CreateTeamFormType = z.infer<typeof createTeamSchema>
|
||||
|
||||
export const updateTeamSchema = buildUpdateTeamSchema(defaultTeamSchemaCopy)
|
||||
|
||||
export type UpdateTeamFormType = z.infer<typeof updateTeamSchema>
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Prisma } from "@/generated/prisma/client"
|
||||
import prisma from "@/lib/prisma"
|
||||
import type { Team, TeamSummary } from "@/types"
|
||||
|
||||
export const TeamService = {
|
||||
findAll: async (
|
||||
db: Prisma.TransactionClient | typeof prisma = prisma,
|
||||
): Promise<TeamSummary[]> => {
|
||||
return db.team.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
})
|
||||
},
|
||||
|
||||
findById: async (
|
||||
id: string,
|
||||
db: Prisma.TransactionClient | typeof prisma = prisma,
|
||||
): Promise<Team | null> => {
|
||||
return db.team.findUnique({ where: { id } })
|
||||
},
|
||||
|
||||
findByNameCaseInsensitive: async (
|
||||
name: string,
|
||||
db: Prisma.TransactionClient | typeof prisma = prisma,
|
||||
): Promise<Team | null> => {
|
||||
return db.team.findFirst({
|
||||
where: {
|
||||
name: { equals: name.trim(), mode: "insensitive" },
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
create: async (
|
||||
data: Prisma.TeamCreateInput,
|
||||
db: Prisma.TransactionClient | typeof prisma = prisma,
|
||||
): Promise<Team> => {
|
||||
return db.team.create({ data })
|
||||
},
|
||||
|
||||
update: async (
|
||||
id: string,
|
||||
data: Prisma.TeamUpdateInput,
|
||||
db: Prisma.TransactionClient | typeof prisma = prisma,
|
||||
): Promise<Team> => {
|
||||
return db.team.update({ where: { id }, data })
|
||||
},
|
||||
|
||||
delete: async (
|
||||
id: string,
|
||||
db: Prisma.TransactionClient | typeof prisma = prisma,
|
||||
): Promise<Team> => {
|
||||
return db.team.delete({ where: { id } })
|
||||
},
|
||||
}
|
||||
@@ -6,4 +6,5 @@ export * from "./item"
|
||||
export * from "./movement"
|
||||
export * from "./paginate"
|
||||
export * from "./person"
|
||||
export * from "./team"
|
||||
export * from "./user"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { Team as PrismaTeam } from "@/generated/prisma/client"
|
||||
|
||||
export type Team = PrismaTeam
|
||||
|
||||
export type TeamSummary = Pick<Team, "id" | "name">
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Prisma } from "@/generated/prisma/client"
|
||||
import prisma from "@/lib/prisma"
|
||||
import type {
|
||||
CreateTeamFormType,
|
||||
UpdateTeamFormType,
|
||||
} from "@/schemas/team.schema"
|
||||
import { TeamService } from "@/services/team.service"
|
||||
|
||||
type FieldErrors = Record<string, string[]>
|
||||
|
||||
type TeamUseCaseResult =
|
||||
| {
|
||||
success: true
|
||||
}
|
||||
| {
|
||||
success: false
|
||||
errors: FieldErrors
|
||||
}
|
||||
|
||||
function teamError(errors: FieldErrors): TeamUseCaseResult {
|
||||
return {
|
||||
success: false,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
function isUniqueConstraintError(error: unknown) {
|
||||
return (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === "P2002"
|
||||
)
|
||||
}
|
||||
|
||||
export async function listTeamsUseCase() {
|
||||
return TeamService.findAll()
|
||||
}
|
||||
|
||||
export async function createTeamUseCase(
|
||||
input: CreateTeamFormType,
|
||||
): Promise<TeamUseCaseResult> {
|
||||
const name = input.name.trim()
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const existingTeam = await TeamService.findByNameCaseInsensitive(name, tx)
|
||||
|
||||
if (existingTeam) {
|
||||
return teamError({ name: ["Team already exists"] })
|
||||
}
|
||||
|
||||
await TeamService.create({ name }, tx)
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
} catch (error) {
|
||||
if (isUniqueConstraintError(error)) {
|
||||
return teamError({ name: ["Team already exists"] })
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTeamUseCase(
|
||||
input: UpdateTeamFormType,
|
||||
): Promise<TeamUseCaseResult> {
|
||||
const { id } = input
|
||||
const name = input.name.trim()
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const existingTeam = await TeamService.findById(id, tx)
|
||||
|
||||
if (!existingTeam) {
|
||||
return teamError({ id: ["Team not found"] })
|
||||
}
|
||||
|
||||
if (existingTeam.name.toLowerCase() === name.toLowerCase()) {
|
||||
return teamError({ name: ["Team name is the same"] })
|
||||
}
|
||||
|
||||
const teamWithName = await TeamService.findByNameCaseInsensitive(name, tx)
|
||||
|
||||
if (teamWithName) {
|
||||
return teamError({ name: ["Team already exists"] })
|
||||
}
|
||||
|
||||
await TeamService.update(id, { name }, tx)
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
} catch (error) {
|
||||
if (isUniqueConstraintError(error)) {
|
||||
return teamError({ name: ["Team already exists"] })
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTeamUseCase(
|
||||
id: string,
|
||||
): Promise<TeamUseCaseResult> {
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const team = await TeamService.findById(id, tx)
|
||||
|
||||
if (!team) {
|
||||
return teamError({ id: ["Team not found"] })
|
||||
}
|
||||
|
||||
await TeamService.delete(id, tx)
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
} catch (error) {
|
||||
if (isUniqueConstraintError(error)) {
|
||||
return teamError({ name: ["Team already exists"] })
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,19 @@ export async function createTestPerson(
|
||||
})
|
||||
}
|
||||
|
||||
export async function createTestTeam(
|
||||
prisma: PrismaClient,
|
||||
overrides: Partial<{ name: string }> = {},
|
||||
) {
|
||||
const suffix = nextSuffix()
|
||||
|
||||
return prisma.team.create({
|
||||
data: {
|
||||
name: overrides.name ?? `Test Team ${suffix}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function createTestItem(
|
||||
prisma: PrismaClient,
|
||||
overrides: Partial<{
|
||||
|
||||
@@ -18,6 +18,7 @@ const TABLES_TO_TRUNCATE = [
|
||||
"Asset",
|
||||
"Item",
|
||||
"Category",
|
||||
"Team",
|
||||
"Person",
|
||||
"User",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
|
||||
import type { PrismaClient } from "@/generated/prisma/client"
|
||||
import { createTestPerson, createTestTeam } from "../helpers/factories"
|
||||
import {
|
||||
resetIntegrationTestDatabase,
|
||||
startIntegrationTestDatabase,
|
||||
stopIntegrationTestDatabase,
|
||||
} from "../helpers/test-db"
|
||||
|
||||
let prisma: PrismaClient
|
||||
let createTeamUseCase: typeof import("@/use-cases/team.use-cases").createTeamUseCase
|
||||
let updateTeamUseCase: typeof import("@/use-cases/team.use-cases").updateTeamUseCase
|
||||
let deleteTeamUseCase: typeof import("@/use-cases/team.use-cases").deleteTeamUseCase
|
||||
let listTeamsUseCase: typeof import("@/use-cases/team.use-cases").listTeamsUseCase
|
||||
|
||||
beforeAll(async () => {
|
||||
await startIntegrationTestDatabase()
|
||||
|
||||
const prismaModule = await import("@/lib/prisma")
|
||||
const teamUseCases = await import("@/use-cases/team.use-cases")
|
||||
|
||||
prisma = prismaModule.prisma
|
||||
createTeamUseCase = teamUseCases.createTeamUseCase
|
||||
updateTeamUseCase = teamUseCases.updateTeamUseCase
|
||||
deleteTeamUseCase = teamUseCases.deleteTeamUseCase
|
||||
listTeamsUseCase = teamUseCases.listTeamsUseCase
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetIntegrationTestDatabase(prisma)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma?.$disconnect()
|
||||
await stopIntegrationTestDatabase()
|
||||
})
|
||||
|
||||
describe("team use-cases", () => {
|
||||
it("creates a team and rejects duplicate names", async () => {
|
||||
expect(await createTeamUseCase({ name: "Engineering" })).toEqual({
|
||||
success: true,
|
||||
})
|
||||
|
||||
expect(
|
||||
await prisma.team.findFirst({ where: { name: "Engineering" } }),
|
||||
).toMatchObject({ name: "Engineering" })
|
||||
|
||||
expect(await createTeamUseCase({ name: "Engineering" })).toEqual({
|
||||
success: false,
|
||||
errors: { name: ["Team already exists"] },
|
||||
})
|
||||
|
||||
expect(await prisma.team.count()).toBe(1)
|
||||
})
|
||||
|
||||
it("rejects duplicate names case-insensitively and trims whitespace", async () => {
|
||||
await createTestTeam(prisma, { name: "Engineering" })
|
||||
|
||||
expect(await createTeamUseCase({ name: "engineering" })).toEqual({
|
||||
success: false,
|
||||
errors: { name: ["Team already exists"] },
|
||||
})
|
||||
|
||||
expect(await createTeamUseCase({ name: " ENGINEERING " })).toEqual({
|
||||
success: false,
|
||||
errors: { name: ["Team already exists"] },
|
||||
})
|
||||
|
||||
expect(await prisma.team.count()).toBe(1)
|
||||
})
|
||||
|
||||
it("trims the name before saving", async () => {
|
||||
await createTeamUseCase({ name: " Engineering " })
|
||||
|
||||
const team = await prisma.team.findFirst({
|
||||
where: { name: "Engineering" },
|
||||
})
|
||||
|
||||
expect(team).not.toBeNull()
|
||||
expect(team?.name).toBe("Engineering")
|
||||
})
|
||||
|
||||
it("updates a team and rejects unchanged or duplicate names", async () => {
|
||||
const team = await createTestTeam(prisma, { name: "Peripherals" })
|
||||
const other = await createTestTeam(prisma, { name: "Networking" })
|
||||
|
||||
expect(
|
||||
await updateTeamUseCase({ id: team.id, name: "Accessories" }),
|
||||
).toEqual({ success: true })
|
||||
|
||||
expect(
|
||||
await prisma.team.findUniqueOrThrow({ where: { id: team.id } }),
|
||||
).toMatchObject({ name: "Accessories" })
|
||||
|
||||
expect(
|
||||
await updateTeamUseCase({ id: team.id, name: "Accessories" }),
|
||||
).toEqual({
|
||||
success: false,
|
||||
errors: { name: ["Team name is the same"] },
|
||||
})
|
||||
|
||||
expect(await updateTeamUseCase({ id: team.id, name: other.name })).toEqual({
|
||||
success: false,
|
||||
errors: { name: ["Team already exists"] },
|
||||
})
|
||||
|
||||
expect(
|
||||
await prisma.team.findUniqueOrThrow({ where: { id: team.id } }),
|
||||
).toMatchObject({ name: "Accessories" })
|
||||
})
|
||||
|
||||
it("returns not found when updating a missing team", async () => {
|
||||
expect(
|
||||
await updateTeamUseCase({
|
||||
id: "00000000-0000-0000-0000-000000000000",
|
||||
name: "Ghost",
|
||||
}),
|
||||
).toEqual({
|
||||
success: false,
|
||||
errors: { id: ["Team not found"] },
|
||||
})
|
||||
})
|
||||
|
||||
it("hard deletes a team", async () => {
|
||||
const team = await createTestTeam(prisma, { name: "Legacy" })
|
||||
|
||||
expect(await deleteTeamUseCase(team.id)).toEqual({ success: true })
|
||||
|
||||
expect(await prisma.team.findUnique({ where: { id: team.id } })).toBeNull()
|
||||
})
|
||||
|
||||
it("returns not found when deleting a missing team", async () => {
|
||||
expect(
|
||||
await deleteTeamUseCase("00000000-0000-0000-0000-000000000000"),
|
||||
).toEqual({
|
||||
success: false,
|
||||
errors: { id: ["Team not found"] },
|
||||
})
|
||||
})
|
||||
|
||||
it("nulls linked Person.teamId when a team is deleted", async () => {
|
||||
const team = await createTestTeam(prisma, { name: "Assigned" })
|
||||
const person = await createTestPerson(prisma)
|
||||
|
||||
await prisma.person.update({
|
||||
where: { id: person.id },
|
||||
data: { teamId: team.id },
|
||||
})
|
||||
|
||||
expect(await deleteTeamUseCase(team.id)).toEqual({ success: true })
|
||||
|
||||
const updatedPerson = await prisma.person.findUnique({
|
||||
where: { id: person.id },
|
||||
})
|
||||
|
||||
expect(updatedPerson).not.toBeNull()
|
||||
expect(updatedPerson?.teamId).toBeNull()
|
||||
})
|
||||
|
||||
it("lists all teams ordered by name", async () => {
|
||||
await createTestTeam(prisma, { name: "Beta" })
|
||||
await createTestTeam(prisma, { name: "Alpha" })
|
||||
await createTestTeam(prisma, { name: "Gamma" })
|
||||
|
||||
const teams = await listTeamsUseCase()
|
||||
|
||||
expect(teams).toHaveLength(3)
|
||||
expect(teams.map((team) => team.name)).toEqual(["Alpha", "Beta", "Gamma"])
|
||||
})
|
||||
})
|
||||
@@ -55,6 +55,7 @@ const basePerson: PersonWithUser = {
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
department: "ENGINEERING",
|
||||
teamId: null,
|
||||
email: "ada@example.test",
|
||||
phone: "1234",
|
||||
userId: null,
|
||||
|
||||
@@ -44,6 +44,10 @@ vi.mock("@/components/common/pagination", () => ({
|
||||
createElement("nav", { "aria-label": "Pagination" }, totalPages),
|
||||
}))
|
||||
|
||||
vi.mock("@/app/(dashboard)/people/_components/teams.tab", () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
describe("person pages", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import {
|
||||
buildCreateTeamSchema,
|
||||
buildUpdateTeamSchema,
|
||||
} from "@/schemas/team.schema"
|
||||
|
||||
const schemaCopy = {
|
||||
nameRequired: "El nombre del equipo es obligatorio",
|
||||
nameMaxLength: "El nombre del equipo no puede superar los 80 caracteres",
|
||||
idRequired: "El ID es obligatorio",
|
||||
}
|
||||
|
||||
describe("team schema", () => {
|
||||
it("rejects blank names", () => {
|
||||
const result = buildCreateTeamSchema(schemaCopy).safeParse({ name: "" })
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.flatten().fieldErrors.name).toContain(
|
||||
schemaCopy.nameRequired,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it("rejects whitespace-only names", () => {
|
||||
const result = buildCreateTeamSchema(schemaCopy).safeParse({ name: " " })
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.flatten().fieldErrors.name).toContain(
|
||||
schemaCopy.nameRequired,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it("rejects names longer than 80 characters", () => {
|
||||
const result = buildCreateTeamSchema(schemaCopy).safeParse({
|
||||
name: "a".repeat(81),
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.flatten().fieldErrors.name).toContain(
|
||||
schemaCopy.nameMaxLength,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it("accepts valid create input", () => {
|
||||
const result = buildCreateTeamSchema(schemaCopy).safeParse({
|
||||
name: "Engineering",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects update with empty id", () => {
|
||||
const result = buildUpdateTeamSchema(schemaCopy).safeParse({
|
||||
id: "",
|
||||
name: "Engineering",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.flatten().fieldErrors.id).toContain(
|
||||
schemaCopy.idRequired,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it("accepts valid update input", () => {
|
||||
const result = buildUpdateTeamSchema(schemaCopy).safeParse({
|
||||
id: "some-id",
|
||||
name: "Engineering",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user