Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7abf298da1 | |||
| 79bd1b5d5e | |||
| d1def3353a | |||
| 66c703e484 | |||
| 925bafff1a | |||
| 1e9c5b9627 | |||
| 723c41f0c9 | |||
| 15494b2539 | |||
| efda051aa3 |
Vendored
+4
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports.biome": "explicit",
|
||||
"source.fixAll.biome": "explicit"
|
||||
@@ -11,5 +12,8 @@
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,41 @@
|
||||
BEGIN;
|
||||
|
||||
-- Seed legacy teams from the old PersonDepartment enum English display names.
|
||||
INSERT INTO "Team" ("id", "name", "createdAt", "updatedAt")
|
||||
VALUES
|
||||
(gen_random_uuid(), 'IT', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
(gen_random_uuid(), 'Engineering', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
(gen_random_uuid(), 'Logistics', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
(gen_random_uuid(), 'Traffic', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
(gen_random_uuid(), 'Driver', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
(gen_random_uuid(), 'Administration', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
(gen_random_uuid(), 'Sales', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
(gen_random_uuid(), 'Other', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (lower("name")) DO NOTHING;
|
||||
|
||||
-- Backfill Person.teamId from the legacy Person.department enum values.
|
||||
UPDATE "Person"
|
||||
SET "teamId" = (
|
||||
SELECT "id" FROM "Team" WHERE lower("name") = lower(CASE "department"
|
||||
WHEN 'IT' THEN 'IT'
|
||||
WHEN 'ENGINEERING' THEN 'Engineering'
|
||||
WHEN 'LOGISTICS' THEN 'Logistics'
|
||||
WHEN 'TRAFFIC' THEN 'Traffic'
|
||||
WHEN 'DRIVER' THEN 'Driver'
|
||||
WHEN 'ADMINISTRATION' THEN 'Administration'
|
||||
WHEN 'SALES' THEN 'Sales'
|
||||
WHEN 'OTHER' THEN 'Other'
|
||||
END)
|
||||
)
|
||||
WHERE "department" IS NOT NULL;
|
||||
|
||||
-- Drop the legacy department index.
|
||||
DROP INDEX "Person_department_deletedAt_idx";
|
||||
|
||||
-- Drop the legacy department column.
|
||||
ALTER TABLE "Person" DROP COLUMN "department";
|
||||
|
||||
-- Drop the legacy enum type.
|
||||
DROP TYPE "PersonDepartment";
|
||||
|
||||
COMMIT;
|
||||
+17
-16
@@ -110,26 +110,17 @@ model UserInvitation {
|
||||
// PEOPLE
|
||||
// ======================================================
|
||||
|
||||
enum PersonDepartment {
|
||||
IT
|
||||
ENGINEERING
|
||||
LOGISTICS
|
||||
TRAFFIC
|
||||
DRIVER
|
||||
ADMINISTRATION
|
||||
SALES
|
||||
OTHER
|
||||
}
|
||||
|
||||
model Person {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
firstName String
|
||||
lastName String
|
||||
department PersonDepartment?
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
firstName String
|
||||
lastName String
|
||||
|
||||
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)
|
||||
|
||||
@@ -140,10 +131,20 @@ model Person {
|
||||
assignments Assignment[]
|
||||
|
||||
@@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
|
||||
// ======================================================
|
||||
|
||||
@@ -35,14 +35,21 @@ export async function createAssignment(formData: CreateAssignmentFormType) {
|
||||
|
||||
try {
|
||||
const createdBy = await getAuthenticatedUserId()
|
||||
const { itemId, assetId, quantity, notes } = validatedFields.data
|
||||
if (!itemId || quantity == null) {
|
||||
throw new Error("Missing required assignment fields")
|
||||
}
|
||||
|
||||
const normalizedQuantity = assetId ? 1 : quantity
|
||||
|
||||
const result = await createAssignmentUseCase({
|
||||
...validatedFields.data,
|
||||
quantity: normalizedQuantity,
|
||||
lines: [
|
||||
{
|
||||
itemId: validatedFields.data.itemId!,
|
||||
quantity: validatedFields.data.quantity!,
|
||||
notes: validatedFields.data.notes,
|
||||
itemId,
|
||||
quantity: normalizedQuantity,
|
||||
notes,
|
||||
},
|
||||
],
|
||||
actorId: createdBy,
|
||||
@@ -86,14 +93,18 @@ export async function updateAssignment(formData: UpdateAssignmentFormType) {
|
||||
|
||||
try {
|
||||
const createdBy = await getAuthenticatedUserId()
|
||||
const { itemId, quantity, notes } = validatedFields.data
|
||||
if (!itemId || quantity == null) {
|
||||
throw new Error("Missing required assignment fields")
|
||||
}
|
||||
|
||||
const result = await updateAssignmentUseCase({
|
||||
...validatedFields.data,
|
||||
lines: [
|
||||
{
|
||||
itemId: validatedFields.data.itemId!,
|
||||
quantity: validatedFields.data.quantity!,
|
||||
notes: validatedFields.data.notes,
|
||||
itemId,
|
||||
quantity,
|
||||
notes,
|
||||
},
|
||||
],
|
||||
actorId: createdBy,
|
||||
|
||||
@@ -303,7 +303,6 @@ export async function importItems(formData: ImportFormType) {
|
||||
lastName,
|
||||
email: undefined,
|
||||
phone: "",
|
||||
department: "OTHER",
|
||||
})
|
||||
} else {
|
||||
newPerson = existingPerson.data[0]
|
||||
|
||||
@@ -6,6 +6,7 @@ type FieldErrors = Record<string, string[]>
|
||||
|
||||
const personErrorMessageKeys = {
|
||||
"Email already exists": "duplicateEmail",
|
||||
"Team not found": "teamNotFound",
|
||||
} as const satisfies Record<string, keyof PersonActionCopy>
|
||||
|
||||
function isPersonErrorMessage(
|
||||
|
||||
@@ -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)),
|
||||
]),
|
||||
)
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export function localizeUnifiedCreateFieldErrors(
|
||||
return message
|
||||
if (field === "lastName" && message === schemaCopy.lastNameRequired)
|
||||
return message
|
||||
if (field === "department" && message === schemaCopy.departmentRequired)
|
||||
if (field === "teamId" && message === schemaCopy.teamIdInvalid)
|
||||
return message
|
||||
if (field === "email" && message === schemaCopy.emailInvalid)
|
||||
return message
|
||||
|
||||
@@ -56,6 +56,7 @@ export default function CreateAssignmentForm({
|
||||
mode: "onSubmit",
|
||||
defaultValues: {
|
||||
personId: personId ?? "",
|
||||
quantity: 1,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -67,7 +68,10 @@ export default function CreateAssignmentForm({
|
||||
}, [assets, itemId])
|
||||
|
||||
const onSubmit = async (formData: CreateAssignmentFormType) => {
|
||||
const response = await createAssignment(formData)
|
||||
const response = await createAssignment({
|
||||
...formData,
|
||||
quantity: itemAssets.length > 0 ? 1 : formData.quantity,
|
||||
})
|
||||
|
||||
if (response?.errors) {
|
||||
Object.values(response.errors as Record<string, string[]>).forEach(
|
||||
@@ -157,29 +161,25 @@ export default function CreateAssignmentForm({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="quantity" className="mb-2 block text-lg">
|
||||
{formCopy.quantityLabel}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="quantity"
|
||||
disabled={!itemId || itemAssets.length > 0}
|
||||
min={1}
|
||||
max={itemId ? items.find((item) => item.id === itemId)?.stock : 0}
|
||||
placeholder={formCopy.quantityPlaceholder}
|
||||
defaultValue={1}
|
||||
{...register("quantity")}
|
||||
className={`w-full rounded-lg border px-4 py-2 ${
|
||||
!itemId || itemAssets.length > 0
|
||||
? "border-gray-300 bg-gray-100"
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
{errors.quantity && (
|
||||
<p className="text-error">{errors.quantity.message}</p>
|
||||
)}
|
||||
</div>
|
||||
{itemId && itemAssets.length === 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="quantity" className="mb-2 block text-lg">
|
||||
{formCopy.quantityLabel}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="quantity"
|
||||
min={1}
|
||||
max={itemId ? items.find((item) => item.id === itemId)?.stock : 0}
|
||||
placeholder={formCopy.quantityPlaceholder}
|
||||
{...register("quantity")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
/>
|
||||
{errors.quantity && (
|
||||
<p className="text-error">{errors.quantity.message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<SubmitButton
|
||||
copy={submitButtonCopy}
|
||||
isSubmitting={isSubmitting}
|
||||
|
||||
@@ -31,6 +31,7 @@ export default async function AssignmentsPage(props: {
|
||||
<PageHeader
|
||||
title={copy.list.title}
|
||||
link="/assignments/new"
|
||||
searchable={true}
|
||||
search={search}
|
||||
data={assignments}
|
||||
addLabel={copy.list.addLabel}
|
||||
|
||||
@@ -7,7 +7,10 @@ import { Button } from "@/components/ui/button"
|
||||
import { getI18n } from "@/i18n/server"
|
||||
import { AssetService } from "@/services/asset.service"
|
||||
|
||||
import type { AssetDetailCopy, AssetStatusCopy } from "../_components/asset.copy"
|
||||
import type {
|
||||
AssetDetailCopy,
|
||||
AssetStatusCopy,
|
||||
} from "../_components/asset.copy"
|
||||
|
||||
function formatAssetStatus(
|
||||
status: string,
|
||||
@@ -77,7 +80,9 @@ export default async function AssetDetailPage({
|
||||
<dd>{asset.serialNumber}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">{copy.labels.assetTag}</dt>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{copy.labels.assetTag}
|
||||
</dt>
|
||||
<dd>{asset.assetTag ?? missingValue}</dd>
|
||||
</div>
|
||||
<div>
|
||||
@@ -119,11 +124,19 @@ export default async function AssetDetailPage({
|
||||
<dd>{asset.notes ?? missingValue}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">{copy.labels.status}</dt>
|
||||
<dd>{formatAssetStatus(asset.status, statusCopy, { unknownStatus: missingValue })}</dd>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{copy.labels.status}
|
||||
</dt>
|
||||
<dd>
|
||||
{formatAssetStatus(asset.status, statusCopy, {
|
||||
unknownStatus: missingValue,
|
||||
})}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">{copy.labels.person}</dt>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{copy.labels.person}
|
||||
</dt>
|
||||
<dd>{formatPersonName(asset.assignment?.person, missingValue)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@@ -54,6 +54,7 @@ export default async function AssetsPage(props: {
|
||||
title={copy.list.title}
|
||||
link="/inventory/assets/new"
|
||||
data={assets}
|
||||
searchable={true}
|
||||
search={search}
|
||||
addLabel={copy.list.addLabel}
|
||||
/>
|
||||
|
||||
@@ -34,6 +34,8 @@ export default async function Items(props: {
|
||||
addLabel={copy.list.addLabel}
|
||||
link="/inventory/categories/new"
|
||||
data={categories}
|
||||
searchable={true}
|
||||
search={search}
|
||||
/>
|
||||
{categories.length === 0 && currentPage === 1 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useMemo } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
@@ -11,12 +10,15 @@ import {
|
||||
type SubmitButtonCopy,
|
||||
} from "@/components/forms/submitButton"
|
||||
import {
|
||||
buildCreateItemResolver,
|
||||
buildCreateItemSchema,
|
||||
type CreateItemData,
|
||||
type CreateItemFormType,
|
||||
} from "@/schemas/item.schema"
|
||||
import type { CategorySummary } from "@/types"
|
||||
|
||||
import type { ItemFormCopy, ItemSchemaCopy } from "./item.copy"
|
||||
import StockPolicyFields from "./stock-policy-fields"
|
||||
|
||||
export default function NewItemForm({
|
||||
categories,
|
||||
@@ -37,13 +39,13 @@ export default function NewItemForm({
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting, isSubmitSuccessful },
|
||||
} = useForm<CreateItemFormType>({
|
||||
resolver: zodResolver(schema),
|
||||
} = useForm<CreateItemFormType, unknown, CreateItemData>({
|
||||
resolver: buildCreateItemResolver(schema),
|
||||
shouldFocusError: true,
|
||||
mode: "onSubmit",
|
||||
})
|
||||
|
||||
const onSubmit = async (formData: CreateItemFormType) => {
|
||||
const onSubmit = async (formData: CreateItemData) => {
|
||||
const response = await createItemAction(formData)
|
||||
|
||||
if (response?.errors) {
|
||||
@@ -61,7 +63,7 @@ export default function NewItemForm({
|
||||
|
||||
if (response?.success) {
|
||||
toast.success(response.message)
|
||||
router.push("/inventory/items ")
|
||||
router.push("/inventory/items")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +80,9 @@ export default function NewItemForm({
|
||||
{...register("name")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
/>
|
||||
{errors?.name && <p className="text-error">{errors.name.message}</p>}
|
||||
{errors?.name && (
|
||||
<p className="text-error">{errors.name.message as string}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="categoryId" className="mb-2 block text-lg">
|
||||
@@ -97,7 +101,7 @@ export default function NewItemForm({
|
||||
))}
|
||||
</select>
|
||||
{errors?.categoryId && (
|
||||
<p className="text-error">{errors.categoryId.message}</p>
|
||||
<p className="text-error">{errors.categoryId.message as string}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
@@ -125,8 +129,22 @@ export default function NewItemForm({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{errors?.stock && <p className="text-error">{errors.stock.message}</p>}
|
||||
{errors?.stock && (
|
||||
<p className="text-error">{errors.stock.message as string}</p>
|
||||
)}
|
||||
</div>
|
||||
<StockPolicyFields
|
||||
copy={{
|
||||
title: formCopy.stockPolicyTitle,
|
||||
description: formCopy.stockPolicyDescription,
|
||||
minStockLabel: formCopy.minStockLabel,
|
||||
minStockPlaceholder: formCopy.minStockPlaceholder,
|
||||
targetStockLabel: formCopy.targetStockLabel,
|
||||
targetStockPlaceholder: formCopy.targetStockPlaceholder,
|
||||
}}
|
||||
register={register}
|
||||
errors={errors}
|
||||
/>
|
||||
<SubmitButton
|
||||
copy={submitButtonCopy}
|
||||
isSubmitting={isSubmitting}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client"
|
||||
|
||||
import type { FieldErrors, Path, UseFormRegister } from "react-hook-form"
|
||||
|
||||
type StockPolicyFieldsCopy = {
|
||||
title: string
|
||||
description: string
|
||||
minStockLabel: string
|
||||
minStockPlaceholder: string
|
||||
targetStockLabel: string
|
||||
targetStockPlaceholder: string
|
||||
}
|
||||
|
||||
type StockPolicyFieldValues = {
|
||||
minStock?: number | string | null
|
||||
targetStock?: number | string | null
|
||||
}
|
||||
|
||||
type StockPolicyFieldsProps<TFieldValues extends StockPolicyFieldValues> = {
|
||||
copy: StockPolicyFieldsCopy
|
||||
register: UseFormRegister<TFieldValues>
|
||||
errors?: FieldErrors<TFieldValues>
|
||||
}
|
||||
|
||||
function StockPolicyNumericInput<TFieldValues extends StockPolicyFieldValues>({
|
||||
id,
|
||||
label,
|
||||
placeholder,
|
||||
error,
|
||||
register,
|
||||
}: {
|
||||
id: "minStock" | "targetStock"
|
||||
label: string
|
||||
placeholder: string
|
||||
error?: string
|
||||
register: UseFormRegister<TFieldValues>
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={id} className="mb-2 block text-lg">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder={placeholder}
|
||||
{...register(id as Path<TFieldValues>)}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
/>
|
||||
{error && <p className="text-error">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StockPolicyFields<
|
||||
TFieldValues extends StockPolicyFieldValues,
|
||||
>({ copy, register, errors }: StockPolicyFieldsProps<TFieldValues>) {
|
||||
const minStockMessage = errors?.minStock?.message
|
||||
const targetStockMessage = errors?.targetStock?.message
|
||||
const minStockError =
|
||||
typeof minStockMessage === "string" ? minStockMessage : undefined
|
||||
const targetStockError =
|
||||
typeof targetStockMessage === "string" ? targetStockMessage : undefined
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border p-4">
|
||||
<div className="mb-4 space-y-1">
|
||||
<h2 className="text-lg font-medium">{copy.title}</h2>
|
||||
<p className="text-muted-foreground text-sm">{copy.description}</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<StockPolicyNumericInput<TFieldValues>
|
||||
id="minStock"
|
||||
label={copy.minStockLabel}
|
||||
placeholder={copy.minStockPlaceholder}
|
||||
error={minStockError}
|
||||
register={register}
|
||||
/>
|
||||
<StockPolicyNumericInput<TFieldValues>
|
||||
id="targetStock"
|
||||
label={copy.targetStockLabel}
|
||||
placeholder={copy.targetStockPlaceholder}
|
||||
error={targetStockError}
|
||||
register={register}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useMemo } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
@@ -11,12 +10,15 @@ import {
|
||||
type SubmitButtonCopy,
|
||||
} from "@/components/forms/submitButton"
|
||||
import {
|
||||
buildUpdateItemResolver,
|
||||
buildUpdateItemSchema,
|
||||
type UpdateItemData,
|
||||
type UpdateItemFormType,
|
||||
} from "@/schemas/item.schema"
|
||||
import type { CategorySummary, ItemWithAssetCount } from "@/types"
|
||||
|
||||
import type { ItemFormCopy, ItemSchemaCopy } from "./item.copy"
|
||||
import StockPolicyFields from "./stock-policy-fields"
|
||||
|
||||
export default function UpdateItemForm({
|
||||
categories,
|
||||
@@ -41,19 +43,21 @@ export default function UpdateItemForm({
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting, isSubmitSuccessful },
|
||||
} = useForm<UpdateItemFormType>({
|
||||
resolver: zodResolver(schema),
|
||||
} = useForm<UpdateItemFormType, unknown, UpdateItemData>({
|
||||
resolver: buildUpdateItemResolver(schema),
|
||||
defaultValues: {
|
||||
id: item?.id,
|
||||
name: item?.name,
|
||||
categoryId: item?.category.id,
|
||||
stock: item?.stock,
|
||||
minStock: item?.minStock ?? undefined,
|
||||
targetStock: item?.targetStock ?? undefined,
|
||||
},
|
||||
shouldFocusError: true,
|
||||
mode: "onSubmit",
|
||||
})
|
||||
|
||||
const onSubmit = async (formData: UpdateItemFormType) => {
|
||||
const onSubmit = async (formData: UpdateItemData) => {
|
||||
const response = await updateItemAction(formData)
|
||||
|
||||
if (response?.errors) {
|
||||
@@ -71,7 +75,7 @@ export default function UpdateItemForm({
|
||||
|
||||
if (response?.success) {
|
||||
toast.success(response.message)
|
||||
router.push("/inventory/items ")
|
||||
router.push("/inventory/items")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +93,9 @@ export default function UpdateItemForm({
|
||||
{...register("name")}
|
||||
className={`w-full rounded-lg border px-4 py-2`}
|
||||
/>
|
||||
{errors?.name && <p className="text-error">{errors.name.message}</p>}
|
||||
{errors?.name && (
|
||||
<p className="text-error">{errors.name.message as string}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="categoryId" className="mb-2 block text-lg">
|
||||
@@ -109,7 +115,7 @@ export default function UpdateItemForm({
|
||||
))}
|
||||
</select>
|
||||
{errors?.categoryId && (
|
||||
<p className="text-error">{errors.categoryId.message}</p>
|
||||
<p className="text-error">{errors.categoryId.message as string}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
@@ -140,8 +146,22 @@ export default function UpdateItemForm({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{errors?.stock && <p className="text-error">{errors.stock.message}</p>}
|
||||
{errors?.stock && (
|
||||
<p className="text-error">{errors.stock.message as string}</p>
|
||||
)}
|
||||
</div>
|
||||
<StockPolicyFields
|
||||
copy={{
|
||||
title: formCopy.stockPolicyTitle,
|
||||
description: formCopy.stockPolicyDescription,
|
||||
minStockLabel: formCopy.minStockLabel,
|
||||
minStockPlaceholder: formCopy.minStockPlaceholder,
|
||||
targetStockLabel: formCopy.targetStockLabel,
|
||||
targetStockPlaceholder: formCopy.targetStockPlaceholder,
|
||||
}}
|
||||
register={register}
|
||||
errors={errors}
|
||||
/>
|
||||
<SubmitButton
|
||||
copy={submitButtonCopy}
|
||||
isSubmitting={isSubmitting}
|
||||
|
||||
@@ -9,6 +9,19 @@ import { ItemService } from "@/services/item.service"
|
||||
|
||||
import DeleteItemButton from "./_components/delete.item.button"
|
||||
|
||||
function formatStockPolicy(
|
||||
item: { minStock: number | null; targetStock: number | null },
|
||||
copy: { configured: string; none: string },
|
||||
) {
|
||||
if (item.minStock === null || item.targetStock === null) {
|
||||
return copy.none
|
||||
}
|
||||
|
||||
return copy.configured
|
||||
.replace("{min}", String(item.minStock))
|
||||
.replace("{target}", String(item.targetStock))
|
||||
}
|
||||
|
||||
export default async function ItemsPage(props: {
|
||||
searchParams?: Promise<{
|
||||
page?: string
|
||||
@@ -33,6 +46,7 @@ export default async function ItemsPage(props: {
|
||||
link="/inventory/items/new"
|
||||
addLabel={copy.list.addLabel}
|
||||
data={items}
|
||||
searchable={true}
|
||||
search={search}
|
||||
/>
|
||||
{items.length === 0 && currentPage === 1 && (
|
||||
@@ -59,6 +73,9 @@ export default async function ItemsPage(props: {
|
||||
<th scope="col" className="p-4">
|
||||
{copy.list.columns.stock}
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
{copy.list.columns.stockPolicy}
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
{copy.list.columns.actions}
|
||||
</th>
|
||||
@@ -71,6 +88,9 @@ export default async function ItemsPage(props: {
|
||||
<td className="p-4">{item.category.name}</td>
|
||||
<td className="p-4">{item._count.assets}</td>
|
||||
<td className="p-4">{item.stock}</td>
|
||||
<td className="p-4">
|
||||
{formatStockPolicy(item, copy.list.stockPolicy)}
|
||||
</td>
|
||||
<td className="flex items-center gap-2 p-4">
|
||||
<Link href={`/inventory/items/${item.id}`} passHref>
|
||||
<Button
|
||||
@@ -99,7 +119,7 @@ export default async function ItemsPage(props: {
|
||||
</tbody>
|
||||
<tfoot className="border-t">
|
||||
<tr>
|
||||
<td colSpan={5} className="p-4 text-center text-sm">
|
||||
<td colSpan={6} className="p-4 text-center text-sm">
|
||||
<PaginationButtons totalPages={totalPages} />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { getI18n } from "@/i18n/server"
|
||||
import { PersonService } from "@/services/person.service"
|
||||
import { listTeamsUseCase } from "@/use-cases/team.use-cases"
|
||||
|
||||
import EditPersonForm from "../../_components/edit.person.form"
|
||||
import EditPersonForm from "../../_components/people/edit.person.form"
|
||||
|
||||
export default async function PersonEditPage({
|
||||
params,
|
||||
@@ -13,6 +14,7 @@ export default async function PersonEditPage({
|
||||
const personCopy = dictionary.inventory.people
|
||||
const userCopy = dictionary.admin.users
|
||||
const person = await PersonService.findByIdWithUser(personId)
|
||||
const teams = await listTeamsUseCase()
|
||||
|
||||
if (!person) {
|
||||
return <div>{personCopy.edit.notFound}</div>
|
||||
@@ -28,10 +30,8 @@ export default async function PersonEditPage({
|
||||
formCopy={userCopy.form}
|
||||
schemaCopy={{ ...userCopy.schema, ...personCopy.schema }}
|
||||
roleLabels={userCopy.roles}
|
||||
userFallbackCopy={userCopy.fallback}
|
||||
departmentCopy={personCopy.departments}
|
||||
fallbackCopy={personCopy.fallback}
|
||||
submitButtonCopy={dictionary.common.submitButton}
|
||||
teams={teams}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,12 +4,11 @@ import { getI18n } from "@/i18n/server"
|
||||
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"
|
||||
} from "../_components/people/user.copy"
|
||||
|
||||
export default async function PersonInfoPage({
|
||||
params,
|
||||
@@ -45,16 +44,8 @@ export default async function PersonInfoPage({
|
||||
<span>{person.phone}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">
|
||||
{copy.detail.labels.department}
|
||||
</span>
|
||||
<span>
|
||||
{formatPersonDepartment(
|
||||
person.department,
|
||||
copy.departments,
|
||||
copy.fallback,
|
||||
)}
|
||||
</span>
|
||||
<span className="text-gray-600">{copy.detail.labels.team}</span>
|
||||
<span>{person.team?.name ?? copy.fallback.noTeam}</span>
|
||||
</div>
|
||||
{person.user ? (
|
||||
<>
|
||||
|
||||
+19
-31
@@ -11,21 +11,16 @@ import {
|
||||
SubmitButton,
|
||||
type SubmitButtonCopy,
|
||||
} from "@/components/forms/submitButton"
|
||||
import { UserStatus } from "@/generated/prisma/client"
|
||||
import { PERSON_DEPARTMENTS } from "@/lib/constants"
|
||||
import {
|
||||
buildUnifiedUpdateSchema,
|
||||
type UnifiedSchemaCopy,
|
||||
type UnifiedUpdateFormType,
|
||||
} from "@/schemas/user.schema"
|
||||
import type { PersonWithUser } from "@/services/person.service"
|
||||
import type { TeamSummary } from "@/types"
|
||||
|
||||
import {
|
||||
formatPersonDepartment,
|
||||
formatUserRole,
|
||||
type PersonDepartmentCopy,
|
||||
type PersonFallbackCopy,
|
||||
type UserFallbackCopy,
|
||||
type UserFormCopy,
|
||||
type UserRoleCopy,
|
||||
} from "./user.copy"
|
||||
@@ -35,19 +30,15 @@ export default function EditPersonForm({
|
||||
formCopy,
|
||||
schemaCopy,
|
||||
roleLabels,
|
||||
userFallbackCopy,
|
||||
departmentCopy,
|
||||
fallbackCopy,
|
||||
submitButtonCopy,
|
||||
teams,
|
||||
}: {
|
||||
person: PersonWithUser
|
||||
formCopy: UserFormCopy
|
||||
schemaCopy: UnifiedSchemaCopy
|
||||
roleLabels: UserRoleCopy
|
||||
userFallbackCopy: UserFallbackCopy
|
||||
departmentCopy: PersonDepartmentCopy
|
||||
fallbackCopy: PersonFallbackCopy
|
||||
submitButtonCopy: SubmitButtonCopy
|
||||
teams: TeamSummary[]
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const schema = useMemo(
|
||||
@@ -68,11 +59,11 @@ export default function EditPersonForm({
|
||||
id: person.id,
|
||||
firstName: person.firstName,
|
||||
lastName: person.lastName,
|
||||
department: person.department ?? "OTHER",
|
||||
teamId: person.teamId ?? null,
|
||||
email: person.email ?? "",
|
||||
phone: person.phone ?? "",
|
||||
...(hasUser && user
|
||||
? { role: user.role, isActive: user.status === UserStatus.ACTIVE }
|
||||
? { role: user.role, isActive: user.status === "ACTIVE" }
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
@@ -116,12 +107,11 @@ export default function EditPersonForm({
|
||||
placeholder={formCopy.lastNamePlaceholder}
|
||||
register={register("lastName")}
|
||||
/>
|
||||
<DepartmentSelect
|
||||
error={errors.department?.message}
|
||||
<TeamSelect
|
||||
error={errors.teamId?.message}
|
||||
formCopy={formCopy}
|
||||
departmentCopy={departmentCopy}
|
||||
fallbackCopy={fallbackCopy}
|
||||
register={register("department")}
|
||||
register={register("teamId")}
|
||||
teams={teams}
|
||||
/>
|
||||
<TextInput
|
||||
error={errors.email?.message}
|
||||
@@ -238,33 +228,31 @@ function RoleSelect({
|
||||
)
|
||||
}
|
||||
|
||||
function DepartmentSelect({
|
||||
function TeamSelect({
|
||||
error,
|
||||
formCopy,
|
||||
departmentCopy,
|
||||
fallbackCopy,
|
||||
register,
|
||||
teams,
|
||||
}: {
|
||||
error?: string
|
||||
formCopy: UserFormCopy
|
||||
departmentCopy: PersonDepartmentCopy
|
||||
fallbackCopy: PersonFallbackCopy
|
||||
register: UseFormRegisterReturn
|
||||
teams: TeamSummary[]
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="department" className="mb-2 block text-lg">
|
||||
{formCopy.departmentLabel}
|
||||
<label htmlFor="teamId" className="mb-2 block text-lg">
|
||||
{formCopy.teamLabel}
|
||||
</label>
|
||||
<select
|
||||
id="department"
|
||||
id="teamId"
|
||||
{...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 value="">{formCopy.teamPlaceholder}</option>
|
||||
{teams.map((team) => (
|
||||
<option key={team.id} value={team.id}>
|
||||
{team.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
+80
-83
@@ -11,35 +11,27 @@ import {
|
||||
SubmitButton,
|
||||
type SubmitButtonCopy,
|
||||
} from "@/components/forms/submitButton"
|
||||
import { PERSON_DEPARTMENTS } from "@/lib/constants"
|
||||
import {
|
||||
buildUnifiedCreateSchema,
|
||||
type UnifiedCreateFormType,
|
||||
type UnifiedSchemaCopy,
|
||||
} from "@/schemas/user.schema"
|
||||
import type { TeamSummary } from "@/types"
|
||||
|
||||
import {
|
||||
formatPersonDepartment,
|
||||
type PersonDepartmentCopy,
|
||||
type PersonFallbackCopy,
|
||||
type UserFormCopy,
|
||||
type UserRoleCopy,
|
||||
} from "./user.copy"
|
||||
import type { UserFormCopy, UserRoleCopy } from "./user.copy"
|
||||
|
||||
export default function NewUserForm({
|
||||
formCopy,
|
||||
schemaCopy,
|
||||
roleLabels,
|
||||
departmentCopy,
|
||||
fallbackCopy,
|
||||
submitButtonCopy,
|
||||
teams,
|
||||
}: {
|
||||
formCopy: UserFormCopy
|
||||
schemaCopy: UnifiedSchemaCopy
|
||||
roleLabels: UserRoleCopy
|
||||
departmentCopy: PersonDepartmentCopy
|
||||
fallbackCopy: PersonFallbackCopy
|
||||
submitButtonCopy: SubmitButtonCopy
|
||||
teams: TeamSummary[]
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const schema = useMemo(
|
||||
@@ -55,7 +47,7 @@ export default function NewUserForm({
|
||||
} = useForm<UnifiedCreateFormType>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
role: "STAFF",
|
||||
role: "NO_USER",
|
||||
isActive: true,
|
||||
},
|
||||
})
|
||||
@@ -87,64 +79,71 @@ export default function NewUserForm({
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<UserTextInput
|
||||
error={errors.firstName?.message}
|
||||
id="firstName"
|
||||
label={formCopy.firstNameLabel}
|
||||
placeholder={formCopy.firstNamePlaceholder}
|
||||
register={register("firstName")}
|
||||
/>
|
||||
<UserTextInput
|
||||
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")}
|
||||
/>
|
||||
<UserTextInput
|
||||
error={errors.email?.message}
|
||||
id="email"
|
||||
label={formCopy.emailLabel}
|
||||
placeholder={formCopy.emailPlaceholder}
|
||||
register={register("email")}
|
||||
type="email"
|
||||
/>
|
||||
<UserTextInput
|
||||
error={errors.phone?.message}
|
||||
id="phone"
|
||||
label={formCopy.phoneLabel}
|
||||
placeholder={formCopy.phonePlaceholder}
|
||||
register={register("phone")}
|
||||
/>
|
||||
<RoleSelect
|
||||
register={register("role")}
|
||||
roleLabel={formCopy.roleLabel}
|
||||
roleLabels={roleLabels}
|
||||
/>
|
||||
{showPassword && (
|
||||
<UserTextInput
|
||||
error={errors.password?.message}
|
||||
id="password"
|
||||
label={formCopy.passwordLabel}
|
||||
placeholder={formCopy.passwordPlaceholder}
|
||||
register={register("password")}
|
||||
type="password"
|
||||
/>
|
||||
)}
|
||||
<SubmitButton
|
||||
copy={submitButtonCopy}
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitSuccessful={isSubmitSuccessful}
|
||||
>
|
||||
{formCopy.createSubmit}
|
||||
</SubmitButton>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<UserTextInput
|
||||
error={errors.firstName?.message}
|
||||
id="firstName"
|
||||
label={formCopy.firstNameLabel}
|
||||
placeholder={formCopy.firstNamePlaceholder}
|
||||
register={register("firstName")}
|
||||
/>
|
||||
<UserTextInput
|
||||
error={errors.lastName?.message}
|
||||
id="lastName"
|
||||
label={formCopy.lastNameLabel}
|
||||
placeholder={formCopy.lastNamePlaceholder}
|
||||
register={register("lastName")}
|
||||
/>
|
||||
<UserTextInput
|
||||
error={errors.email?.message}
|
||||
id="email"
|
||||
label={formCopy.emailLabel}
|
||||
placeholder={formCopy.emailPlaceholder}
|
||||
register={register("email")}
|
||||
type="email"
|
||||
/>
|
||||
<UserTextInput
|
||||
error={errors.phone?.message}
|
||||
id="phone"
|
||||
label={formCopy.phoneLabel}
|
||||
placeholder={formCopy.phonePlaceholder}
|
||||
register={register("phone")}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<TeamSelect
|
||||
error={errors.teamId?.message}
|
||||
formCopy={formCopy}
|
||||
register={register("teamId")}
|
||||
teams={teams}
|
||||
/>
|
||||
<RoleSelect
|
||||
register={register("role")}
|
||||
roleLabel={formCopy.roleLabel}
|
||||
roleLabels={roleLabels}
|
||||
/>
|
||||
{showPassword && (
|
||||
<UserTextInput
|
||||
error={errors.password?.message}
|
||||
id="password"
|
||||
label={formCopy.passwordLabel}
|
||||
placeholder={formCopy.passwordPlaceholder}
|
||||
register={register("password")}
|
||||
type="password"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<SubmitButton
|
||||
copy={submitButtonCopy}
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitSuccessful={isSubmitSuccessful}
|
||||
>
|
||||
{formCopy.createSubmit}
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -210,33 +209,31 @@ function RoleSelect({
|
||||
)
|
||||
}
|
||||
|
||||
function DepartmentSelect({
|
||||
function TeamSelect({
|
||||
error,
|
||||
formCopy,
|
||||
departmentCopy,
|
||||
fallbackCopy,
|
||||
register,
|
||||
teams,
|
||||
}: {
|
||||
error?: string
|
||||
formCopy: UserFormCopy
|
||||
departmentCopy: PersonDepartmentCopy
|
||||
fallbackCopy: PersonFallbackCopy
|
||||
register: UseFormRegisterReturn
|
||||
teams: TeamSummary[]
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="department" className="mb-2 block text-lg">
|
||||
{formCopy.departmentLabel}
|
||||
<label htmlFor="teamId" className="mb-2 block text-lg">
|
||||
{formCopy.teamLabel}
|
||||
</label>
|
||||
<select
|
||||
id="department"
|
||||
id="teamId"
|
||||
{...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 value="">{formCopy.teamPlaceholder}</option>
|
||||
{teams.map((team) => (
|
||||
<option key={team.id} value={team.id}>
|
||||
{team.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Eye, Pencil, UserPlus } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
import PageHeader from "@/components/common/pageheader"
|
||||
import PaginationButtons from "@/components/common/pagination"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { UserStatus } from "@/generated/prisma/client"
|
||||
import { getI18n } from "@/i18n/server"
|
||||
import { PersonService } from "@/services/person.service"
|
||||
import {
|
||||
formatUserRole,
|
||||
type UserFallbackCopy,
|
||||
type UserRoleCopy,
|
||||
} from "./user.copy"
|
||||
|
||||
export default async function PersonPage(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: people, totalPages } = await PersonService.findAllPaginated({
|
||||
page: currentPage,
|
||||
pageSize: 10,
|
||||
search,
|
||||
})
|
||||
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 personFallbackCopy = copy.fallback
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 mt-4">
|
||||
<PageHeader
|
||||
title={copy.list.title}
|
||||
link="/people/new"
|
||||
addLabel={copy.list.addLabel}
|
||||
data={people}
|
||||
searchable={true}
|
||||
search={search}
|
||||
/>
|
||||
{people.length === 0 && <div>{copy.list.empty}</div>}
|
||||
{people.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.phone}
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
{copy.list.columns.team}
|
||||
</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) => (
|
||||
<tr key={person.id} className="border-b">
|
||||
<td className="p-4">
|
||||
{`${person.firstName} ${person.lastName}`}
|
||||
</td>
|
||||
<td className="p-4">{person.email}</td>
|
||||
<td className="p-4">{person.phone}</td>
|
||||
<td className="p-4">
|
||||
{person.team?.name ?? personFallbackCopy.noTeam}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{person.user
|
||||
? formatUserRole(
|
||||
person.user.role,
|
||||
userRoleLabels,
|
||||
userFallbackCopy,
|
||||
)
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{person.user
|
||||
? person.user.status === UserStatus.ACTIVE
|
||||
? userStatusCopy.active
|
||||
: userStatusCopy.inactive
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="flex items-center gap-2 p-4">
|
||||
<Link href={`/people/${person.id}`} passHref>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label={copy.list.actions.view}
|
||||
>
|
||||
<Eye />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/people/${person.id}/edit`} passHref>
|
||||
<Button
|
||||
className="btn btn-primary"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label={copy.list.actions.edit}
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/assignments/new?personId=${person.id}`}
|
||||
passHref
|
||||
>
|
||||
<Button
|
||||
className="btn btn-primary"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label={copy.list.actions.edit}
|
||||
>
|
||||
<UserPlus />
|
||||
</Button>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="border-t">
|
||||
<tr>
|
||||
<td colSpan={7} className="p-4 text-center text-sm">
|
||||
<PaginationButtons totalPages={totalPages} />
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { Dictionary } from "@/i18n/dictionaries"
|
||||
|
||||
export type PersonListCopy = Dictionary["inventory"]["people"]["list"]
|
||||
export type PersonDetailCopy = Dictionary["inventory"]["people"]["detail"]
|
||||
export type PersonFormCopy = Dictionary["inventory"]["people"]["form"]
|
||||
export type PersonFallbackCopy = Dictionary["inventory"]["people"]["fallback"]
|
||||
-16
@@ -6,8 +6,6 @@ export type UserStatusCopy = Dictionary["admin"]["users"]["status"]
|
||||
export type UserFallbackCopy = Dictionary["admin"]["users"]["fallback"]
|
||||
export type UserResetPasswordCopy =
|
||||
Dictionary["admin"]["users"]["resetPassword"]
|
||||
export type PersonDepartmentCopy =
|
||||
Dictionary["inventory"]["people"]["departments"]
|
||||
export type PersonFallbackCopy = Dictionary["inventory"]["people"]["fallback"]
|
||||
|
||||
export function formatUserRole(
|
||||
@@ -19,17 +17,3 @@ export function formatUserRole(
|
||||
? roleCopy[role as keyof UserRoleCopy]
|
||||
: fallbackCopy.unknownRole
|
||||
}
|
||||
|
||||
export function formatPersonDepartment(
|
||||
department: string | null | undefined,
|
||||
departmentCopy: PersonDepartmentCopy,
|
||||
fallbackCopy: PersonFallbackCopy,
|
||||
): string {
|
||||
if (!department) {
|
||||
return fallbackCopy.unknownDepartment
|
||||
}
|
||||
|
||||
return department in departmentCopy
|
||||
? departmentCopy[department as keyof PersonDepartmentCopy]
|
||||
: fallbackCopy.unknownDepartment
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { Dictionary } from "@/i18n/dictionaries"
|
||||
|
||||
export type PersonListCopy = Dictionary["inventory"]["people"]["list"]
|
||||
export type PersonDetailCopy = Dictionary["inventory"]["people"]["detail"]
|
||||
export type PersonFormCopy = Dictionary["inventory"]["people"]["form"]
|
||||
export type PersonDepartmentCopy =
|
||||
Dictionary["inventory"]["people"]["departments"]
|
||||
export type PersonFallbackCopy = Dictionary["inventory"]["people"]["fallback"]
|
||||
|
||||
export function formatPersonDepartment(
|
||||
department: string | null | undefined,
|
||||
departmentCopy: PersonDepartmentCopy,
|
||||
fallbackCopy: PersonFallbackCopy,
|
||||
) {
|
||||
if (!department) {
|
||||
return fallbackCopy.unknownDepartment
|
||||
}
|
||||
|
||||
return department in departmentCopy
|
||||
? departmentCopy[department as keyof PersonDepartmentCopy]
|
||||
: fallbackCopy.unknownDepartment
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import PageHeader from "@/components/common/pageheader"
|
||||
import { getI18n } from "@/i18n/server"
|
||||
import { listTeamsUseCase } from "@/use-cases/team.use-cases"
|
||||
|
||||
import TeamListTable from "./team.list.table"
|
||||
|
||||
export default async function TeamsPage() {
|
||||
const teams = await listTeamsUseCase()
|
||||
const { dictionary } = await getI18n()
|
||||
const copy = dictionary.inventory.teams
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 mt-4">
|
||||
<PageHeader
|
||||
title={copy.list.title}
|
||||
link="/people/team/new"
|
||||
addLabel={copy.list.addLabel}
|
||||
data={teams}
|
||||
/>
|
||||
<TeamListTable
|
||||
teams={teams}
|
||||
formCopy={copy.form}
|
||||
schemaCopy={copy.schema}
|
||||
listCopy={copy.list}
|
||||
submitButtonCopy={dictionary.common.submitButton}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { getI18n } from "@/i18n/server"
|
||||
import { listTeamsUseCase } from "@/use-cases/team.use-cases"
|
||||
|
||||
import NewPersonForm from "../_components/new.person.form"
|
||||
import NewPersonForm from "../_components/people/new.person.form"
|
||||
|
||||
export default async function NewUserPage() {
|
||||
const { dictionary } = await getI18n()
|
||||
const copy = dictionary.admin.users
|
||||
const teams = await listTeamsUseCase()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -15,9 +17,8 @@ export default async function NewUserPage() {
|
||||
formCopy={copy.form}
|
||||
schemaCopy={{ ...copy.schema, ...dictionary.inventory.people.schema }}
|
||||
roleLabels={copy.roles}
|
||||
departmentCopy={dictionary.inventory.people.departments}
|
||||
fallbackCopy={dictionary.inventory.people.fallback}
|
||||
submitButtonCopy={dictionary.common.submitButton}
|
||||
teams={teams}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,23 +1,8 @@
|
||||
import { Eye, Pencil, UserPlus } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
import PageHeader from "@/components/common/pageheader"
|
||||
import PaginationButtons from "@/components/common/pagination"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { UserStatus } from "@/generated/prisma/client"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { getI18n } from "@/i18n/server"
|
||||
import { PersonService } from "@/services/person.service"
|
||||
|
||||
import {
|
||||
formatPersonDepartment,
|
||||
type PersonDepartmentCopy,
|
||||
type PersonFallbackCopy,
|
||||
} from "./_components/person.copy"
|
||||
import {
|
||||
formatUserRole,
|
||||
type UserFallbackCopy,
|
||||
type UserRoleCopy,
|
||||
} from "./_components/user.copy"
|
||||
import PersonPage from "./_components/people/page"
|
||||
import TeamsPage from "./_components/team/page"
|
||||
|
||||
export default async function PeoplePage(props: {
|
||||
searchParams?: Promise<{
|
||||
@@ -25,139 +10,24 @@ export default async function PeoplePage(props: {
|
||||
search?: string
|
||||
}>
|
||||
}) {
|
||||
const searchParams = await props.searchParams
|
||||
const currentPage = searchParams?.page ? parseInt(searchParams.page, 10) : 1
|
||||
const search = searchParams?.search || ""
|
||||
const { data: people, totalPages } = await PersonService.findAllPaginated({
|
||||
page: currentPage,
|
||||
pageSize: 10,
|
||||
search,
|
||||
})
|
||||
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
|
||||
const teamCopy = dictionary.inventory.teams
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PageHeader
|
||||
title={copy.list.title}
|
||||
link="/people/new"
|
||||
addLabel={copy.list.addLabel}
|
||||
data={people}
|
||||
search={search}
|
||||
/>
|
||||
{people.length === 0 && <div>{copy.list.empty}</div>}
|
||||
{people.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.phone}
|
||||
</th>
|
||||
<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) => (
|
||||
<tr key={person.id} className="border-b">
|
||||
<td className="p-4">
|
||||
{`${person.firstName} ${person.lastName}`}
|
||||
</td>
|
||||
<td className="p-4">{person.email}</td>
|
||||
<td className="p-4">{person.phone}</td>
|
||||
<td className="p-4">
|
||||
{formatPersonDepartment(
|
||||
person.department,
|
||||
departmentCopy,
|
||||
personFallbackCopy,
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{person.user
|
||||
? formatUserRole(
|
||||
person.user.role,
|
||||
userRoleLabels,
|
||||
userFallbackCopy,
|
||||
)
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{person.user
|
||||
? person.user.status === UserStatus.ACTIVE
|
||||
? userStatusCopy.active
|
||||
: userStatusCopy.inactive
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="flex items-center gap-2 p-4">
|
||||
<Link href={`/people/${person.id}`} passHref>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label={copy.list.actions.view}
|
||||
>
|
||||
<Eye />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/people/${person.id}/edit`} passHref>
|
||||
<Button
|
||||
className="btn btn-primary"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label={copy.list.actions.edit}
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/assignments/new?personId=${person.id}`}
|
||||
passHref
|
||||
>
|
||||
<Button
|
||||
className="btn btn-primary"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label={copy.list.actions.edit}
|
||||
>
|
||||
<UserPlus />
|
||||
</Button>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="border-t">
|
||||
<tr>
|
||||
<td colSpan={7} className="p-4 text-center text-sm">
|
||||
<PaginationButtons totalPages={totalPages} />
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<Tabs defaultValue="people">
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="people">{copy.list.title}</TabsTrigger>
|
||||
<TabsTrigger value="teams">{teamCopy.list.title}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="people">
|
||||
<PersonPage searchParams={props.searchParams} />
|
||||
</TabsContent>
|
||||
<TabsContent value="teams">
|
||||
<TeamsPage />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { getI18n } from "@/i18n/server"
|
||||
|
||||
import TeamCreateForm from "../../_components/team/team.create.form"
|
||||
|
||||
export default async function NewUserPage() {
|
||||
const { dictionary } = await getI18n()
|
||||
const copy = dictionary.inventory.teams
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold">{copy.list.addLabel}</h1>
|
||||
</div>
|
||||
<TeamCreateForm
|
||||
formCopy={copy.form}
|
||||
schemaCopy={copy.schema}
|
||||
submitButtonCopy={dictionary.common.submitButton}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ interface PageHeaderProps {
|
||||
title?: string
|
||||
link?: string
|
||||
addLabel?: string
|
||||
searchable?: boolean
|
||||
search?: string
|
||||
data: unknown[]
|
||||
}
|
||||
@@ -19,6 +20,7 @@ export default async function PageHeader({
|
||||
title,
|
||||
link,
|
||||
addLabel,
|
||||
searchable = false,
|
||||
search,
|
||||
data,
|
||||
}: PageHeaderProps) {
|
||||
@@ -27,10 +29,12 @@ export default async function PageHeader({
|
||||
<header className="mb-4 flex w-full flex-col gap-4 md:flex-row">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-2xl font-bold">{title}</h1>
|
||||
<Search
|
||||
copy={dictionary.common.search}
|
||||
hidden={data.length === 0 && !search}
|
||||
/>
|
||||
{searchable && (
|
||||
<Search
|
||||
copy={dictionary.common.search}
|
||||
hidden={data.length === 0 && !search}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{link && (
|
||||
<div className="justify-end md:ml-auto md:flex">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
ArrowLeftRight,
|
||||
Clipboard,
|
||||
Home,
|
||||
Package,
|
||||
@@ -76,18 +76,18 @@ const items: SidebarItem[] = [
|
||||
url: "/people",
|
||||
icon: User,
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
labelKey: "movements",
|
||||
url: "/movements",
|
||||
icon: BarChart,
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
labelKey: "assignments",
|
||||
url: "/assignments",
|
||||
icon: Clipboard,
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
labelKey: "movements",
|
||||
url: "/movements",
|
||||
icon: ArrowLeftRight,
|
||||
},
|
||||
]
|
||||
|
||||
export default function AppSidebar({
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
+65
-18
@@ -123,8 +123,13 @@ export const en = {
|
||||
category: "Category",
|
||||
assets: "Assets",
|
||||
stock: "Stock",
|
||||
stockPolicy: "Stock policy",
|
||||
actions: "Actions",
|
||||
},
|
||||
stockPolicy: {
|
||||
configured: "Min {min} / Target {target}",
|
||||
none: "No stock policy configured",
|
||||
},
|
||||
actions: {
|
||||
view: "View item",
|
||||
edit: "Edit item",
|
||||
@@ -153,6 +158,13 @@ export const en = {
|
||||
categoryPlaceholder: "Select a category",
|
||||
stockLabel: "Stock",
|
||||
stockPlaceholder: "0",
|
||||
stockPolicyTitle: "Stock Policy",
|
||||
stockPolicyDescription:
|
||||
"Define the minimum and target stock levels for this item.",
|
||||
minStockLabel: "Minimum stock",
|
||||
minStockPlaceholder: "Optional",
|
||||
targetStockLabel: "Target stock",
|
||||
targetStockPlaceholder: "Optional",
|
||||
createSubmit: "Create Item",
|
||||
updateSubmit: "Update Item",
|
||||
},
|
||||
@@ -184,6 +196,12 @@ export const en = {
|
||||
statusRequired: "Status is required",
|
||||
invalidStatus: "Invalid status",
|
||||
itemRequired: "Item is required",
|
||||
stockPolicyPairRequired:
|
||||
"Stock policy values must be provided together",
|
||||
stockPolicyValueInvalid:
|
||||
"Stock policy values must be whole numbers greater than or equal to 0",
|
||||
stockPolicyOrderInvalid:
|
||||
"Target stock must be greater than or equal to minimum stock",
|
||||
},
|
||||
},
|
||||
assets: {
|
||||
@@ -384,6 +402,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",
|
||||
@@ -393,7 +449,7 @@ export const en = {
|
||||
name: "Name",
|
||||
email: "Email",
|
||||
phone: "Phone",
|
||||
department: "Department",
|
||||
team: "Team",
|
||||
role: "Role",
|
||||
status: "Status",
|
||||
actions: "Actions",
|
||||
@@ -408,7 +464,7 @@ export const en = {
|
||||
labels: {
|
||||
email: "Email",
|
||||
phone: "Phone",
|
||||
department: "Department",
|
||||
team: "Team",
|
||||
role: "Role",
|
||||
status: "Status",
|
||||
noUser: "No user account",
|
||||
@@ -426,8 +482,8 @@ export const en = {
|
||||
firstNamePlaceholder: "First name",
|
||||
lastNameLabel: "Last Name",
|
||||
lastNamePlaceholder: "Last name",
|
||||
departmentLabel: "Department",
|
||||
departmentPlaceholder: "Select a department",
|
||||
teamLabel: "Team",
|
||||
teamPlaceholder: "Select a team",
|
||||
emailLabel: "Email",
|
||||
emailPlaceholder: "Email",
|
||||
phoneLabel: "Phone",
|
||||
@@ -441,19 +497,9 @@ export const en = {
|
||||
updateSubmit: "Update Person",
|
||||
},
|
||||
fallback: {
|
||||
unknownDepartment: "Unknown department",
|
||||
noTeam: "—",
|
||||
unknownStatus: "Unknown status",
|
||||
},
|
||||
departments: {
|
||||
IT: "IT",
|
||||
ENGINEERING: "Engineering",
|
||||
LOGISTICS: "Logistics",
|
||||
TRAFFIC: "Traffic",
|
||||
DRIVER: "Driver",
|
||||
ADMINISTRATION: "Administration",
|
||||
SALES: "Sales",
|
||||
OTHER: "Other",
|
||||
},
|
||||
actions: {
|
||||
createSuccess: "Person created successfully",
|
||||
createFailure: "Failed to create person",
|
||||
@@ -461,14 +507,15 @@ export const en = {
|
||||
updateFailure: "Failed to update person",
|
||||
duplicateEmail: "Email already exists",
|
||||
notFound: "Person not found",
|
||||
teamNotFound: "Team not found",
|
||||
},
|
||||
schema: {
|
||||
firstNameRequired: "First name is required",
|
||||
lastNameRequired: "Last name is required",
|
||||
departmentRequired: "Department is required",
|
||||
emailInvalid: "Email format is invalid",
|
||||
idRequired: "ID is required",
|
||||
userIdInvalid: "User ID must be a valid UUID",
|
||||
teamIdInvalid: "Team must be a valid id",
|
||||
},
|
||||
},
|
||||
movements: {
|
||||
@@ -535,8 +582,8 @@ export const en = {
|
||||
firstNamePlaceholder: "First name",
|
||||
lastNameLabel: "Last Name",
|
||||
lastNamePlaceholder: "Last name",
|
||||
departmentLabel: "Department",
|
||||
departmentPlaceholder: "Select a department",
|
||||
teamLabel: "Team",
|
||||
teamPlaceholder: "Select a team",
|
||||
emailLabel: "Email",
|
||||
emailPlaceholder: "user@example.com",
|
||||
phoneLabel: "Phone",
|
||||
|
||||
+66
-18
@@ -126,8 +126,13 @@ export const es = {
|
||||
category: "Categoría",
|
||||
assets: "Activos",
|
||||
stock: "Stock",
|
||||
stockPolicy: "Política de stock",
|
||||
actions: "Acciones",
|
||||
},
|
||||
stockPolicy: {
|
||||
configured: "Mín. {min} / Obj. {target}",
|
||||
none: "No hay política de stock configurada",
|
||||
},
|
||||
actions: {
|
||||
view: "Ver artículo",
|
||||
edit: "Editar artículo",
|
||||
@@ -156,6 +161,13 @@ export const es = {
|
||||
categoryPlaceholder: "Selecciona una categoría",
|
||||
stockLabel: "Stock",
|
||||
stockPlaceholder: "0",
|
||||
stockPolicyTitle: "Política de stock",
|
||||
stockPolicyDescription:
|
||||
"Define los niveles mínimo y objetivo de stock de este artículo.",
|
||||
minStockLabel: "Stock mínimo",
|
||||
minStockPlaceholder: "Opcional",
|
||||
targetStockLabel: "Stock objetivo",
|
||||
targetStockPlaceholder: "Opcional",
|
||||
createSubmit: "Crear artículo",
|
||||
updateSubmit: "Actualizar artículo",
|
||||
},
|
||||
@@ -187,6 +199,12 @@ export const es = {
|
||||
statusRequired: "El estado es obligatorio",
|
||||
invalidStatus: "Estado inválido",
|
||||
itemRequired: "El artículo es obligatorio",
|
||||
stockPolicyPairRequired:
|
||||
"Los valores de la política de stock deben definirse juntos",
|
||||
stockPolicyValueInvalid:
|
||||
"Los valores de la política de stock deben ser números enteros mayores o iguales a 0",
|
||||
stockPolicyOrderInvalid:
|
||||
"El stock objetivo debe ser mayor o igual que el stock mínimo",
|
||||
},
|
||||
},
|
||||
assets: {
|
||||
@@ -389,6 +407,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",
|
||||
@@ -398,7 +455,7 @@ export const es = {
|
||||
name: "Nombre",
|
||||
email: "Correo electrónico",
|
||||
phone: "Teléfono",
|
||||
department: "Departamento",
|
||||
team: "Equipo",
|
||||
role: "Rol",
|
||||
status: "Estado",
|
||||
actions: "Acciones",
|
||||
@@ -413,7 +470,7 @@ export const es = {
|
||||
labels: {
|
||||
email: "Correo electrónico",
|
||||
phone: "Teléfono",
|
||||
department: "Departamento",
|
||||
team: "Equipo",
|
||||
role: "Rol",
|
||||
status: "Estado",
|
||||
noUser: "Sin cuenta de usuario",
|
||||
@@ -431,8 +488,8 @@ export const es = {
|
||||
firstNamePlaceholder: "Nombre",
|
||||
lastNameLabel: "Apellido",
|
||||
lastNamePlaceholder: "Apellido",
|
||||
departmentLabel: "Departamento",
|
||||
departmentPlaceholder: "Selecciona un departamento",
|
||||
teamLabel: "Equipo",
|
||||
teamPlaceholder: "Selecciona un equipo",
|
||||
emailLabel: "Correo electrónico",
|
||||
emailPlaceholder: "Correo electrónico",
|
||||
phoneLabel: "Teléfono",
|
||||
@@ -447,19 +504,9 @@ export const es = {
|
||||
updateSubmit: "Actualizar persona",
|
||||
},
|
||||
fallback: {
|
||||
unknownDepartment: "Departamento desconocido",
|
||||
noTeam: "—",
|
||||
unknownStatus: "Estado desconocido",
|
||||
},
|
||||
departments: {
|
||||
IT: "IT",
|
||||
ENGINEERING: "Ingeniería",
|
||||
LOGISTICS: "Logística",
|
||||
TRAFFIC: "Tráfico",
|
||||
DRIVER: "Chofer",
|
||||
ADMINISTRATION: "Administración",
|
||||
SALES: "Ventas",
|
||||
OTHER: "Otro",
|
||||
},
|
||||
actions: {
|
||||
createSuccess: "Persona creada correctamente",
|
||||
createFailure: "Error al crear la persona",
|
||||
@@ -467,14 +514,15 @@ export const es = {
|
||||
updateFailure: "Error al actualizar la persona",
|
||||
duplicateEmail: "El correo electrónico ya existe",
|
||||
notFound: "Persona no encontrada",
|
||||
teamNotFound: "Equipo no encontrado",
|
||||
},
|
||||
schema: {
|
||||
firstNameRequired: "El nombre es obligatorio",
|
||||
lastNameRequired: "El apellido es obligatorio",
|
||||
departmentRequired: "El departamento es obligatorio",
|
||||
emailInvalid: "El correo electrónico no es válido",
|
||||
idRequired: "El ID es obligatorio",
|
||||
userIdInvalid: "El ID de usuario debe ser un UUID válido",
|
||||
teamIdInvalid: "El equipo debe ser un id válido",
|
||||
},
|
||||
},
|
||||
movements: {
|
||||
@@ -541,8 +589,8 @@ export const es = {
|
||||
firstNamePlaceholder: "Nombre",
|
||||
lastNameLabel: "Apellido",
|
||||
lastNamePlaceholder: "Apellido",
|
||||
departmentLabel: "Departamento",
|
||||
departmentPlaceholder: "Selecciona un departamento",
|
||||
teamLabel: "Equipo",
|
||||
teamPlaceholder: "Selecciona un equipo",
|
||||
emailLabel: "Correo electrónico",
|
||||
emailPlaceholder: "usuario@ejemplo.com",
|
||||
phoneLabel: "Teléfono",
|
||||
|
||||
+3
-1
@@ -39,7 +39,9 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
|
||||
if (!success) throw new Error("Invalid email or password")
|
||||
|
||||
const user = await getUserCredentialsByEmail(normalizeEmail(data.email))
|
||||
const user = await getUserCredentialsByEmail(
|
||||
normalizeEmail(data.email),
|
||||
)
|
||||
|
||||
if (!user) {
|
||||
throw new Error("Invalid email or password")
|
||||
|
||||
@@ -8,17 +8,6 @@ export const SIGN_IN_URL = "/login"
|
||||
|
||||
export const TOKEN_EXPIRATION_SECONDS = 60 * 60 * 2 // 2 hour
|
||||
|
||||
export const PERSON_DEPARTMENTS = {
|
||||
IT: "IT",
|
||||
ENGINEERING: "ENGINEERING",
|
||||
LOGISTICS: "LOGISTICS",
|
||||
TRAFFIC: "TRAFFIC",
|
||||
DRIVER: "DRIVER",
|
||||
ADMINISTRATION: "ADMINISTRATION",
|
||||
SALES: "SALES",
|
||||
OTHER: "OTHER",
|
||||
} as const
|
||||
|
||||
export const ITEM_STATUS = {
|
||||
AVAILABLE: "AVAILABLE",
|
||||
ASSIGNED: "ASSIGNED",
|
||||
|
||||
+131
-33
@@ -1,3 +1,5 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import type { Resolver } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
|
||||
import type { InventoryMovementReason } from "@/generated/prisma/client"
|
||||
@@ -14,21 +16,36 @@ const defaultItemSchemaCopy: ItemSchemaCopy = {
|
||||
statusRequired: "Status is required",
|
||||
invalidStatus: "Invalid status",
|
||||
itemRequired: "Item is required",
|
||||
stockPolicyPairRequired: "Stock policy values must be provided together",
|
||||
stockPolicyValueInvalid:
|
||||
"Stock policy values must be whole numbers greater than or equal to 0",
|
||||
stockPolicyOrderInvalid:
|
||||
"Target stock must be greater than or equal to minimum stock",
|
||||
}
|
||||
|
||||
const itemTrackingTypes = ["QUANTITY", "SERIALIZED"] as const
|
||||
const itemStatuses = ["ACTIVE", "DISCONTINUED", "ARCHIVED"] as const
|
||||
|
||||
function buildOptionalNonNegativeIntSchema(copy: ItemSchemaCopy) {
|
||||
function buildNullableStockPolicyIntSchema(copy: ItemSchemaCopy) {
|
||||
return z
|
||||
.preprocess(
|
||||
(value) => (value === "" || value === null ? undefined : value),
|
||||
z.coerce
|
||||
.number({ error: copy.stockRequired })
|
||||
.int({ error: copy.stockRequired })
|
||||
.nonnegative({ error: copy.stockRequired }),
|
||||
)
|
||||
.optional()
|
||||
.union([z.string(), z.number(), z.null(), z.undefined()])
|
||||
.transform<number | null>((value, ctx) => {
|
||||
if (value === "" || value === null || value === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
const numericValue = typeof value === "number" ? value : Number(value)
|
||||
|
||||
if (!Number.isInteger(numericValue) || numericValue < 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: copy.stockPolicyValueInvalid,
|
||||
})
|
||||
return z.NEVER
|
||||
}
|
||||
|
||||
return numericValue
|
||||
})
|
||||
}
|
||||
|
||||
function buildTrackingTypeSchema(copy: ItemSchemaCopy) {
|
||||
@@ -44,12 +61,14 @@ function buildTrackingTypeSchema(copy: ItemSchemaCopy) {
|
||||
}
|
||||
|
||||
function buildOptionalTrackingTypeSchema(copy: ItemSchemaCopy) {
|
||||
return z.preprocess(
|
||||
(value) => (value === "" || value === null ? undefined : value),
|
||||
z.enum(itemTrackingTypes, {
|
||||
error: () => copy.invalidTrackingType,
|
||||
}),
|
||||
).optional()
|
||||
return z
|
||||
.preprocess(
|
||||
(value) => (value === "" || value === null ? undefined : value),
|
||||
z.enum(itemTrackingTypes, {
|
||||
error: () => copy.invalidTrackingType,
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
}
|
||||
|
||||
function buildStatusSchema(copy: ItemSchemaCopy) {
|
||||
@@ -65,12 +84,14 @@ function buildStatusSchema(copy: ItemSchemaCopy) {
|
||||
}
|
||||
|
||||
function buildOptionalStatusSchema(copy: ItemSchemaCopy) {
|
||||
return z.preprocess(
|
||||
(value) => (value === "" || value === null ? undefined : value),
|
||||
z.enum(itemStatuses, {
|
||||
error: () => copy.invalidStatus,
|
||||
}),
|
||||
).optional()
|
||||
return z
|
||||
.preprocess(
|
||||
(value) => (value === "" || value === null ? undefined : value),
|
||||
z.enum(itemStatuses, {
|
||||
error: () => copy.invalidStatus,
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
}
|
||||
|
||||
function buildOptionalReasonSchema() {
|
||||
@@ -96,7 +117,44 @@ function buildOptionalReasonSchema() {
|
||||
.optional()
|
||||
}
|
||||
|
||||
export function buildCreateItemSchema(copy: ItemSchemaCopy) {
|
||||
function addStockPolicyValidation<
|
||||
T extends { minStock: number | null; targetStock: number | null },
|
||||
>(schema: z.ZodType<T>, copy: ItemSchemaCopy) {
|
||||
return schema.superRefine((data, ctx) => {
|
||||
const hasMinStock = data.minStock !== null
|
||||
const hasTargetStock = data.targetStock !== null
|
||||
|
||||
if (hasMinStock !== hasTargetStock) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["minStock"],
|
||||
message: copy.stockPolicyPairRequired,
|
||||
})
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["targetStock"],
|
||||
message: copy.stockPolicyPairRequired,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
hasMinStock &&
|
||||
hasTargetStock &&
|
||||
data.targetStock !== null &&
|
||||
data.minStock !== null &&
|
||||
data.targetStock < data.minStock
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["targetStock"],
|
||||
message: copy.stockPolicyOrderInvalid,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildItemBaseSchema(copy: ItemSchemaCopy) {
|
||||
return z.object({
|
||||
name: z.string().min(1, {
|
||||
error: copy.nameRequired,
|
||||
@@ -113,32 +171,72 @@ export function buildCreateItemSchema(copy: ItemSchemaCopy) {
|
||||
}),
|
||||
trackingType: buildTrackingTypeSchema(copy),
|
||||
status: buildStatusSchema(copy),
|
||||
minStock: buildOptionalNonNegativeIntSchema(copy),
|
||||
targetStock: buildOptionalNonNegativeIntSchema(copy),
|
||||
minStock: buildNullableStockPolicyIntSchema(copy),
|
||||
targetStock: buildNullableStockPolicyIntSchema(copy),
|
||||
})
|
||||
}
|
||||
|
||||
export function buildCreateItemSchema(copy: ItemSchemaCopy) {
|
||||
return addStockPolicyValidation(buildItemBaseSchema(copy), copy)
|
||||
}
|
||||
|
||||
export const createItemSchema = buildCreateItemSchema(defaultItemSchemaCopy)
|
||||
|
||||
export type CreateItemFormType = z.input<typeof createItemSchema>
|
||||
export type CreateItemFormType = {
|
||||
name: string
|
||||
categoryId: string
|
||||
stock: number | string
|
||||
trackingType?: (typeof itemTrackingTypes)[number] | ""
|
||||
status?: (typeof itemStatuses)[number] | ""
|
||||
minStock?: number | string | null
|
||||
targetStock?: number | string | null
|
||||
}
|
||||
|
||||
export type CreateItemData = z.output<typeof createItemSchema>
|
||||
|
||||
export function buildCreateItemResolver(
|
||||
schema: z.ZodTypeAny,
|
||||
): Resolver<CreateItemFormType, unknown, CreateItemData> {
|
||||
return zodResolver(schema as never) as Resolver<
|
||||
CreateItemFormType,
|
||||
unknown,
|
||||
CreateItemData
|
||||
>
|
||||
}
|
||||
|
||||
export function buildUpdateItemSchema(copy: ItemSchemaCopy) {
|
||||
return buildCreateItemSchema(copy).extend({
|
||||
id: z.string().min(1, {
|
||||
error: copy.itemRequired,
|
||||
return addStockPolicyValidation(
|
||||
buildItemBaseSchema(copy).extend({
|
||||
id: z.string().min(1, {
|
||||
error: copy.itemRequired,
|
||||
}),
|
||||
trackingType: buildOptionalTrackingTypeSchema(copy),
|
||||
status: buildOptionalStatusSchema(copy),
|
||||
reason: buildOptionalReasonSchema(),
|
||||
}),
|
||||
trackingType: buildOptionalTrackingTypeSchema(copy),
|
||||
status: buildOptionalStatusSchema(copy),
|
||||
reason: buildOptionalReasonSchema(),
|
||||
})
|
||||
copy,
|
||||
)
|
||||
}
|
||||
|
||||
export const updateItemSchema = buildUpdateItemSchema(defaultItemSchemaCopy)
|
||||
|
||||
export type UpdateItemFormType = z.input<typeof updateItemSchema>
|
||||
export type UpdateItemFormType = CreateItemFormType & {
|
||||
id: string
|
||||
reason?: InventoryMovementReason | ""
|
||||
}
|
||||
|
||||
export type UpdateItemData = z.output<typeof updateItemSchema>
|
||||
|
||||
export function buildUpdateItemResolver(
|
||||
schema: z.ZodTypeAny,
|
||||
): Resolver<UpdateItemFormType, unknown, UpdateItemData> {
|
||||
return zodResolver(schema as never) as Resolver<
|
||||
UpdateItemFormType,
|
||||
unknown,
|
||||
UpdateItemData
|
||||
>
|
||||
}
|
||||
|
||||
export function buildGetItemByIdSchema(copy: ItemSchemaCopy) {
|
||||
return z.object({
|
||||
id: z.string().min(1, {
|
||||
|
||||
@@ -7,23 +7,12 @@ export type PersonSchemaCopy = Dictionary["inventory"]["people"]["schema"]
|
||||
const defaultPersonSchemaCopy: PersonSchemaCopy = {
|
||||
firstNameRequired: "First name is required",
|
||||
lastNameRequired: "Last name is required",
|
||||
departmentRequired: "Department is required",
|
||||
emailInvalid: "Email format is invalid",
|
||||
idRequired: "ID is required",
|
||||
userIdInvalid: "User ID must be a valid UUID",
|
||||
teamIdInvalid: "Team must be a valid id",
|
||||
}
|
||||
|
||||
export const personDepartments = [
|
||||
"IT",
|
||||
"ENGINEERING",
|
||||
"TRAFFIC",
|
||||
"DRIVER",
|
||||
"LOGISTICS",
|
||||
"ADMINISTRATION",
|
||||
"SALES",
|
||||
"OTHER",
|
||||
] as const
|
||||
|
||||
function buildPersonBaseSchema(copy: PersonSchemaCopy) {
|
||||
return z.object({
|
||||
id: z.string().optional(),
|
||||
@@ -33,9 +22,11 @@ function buildPersonBaseSchema(copy: PersonSchemaCopy) {
|
||||
lastName: z.string().min(1, {
|
||||
error: copy.lastNameRequired,
|
||||
}),
|
||||
department: z.enum(personDepartments, {
|
||||
error: copy.departmentRequired,
|
||||
}),
|
||||
teamId: z
|
||||
.union([z.string().uuid({ error: copy.teamIdInvalid }), z.literal("")])
|
||||
.transform((val) => (val === "" ? null : val))
|
||||
.nullable()
|
||||
.optional(),
|
||||
email: z.string().optional().nullable(),
|
||||
phone: z.string().optional().nullable(),
|
||||
userId: z
|
||||
|
||||
@@ -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>
|
||||
@@ -1,7 +1,6 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import type { Dictionary } from "@/i18n/dictionaries"
|
||||
import { personDepartments } from "@/schemas/person.schema"
|
||||
|
||||
export type UserSchemaCopy = Dictionary["admin"]["users"]["schema"]
|
||||
|
||||
@@ -93,9 +92,11 @@ export function buildUnifiedUpdateSchema(copy: UnifiedSchemaCopy) {
|
||||
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,
|
||||
}),
|
||||
teamId: z
|
||||
.union([z.string().uuid({ error: copy.teamIdInvalid }), z.literal("")])
|
||||
.transform((val) => (val === "" ? null : val))
|
||||
.nullable()
|
||||
.optional(),
|
||||
email: z
|
||||
.union([z.email({ error: copy.emailInvalid }), z.literal(""), z.null()])
|
||||
.optional(),
|
||||
@@ -129,9 +130,11 @@ export function buildUnifiedCreateSchema(copy: UnifiedSchemaCopy) {
|
||||
.object({
|
||||
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,
|
||||
}),
|
||||
teamId: z
|
||||
.union([z.string().uuid({ error: copy.teamIdInvalid }), z.literal("")])
|
||||
.transform((val) => (val === "" ? null : val))
|
||||
.nullable()
|
||||
.optional(),
|
||||
email: z.email({ error: copy.emailInvalid }),
|
||||
phone: z.string().optional().nullable(),
|
||||
role: unifiedFormRoleSchema,
|
||||
|
||||
@@ -39,6 +39,10 @@ export const ItemService = {
|
||||
id: true,
|
||||
name: true,
|
||||
stock: true,
|
||||
trackingType: true,
|
||||
status: true,
|
||||
minStock: true,
|
||||
targetStock: true,
|
||||
category: { select: { id: true, name: true } },
|
||||
_count: { select: { assets: true } },
|
||||
},
|
||||
@@ -110,7 +114,14 @@ export const ItemService = {
|
||||
): Promise<ItemWithAssetCount | null> => {
|
||||
return db.item.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
stock: true,
|
||||
trackingType: true,
|
||||
status: true,
|
||||
minStock: true,
|
||||
targetStock: true,
|
||||
category: { select: { id: true, name: true } },
|
||||
_count: { select: { assets: true } },
|
||||
},
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { AssetStatus, InventoryMovementType, Prisma } from "@/generated/prisma/client"
|
||||
import type {
|
||||
AssetStatus,
|
||||
InventoryMovementType,
|
||||
Prisma,
|
||||
} from "@/generated/prisma/client"
|
||||
import { paginate } from "@/lib/paginate"
|
||||
import prisma from "@/lib/prisma"
|
||||
import type { CreateMovementFormType } from "@/schemas/movement.schema"
|
||||
|
||||
@@ -4,6 +4,12 @@ import prisma from "@/lib/prisma"
|
||||
|
||||
const personWithUserSelect = {
|
||||
include: {
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -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 } })
|
||||
},
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
Prisma,
|
||||
Asset as PrismaAsset,
|
||||
AssetStatus as PrismaAssetStatus,
|
||||
Prisma,
|
||||
} from "@/generated/prisma/client"
|
||||
|
||||
import type { Assignment } from "./assignment"
|
||||
|
||||
@@ -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">
|
||||
@@ -7,14 +7,20 @@ import { buildItemSku } from "./item.helpers"
|
||||
|
||||
type FieldErrors = Record<string, string[]>
|
||||
|
||||
type CreateItemUseCaseInput = Omit<CreateItemData, "trackingType" | "status"> &
|
||||
Partial<Pick<CreateItemData, "trackingType" | "status">> & {
|
||||
actorId: string
|
||||
}
|
||||
type CreateItemUseCaseInput = Omit<
|
||||
CreateItemData,
|
||||
"trackingType" | "status" | "minStock" | "targetStock"
|
||||
> &
|
||||
Partial<
|
||||
Pick<CreateItemData, "trackingType" | "status" | "minStock" | "targetStock">
|
||||
> & {
|
||||
actorId: string
|
||||
}
|
||||
|
||||
type UpdateItemUseCaseInput = UpdateItemData & {
|
||||
actorId: string
|
||||
}
|
||||
type UpdateItemUseCaseInput = Omit<UpdateItemData, "minStock" | "targetStock"> &
|
||||
Partial<Pick<UpdateItemData, "minStock" | "targetStock">> & {
|
||||
actorId: string
|
||||
}
|
||||
|
||||
type ItemUseCaseResult =
|
||||
| {
|
||||
@@ -145,9 +151,12 @@ export async function updateItemUseCase(
|
||||
return itemError({ name: ["An item with this name already exists"] })
|
||||
}
|
||||
|
||||
const effectiveTrackingType =
|
||||
trackingType ?? existingItem.trackingType
|
||||
const effectiveTrackingType = trackingType ?? existingItem.trackingType
|
||||
const isSerialized = effectiveTrackingType === "SERIALIZED"
|
||||
const nextMinStock =
|
||||
minStock === undefined ? existingItem.minStock : minStock
|
||||
const nextTargetStock =
|
||||
targetStock === undefined ? existingItem.targetStock : targetStock
|
||||
|
||||
await ItemService.update(
|
||||
id,
|
||||
@@ -156,8 +165,8 @@ export async function updateItemUseCase(
|
||||
name: name || existingItem.name,
|
||||
trackingType: trackingType ?? existingItem.trackingType,
|
||||
status: status ?? existingItem.status,
|
||||
minStock: minStock ?? existingItem.minStock,
|
||||
targetStock: targetStock ?? existingItem.targetStock,
|
||||
minStock: nextMinStock,
|
||||
targetStock: nextTargetStock,
|
||||
category: { connect: { id: categoryId } },
|
||||
},
|
||||
tx,
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
UnifiedUpdateFormType,
|
||||
} from "@/schemas/user.schema"
|
||||
import { PersonService } from "@/services/person.service"
|
||||
import { TeamService } from "@/services/team.service"
|
||||
import { getUserByEmail } from "@/services/user.service"
|
||||
|
||||
type FieldErrors = Record<string, string[]>
|
||||
@@ -48,10 +49,70 @@ function uniqueErrorFor(error: unknown): FieldErrors | null {
|
||||
return { email: ["Email already exists"] }
|
||||
}
|
||||
|
||||
function foreignKeyErrorFor(error: unknown): FieldErrors | null {
|
||||
if (
|
||||
!(error instanceof Prisma.PrismaClientKnownRequestError) ||
|
||||
error.code !== "P2003"
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const fieldName = error.meta?.field_name
|
||||
|
||||
if (fieldName === "Person_teamId_fkey" || fieldName === "teamId") {
|
||||
return { teamId: ["Team not found"] }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function errorFor(error: unknown): FieldErrors | null {
|
||||
return uniqueErrorFor(error) ?? foreignKeyErrorFor(error)
|
||||
}
|
||||
|
||||
function teamRelationInputForCreate(teamId: string | null | undefined) {
|
||||
if (teamId) {
|
||||
return { team: { connect: { id: teamId } } }
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
function teamRelationInputForUpdate(teamId: string | null | undefined) {
|
||||
if (teamId) {
|
||||
return { team: { connect: { id: teamId } } }
|
||||
}
|
||||
|
||||
return { team: { disconnect: true } }
|
||||
}
|
||||
|
||||
function userRelationInputForCreate(userId: string | null | undefined) {
|
||||
if (userId) {
|
||||
return { user: { connect: { id: userId } } }
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
async function validateTeamId(
|
||||
teamId: string | null | undefined,
|
||||
tx: Prisma.TransactionClient,
|
||||
): Promise<PersonUseCaseResult | null> {
|
||||
if (!teamId) return null
|
||||
|
||||
const team = await TeamService.findById(teamId, tx)
|
||||
|
||||
if (!team) {
|
||||
return personError({ teamId: ["Team not found"] })
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function createPersonUseCase(
|
||||
input: CreatePersonFormType,
|
||||
): Promise<PersonUseCaseResult> {
|
||||
const { firstName, lastName, department, email, phone, userId } = input
|
||||
const { firstName, lastName, teamId, email, phone, userId } = input
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
@@ -63,14 +124,17 @@ export async function createPersonUseCase(
|
||||
}
|
||||
}
|
||||
|
||||
const teamError = await validateTeamId(teamId, tx)
|
||||
if (teamError) return teamError
|
||||
|
||||
await PersonService.create(
|
||||
{
|
||||
firstName,
|
||||
lastName,
|
||||
department,
|
||||
...teamRelationInputForCreate(teamId),
|
||||
email: email || null,
|
||||
phone: phone || null,
|
||||
...(userId ? { user: { connect: { id: userId } } } : {}),
|
||||
...userRelationInputForCreate(userId),
|
||||
},
|
||||
tx,
|
||||
)
|
||||
@@ -80,7 +144,7 @@ export async function createPersonUseCase(
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
const errors = uniqueErrorFor(error)
|
||||
const errors = errorFor(error)
|
||||
|
||||
if (errors) {
|
||||
return personError(errors)
|
||||
@@ -93,7 +157,7 @@ export async function createPersonUseCase(
|
||||
export async function updatePersonUseCase(
|
||||
input: UpdatePersonFormType,
|
||||
): Promise<PersonUseCaseResult> {
|
||||
const { id, firstName, lastName, department, email, phone, userId } = input
|
||||
const { id, firstName, lastName, teamId, email, phone, userId } = input
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
@@ -105,17 +169,20 @@ export async function updatePersonUseCase(
|
||||
}
|
||||
}
|
||||
|
||||
const teamError = await validateTeamId(teamId, tx)
|
||||
if (teamError) return teamError
|
||||
|
||||
await PersonService.update(
|
||||
id,
|
||||
{
|
||||
firstName,
|
||||
lastName,
|
||||
department,
|
||||
...teamRelationInputForUpdate(teamId),
|
||||
email: email || null,
|
||||
phone: phone || null,
|
||||
...(userId
|
||||
? { user: { connect: { id: userId } } }
|
||||
: { userId: null }),
|
||||
: { user: { disconnect: true } }),
|
||||
},
|
||||
tx,
|
||||
)
|
||||
@@ -125,7 +192,7 @@ export async function updatePersonUseCase(
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
const errors = uniqueErrorFor(error)
|
||||
const errors = errorFor(error)
|
||||
|
||||
if (errors) {
|
||||
return personError(errors)
|
||||
@@ -141,7 +208,7 @@ export async function createPersonUserUseCase(
|
||||
const {
|
||||
firstName,
|
||||
lastName,
|
||||
department,
|
||||
teamId,
|
||||
email,
|
||||
phone,
|
||||
role,
|
||||
@@ -162,13 +229,16 @@ export async function createPersonUserUseCase(
|
||||
return personError({ email: ["Email already exists"] })
|
||||
}
|
||||
|
||||
const teamError = await validateTeamId(teamId, tx)
|
||||
if (teamError) return teamError
|
||||
|
||||
if (role === "NO_USER") {
|
||||
// Person-only creation — no User record
|
||||
await PersonService.create(
|
||||
{
|
||||
firstName,
|
||||
lastName,
|
||||
department,
|
||||
...teamRelationInputForCreate(teamId),
|
||||
email,
|
||||
phone: phone ?? null,
|
||||
},
|
||||
@@ -187,7 +257,7 @@ export async function createPersonUserUseCase(
|
||||
{
|
||||
firstName,
|
||||
lastName,
|
||||
department,
|
||||
...teamRelationInputForCreate(teamId),
|
||||
email,
|
||||
phone: phone ?? null,
|
||||
},
|
||||
@@ -221,7 +291,7 @@ export async function createPersonUserUseCase(
|
||||
return { success: true }
|
||||
})
|
||||
} catch (error) {
|
||||
const errors = uniqueErrorFor(error)
|
||||
const errors = errorFor(error)
|
||||
|
||||
if (errors) {
|
||||
return personError(errors)
|
||||
@@ -238,7 +308,7 @@ export async function updatePersonUserUseCase(
|
||||
id,
|
||||
firstName,
|
||||
lastName,
|
||||
department,
|
||||
teamId,
|
||||
email,
|
||||
phone,
|
||||
role,
|
||||
@@ -261,12 +331,15 @@ export async function updatePersonUserUseCase(
|
||||
}
|
||||
}
|
||||
|
||||
const teamError = await validateTeamId(teamId, tx)
|
||||
if (teamError) return teamError
|
||||
|
||||
await PersonService.update(
|
||||
id,
|
||||
{
|
||||
firstName,
|
||||
lastName,
|
||||
department,
|
||||
...teamRelationInputForUpdate(teamId),
|
||||
email: email || null,
|
||||
phone: phone || null,
|
||||
},
|
||||
@@ -302,7 +375,7 @@ export async function updatePersonUserUseCase(
|
||||
return { success: true }
|
||||
})
|
||||
} catch (error) {
|
||||
const errors = uniqueErrorFor(error)
|
||||
const errors = errorFor(error)
|
||||
|
||||
if (errors) {
|
||||
return personError(errors)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ async function createPerson(page: Page, name: string, email: string) {
|
||||
await page.goto("/people/new")
|
||||
await page.getByLabel("Nombre").fill(name)
|
||||
await page.getByLabel("Apellido").fill("E2E")
|
||||
await page.getByLabel("Departamento").selectOption("OTHER")
|
||||
await page.getByLabel("Equipo").selectOption({ label: "Other" })
|
||||
await page.getByLabel("Correo electrónico").fill(email)
|
||||
await page.getByLabel("Teléfono").fill("123456789")
|
||||
await page.getByLabel("Rol").selectOption("NO_USER")
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { expect, type Page, test } from "@playwright/test"
|
||||
|
||||
async function setLocaleCookie(
|
||||
page: Page,
|
||||
locale: "en" | "es",
|
||||
baseURL?: string,
|
||||
) {
|
||||
await page.context().addCookies([
|
||||
{
|
||||
name: "stock-manager-locale",
|
||||
value: locale,
|
||||
url: baseURL ?? "http://127.0.0.1:3100",
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
async function signInAsAdmin(page: Page, baseURL?: string) {
|
||||
await setLocaleCookie(page, "en", baseURL)
|
||||
await page.goto("/login")
|
||||
await page.getByLabel("Email").fill("admin@example.test")
|
||||
await page.getByLabel("Password").fill("admin-password")
|
||||
await page.getByRole("button", { name: "Sign In" }).click()
|
||||
await expect(page).toHaveURL("/")
|
||||
}
|
||||
|
||||
async function createTeam(page: Page, name: string) {
|
||||
await page.goto("/people?tab=teams")
|
||||
await page.getByLabel("Team name").fill(name)
|
||||
await page.getByRole("button", { name: "Create Team" }).click()
|
||||
await expect(page.getByText("Team created successfully")).toBeVisible()
|
||||
}
|
||||
|
||||
test.describe("people and teams", () => {
|
||||
test("switches between people and teams tabs via URL", async ({
|
||||
baseURL,
|
||||
page,
|
||||
}) => {
|
||||
await signInAsAdmin(page, baseURL)
|
||||
await page.goto("/people")
|
||||
|
||||
const sections = page.getByRole("navigation", { name: "People sections" })
|
||||
await expect(sections).toBeVisible()
|
||||
|
||||
await page.goto("/people?tab=people")
|
||||
await expect(
|
||||
sections.getByRole("link", { name: "People" }),
|
||||
).toHaveAttribute("aria-current", "page")
|
||||
|
||||
await page.goto("/people?tab=teams")
|
||||
await expect(sections.getByRole("link", { name: "Teams" })).toHaveAttribute(
|
||||
"aria-current",
|
||||
"page",
|
||||
)
|
||||
await expect(page.getByLabel("Team name")).toBeVisible()
|
||||
|
||||
await page.goto("/people?tab=invalid")
|
||||
await expect(
|
||||
sections.getByRole("link", { name: "People" }),
|
||||
).toHaveAttribute("aria-current", "page")
|
||||
})
|
||||
|
||||
test("creates, renames, and deletes a team", async ({ baseURL, page }) => {
|
||||
const timestamp = Date.now()
|
||||
const originalName = `E2E Team ${timestamp}`
|
||||
const updatedName = `E2E Team Updated ${timestamp}`
|
||||
|
||||
await signInAsAdmin(page, baseURL)
|
||||
await createTeam(page, originalName)
|
||||
|
||||
const row = page.getByRole("row", { name: new RegExp(originalName) })
|
||||
await expect(row).toBeVisible()
|
||||
|
||||
await row.getByRole("button", { name: "Edit team" }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toBeVisible()
|
||||
await dialog.getByLabel("Team name").fill(updatedName)
|
||||
await dialog.getByRole("button", { name: "Update Team" }).click()
|
||||
await expect(dialog).not.toBeVisible()
|
||||
await expect(page.getByText("Team updated successfully")).toBeVisible()
|
||||
await expect(page.getByRole("row", { name: updatedName })).toBeVisible()
|
||||
|
||||
await page
|
||||
.getByRole("row", { name: updatedName })
|
||||
.getByRole("button", { name: "Delete team" })
|
||||
.click()
|
||||
await expect(page.getByText("Team deleted successfully")).toBeVisible()
|
||||
await expect(page.getByRole("row", { name: updatedName })).not.toBeVisible()
|
||||
})
|
||||
|
||||
test("creates a person with a team and shows the team in the list", async ({
|
||||
baseURL,
|
||||
page,
|
||||
}) => {
|
||||
const timestamp = Date.now()
|
||||
const teamName = `E2E Person Team ${timestamp}`
|
||||
const personName = `E2E Person ${timestamp}`
|
||||
|
||||
await signInAsAdmin(page, baseURL)
|
||||
await createTeam(page, teamName)
|
||||
|
||||
await page.goto("/people/new")
|
||||
await page.getByLabel("First Name").fill(personName)
|
||||
await page.getByLabel("Last Name").fill("E2E")
|
||||
await page.getByLabel("Team").selectOption({ label: teamName })
|
||||
await page.getByLabel("Email").fill(`e2e-${timestamp}@example.test`)
|
||||
await page.getByLabel("Phone").fill("123456789")
|
||||
await page.getByLabel("Role").selectOption("NO_USER")
|
||||
await page.getByRole("button", { name: "Create User" }).click()
|
||||
await expect(page.getByText("User created successfully")).toBeVisible()
|
||||
|
||||
await page.goto("/people?tab=people")
|
||||
const row = page.getByRole("row", { name: new RegExp(personName) })
|
||||
await expect(row).toContainText(teamName)
|
||||
|
||||
await row.getByRole("link", { name: "View person" }).click()
|
||||
await expect(page.getByText(teamName)).toBeVisible()
|
||||
})
|
||||
|
||||
test("shows no team fallback for a person without a team", async ({
|
||||
baseURL,
|
||||
page,
|
||||
}) => {
|
||||
const timestamp = Date.now()
|
||||
const personName = `E2E No Team ${timestamp}`
|
||||
|
||||
await signInAsAdmin(page, baseURL)
|
||||
|
||||
await page.goto("/people/new")
|
||||
await page.getByLabel("First Name").fill(personName)
|
||||
await page.getByLabel("Last Name").fill("E2E")
|
||||
await page.getByLabel("Team").selectOption({ value: "" })
|
||||
await page.getByLabel("Email").fill(`e2e-noteam-${timestamp}@example.test`)
|
||||
await page.getByLabel("Phone").fill("123456789")
|
||||
await page.getByLabel("Role").selectOption("NO_USER")
|
||||
await page.getByRole("button", { name: "Create User" }).click()
|
||||
await expect(page.getByText("User created successfully")).toBeVisible()
|
||||
|
||||
await page.goto("/people?tab=people")
|
||||
const row = page.getByRole("row", { name: new RegExp(personName) })
|
||||
await expect(row).toContainText("—")
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,4 @@
|
||||
import type {
|
||||
PersonDepartment,
|
||||
PrismaClient,
|
||||
UserRole,
|
||||
} from "@/generated/prisma/client"
|
||||
import type { PrismaClient, UserRole } from "@/generated/prisma/client"
|
||||
import { UserStatus } from "@/generated/prisma/client"
|
||||
import { normalizeEmail } from "@/lib/email"
|
||||
|
||||
@@ -54,12 +50,25 @@ export async function createTestCategory(
|
||||
})
|
||||
}
|
||||
|
||||
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 createTestPerson(
|
||||
prisma: PrismaClient,
|
||||
overrides: Partial<{
|
||||
firstName: string
|
||||
lastName: string
|
||||
department: PersonDepartment
|
||||
teamId: string | null
|
||||
email: string | null
|
||||
phone: string | null
|
||||
}> = {},
|
||||
@@ -70,7 +79,7 @@ export async function createTestPerson(
|
||||
data: {
|
||||
firstName: overrides.firstName ?? "Test",
|
||||
lastName: overrides.lastName ?? `Person-${suffix}`,
|
||||
department: overrides.department ?? "OTHER",
|
||||
teamId: overrides.teamId ?? null,
|
||||
email: overrides.email ?? null,
|
||||
phone: overrides.phone ?? null,
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@ const TABLES_TO_TRUNCATE = [
|
||||
"Asset",
|
||||
"Item",
|
||||
"Category",
|
||||
"Team",
|
||||
"Person",
|
||||
"User",
|
||||
]
|
||||
|
||||
@@ -76,6 +76,37 @@ describe("item use-cases", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("exposes saved stock policy values when reloading the item list", async () => {
|
||||
const actor = await createTestUser(prisma)
|
||||
const category = await createTestCategory(prisma)
|
||||
const { ItemService } = await import("@/services/item.service")
|
||||
|
||||
const result = await createItemUseCase({
|
||||
actorId: actor.id,
|
||||
name: "Laptop",
|
||||
categoryId: category.id,
|
||||
stock: 3,
|
||||
trackingType: "QUANTITY",
|
||||
status: "ACTIVE",
|
||||
minStock: 1,
|
||||
targetStock: 6,
|
||||
})
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
|
||||
const { data: items } = await ItemService.findAllWithAssetCount({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})
|
||||
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toMatchObject({
|
||||
name: "Laptop",
|
||||
minStock: 1,
|
||||
targetStock: 6,
|
||||
})
|
||||
})
|
||||
|
||||
it("generates unique skus for different names with the same normalized base", async () => {
|
||||
const actor = await createTestUser(prisma)
|
||||
const category = await createTestCategory(prisma)
|
||||
@@ -159,6 +190,45 @@ describe("item use-cases", () => {
|
||||
expect(stockMovements).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("clears a saved stock policy on update", async () => {
|
||||
const actor = await createTestUser(prisma)
|
||||
const category = await createTestCategory(prisma)
|
||||
|
||||
const createResult = await createItemUseCase({
|
||||
actorId: actor.id,
|
||||
name: "Monitor",
|
||||
categoryId: category.id,
|
||||
stock: 1,
|
||||
minStock: 2,
|
||||
targetStock: 5,
|
||||
})
|
||||
|
||||
expect(createResult).toEqual({ success: true })
|
||||
|
||||
const item = await prisma.item.findUniqueOrThrow({
|
||||
where: { sku: "MONITOR" },
|
||||
})
|
||||
|
||||
const updateResult = await updateItemUseCase({
|
||||
actorId: actor.id,
|
||||
id: item.id,
|
||||
name: "Monitor",
|
||||
categoryId: category.id,
|
||||
stock: 1,
|
||||
minStock: null,
|
||||
targetStock: null,
|
||||
} as never)
|
||||
|
||||
expect(updateResult).toEqual({ success: true })
|
||||
|
||||
const updatedItem = await prisma.item.findUniqueOrThrow({
|
||||
where: { id: item.id },
|
||||
})
|
||||
|
||||
expect(updatedItem.minStock).toBeNull()
|
||||
expect(updatedItem.targetStock).toBeNull()
|
||||
})
|
||||
|
||||
it("rejects duplicate item names", async () => {
|
||||
const actor = await createTestUser(prisma)
|
||||
const category = await createTestCategory(prisma)
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
|
||||
import type { PrismaClient } from "@/generated/prisma/client"
|
||||
import { normalizeEmail } from "@/lib/email"
|
||||
import { createTestPerson, createTestUser } from "../helpers/factories"
|
||||
import {
|
||||
createTestPerson,
|
||||
createTestTeam,
|
||||
createTestUser,
|
||||
} from "../helpers/factories"
|
||||
import {
|
||||
resetIntegrationTestDatabase,
|
||||
startIntegrationTestDatabase,
|
||||
@@ -33,10 +37,12 @@ afterAll(async () => {
|
||||
describe("createPersonUserUseCase", () => {
|
||||
describe("NO_USER role (person-only creation)", () => {
|
||||
it("creates a Person without a User record when role is NO_USER", async () => {
|
||||
const team = await createTestTeam(prisma)
|
||||
|
||||
const result = await createPersonUserUseCase({
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
department: "IT",
|
||||
teamId: team.id,
|
||||
email: "john@example.test",
|
||||
phone: null,
|
||||
role: "NO_USER",
|
||||
@@ -51,7 +57,7 @@ describe("createPersonUserUseCase", () => {
|
||||
expect(person).toMatchObject({
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
department: "IT",
|
||||
teamId: team.id,
|
||||
email: "john@example.test",
|
||||
phone: null,
|
||||
userId: null,
|
||||
@@ -66,10 +72,12 @@ describe("createPersonUserUseCase", () => {
|
||||
})
|
||||
|
||||
it("creates a Person with null email when not providing email and role is NO_USER", async () => {
|
||||
const team = await createTestTeam(prisma)
|
||||
|
||||
const result = await createPersonUserUseCase({
|
||||
firstName: "Jane",
|
||||
lastName: "Smith",
|
||||
department: "ENGINEERING",
|
||||
teamId: team.id,
|
||||
email: "jane-noemail@example.test",
|
||||
phone: "555-1234",
|
||||
role: "NO_USER",
|
||||
@@ -87,10 +95,12 @@ describe("createPersonUserUseCase", () => {
|
||||
|
||||
describe("real role (person + user creation)", () => {
|
||||
it("creates Person and User with linked userId when role is ADMIN", async () => {
|
||||
const team = await createTestTeam(prisma)
|
||||
|
||||
const result = await createPersonUserUseCase({
|
||||
firstName: "Admin",
|
||||
lastName: "User",
|
||||
department: "IT",
|
||||
teamId: team.id,
|
||||
email: "admin@example.test",
|
||||
phone: null,
|
||||
role: "ADMIN",
|
||||
@@ -101,12 +111,12 @@ describe("createPersonUserUseCase", () => {
|
||||
expect(result).toEqual({ success: true })
|
||||
|
||||
const person = await prisma.person.findFirstOrThrow({
|
||||
where: { firstName: "Admin", lastName: "User" },
|
||||
where: { firstName: "Admin" },
|
||||
})
|
||||
expect(person).toMatchObject({
|
||||
firstName: "Admin",
|
||||
lastName: "User",
|
||||
department: "IT",
|
||||
teamId: team.id,
|
||||
email: "admin@example.test",
|
||||
})
|
||||
|
||||
@@ -128,6 +138,7 @@ describe("createPersonUserUseCase", () => {
|
||||
})
|
||||
|
||||
it("creates Person and User for all real roles (MANAGER, STAFF, VIEWER)", async () => {
|
||||
const team = await createTestTeam(prisma)
|
||||
const roles = ["MANAGER", "STAFF", "VIEWER"] as const
|
||||
|
||||
for (const role of roles) {
|
||||
@@ -135,7 +146,7 @@ describe("createPersonUserUseCase", () => {
|
||||
const result = await createPersonUserUseCase({
|
||||
firstName: "Person",
|
||||
lastName: suffix,
|
||||
department: "IT",
|
||||
teamId: team.id,
|
||||
email: `${suffix}@example.test`,
|
||||
phone: null,
|
||||
role,
|
||||
@@ -160,10 +171,12 @@ describe("createPersonUserUseCase", () => {
|
||||
})
|
||||
|
||||
it("derives User.name from firstName + lastName", async () => {
|
||||
const team = await createTestTeam(prisma)
|
||||
|
||||
await createPersonUserUseCase({
|
||||
firstName: "Maria",
|
||||
lastName: "Garcia",
|
||||
department: "SALES",
|
||||
teamId: team.id,
|
||||
email: "maria@example.test",
|
||||
phone: null,
|
||||
role: "STAFF",
|
||||
@@ -178,10 +191,12 @@ describe("createPersonUserUseCase", () => {
|
||||
})
|
||||
|
||||
it("hashes the password when creating a User", async () => {
|
||||
const team = await createTestTeam(prisma)
|
||||
|
||||
await createPersonUserUseCase({
|
||||
firstName: "Hash",
|
||||
lastName: "Test",
|
||||
department: "IT",
|
||||
teamId: team.id,
|
||||
email: "hash-test@example.test",
|
||||
phone: null,
|
||||
role: "STAFF",
|
||||
@@ -196,20 +211,21 @@ describe("createPersonUserUseCase", () => {
|
||||
if (!user.passwordHash) throw new Error("Expected password hash")
|
||||
|
||||
const { verifyPassword } = await import("@/lib/security")
|
||||
expect(await verifyPassword("plaintext-password", user.passwordHash)).toBe(
|
||||
true,
|
||||
)
|
||||
expect(
|
||||
await verifyPassword("plaintext-password", user.passwordHash),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("cross-table email uniqueness", () => {
|
||||
it("rejects submission when email already exists in Person table", async () => {
|
||||
const team = await createTestTeam(prisma)
|
||||
await createTestPerson(prisma, { email: "existing-person@example.test" })
|
||||
|
||||
const result = await createPersonUserUseCase({
|
||||
firstName: "Duplicate",
|
||||
lastName: "Person",
|
||||
department: "IT",
|
||||
teamId: team.id,
|
||||
email: "existing-person@example.test",
|
||||
phone: null,
|
||||
role: "NO_USER",
|
||||
@@ -225,12 +241,13 @@ describe("createPersonUserUseCase", () => {
|
||||
})
|
||||
|
||||
it("rejects submission when email already exists in User table", async () => {
|
||||
const team = await createTestTeam(prisma)
|
||||
await createTestUser(prisma, { email: "existing-user@example.test" })
|
||||
|
||||
const result = await createPersonUserUseCase({
|
||||
firstName: "Duplicate",
|
||||
lastName: "User",
|
||||
department: "IT",
|
||||
teamId: team.id,
|
||||
email: "existing-user@example.test",
|
||||
phone: null,
|
||||
role: "STAFF",
|
||||
@@ -249,6 +266,7 @@ describe("createPersonUserUseCase", () => {
|
||||
})
|
||||
|
||||
it("accepts submission when email is unique across both tables", async () => {
|
||||
const team = await createTestTeam(prisma)
|
||||
// Create a Person and a User with different emails
|
||||
await createTestPerson(prisma, { email: "person@example.test" })
|
||||
await createTestUser(prisma, { email: "user@example.test" })
|
||||
@@ -256,7 +274,7 @@ describe("createPersonUserUseCase", () => {
|
||||
const result = await createPersonUserUseCase({
|
||||
firstName: "New",
|
||||
lastName: "Person",
|
||||
department: "IT",
|
||||
teamId: team.id,
|
||||
email: "new@example.test",
|
||||
phone: null,
|
||||
role: "NO_USER",
|
||||
@@ -266,4 +284,24 @@ describe("createPersonUserUseCase", () => {
|
||||
expect(result).toEqual({ success: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe("team validation", () => {
|
||||
it("rejects an unknown team id", async () => {
|
||||
const result = await createPersonUserUseCase({
|
||||
firstName: "No",
|
||||
lastName: "Team",
|
||||
teamId: "00000000-0000-0000-0000-000000000000",
|
||||
email: "no-team@example.test",
|
||||
phone: null,
|
||||
role: "NO_USER",
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.errors.teamId).toBeDefined()
|
||||
}
|
||||
expect(await prisma.person.count()).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
|
||||
import type { PrismaClient } from "@/generated/prisma/client"
|
||||
import { createTestPerson, createTestUser } from "../helpers/factories"
|
||||
import {
|
||||
createTestPerson,
|
||||
createTestTeam,
|
||||
createTestUser,
|
||||
} from "../helpers/factories"
|
||||
import {
|
||||
resetIntegrationTestDatabase,
|
||||
startIntegrationTestDatabase,
|
||||
@@ -40,7 +44,7 @@ describe("person use-cases", () => {
|
||||
createPersonUseCase({
|
||||
firstName: "Person",
|
||||
lastName: "One",
|
||||
department: "IT",
|
||||
teamId: null,
|
||||
email: "",
|
||||
phone: "",
|
||||
}),
|
||||
@@ -53,7 +57,7 @@ describe("person use-cases", () => {
|
||||
).toMatchObject({
|
||||
firstName: "Person",
|
||||
lastName: "One",
|
||||
department: "IT",
|
||||
teamId: null,
|
||||
email: null,
|
||||
phone: null,
|
||||
userId: null,
|
||||
@@ -62,12 +66,13 @@ describe("person use-cases", () => {
|
||||
|
||||
it("creates a person with linked userId", async () => {
|
||||
const user = await createTestUser(prisma)
|
||||
const team = await createTestTeam(prisma)
|
||||
|
||||
await expect(
|
||||
createPersonUseCase({
|
||||
firstName: "Linked",
|
||||
lastName: "Person",
|
||||
department: "ENGINEERING",
|
||||
teamId: team.id,
|
||||
email: "linked@example.test",
|
||||
phone: null,
|
||||
userId: user.id,
|
||||
@@ -81,7 +86,7 @@ describe("person use-cases", () => {
|
||||
).toMatchObject({
|
||||
firstName: "Linked",
|
||||
lastName: "Person",
|
||||
department: "ENGINEERING",
|
||||
teamId: team.id,
|
||||
email: "linked@example.test",
|
||||
userId: user.id,
|
||||
})
|
||||
@@ -96,7 +101,7 @@ describe("person use-cases", () => {
|
||||
createPersonUseCase({
|
||||
firstName: "Duplicate",
|
||||
lastName: "Email",
|
||||
department: "OTHER",
|
||||
teamId: null,
|
||||
email: "existing@example.test",
|
||||
phone: null,
|
||||
}),
|
||||
@@ -108,7 +113,24 @@ describe("person use-cases", () => {
|
||||
expect(await prisma.person.count()).toBe(1)
|
||||
})
|
||||
|
||||
it("rejects an unknown team id on create", async () => {
|
||||
const result = await createPersonUseCase({
|
||||
firstName: "Unknown",
|
||||
lastName: "Team",
|
||||
teamId: "00000000-0000-0000-0000-000000000000",
|
||||
email: "unknown-team@example.test",
|
||||
phone: null,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.errors.teamId).toBeDefined()
|
||||
}
|
||||
expect(await prisma.person.count()).toBe(0)
|
||||
})
|
||||
|
||||
it("updates a person and rejects duplicate emails", async () => {
|
||||
const team = await createTestTeam(prisma)
|
||||
const person = await createTestPerson(prisma, {
|
||||
email: "person@example.test",
|
||||
phone: "111111111",
|
||||
@@ -122,16 +144,18 @@ describe("person use-cases", () => {
|
||||
id: person.id,
|
||||
firstName: "Edited",
|
||||
lastName: "Person",
|
||||
department: "ENGINEERING",
|
||||
teamId: team.id,
|
||||
email: "edited@example.test",
|
||||
phone: "222222222",
|
||||
}),
|
||||
).resolves.toEqual({ success: true })
|
||||
|
||||
expect(await prisma.person.findUniqueOrThrow({ where: { id: person.id } })).toMatchObject({
|
||||
expect(
|
||||
await prisma.person.findUniqueOrThrow({ where: { id: person.id } }),
|
||||
).toMatchObject({
|
||||
firstName: "Edited",
|
||||
lastName: "Person",
|
||||
department: "ENGINEERING",
|
||||
teamId: team.id,
|
||||
email: "edited@example.test",
|
||||
phone: "222222222",
|
||||
})
|
||||
@@ -141,7 +165,7 @@ describe("person use-cases", () => {
|
||||
id: person.id,
|
||||
firstName: "Edited",
|
||||
lastName: "Person",
|
||||
department: "ENGINEERING",
|
||||
teamId: team.id,
|
||||
email: other.email,
|
||||
phone: "222222222",
|
||||
}),
|
||||
@@ -150,12 +174,37 @@ describe("person use-cases", () => {
|
||||
errors: { email: ["Email already exists"] },
|
||||
})
|
||||
|
||||
expect(await prisma.person.findUniqueOrThrow({ where: { id: person.id } })).toMatchObject({
|
||||
expect(
|
||||
await prisma.person.findUniqueOrThrow({ where: { id: person.id } }),
|
||||
).toMatchObject({
|
||||
email: "edited@example.test",
|
||||
})
|
||||
expect(await prisma.person.count()).toBe(2)
|
||||
})
|
||||
|
||||
it("updates a person team to null", async () => {
|
||||
const team = await createTestTeam(prisma)
|
||||
const person = await createTestPerson(prisma, {
|
||||
teamId: team.id,
|
||||
})
|
||||
|
||||
await expect(
|
||||
updatePersonUseCase({
|
||||
id: person.id,
|
||||
firstName: person.firstName,
|
||||
lastName: person.lastName,
|
||||
teamId: null,
|
||||
email: person.email,
|
||||
phone: person.phone,
|
||||
}),
|
||||
).resolves.toEqual({ success: true })
|
||||
|
||||
const updated = await prisma.person.findUniqueOrThrow({
|
||||
where: { id: person.id },
|
||||
})
|
||||
expect(updated.teamId).toBeNull()
|
||||
})
|
||||
|
||||
it("searches by email and name in paginated results", async () => {
|
||||
await createTestPerson(prisma, {
|
||||
firstName: "Alice",
|
||||
|
||||
@@ -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"])
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,11 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
|
||||
import { type PrismaClient, UserStatus } from "@/generated/prisma/client"
|
||||
import { normalizeEmail } from "@/lib/email"
|
||||
import { getPasswordHash } from "@/lib/security"
|
||||
import { createTestPerson, createTestUser } from "../helpers/factories"
|
||||
import {
|
||||
createTestPerson,
|
||||
createTestTeam,
|
||||
createTestUser,
|
||||
} from "../helpers/factories"
|
||||
import {
|
||||
resetIntegrationTestDatabase,
|
||||
startIntegrationTestDatabase,
|
||||
@@ -34,6 +38,7 @@ afterAll(async () => {
|
||||
describe("updatePersonUserUseCase", () => {
|
||||
describe("person-only update", () => {
|
||||
it("updates only the Person when person has no linked User", async () => {
|
||||
const team = await createTestTeam(prisma)
|
||||
const person = await createTestPerson(prisma, {
|
||||
firstName: "Old",
|
||||
lastName: "Name",
|
||||
@@ -44,7 +49,7 @@ describe("updatePersonUserUseCase", () => {
|
||||
id: person.id,
|
||||
firstName: "New",
|
||||
lastName: "Name",
|
||||
department: "IT",
|
||||
teamId: team.id,
|
||||
email: "new@example.test",
|
||||
phone: "1234",
|
||||
})
|
||||
@@ -57,7 +62,7 @@ describe("updatePersonUserUseCase", () => {
|
||||
expect(updated).toMatchObject({
|
||||
firstName: "New",
|
||||
lastName: "Name",
|
||||
department: "IT",
|
||||
teamId: team.id,
|
||||
email: "new@example.test",
|
||||
phone: "1234",
|
||||
userId: null,
|
||||
@@ -71,7 +76,7 @@ describe("updatePersonUserUseCase", () => {
|
||||
id: person.id,
|
||||
firstName: "Empty",
|
||||
lastName: "Email",
|
||||
department: "OTHER",
|
||||
teamId: null,
|
||||
email: "",
|
||||
phone: null,
|
||||
})
|
||||
@@ -87,6 +92,7 @@ describe("updatePersonUserUseCase", () => {
|
||||
|
||||
describe("person+user update", () => {
|
||||
it("updates Person fields and User role/isActive when person has a User linked", async () => {
|
||||
const team = await createTestTeam(prisma)
|
||||
const user = await createTestUser(prisma, {
|
||||
email: "user-update@example.test",
|
||||
name: "Old Name",
|
||||
@@ -107,7 +113,7 @@ describe("updatePersonUserUseCase", () => {
|
||||
id: person.id,
|
||||
firstName: "Linked",
|
||||
lastName: "Person",
|
||||
department: "ENGINEERING",
|
||||
teamId: team.id,
|
||||
email: "user-update@example.test",
|
||||
phone: null,
|
||||
role: "ADMIN",
|
||||
@@ -120,7 +126,7 @@ describe("updatePersonUserUseCase", () => {
|
||||
where: { id: person.id },
|
||||
include: { user: true },
|
||||
})
|
||||
expect(updatedPerson.department).toBe("ENGINEERING")
|
||||
expect(updatedPerson.teamId).toBe(team.id)
|
||||
expect(updatedPerson.user).toMatchObject({
|
||||
id: user.id,
|
||||
role: "ADMIN",
|
||||
@@ -144,7 +150,7 @@ describe("updatePersonUserUseCase", () => {
|
||||
id: person.id,
|
||||
firstName: person.firstName,
|
||||
lastName: person.lastName,
|
||||
department: "OTHER",
|
||||
teamId: null,
|
||||
email: "pw-reset@example.test",
|
||||
phone: null,
|
||||
role: "STAFF",
|
||||
@@ -190,7 +196,7 @@ describe("updatePersonUserUseCase", () => {
|
||||
id: person.id,
|
||||
firstName: person.firstName,
|
||||
lastName: person.lastName,
|
||||
department: "OTHER",
|
||||
teamId: null,
|
||||
email: "no-pw@example.test",
|
||||
phone: null,
|
||||
role: "STAFF",
|
||||
@@ -216,7 +222,7 @@ describe("updatePersonUserUseCase", () => {
|
||||
id: "00000000-0000-0000-0000-000000000000",
|
||||
firstName: "Ghost",
|
||||
lastName: "Person",
|
||||
department: "OTHER",
|
||||
teamId: null,
|
||||
email: "ghost@example.test",
|
||||
phone: null,
|
||||
})
|
||||
@@ -239,7 +245,7 @@ describe("updatePersonUserUseCase", () => {
|
||||
id: person.id,
|
||||
firstName: "Mine",
|
||||
lastName: "Person",
|
||||
department: "OTHER",
|
||||
teamId: null,
|
||||
email: "theirs@example.test",
|
||||
phone: null,
|
||||
})
|
||||
@@ -249,5 +255,23 @@ describe("updatePersonUserUseCase", () => {
|
||||
errors: { email: ["Email already exists"] },
|
||||
})
|
||||
})
|
||||
|
||||
it("rejects an unknown team id", async () => {
|
||||
const person = await createTestPerson(prisma)
|
||||
|
||||
const result = await updatePersonUserUseCase({
|
||||
id: person.id,
|
||||
firstName: person.firstName,
|
||||
lastName: person.lastName,
|
||||
teamId: "00000000-0000-0000-0000-000000000000",
|
||||
email: person.email,
|
||||
phone: null,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.errors.teamId).toBeDefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -147,7 +147,9 @@ describe("user use-cases", () => {
|
||||
}),
|
||||
).resolves.toEqual({ success: true })
|
||||
|
||||
expect(await prisma.user.findUniqueOrThrow({ where: { id: user.id } })).toMatchObject({
|
||||
expect(
|
||||
await prisma.user.findUniqueOrThrow({ where: { id: user.id } }),
|
||||
).toMatchObject({
|
||||
name: "Edited User",
|
||||
email: "edited@example.test",
|
||||
role: "MANAGER",
|
||||
@@ -168,7 +170,9 @@ describe("user use-cases", () => {
|
||||
errors: { email: ["Email already exists"] },
|
||||
})
|
||||
|
||||
expect(await prisma.user.findUniqueOrThrow({ where: { id: user.id } })).toMatchObject({
|
||||
expect(
|
||||
await prisma.user.findUniqueOrThrow({ where: { id: user.id } }),
|
||||
).toMatchObject({
|
||||
email: "edited@example.test",
|
||||
})
|
||||
})
|
||||
@@ -190,7 +194,9 @@ describe("user use-cases", () => {
|
||||
errors: { id: ["You cannot remove your own administrator access"] },
|
||||
})
|
||||
|
||||
expect(await prisma.user.findUniqueOrThrow({ where: { id: admin.id } })).toMatchObject({
|
||||
expect(
|
||||
await prisma.user.findUniqueOrThrow({ where: { id: admin.id } }),
|
||||
).toMatchObject({
|
||||
role: "ADMIN",
|
||||
status: "ACTIVE",
|
||||
})
|
||||
@@ -232,7 +238,9 @@ describe("user use-cases", () => {
|
||||
}),
|
||||
).resolves.toEqual({ success: true })
|
||||
|
||||
expect(await prisma.user.findUniqueOrThrow({ where: { id: firstAdmin.id } })).toMatchObject({
|
||||
expect(
|
||||
await prisma.user.findUniqueOrThrow({ where: { id: firstAdmin.id } }),
|
||||
).toMatchObject({
|
||||
status: "DISABLED",
|
||||
})
|
||||
})
|
||||
@@ -252,7 +260,9 @@ describe("user use-cases", () => {
|
||||
errors: { id: ["You cannot deactivate your own user"] },
|
||||
})
|
||||
|
||||
expect(await prisma.user.findUniqueOrThrow({ where: { id: admin.id } })).toMatchObject({
|
||||
expect(
|
||||
await prisma.user.findUniqueOrThrow({ where: { id: admin.id } }),
|
||||
).toMatchObject({
|
||||
status: "ACTIVE",
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({
|
||||
revalidatePath: vi.fn(),
|
||||
getI18n: vi.fn(),
|
||||
getAuthenticatedUserId: vi.fn(),
|
||||
createAssignmentUseCase: vi.fn(),
|
||||
returnAssignmentUseCase: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -22,18 +23,53 @@ vi.mock("@/services/auth.service", () => ({
|
||||
}))
|
||||
|
||||
vi.mock("@/use-cases/assignment.use-cases", () => ({
|
||||
createAssignmentUseCase: mocks.createAssignmentUseCase,
|
||||
returnAssignmentUseCase: mocks.returnAssignmentUseCase,
|
||||
}))
|
||||
|
||||
import { returnAssignment } from "@/actions/assignment.actions"
|
||||
import {
|
||||
createAssignment,
|
||||
returnAssignment,
|
||||
} from "@/actions/assignment.actions"
|
||||
|
||||
describe("returnAssignment action", () => {
|
||||
describe("assignment actions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
|
||||
mocks.getAuthenticatedUserId.mockResolvedValue("user-1")
|
||||
})
|
||||
|
||||
it("normalizes quantity to 1 for asset-backed assignments", async () => {
|
||||
mocks.createAssignmentUseCase.mockResolvedValue({ success: true })
|
||||
|
||||
const result = await createAssignment({
|
||||
personId: "person-1",
|
||||
itemId: "item-2",
|
||||
assetId: "asset-2",
|
||||
quantity: 5,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true as const,
|
||||
message: en.inventory.assignments.actions.createSuccess,
|
||||
})
|
||||
expect(mocks.createAssignmentUseCase).toHaveBeenCalledWith({
|
||||
personId: "person-1",
|
||||
itemId: "item-2",
|
||||
assetId: "asset-2",
|
||||
quantity: 1,
|
||||
lines: [
|
||||
{
|
||||
itemId: "item-2",
|
||||
quantity: 1,
|
||||
notes: undefined,
|
||||
},
|
||||
],
|
||||
actorId: "user-1",
|
||||
})
|
||||
expect(mocks.revalidatePath).toHaveBeenCalledWith("/assignments")
|
||||
})
|
||||
|
||||
it("returns validation errors for a missing assignment id", async () => {
|
||||
const result = await returnAssignment({ id: "" })
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { en } from "@/i18n/dictionaries/en"
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
revalidatePath: vi.fn(),
|
||||
getI18n: vi.fn(),
|
||||
getAuthenticatedUserId: vi.fn(),
|
||||
createItemUseCase: vi.fn(),
|
||||
updateItemUseCase: vi.fn(),
|
||||
deleteItemUseCase: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("next/cache", () => ({
|
||||
revalidatePath: mocks.revalidatePath,
|
||||
}))
|
||||
|
||||
vi.mock("@/i18n/server", () => ({
|
||||
getI18n: mocks.getI18n,
|
||||
}))
|
||||
|
||||
vi.mock("@/services/auth.service", () => ({
|
||||
getAuthenticatedUserId: mocks.getAuthenticatedUserId,
|
||||
}))
|
||||
|
||||
vi.mock("@/use-cases/item.use-cases", () => ({
|
||||
createItemUseCase: mocks.createItemUseCase,
|
||||
updateItemUseCase: mocks.updateItemUseCase,
|
||||
deleteItemUseCase: mocks.deleteItemUseCase,
|
||||
}))
|
||||
|
||||
import {
|
||||
createItemAction,
|
||||
deleteItemAction,
|
||||
updateItemAction,
|
||||
} from "@/actions/item.actions"
|
||||
|
||||
describe("item actions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
|
||||
mocks.getAuthenticatedUserId.mockResolvedValue("user-1")
|
||||
})
|
||||
|
||||
it("treats empty stock policy fields as null on create and forwards them to the use case", async () => {
|
||||
mocks.createItemUseCase.mockResolvedValue({ success: true })
|
||||
|
||||
const result = await createItemAction({
|
||||
name: "Cable",
|
||||
categoryId: "category-1",
|
||||
stock: "0",
|
||||
minStock: "",
|
||||
targetStock: "",
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
message: en.inventory.items.actions.createSuccess,
|
||||
})
|
||||
expect(mocks.createItemUseCase).toHaveBeenCalledWith({
|
||||
actorId: "user-1",
|
||||
name: "Cable",
|
||||
categoryId: "category-1",
|
||||
stock: 0,
|
||||
trackingType: "QUANTITY",
|
||||
status: "ACTIVE",
|
||||
minStock: null,
|
||||
targetStock: null,
|
||||
})
|
||||
expect(mocks.revalidatePath).toHaveBeenCalledWith("/inventory/items")
|
||||
expect(mocks.revalidatePath).toHaveBeenCalledWith("/movements")
|
||||
})
|
||||
|
||||
it("treats empty stock policy fields as null on update and forwards them to the use case", async () => {
|
||||
mocks.updateItemUseCase.mockResolvedValue({ success: true })
|
||||
|
||||
const result = await updateItemAction({
|
||||
id: "item-1",
|
||||
name: "Cable",
|
||||
categoryId: "category-1",
|
||||
stock: "0",
|
||||
trackingType: "QUANTITY",
|
||||
minStock: "",
|
||||
targetStock: "",
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
message: en.inventory.items.actions.updateSuccess,
|
||||
})
|
||||
expect(mocks.updateItemUseCase).toHaveBeenCalledWith({
|
||||
actorId: "user-1",
|
||||
id: "item-1",
|
||||
name: "Cable",
|
||||
categoryId: "category-1",
|
||||
stock: 0,
|
||||
trackingType: "QUANTITY",
|
||||
status: undefined,
|
||||
reason: undefined,
|
||||
minStock: null,
|
||||
targetStock: null,
|
||||
})
|
||||
expect(mocks.revalidatePath).toHaveBeenCalledWith("/inventory/items")
|
||||
expect(mocks.revalidatePath).toHaveBeenCalledWith("/movements")
|
||||
})
|
||||
|
||||
it("returns create validation errors without calling the use case", async () => {
|
||||
const result = await createItemAction({
|
||||
name: "Cable",
|
||||
categoryId: "category-1",
|
||||
stock: "0",
|
||||
minStock: "3",
|
||||
targetStock: "",
|
||||
})
|
||||
|
||||
expect(mocks.createItemUseCase).not.toHaveBeenCalled()
|
||||
expect(result.errors).toEqual(
|
||||
expect.objectContaining({
|
||||
minStock: expect.any(Array),
|
||||
targetStock: expect.any(Array),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("returns update validation errors without calling the use case", async () => {
|
||||
const result = await updateItemAction({
|
||||
id: "item-1",
|
||||
name: "Cable",
|
||||
categoryId: "category-1",
|
||||
stock: "0",
|
||||
trackingType: "QUANTITY",
|
||||
minStock: "3",
|
||||
targetStock: "",
|
||||
})
|
||||
|
||||
expect(mocks.updateItemUseCase).not.toHaveBeenCalled()
|
||||
expect(result.errors).toEqual(
|
||||
expect.objectContaining({
|
||||
minStock: expect.any(Array),
|
||||
targetStock: expect.any(Array),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("successfully deletes an item and revalidates the inventory list", async () => {
|
||||
mocks.deleteItemUseCase.mockResolvedValue({ success: true })
|
||||
|
||||
const formData = new FormData()
|
||||
formData.set("id", "item-1")
|
||||
|
||||
const result = await deleteItemAction(formData)
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
message: en.inventory.items.actions.deleteSuccess,
|
||||
})
|
||||
expect(mocks.deleteItemUseCase).toHaveBeenCalledWith("item-1")
|
||||
expect(mocks.revalidatePath).toHaveBeenCalledWith("/inventory/items")
|
||||
})
|
||||
|
||||
it("localizes delete failures before returning them", async () => {
|
||||
mocks.deleteItemUseCase.mockResolvedValue({
|
||||
success: false,
|
||||
errors: {
|
||||
id: ["Item not found"],
|
||||
},
|
||||
})
|
||||
|
||||
const formData = new FormData()
|
||||
formData.set("id", "item-1")
|
||||
|
||||
const result = await deleteItemAction(formData)
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
errors: {
|
||||
id: [en.inventory.items.actions.notFound],
|
||||
},
|
||||
message: en.inventory.items.actions.deleteFailure,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -25,6 +25,8 @@ vi.mock("@/use-cases/person.use-cases", () => ({
|
||||
|
||||
import { createNewPerson, updatePerson } from "@/actions/person.actions"
|
||||
|
||||
const validTeamId = "550e8400-e29b-41d4-a716-446655440000"
|
||||
|
||||
describe("person actions localization", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -35,7 +37,7 @@ describe("person actions localization", () => {
|
||||
const result = await createNewPerson({
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
department: "",
|
||||
teamId: "not-a-uuid",
|
||||
email: "not-an-email",
|
||||
} as unknown as Parameters<typeof createNewPerson>[0])
|
||||
|
||||
@@ -46,7 +48,8 @@ describe("person actions localization", () => {
|
||||
errors: {
|
||||
firstName: [es.inventory.people.schema.firstNameRequired],
|
||||
lastName: [es.inventory.people.schema.lastNameRequired],
|
||||
department: [es.inventory.people.schema.departmentRequired],
|
||||
teamId: [es.inventory.people.schema.teamIdInvalid],
|
||||
email: [es.inventory.people.schema.emailInvalid],
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -62,7 +65,7 @@ describe("person actions localization", () => {
|
||||
const result = await createNewPerson({
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
department: "ENGINEERING",
|
||||
teamId: validTeamId,
|
||||
email: "ada@example.test",
|
||||
})
|
||||
|
||||
@@ -83,7 +86,7 @@ describe("person actions localization", () => {
|
||||
id: "person-1",
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
department: "ENGINEERING",
|
||||
teamId: validTeamId,
|
||||
email: "ada@example.test",
|
||||
})
|
||||
|
||||
@@ -98,7 +101,7 @@ describe("person actions localization", () => {
|
||||
const result = await createNewPerson({
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
department: "ENGINEERING",
|
||||
teamId: null,
|
||||
userId: "not-a-uuid",
|
||||
} as unknown as Parameters<typeof createNewPerson>[0])
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ const actionCopy = {
|
||||
updateFailure: "Error al actualizar la persona",
|
||||
duplicateEmail: "El correo electrónico ya existe",
|
||||
notFound: "Persona no encontrada",
|
||||
teamNotFound: "Equipo no encontrado",
|
||||
}
|
||||
|
||||
describe("person action message localization", () => {
|
||||
@@ -25,6 +26,19 @@ describe("person action message localization", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("localizes team not found errors", () => {
|
||||
expect(
|
||||
localizePersonFieldErrors(
|
||||
{
|
||||
teamId: ["Team not found"],
|
||||
},
|
||||
actionCopy,
|
||||
),
|
||||
).toEqual({
|
||||
teamId: [actionCopy.teamNotFound],
|
||||
})
|
||||
})
|
||||
|
||||
it("keeps unknown messages unchanged", () => {
|
||||
expect(
|
||||
localizePersonFieldErrors(
|
||||
|
||||
@@ -28,6 +28,8 @@ vi.mock("@/use-cases/person.use-cases", () => ({
|
||||
|
||||
import { updatePersonUserAction } from "@/actions/person.actions"
|
||||
|
||||
const validTeamId = "550e8400-e29b-41d4-a716-446655440000"
|
||||
|
||||
describe("updatePersonUserAction", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -41,7 +43,7 @@ describe("updatePersonUserAction", () => {
|
||||
id: "",
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
department: "ENGINEERING",
|
||||
teamId: validTeamId,
|
||||
email: "ada@example.test",
|
||||
phone: null,
|
||||
})
|
||||
@@ -60,7 +62,7 @@ describe("updatePersonUserAction", () => {
|
||||
id: "person-1",
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
department: "ENGINEERING",
|
||||
teamId: validTeamId,
|
||||
email: "not-an-email",
|
||||
phone: null,
|
||||
})
|
||||
@@ -74,12 +76,31 @@ describe("updatePersonUserAction", () => {
|
||||
expect(mocks.updatePersonUserUseCase).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("rejects invalid teamId with localized teamIdInvalid error", async () => {
|
||||
const result = await updatePersonUserAction({
|
||||
id: "person-1",
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
teamId: "not-a-uuid",
|
||||
email: "ada@example.test",
|
||||
phone: null,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
errors: {
|
||||
teamId: [es.inventory.people.schema.teamIdInvalid],
|
||||
},
|
||||
})
|
||||
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",
|
||||
teamId: validTeamId,
|
||||
email: "ada@example.test",
|
||||
phone: null,
|
||||
role: "ADMIN",
|
||||
@@ -101,7 +122,7 @@ describe("updatePersonUserAction", () => {
|
||||
id: "person-1",
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
department: "ENGINEERING",
|
||||
teamId: validTeamId,
|
||||
email: "ada@example.test",
|
||||
phone: null,
|
||||
role: "NO_USER" as unknown as "ADMIN",
|
||||
@@ -117,7 +138,7 @@ describe("updatePersonUserAction", () => {
|
||||
id: "person-1",
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
department: "ENGINEERING",
|
||||
teamId: validTeamId,
|
||||
email: "ada@example.test",
|
||||
phone: null,
|
||||
role: "SUPER_ADMIN" as unknown as "ADMIN",
|
||||
@@ -140,7 +161,7 @@ describe("updatePersonUserAction", () => {
|
||||
id: "person-1",
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
department: "ENGINEERING",
|
||||
teamId: validTeamId,
|
||||
email: "duplicate@example.test",
|
||||
phone: null,
|
||||
})
|
||||
@@ -164,7 +185,7 @@ describe("updatePersonUserAction", () => {
|
||||
id: "missing",
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
department: "ENGINEERING",
|
||||
teamId: validTeamId,
|
||||
email: "ada@example.test",
|
||||
phone: null,
|
||||
})
|
||||
@@ -184,7 +205,7 @@ describe("updatePersonUserAction", () => {
|
||||
id: "person-1",
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
department: "ENGINEERING",
|
||||
teamId: validTeamId,
|
||||
email: "ada@example.test",
|
||||
phone: null,
|
||||
})
|
||||
@@ -204,7 +225,7 @@ describe("updatePersonUserAction", () => {
|
||||
id: "person-1",
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
department: "ENGINEERING",
|
||||
teamId: validTeamId,
|
||||
email: "ada@example.test",
|
||||
phone: null,
|
||||
})
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest"
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react"
|
||||
import userEvent from "@testing-library/user-event"
|
||||
import { renderToStaticMarkup } from "react-dom/server"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { es } from "@/i18n/dictionaries/es"
|
||||
|
||||
@@ -88,6 +92,11 @@ describe("assignment form pages localization", () => {
|
||||
name: "Laptop",
|
||||
stock: 5,
|
||||
},
|
||||
{
|
||||
id: "item-2",
|
||||
name: "Projector",
|
||||
stock: 1,
|
||||
},
|
||||
])
|
||||
mocks.findAllItems.mockResolvedValue([
|
||||
{
|
||||
@@ -95,12 +104,17 @@ describe("assignment form pages localization", () => {
|
||||
name: "Laptop",
|
||||
stock: 5,
|
||||
},
|
||||
{
|
||||
id: "item-2",
|
||||
name: "Projector",
|
||||
stock: 1,
|
||||
},
|
||||
])
|
||||
mocks.findAllAvailableAssets.mockResolvedValue([
|
||||
{
|
||||
id: "asset-1",
|
||||
itemId: "item-1",
|
||||
serialNumber: "SN-001",
|
||||
id: "asset-2",
|
||||
itemId: "item-2",
|
||||
serialNumber: "SN-002",
|
||||
},
|
||||
])
|
||||
mocks.findAllAssets.mockResolvedValue([
|
||||
@@ -109,6 +123,11 @@ describe("assignment form pages localization", () => {
|
||||
itemId: "item-1",
|
||||
serialNumber: "SN-001",
|
||||
},
|
||||
{
|
||||
id: "asset-2",
|
||||
itemId: "item-2",
|
||||
serialNumber: "SN-002",
|
||||
},
|
||||
])
|
||||
mocks.findItemById.mockResolvedValue({
|
||||
id: "item-1",
|
||||
@@ -117,7 +136,11 @@ describe("assignment form pages localization", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("renders the new assignment page with localized heading and form copy", async () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it("renders the new assignment page with localized heading and hides quantity until a stock item is selected", async () => {
|
||||
const { default: NewAssignmentPage } = await import(
|
||||
"@/app/(dashboard)/assignments/new/page"
|
||||
)
|
||||
@@ -129,13 +152,55 @@ describe("assignment form pages localization", () => {
|
||||
expect(html).toContain('option value="">Selecciona una persona</option>')
|
||||
expect(html).toContain("Artículo")
|
||||
expect(html).toContain('option value="">Selecciona un artículo</option>')
|
||||
expect(html).toContain("Cantidad")
|
||||
expect(html).toContain('placeholder="1"')
|
||||
expect(html).not.toContain("Cantidad")
|
||||
expect(html).not.toContain('placeholder="1"')
|
||||
expect(html).toContain("Crear asignación")
|
||||
expect(html).toContain("Ada Lovelace")
|
||||
expect(html).toContain("Laptop")
|
||||
})
|
||||
|
||||
it("shows quantity for stock items and normalizes stale quantity when switching to an asset-backed item", async () => {
|
||||
mocks.createAssignment.mockResolvedValue({
|
||||
success: true,
|
||||
message: "Creada",
|
||||
})
|
||||
|
||||
const { default: NewAssignmentPage } = await import(
|
||||
"@/app/(dashboard)/assignments/new/page"
|
||||
)
|
||||
|
||||
render(await NewAssignmentPage())
|
||||
|
||||
await userEvent.selectOptions(screen.getByLabelText("Persona"), "person-1")
|
||||
await userEvent.selectOptions(screen.getByLabelText("Artículo"), "item-1")
|
||||
|
||||
const quantityInput = screen.getByLabelText("Cantidad")
|
||||
expect(quantityInput).toBeInTheDocument()
|
||||
|
||||
await userEvent.clear(quantityInput)
|
||||
await userEvent.type(quantityInput, "5")
|
||||
|
||||
await userEvent.selectOptions(screen.getByLabelText("Artículo"), "item-2")
|
||||
|
||||
expect(screen.queryByLabelText("Cantidad")).not.toBeInTheDocument()
|
||||
|
||||
await userEvent.selectOptions(screen.getByLabelText("Activo"), "asset-2")
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Crear asignación" }),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.createAssignment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
personId: "person-1",
|
||||
itemId: "item-2",
|
||||
assetId: "asset-2",
|
||||
quantity: 1,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it("renders the edit assignment page with localized heading, not-found copy, and submit text", async () => {
|
||||
const { default: EditAssignmentPage } = await import(
|
||||
"@/app/(dashboard)/assignments/[assignmentId]/edit/page"
|
||||
|
||||
@@ -24,7 +24,7 @@ vi.mock("@/components/common/pageheader", () => ({
|
||||
|
||||
vi.mock("@/components/ui/button", () => ({
|
||||
Button: ({ children }: { children: React.ReactNode }) =>
|
||||
createElement("button", null, children),
|
||||
createElement("button", { type: "button" }, children),
|
||||
}))
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
@@ -109,7 +109,9 @@ describe("asset detail page", () => {
|
||||
)
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
await AssetDetailPage({ params: Promise.resolve({ assetId: "asset-1" }) }),
|
||||
await AssetDetailPage({
|
||||
params: Promise.resolve({ assetId: "asset-1" }),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain("Asset Details")
|
||||
|
||||
@@ -29,7 +29,7 @@ vi.mock("@/components/common/pagination", () => ({
|
||||
|
||||
vi.mock("@/components/ui/button", () => ({
|
||||
Button: ({ children }: { children: React.ReactNode }) =>
|
||||
createElement("button", null, children),
|
||||
createElement("button", { type: "button" }, children),
|
||||
}))
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest"
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react"
|
||||
import userEvent from "@testing-library/user-event"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import type {
|
||||
ItemFormCopy,
|
||||
ItemSchemaCopy,
|
||||
} from "@/app/(dashboard)/inventory/items/_components/item.copy"
|
||||
import NewItemForm from "@/app/(dashboard)/inventory/items/_components/new.item.form"
|
||||
import UpdateItemForm from "@/app/(dashboard)/inventory/items/_components/update.item.form"
|
||||
import type { SubmitButtonCopy } from "@/components/forms/submitButton"
|
||||
import type { CategorySummary, ItemWithAssetCount } from "@/types"
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createItemAction: vi.fn(),
|
||||
updateItemAction: vi.fn(),
|
||||
push: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/actions/item.actions", () => ({
|
||||
createItemAction: mocks.createItemAction,
|
||||
updateItemAction: mocks.updateItemAction,
|
||||
}))
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: mocks.push }),
|
||||
}))
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { error: mocks.toastError, success: mocks.toastSuccess },
|
||||
}))
|
||||
|
||||
const formCopy = {
|
||||
nameLabel: "Name",
|
||||
namePlaceholder: "Item name",
|
||||
categoryLabel: "Category",
|
||||
categoryPlaceholder: "Select a category",
|
||||
stockLabel: "Stock",
|
||||
stockPlaceholder: "0",
|
||||
stockPolicyTitle: "Stock Policy",
|
||||
stockPolicyDescription:
|
||||
"Define the minimum and target stock levels for this item.",
|
||||
minStockLabel: "Minimum stock",
|
||||
minStockPlaceholder: "Optional",
|
||||
targetStockLabel: "Target stock",
|
||||
targetStockPlaceholder: "Optional",
|
||||
createSubmit: "Create Item",
|
||||
updateSubmit: "Update Item",
|
||||
} satisfies ItemFormCopy
|
||||
|
||||
const schemaCopy = {
|
||||
nameRequired: "Name is required",
|
||||
categoryRequired: "Category is required",
|
||||
stockRequired: "Stock is required",
|
||||
trackingTypeRequired: "Tracking type is required",
|
||||
invalidTrackingType: "Invalid tracking type",
|
||||
statusRequired: "Status is required",
|
||||
invalidStatus: "Invalid status",
|
||||
itemRequired: "Item is required",
|
||||
stockPolicyPairRequired: "Stock policy values must be provided together",
|
||||
stockPolicyValueInvalid:
|
||||
"Stock policy values must be whole numbers greater than or equal to 0",
|
||||
stockPolicyOrderInvalid:
|
||||
"Target stock must be greater than or equal to minimum stock",
|
||||
} satisfies ItemSchemaCopy
|
||||
|
||||
const submitButtonCopy = {
|
||||
defaultLabel: "Submit",
|
||||
processing: "Processing",
|
||||
success: "Success",
|
||||
} satisfies SubmitButtonCopy
|
||||
|
||||
const categories: CategorySummary[] = [{ id: "cat-1", name: "Hardware" }]
|
||||
|
||||
const item: ItemWithAssetCount = {
|
||||
id: "item-1",
|
||||
name: "Laptop",
|
||||
category: { id: "cat-1", name: "Hardware" },
|
||||
stock: 7,
|
||||
trackingType: "QUANTITY",
|
||||
status: "ACTIVE",
|
||||
minStock: 2,
|
||||
targetStock: 5,
|
||||
_count: { assets: 0 },
|
||||
}
|
||||
|
||||
describe("item forms", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it("submits stock policy values through the create item form", async () => {
|
||||
mocks.createItemAction.mockResolvedValue({
|
||||
success: true,
|
||||
message: "Item created successfully!",
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<NewItemForm
|
||||
categories={categories}
|
||||
formCopy={formCopy}
|
||||
schemaCopy={schemaCopy}
|
||||
submitButtonCopy={submitButtonCopy}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.type(screen.getByLabelText(formCopy.nameLabel), "Laptop")
|
||||
await user.selectOptions(
|
||||
screen.getByLabelText(formCopy.categoryLabel),
|
||||
"cat-1",
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByLabelText(formCopy.stockLabel), {
|
||||
target: { value: "4" },
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText(formCopy.minStockLabel), {
|
||||
target: { value: "2" },
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText(formCopy.targetStockLabel), {
|
||||
target: { value: "6" },
|
||||
})
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: formCopy.createSubmit }),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.createItemAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "Laptop",
|
||||
categoryId: "cat-1",
|
||||
stock: 4,
|
||||
minStock: 2,
|
||||
targetStock: 6,
|
||||
trackingType: "QUANTITY",
|
||||
status: "ACTIVE",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith(
|
||||
"Item created successfully!",
|
||||
)
|
||||
expect(mocks.push).toHaveBeenCalledWith("/inventory/items")
|
||||
})
|
||||
|
||||
it("prefills and submits stock policy values through the update item form", async () => {
|
||||
mocks.updateItemAction.mockResolvedValue({
|
||||
success: true,
|
||||
message: "Item updated successfully!",
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<UpdateItemForm
|
||||
categories={categories}
|
||||
item={item}
|
||||
formCopy={formCopy}
|
||||
schemaCopy={schemaCopy}
|
||||
submitButtonCopy={submitButtonCopy}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByLabelText(formCopy.minStockLabel)).toHaveValue(2)
|
||||
expect(screen.getByLabelText(formCopy.targetStockLabel)).toHaveValue(5)
|
||||
|
||||
fireEvent.change(screen.getByLabelText(formCopy.minStockLabel), {
|
||||
target: { value: "3" },
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText(formCopy.targetStockLabel), {
|
||||
target: { value: "8" },
|
||||
})
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: formCopy.updateSubmit }),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.updateItemAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "item-1",
|
||||
name: "Laptop",
|
||||
categoryId: "cat-1",
|
||||
stock: 7,
|
||||
minStock: 3,
|
||||
targetStock: 8,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith(
|
||||
"Item updated successfully!",
|
||||
)
|
||||
expect(mocks.push).toHaveBeenCalledWith("/inventory/items")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
import { createElement, type ReactNode } from "react"
|
||||
import { renderToStaticMarkup } from "react-dom/server"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findAllWithAssetCount: vi.fn(),
|
||||
getI18n: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/services/item.service", () => ({
|
||||
ItemService: {
|
||||
findAllWithAssetCount: mocks.findAllWithAssetCount,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/i18n/server", () => ({
|
||||
getI18n: mocks.getI18n,
|
||||
}))
|
||||
|
||||
vi.mock("@/components/common/pageheader", () => ({
|
||||
default: ({ title }: { title: string }) =>
|
||||
createElement("header", null, title),
|
||||
}))
|
||||
|
||||
vi.mock("@/components/common/pagination", () => ({
|
||||
default: ({ totalPages }: { totalPages: number }) =>
|
||||
createElement("nav", null, `pages:${totalPages}`),
|
||||
}))
|
||||
|
||||
vi.mock("@/components/ui/button", () => ({
|
||||
Button: ({ children }: { children: ReactNode }) =>
|
||||
createElement("button", { type: "button" }, children),
|
||||
}))
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ href, children }: { href: string; children: ReactNode }) =>
|
||||
createElement("a", { href }, children),
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/inventory/items/_components/delete.item.button",
|
||||
() => ({
|
||||
default: () => createElement("button", { type: "button" }, "Delete item"),
|
||||
}),
|
||||
)
|
||||
|
||||
const buildDictionary = () => ({
|
||||
inventory: {
|
||||
items: {
|
||||
list: {
|
||||
title: "Items",
|
||||
addLabel: "Add Item",
|
||||
empty: "No items found.",
|
||||
columns: {
|
||||
name: "Name",
|
||||
category: "Category",
|
||||
assets: "Assets",
|
||||
stock: "Stock",
|
||||
stockPolicy: "Stock policy",
|
||||
actions: "Actions",
|
||||
},
|
||||
stockPolicy: {
|
||||
configured: "Min {min} / Target {target}",
|
||||
none: "No stock policy configured",
|
||||
},
|
||||
actions: {
|
||||
view: "View item",
|
||||
edit: "Edit item",
|
||||
delete: "Delete item",
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
label: "Delete item",
|
||||
pending: "Deleting...",
|
||||
unknownError: "Unknown error",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
describe("ItemsPage stock policy display", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.getI18n.mockResolvedValue({ dictionary: buildDictionary() })
|
||||
})
|
||||
|
||||
it("renders configured stock policy values in the list", async () => {
|
||||
mocks.findAllWithAssetCount.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "item-1",
|
||||
name: "Laptop",
|
||||
category: { id: "cat-1", name: "Computers" },
|
||||
_count: { assets: 0 },
|
||||
stock: 7,
|
||||
minStock: 2,
|
||||
targetStock: 5,
|
||||
},
|
||||
],
|
||||
totalPages: 1,
|
||||
})
|
||||
|
||||
const { default: ItemsPage } = await import(
|
||||
"@/app/(dashboard)/inventory/items/page"
|
||||
)
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
await ItemsPage({ searchParams: Promise.resolve({}) }),
|
||||
)
|
||||
|
||||
expect(html).toContain("Stock policy")
|
||||
expect(html).toContain("Min 2 / Target 5")
|
||||
})
|
||||
|
||||
it("renders the no-policy state in the list", async () => {
|
||||
mocks.findAllWithAssetCount.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "item-2",
|
||||
name: "Cable",
|
||||
category: { id: "cat-1", name: "Computers" },
|
||||
_count: { assets: 0 },
|
||||
stock: 0,
|
||||
minStock: null,
|
||||
targetStock: null,
|
||||
},
|
||||
],
|
||||
totalPages: 1,
|
||||
})
|
||||
|
||||
const { default: ItemsPage } = await import(
|
||||
"@/app/(dashboard)/inventory/items/page"
|
||||
)
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
await ItemsPage({ searchParams: Promise.resolve({}) }),
|
||||
)
|
||||
|
||||
expect(html).toContain("Stock policy")
|
||||
expect(html).toContain("No stock policy configured")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest"
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
import StockPolicyFields from "@/app/(dashboard)/inventory/items/_components/stock-policy-fields"
|
||||
|
||||
type StockPolicyFormValues = {
|
||||
minStock?: number | string | null
|
||||
targetStock?: number | string | null
|
||||
}
|
||||
|
||||
const copy = {
|
||||
title: "Stock Policy",
|
||||
description: "Define the minimum and target stock levels for this item.",
|
||||
minStockLabel: "Minimum stock",
|
||||
minStockPlaceholder: "Optional",
|
||||
targetStockLabel: "Target stock",
|
||||
targetStockPlaceholder: "Optional",
|
||||
}
|
||||
|
||||
function StockPolicyFormHarness({
|
||||
onSubmit,
|
||||
}: {
|
||||
onSubmit: (values: StockPolicyFormValues) => void
|
||||
}) {
|
||||
const { register, handleSubmit } = useForm<StockPolicyFormValues>()
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<StockPolicyFields copy={copy} register={register} errors={{}} />
|
||||
<button type="submit">Save policy</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function StockPolicyErrorHarness() {
|
||||
const { register } = useForm<StockPolicyFormValues>()
|
||||
|
||||
return (
|
||||
<StockPolicyFields
|
||||
copy={copy}
|
||||
register={register}
|
||||
errors={{
|
||||
minStock: { type: "manual", message: "Minimum stock is invalid" },
|
||||
targetStock: {
|
||||
type: "manual",
|
||||
message: "Target stock must be greater or equal",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe("StockPolicyFields", () => {
|
||||
it("renders neutral stock policy copy and submits both values", async () => {
|
||||
const onSubmit = vi.fn()
|
||||
|
||||
render(<StockPolicyFormHarness onSubmit={onSubmit} />)
|
||||
|
||||
const minStockInput = screen.getByLabelText(copy.minStockLabel)
|
||||
const targetStockInput = screen.getByLabelText(copy.targetStockLabel)
|
||||
|
||||
expect(minStockInput).toHaveAttribute("type", "number")
|
||||
expect(minStockInput).toHaveAttribute("min", "0")
|
||||
expect(minStockInput).toHaveAttribute("step", "1")
|
||||
expect(minStockInput).toHaveAttribute(
|
||||
"placeholder",
|
||||
copy.minStockPlaceholder,
|
||||
)
|
||||
|
||||
expect(targetStockInput).toHaveAttribute("type", "number")
|
||||
expect(targetStockInput).toHaveAttribute("min", "0")
|
||||
expect(targetStockInput).toHaveAttribute("step", "1")
|
||||
expect(targetStockInput).toHaveAttribute(
|
||||
"placeholder",
|
||||
copy.targetStockPlaceholder,
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { name: copy.title }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText(copy.description)).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(minStockInput, { target: { value: "3" } })
|
||||
fireEvent.change(targetStockInput, { target: { value: "5" } })
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save policy" }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ minStock: "3", targetStock: "5" }),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it("shows field-level validation messages", () => {
|
||||
render(<StockPolicyErrorHarness />)
|
||||
|
||||
expect(screen.getByText("Minimum stock is invalid")).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText("Target stock must be greater or equal"),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({
|
||||
getI18n: vi.fn(),
|
||||
findByIdWithUser: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
listTeamsUseCase: vi.fn(),
|
||||
personForm: vi.fn(),
|
||||
push: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
@@ -27,7 +28,11 @@ vi.mock("@/services/person.service", () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/app/(dashboard)/people/_components/edit.person.form", () => ({
|
||||
vi.mock("@/use-cases/team.use-cases", () => ({
|
||||
listTeamsUseCase: mocks.listTeamsUseCase,
|
||||
}))
|
||||
|
||||
vi.mock("@/app/(dashboard)/people/_components/people/edit.person.form", () => ({
|
||||
default: (props: unknown) => {
|
||||
mocks.personForm(props)
|
||||
return createElement("div", null, "Edit person form")
|
||||
@@ -54,7 +59,8 @@ const basePerson: PersonWithUser = {
|
||||
id: "person-1",
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
department: "ENGINEERING",
|
||||
teamId: null,
|
||||
team: null,
|
||||
email: "ada@example.test",
|
||||
phone: "1234",
|
||||
userId: null,
|
||||
@@ -79,10 +85,16 @@ const personWithUser: PersonWithUser = {
|
||||
},
|
||||
}
|
||||
|
||||
const teams = [
|
||||
{ id: "team-1", name: "Engineering" },
|
||||
{ id: "team-2", name: "Sales" },
|
||||
]
|
||||
|
||||
describe("edit person page wiring", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
|
||||
mocks.listTeamsUseCase.mockResolvedValue(teams)
|
||||
})
|
||||
|
||||
it("loads the person without user, passes PersonWithoutUser to the edit form", async () => {
|
||||
@@ -111,6 +123,7 @@ describe("edit person page wiring", () => {
|
||||
...en.inventory.people.schema,
|
||||
},
|
||||
roleLabels: en.admin.users.roles,
|
||||
teams,
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -141,10 +154,7 @@ describe("edit person page wiring", () => {
|
||||
}),
|
||||
formCopy: es.admin.users.form,
|
||||
roleLabels: es.admin.users.roles,
|
||||
departmentCopy: es.inventory.people.departments,
|
||||
fallbackCopy: expect.objectContaining({
|
||||
unknownDepartment: es.inventory.people.fallback.unknownDepartment,
|
||||
}),
|
||||
teams,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({
|
||||
getI18n: vi.fn(),
|
||||
findByIdWithUser: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
listTeamsUseCase: vi.fn(),
|
||||
redirect: vi.fn(),
|
||||
personForm: vi.fn(),
|
||||
}))
|
||||
@@ -22,6 +23,10 @@ vi.mock("@/services/person.service", () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/use-cases/team.use-cases", () => ({
|
||||
listTeamsUseCase: mocks.listTeamsUseCase,
|
||||
}))
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: mocks.redirect,
|
||||
useRouter: () => ({
|
||||
@@ -35,7 +40,7 @@ vi.mock("@/actions/person.actions", () => ({
|
||||
updatePersonUserAction: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/app/(dashboard)/people/_components/edit.person.form", () => ({
|
||||
vi.mock("@/app/(dashboard)/people/_components/people/edit.person.form", () => ({
|
||||
default: (props: unknown) => {
|
||||
mocks.personForm(props)
|
||||
return null
|
||||
@@ -49,10 +54,16 @@ vi.mock("sonner", () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
const teams = [
|
||||
{ id: "team-1", name: "Engineering" },
|
||||
{ id: "team-2", name: "Sales" },
|
||||
]
|
||||
|
||||
describe("person pages", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
|
||||
mocks.listTeamsUseCase.mockResolvedValue(teams)
|
||||
})
|
||||
|
||||
it("renders the edit person page with Person heading and passes person to unified form", async () => {
|
||||
@@ -66,7 +77,8 @@ describe("person pages", () => {
|
||||
lastName: "Lovelace",
|
||||
email: "ada@example.test",
|
||||
phone: "1234",
|
||||
department: "ENGINEERING",
|
||||
teamId: "team-1",
|
||||
team: { id: "team-1", name: "Engineering" },
|
||||
userId: null,
|
||||
isActive: true,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
@@ -88,6 +100,7 @@ describe("person pages", () => {
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
}),
|
||||
teams,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createElement } from "react"
|
||||
import { createElement, isValidElement, type ReactNode } from "react"
|
||||
import { renderToStaticMarkup } from "react-dom/server"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
@@ -44,16 +44,69 @@ vi.mock("@/components/common/pagination", () => ({
|
||||
createElement("nav", { "aria-label": "Pagination" }, totalPages),
|
||||
}))
|
||||
|
||||
vi.mock("@/components/ui/tabs", () => ({
|
||||
Tabs: ({ children }: { children: ReactNode }) =>
|
||||
createElement("div", { role: "tablist-wrapper" }, children),
|
||||
TabsList: ({ children }: { children: ReactNode }) =>
|
||||
createElement("div", { role: "tablist" }, children),
|
||||
TabsTrigger: ({ children, value }: { children: ReactNode; value: string }) =>
|
||||
createElement("button", { type: "button", role: "tab", value }, children),
|
||||
TabsContent: ({ children, value }: { children: ReactNode; value: string }) =>
|
||||
createElement("section", { "data-tab": value }, children),
|
||||
}))
|
||||
|
||||
vi.mock("@/app/(dashboard)/people/_components/team/page", () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
function findElementByType(node: ReactNode, type: unknown): ReactNode | null {
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) {
|
||||
const found = findElementByType(child, type)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (!isValidElement(node)) return null
|
||||
|
||||
if (node.type === type) return node
|
||||
|
||||
return findElementByType(
|
||||
(node.props as { children?: ReactNode }).children,
|
||||
type,
|
||||
)
|
||||
}
|
||||
|
||||
describe("person pages", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
|
||||
})
|
||||
|
||||
it("renders the person list page with Person data and no username column", async () => {
|
||||
it("forwards route searchParams from /people to the person list", async () => {
|
||||
const { default: PeoplePage } = await import(
|
||||
"@/app/(dashboard)/people/page"
|
||||
)
|
||||
const { default: PersonPage } = await import(
|
||||
"@/app/(dashboard)/people/_components/people/page"
|
||||
)
|
||||
const searchParams = Promise.resolve({ page: "2", search: "ada" })
|
||||
|
||||
const tree = await PeoplePage({ searchParams })
|
||||
const personPageElement = findElementByType(tree, PersonPage)
|
||||
|
||||
expect(personPageElement).toEqual(
|
||||
expect.objectContaining({
|
||||
props: expect.objectContaining({ searchParams }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("renders the person list page with Person data and no username column", async () => {
|
||||
const { default: PeoplePage } = await import(
|
||||
"@/app/(dashboard)/people/_components/people/page"
|
||||
)
|
||||
|
||||
mocks.findAllPaginated.mockResolvedValue({
|
||||
data: [
|
||||
@@ -63,7 +116,8 @@ describe("person pages", () => {
|
||||
lastName: "Lovelace",
|
||||
email: "ada@example.test",
|
||||
phone: "1234",
|
||||
department: "ENGINEERING",
|
||||
teamId: "team-1",
|
||||
team: { id: "team-1", name: "Engineering" },
|
||||
userId: null,
|
||||
isActive: true,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
@@ -83,7 +137,7 @@ describe("person pages", () => {
|
||||
expect(html).toContain("Add Person")
|
||||
// No username column — username header must not appear
|
||||
expect(html).not.toContain("Username")
|
||||
// No standalone username cell — only name, email, phone, department columns
|
||||
// No standalone username cell — only name, email, phone, team columns
|
||||
expect(html).not.toContain(">ada<")
|
||||
// Name and other fields rendered
|
||||
expect(html).toContain("Ada Lovelace")
|
||||
@@ -95,7 +149,7 @@ describe("person pages", () => {
|
||||
|
||||
it("renders role and status columns for people with linked users", async () => {
|
||||
const { default: PeoplePage } = await import(
|
||||
"@/app/(dashboard)/people/page"
|
||||
"@/app/(dashboard)/people/_components/people/page"
|
||||
)
|
||||
|
||||
mocks.findAllPaginated.mockResolvedValue({
|
||||
@@ -106,7 +160,8 @@ describe("person pages", () => {
|
||||
lastName: "Lovelace",
|
||||
email: "ada@example.test",
|
||||
phone: "1234",
|
||||
department: "ENGINEERING",
|
||||
teamId: "team-1",
|
||||
team: { id: "team-1", name: "Engineering" },
|
||||
userId: "user-1",
|
||||
isActive: true,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
@@ -133,7 +188,8 @@ describe("person pages", () => {
|
||||
lastName: "Jones",
|
||||
email: "bob@example.test",
|
||||
phone: null,
|
||||
department: "IT",
|
||||
teamId: null,
|
||||
team: null,
|
||||
userId: null,
|
||||
isActive: true,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
@@ -163,7 +219,7 @@ describe("person pages", () => {
|
||||
|
||||
it("renders the person list empty state from Person copy", async () => {
|
||||
const { default: PeoplePage } = await import(
|
||||
"@/app/(dashboard)/people/page"
|
||||
"@/app/(dashboard)/people/_components/people/page"
|
||||
)
|
||||
|
||||
mocks.findAllPaginated.mockResolvedValue({
|
||||
@@ -189,7 +245,8 @@ describe("person pages", () => {
|
||||
lastName: "Lovelace",
|
||||
email: "ada@example.test",
|
||||
phone: "1234",
|
||||
department: "DRIVER",
|
||||
teamId: "team-2",
|
||||
team: { id: "team-2", name: "Driver" },
|
||||
userId: null,
|
||||
isActive: true,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
@@ -217,7 +274,7 @@ describe("person pages", () => {
|
||||
// Person detail fields
|
||||
expect(html).toContain("Email")
|
||||
expect(html).toContain("Phone")
|
||||
expect(html).toContain("Department")
|
||||
expect(html).toContain("Team")
|
||||
expect(html).toContain("ada@example.test")
|
||||
expect(html).toContain("Driver")
|
||||
// Embedded assignments
|
||||
@@ -235,7 +292,8 @@ describe("person pages", () => {
|
||||
lastName: "Lovelace",
|
||||
email: "ada@example.test",
|
||||
phone: "1234",
|
||||
department: "DRIVER",
|
||||
teamId: null,
|
||||
team: null,
|
||||
userId: "user-1",
|
||||
isActive: true,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
@@ -283,7 +341,8 @@ describe("person pages", () => {
|
||||
lastName: "Lovelace",
|
||||
email: "ada@example.test",
|
||||
phone: "1234",
|
||||
department: "DRIVER",
|
||||
teamId: null,
|
||||
team: null,
|
||||
userId: null,
|
||||
isActive: true,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { es } from "@/i18n/dictionaries/es"
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createPersonUser: vi.fn(),
|
||||
getI18n: vi.fn(),
|
||||
listTeamsUseCase: vi.fn(),
|
||||
push: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
@@ -26,6 +27,10 @@ vi.mock("@/services/person.service", () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/use-cases/team.use-cases", () => ({
|
||||
listTeamsUseCase: mocks.listTeamsUseCase,
|
||||
}))
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: mocks.push,
|
||||
@@ -39,13 +44,19 @@ vi.mock("sonner", () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
const teams = [
|
||||
{ id: "team-1", name: "Engineering" },
|
||||
{ id: "team-2", name: "Sales" },
|
||||
]
|
||||
|
||||
describe("unified creation form page", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
|
||||
mocks.listTeamsUseCase.mockResolvedValue(teams)
|
||||
})
|
||||
|
||||
it("renders unified form with Person fields, email, password, role, and NO_USER option in Spanish", async () => {
|
||||
it("renders unified form with Person fields, email, role, and NO_USER option in Spanish", async () => {
|
||||
const { default: NewUserPage } = await import(
|
||||
"@/app/(dashboard)/people/new/page"
|
||||
)
|
||||
@@ -55,13 +66,13 @@ describe("unified creation form page", () => {
|
||||
// Person fields
|
||||
expect(html).toContain("Nombre")
|
||||
expect(html).toContain("Apellido")
|
||||
expect(html).toContain("Departamento")
|
||||
expect(html).toContain("Equipo")
|
||||
expect(html).toContain("Teléfono")
|
||||
|
||||
// User fields
|
||||
expect(html).toContain("Correo electrónico")
|
||||
expect(html).toContain("Contraseña")
|
||||
expect(html).toContain("Rol")
|
||||
expect(html).not.toContain("Contraseña")
|
||||
|
||||
// NO_USER role option
|
||||
expect(html).toContain("Sin cuenta de usuario")
|
||||
@@ -74,7 +85,7 @@ describe("unified creation form page", () => {
|
||||
expect(html).toContain('value="VIEWER"')
|
||||
})
|
||||
|
||||
it("renders unified form with Person fields and NO_USER option in English", async () => {
|
||||
it("renders unified form with Person fields, role, and NO_USER option in English", async () => {
|
||||
mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
|
||||
|
||||
const { default: NewUserPage } = await import(
|
||||
@@ -86,12 +97,12 @@ describe("unified creation form page", () => {
|
||||
// Person fields
|
||||
expect(html).toContain("First Name")
|
||||
expect(html).toContain("Last Name")
|
||||
expect(html).toContain("Department")
|
||||
expect(html).toContain("Team")
|
||||
expect(html).toContain("Phone")
|
||||
|
||||
// User fields
|
||||
expect(html).toContain("Password")
|
||||
expect(html).toContain("Email")
|
||||
expect(html).not.toContain("Password")
|
||||
|
||||
// NO_USER role option
|
||||
expect(html).toContain("No user account")
|
||||
@@ -108,18 +119,20 @@ describe("unified creation form page", () => {
|
||||
// Person field placeholders
|
||||
expect(html).toContain('placeholder="Nombre"') // firstNamePlaceholder (es)
|
||||
expect(html).toContain('placeholder="Apellido"') // lastNamePlaceholder (es)
|
||||
expect(html).toContain("Selecciona un departamento") // departmentPlaceholder
|
||||
expect(html).toContain("Selecciona un equipo") // teamPlaceholder
|
||||
expect(html).toContain('placeholder="Teléfono"') // phonePlaceholder (es)
|
||||
})
|
||||
|
||||
it("renders department select with all PERSON_DEPARTMENTS values", async () => {
|
||||
it("renders team select with active teams from listTeamsUseCase", async () => {
|
||||
const { default: NewUserPage } = await import(
|
||||
"@/app/(dashboard)/people/new/page"
|
||||
)
|
||||
|
||||
const html = renderToStaticMarkup(await NewUserPage())
|
||||
|
||||
// Department values must use canonical enum values
|
||||
expect(html).toContain('value="ADMINISTRATION"')
|
||||
expect(html).toContain('value="team-1"')
|
||||
expect(html).toContain("Engineering")
|
||||
expect(html).toContain('value="team-2"')
|
||||
expect(html).toContain("Sales")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import {
|
||||
formatPersonDepartment,
|
||||
formatUserRole,
|
||||
} from "@/app/(dashboard)/people/_components/user.copy"
|
||||
import { formatUserRole } from "@/app/(dashboard)/people/_components/people/user.copy"
|
||||
|
||||
describe("user copy helpers", () => {
|
||||
const roleCopy = {
|
||||
@@ -37,48 +34,3 @@ describe("user copy helpers", () => {
|
||||
).toBe("Rol desconocido")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatPersonDepartment helper", () => {
|
||||
const departmentCopy = {
|
||||
IT: "IT",
|
||||
ENGINEERING: "Ingeniería",
|
||||
LOGISTICS: "Logística",
|
||||
TRAFFIC: "Tráfico",
|
||||
DRIVER: "Chofer",
|
||||
ADMINISTRATION: "Administración",
|
||||
SALES: "Ventas",
|
||||
OTHER: "Otro",
|
||||
}
|
||||
|
||||
const fallbackCopy = {
|
||||
unknownDepartment: "Departamento desconocido",
|
||||
unknownStatus: "Estado desconocido",
|
||||
}
|
||||
|
||||
it("formats known department values with localized labels", () => {
|
||||
expect(
|
||||
formatPersonDepartment("ENGINEERING", departmentCopy, fallbackCopy),
|
||||
).toBe("Ingeniería")
|
||||
expect(
|
||||
formatPersonDepartment("ADMINISTRATION", departmentCopy, fallbackCopy),
|
||||
).toBe("Administración")
|
||||
})
|
||||
|
||||
it("falls back for unknown department values", () => {
|
||||
expect(
|
||||
formatPersonDepartment("UNKNOWN_DEPT", departmentCopy, fallbackCopy),
|
||||
).toBe("Departamento desconocido")
|
||||
})
|
||||
|
||||
it("falls back for null department values", () => {
|
||||
expect(formatPersonDepartment(null, departmentCopy, fallbackCopy)).toBe(
|
||||
"Departamento desconocido",
|
||||
)
|
||||
})
|
||||
|
||||
it("falls back for undefined department values", () => {
|
||||
expect(
|
||||
formatPersonDepartment(undefined, departmentCopy, fallbackCopy),
|
||||
).toBe("Departamento desconocido")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,8 +32,8 @@ describe("admin users dictionary", () => {
|
||||
firstNamePlaceholder: "First name",
|
||||
lastNameLabel: "Last Name",
|
||||
lastNamePlaceholder: "Last name",
|
||||
departmentLabel: "Department",
|
||||
departmentPlaceholder: "Select a department",
|
||||
teamLabel: "Team",
|
||||
teamPlaceholder: "Select a team",
|
||||
emailLabel: "Email",
|
||||
emailPlaceholder: "user@example.com",
|
||||
phoneLabel: "Phone",
|
||||
@@ -127,8 +127,8 @@ describe("admin users dictionary", () => {
|
||||
firstNamePlaceholder: "Nombre",
|
||||
lastNameLabel: "Apellido",
|
||||
lastNamePlaceholder: "Apellido",
|
||||
departmentLabel: "Departamento",
|
||||
departmentPlaceholder: "Selecciona un departamento",
|
||||
teamLabel: "Equipo",
|
||||
teamPlaceholder: "Selecciona un equipo",
|
||||
emailLabel: "Correo electrónico",
|
||||
emailPlaceholder: "usuario@ejemplo.com",
|
||||
phoneLabel: "Teléfono",
|
||||
|
||||
@@ -277,8 +277,13 @@ describe("i18n dictionaries", () => {
|
||||
category: "Category",
|
||||
assets: "Assets",
|
||||
stock: "Stock",
|
||||
stockPolicy: "Stock policy",
|
||||
actions: "Actions",
|
||||
},
|
||||
stockPolicy: {
|
||||
configured: "Min {min} / Target {target}",
|
||||
none: "No stock policy configured",
|
||||
},
|
||||
actions: {
|
||||
view: "View item",
|
||||
edit: "Edit item",
|
||||
@@ -307,6 +312,13 @@ describe("i18n dictionaries", () => {
|
||||
categoryPlaceholder: "Select a category",
|
||||
stockLabel: "Stock",
|
||||
stockPlaceholder: "0",
|
||||
stockPolicyTitle: "Stock Policy",
|
||||
stockPolicyDescription:
|
||||
"Define the minimum and target stock levels for this item.",
|
||||
minStockLabel: "Minimum stock",
|
||||
minStockPlaceholder: "Optional",
|
||||
targetStockLabel: "Target stock",
|
||||
targetStockPlaceholder: "Optional",
|
||||
createSubmit: "Create Item",
|
||||
updateSubmit: "Update Item",
|
||||
},
|
||||
@@ -338,6 +350,12 @@ describe("i18n dictionaries", () => {
|
||||
statusRequired: "Status is required",
|
||||
invalidStatus: "Invalid status",
|
||||
itemRequired: "Item is required",
|
||||
stockPolicyPairRequired:
|
||||
"Stock policy values must be provided together",
|
||||
stockPolicyValueInvalid:
|
||||
"Stock policy values must be whole numbers greater than or equal to 0",
|
||||
stockPolicyOrderInvalid:
|
||||
"Target stock must be greater than or equal to minimum stock",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -351,8 +369,13 @@ describe("i18n dictionaries", () => {
|
||||
category: "Categoría",
|
||||
assets: "Activos",
|
||||
stock: "Stock",
|
||||
stockPolicy: "Política de stock",
|
||||
actions: "Acciones",
|
||||
},
|
||||
stockPolicy: {
|
||||
configured: "Mín. {min} / Obj. {target}",
|
||||
none: "No hay política de stock configurada",
|
||||
},
|
||||
actions: {
|
||||
view: "Ver artículo",
|
||||
edit: "Editar artículo",
|
||||
@@ -381,6 +404,13 @@ describe("i18n dictionaries", () => {
|
||||
categoryPlaceholder: "Selecciona una categoría",
|
||||
stockLabel: "Stock",
|
||||
stockPlaceholder: "0",
|
||||
stockPolicyTitle: "Política de stock",
|
||||
stockPolicyDescription:
|
||||
"Define los niveles mínimo y objetivo de stock de este artículo.",
|
||||
minStockLabel: "Stock mínimo",
|
||||
minStockPlaceholder: "Opcional",
|
||||
targetStockLabel: "Stock objetivo",
|
||||
targetStockPlaceholder: "Opcional",
|
||||
createSubmit: "Crear artículo",
|
||||
updateSubmit: "Actualizar artículo",
|
||||
},
|
||||
@@ -412,6 +442,12 @@ describe("i18n dictionaries", () => {
|
||||
statusRequired: "El estado es obligatorio",
|
||||
invalidStatus: "Estado inválido",
|
||||
itemRequired: "El artículo es obligatorio",
|
||||
stockPolicyPairRequired:
|
||||
"Los valores de la política de stock deben definirse juntos",
|
||||
stockPolicyValueInvalid:
|
||||
"Los valores de la política de stock deben ser números enteros mayores o iguales a 0",
|
||||
stockPolicyOrderInvalid:
|
||||
"El stock objetivo debe ser mayor o igual que el stock mínimo",
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -832,7 +868,7 @@ describe("i18n dictionaries", () => {
|
||||
name: "Name",
|
||||
email: "Email",
|
||||
phone: "Phone",
|
||||
department: "Department",
|
||||
team: "Team",
|
||||
role: "Role",
|
||||
status: "Status",
|
||||
actions: "Actions",
|
||||
@@ -847,7 +883,7 @@ describe("i18n dictionaries", () => {
|
||||
labels: {
|
||||
email: "Email",
|
||||
phone: "Phone",
|
||||
department: "Department",
|
||||
team: "Team",
|
||||
role: "Role",
|
||||
status: "Status",
|
||||
noUser: "No user account",
|
||||
@@ -865,8 +901,8 @@ describe("i18n dictionaries", () => {
|
||||
firstNamePlaceholder: "First name",
|
||||
lastNameLabel: "Last Name",
|
||||
lastNamePlaceholder: "Last name",
|
||||
departmentLabel: "Department",
|
||||
departmentPlaceholder: "Select a department",
|
||||
teamLabel: "Team",
|
||||
teamPlaceholder: "Select a team",
|
||||
emailLabel: "Email",
|
||||
emailPlaceholder: "Email",
|
||||
phoneLabel: "Phone",
|
||||
@@ -880,19 +916,9 @@ describe("i18n dictionaries", () => {
|
||||
updateSubmit: "Update Person",
|
||||
},
|
||||
fallback: {
|
||||
unknownDepartment: "Unknown department",
|
||||
noTeam: "—",
|
||||
unknownStatus: "Unknown status",
|
||||
},
|
||||
departments: {
|
||||
IT: "IT",
|
||||
ENGINEERING: "Engineering",
|
||||
LOGISTICS: "Logistics",
|
||||
TRAFFIC: "Traffic",
|
||||
DRIVER: "Driver",
|
||||
ADMINISTRATION: "Administration",
|
||||
SALES: "Sales",
|
||||
OTHER: "Other",
|
||||
},
|
||||
actions: {
|
||||
createSuccess: "Person created successfully",
|
||||
createFailure: "Failed to create person",
|
||||
@@ -900,14 +926,15 @@ describe("i18n dictionaries", () => {
|
||||
updateFailure: "Failed to update person",
|
||||
duplicateEmail: "Email already exists",
|
||||
notFound: "Person not found",
|
||||
teamNotFound: "Team not found",
|
||||
},
|
||||
schema: {
|
||||
firstNameRequired: "First name is required",
|
||||
lastNameRequired: "Last name is required",
|
||||
departmentRequired: "Department is required",
|
||||
emailInvalid: "Email format is invalid",
|
||||
idRequired: "ID is required",
|
||||
userIdInvalid: "User ID must be a valid UUID",
|
||||
teamIdInvalid: "Team must be a valid id",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -920,7 +947,7 @@ describe("i18n dictionaries", () => {
|
||||
name: "Nombre",
|
||||
email: "Correo electrónico",
|
||||
phone: "Teléfono",
|
||||
department: "Departamento",
|
||||
team: "Equipo",
|
||||
role: "Rol",
|
||||
status: "Estado",
|
||||
actions: "Acciones",
|
||||
@@ -935,7 +962,7 @@ describe("i18n dictionaries", () => {
|
||||
labels: {
|
||||
email: "Correo electrónico",
|
||||
phone: "Teléfono",
|
||||
department: "Departamento",
|
||||
team: "Equipo",
|
||||
role: "Rol",
|
||||
status: "Estado",
|
||||
noUser: "Sin cuenta de usuario",
|
||||
@@ -953,8 +980,8 @@ describe("i18n dictionaries", () => {
|
||||
firstNamePlaceholder: "Nombre",
|
||||
lastNameLabel: "Apellido",
|
||||
lastNamePlaceholder: "Apellido",
|
||||
departmentLabel: "Departamento",
|
||||
departmentPlaceholder: "Selecciona un departamento",
|
||||
teamLabel: "Equipo",
|
||||
teamPlaceholder: "Selecciona un equipo",
|
||||
emailLabel: "Correo electrónico",
|
||||
emailPlaceholder: "Correo electrónico",
|
||||
phoneLabel: "Teléfono",
|
||||
@@ -969,19 +996,9 @@ describe("i18n dictionaries", () => {
|
||||
updateSubmit: "Actualizar persona",
|
||||
},
|
||||
fallback: {
|
||||
unknownDepartment: "Departamento desconocido",
|
||||
noTeam: "—",
|
||||
unknownStatus: "Estado desconocido",
|
||||
},
|
||||
departments: {
|
||||
IT: "IT",
|
||||
ENGINEERING: "Ingeniería",
|
||||
LOGISTICS: "Logística",
|
||||
TRAFFIC: "Tráfico",
|
||||
DRIVER: "Chofer",
|
||||
ADMINISTRATION: "Administración",
|
||||
SALES: "Ventas",
|
||||
OTHER: "Otro",
|
||||
},
|
||||
actions: {
|
||||
createSuccess: "Persona creada correctamente",
|
||||
createFailure: "Error al crear la persona",
|
||||
@@ -989,14 +1006,15 @@ describe("i18n dictionaries", () => {
|
||||
updateFailure: "Error al actualizar la persona",
|
||||
duplicateEmail: "El correo electrónico ya existe",
|
||||
notFound: "Persona no encontrada",
|
||||
teamNotFound: "Equipo no encontrado",
|
||||
},
|
||||
schema: {
|
||||
firstNameRequired: "El nombre es obligatorio",
|
||||
lastNameRequired: "El apellido es obligatorio",
|
||||
departmentRequired: "El departamento es obligatorio",
|
||||
emailInvalid: "El correo electrónico no es válido",
|
||||
idRequired: "El ID es obligatorio",
|
||||
userIdInvalid: "El ID de usuario debe ser un UUID válido",
|
||||
teamIdInvalid: "El equipo debe ser un id válido",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,8 +10,8 @@ describe("admin users unified form dictionary", () => {
|
||||
expect(form.firstNamePlaceholder).toBe("First name")
|
||||
expect(form.lastNameLabel).toBe("Last Name")
|
||||
expect(form.lastNamePlaceholder).toBe("Last name")
|
||||
expect(form.departmentLabel).toBe("Department")
|
||||
expect(form.departmentPlaceholder).toBe("Select a department")
|
||||
expect(form.teamLabel).toBe("Team")
|
||||
expect(form.teamPlaceholder).toBe("Select a team")
|
||||
expect(form.phoneLabel).toBe("Phone")
|
||||
expect(form.phonePlaceholder).toBe("Phone")
|
||||
})
|
||||
@@ -23,8 +23,8 @@ describe("admin users unified form dictionary", () => {
|
||||
expect(form.firstNamePlaceholder).toBe("Nombre")
|
||||
expect(form.lastNameLabel).toBe("Apellido")
|
||||
expect(form.lastNamePlaceholder).toBe("Apellido")
|
||||
expect(form.departmentLabel).toBe("Departamento")
|
||||
expect(form.departmentPlaceholder).toBe("Selecciona un departamento")
|
||||
expect(form.teamLabel).toBe("Equipo")
|
||||
expect(form.teamPlaceholder).toBe("Selecciona un equipo")
|
||||
expect(form.phoneLabel).toBe("Teléfono")
|
||||
expect(form.phonePlaceholder).toBe("Teléfono")
|
||||
})
|
||||
|
||||
@@ -125,7 +125,7 @@ describe("core schemas", () => {
|
||||
createPersonSchema.safeParse({
|
||||
firstName: "Per",
|
||||
lastName: "Son",
|
||||
department: "IT",
|
||||
teamId: null,
|
||||
email: "person@example.test",
|
||||
}).success,
|
||||
).toBe(true)
|
||||
@@ -134,7 +134,7 @@ describe("core schemas", () => {
|
||||
createPersonSchema.safeParse({
|
||||
firstName: "Per",
|
||||
lastName: "Son",
|
||||
department: "IT",
|
||||
teamId: null,
|
||||
email: "not-an-email",
|
||||
}).success,
|
||||
).toBe(false)
|
||||
@@ -143,7 +143,7 @@ describe("core schemas", () => {
|
||||
createPersonSchema.safeParse({
|
||||
firstName: "Per",
|
||||
lastName: "Son",
|
||||
department: "IT",
|
||||
teamId: null,
|
||||
email: "",
|
||||
}).success,
|
||||
).toBe(true)
|
||||
|
||||
@@ -15,6 +15,11 @@ const schemaCopy = {
|
||||
statusRequired: "El estado es obligatorio",
|
||||
invalidStatus: "Estado inválido",
|
||||
itemRequired: "El artículo es obligatorio",
|
||||
stockPolicyPairRequired: "La política de stock debe definirse completa",
|
||||
stockPolicyValueInvalid:
|
||||
"Los valores de política de stock deben ser enteros mayores o iguales a 0",
|
||||
stockPolicyOrderInvalid:
|
||||
"El stock objetivo debe ser mayor o igual al stock mínimo",
|
||||
}
|
||||
|
||||
describe("item schema localization", () => {
|
||||
@@ -76,6 +81,75 @@ describe("item schema localization", () => {
|
||||
}
|
||||
})
|
||||
|
||||
it("normalizes empty stock policy inputs to explicit null values", () => {
|
||||
const result = buildCreateItemSchema(schemaCopy as never).safeParse({
|
||||
name: "Laptop",
|
||||
categoryId: "category-1",
|
||||
stock: "2",
|
||||
minStock: "",
|
||||
targetStock: "",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.minStock).toBeNull()
|
||||
expect(result.data.targetStock).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it("rejects a partial stock policy", () => {
|
||||
const result = buildCreateItemSchema(schemaCopy as never).safeParse({
|
||||
name: "Laptop",
|
||||
categoryId: "category-1",
|
||||
stock: "2",
|
||||
minStock: "3",
|
||||
targetStock: "",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const errors = result.error.flatten().fieldErrors
|
||||
|
||||
expect(errors.minStock).toContain(schemaCopy.stockPolicyPairRequired)
|
||||
expect(errors.targetStock).toContain(schemaCopy.stockPolicyPairRequired)
|
||||
}
|
||||
})
|
||||
|
||||
it("rejects negative and decimal stock policy values", () => {
|
||||
const result = buildCreateItemSchema(schemaCopy as never).safeParse({
|
||||
name: "Laptop",
|
||||
categoryId: "category-1",
|
||||
stock: "2",
|
||||
minStock: "-1",
|
||||
targetStock: "1.5",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const errors = result.error.flatten().fieldErrors
|
||||
|
||||
expect(errors.minStock).toContain(schemaCopy.stockPolicyValueInvalid)
|
||||
expect(errors.targetStock).toContain(schemaCopy.stockPolicyValueInvalid)
|
||||
}
|
||||
})
|
||||
|
||||
it("rejects a target stock lower than the minimum stock", () => {
|
||||
const result = buildCreateItemSchema(schemaCopy as never).safeParse({
|
||||
name: "Laptop",
|
||||
categoryId: "category-1",
|
||||
stock: "2",
|
||||
minStock: "5",
|
||||
targetStock: "3",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const errors = result.error.flatten().fieldErrors
|
||||
|
||||
expect(errors.targetStock).toContain(schemaCopy.stockPolicyOrderInvalid)
|
||||
}
|
||||
})
|
||||
|
||||
it("uses localized update identifier validation messages", () => {
|
||||
const result = buildUpdateItemSchema(schemaCopy).safeParse({
|
||||
id: "",
|
||||
|
||||
@@ -8,18 +8,20 @@ import {
|
||||
const schemaCopy = {
|
||||
firstNameRequired: "El nombre es obligatorio",
|
||||
lastNameRequired: "El apellido es obligatorio",
|
||||
departmentRequired: "El departamento es obligatorio",
|
||||
emailInvalid: "El correo electrónico no es válido",
|
||||
idRequired: "El ID es obligatorio",
|
||||
userIdInvalid: "El ID de usuario debe ser un UUID válido",
|
||||
teamIdInvalid: "El equipo debe ser un id válido",
|
||||
}
|
||||
|
||||
const validTeamId = "550e8400-e29b-41d4-a716-446655440000"
|
||||
|
||||
describe("person schema validation", () => {
|
||||
it("uses localized required-field validation messages for create (no username)", () => {
|
||||
const result = buildCreatePersonSchema(schemaCopy).safeParse({
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
department: "",
|
||||
teamId: null,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
@@ -28,7 +30,6 @@ describe("person schema validation", () => {
|
||||
|
||||
expect(errors.firstName).toContain(schemaCopy.firstNameRequired)
|
||||
expect(errors.lastName).toContain(schemaCopy.lastNameRequired)
|
||||
expect(errors.department).toContain(schemaCopy.departmentRequired)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -36,7 +37,7 @@ describe("person schema validation", () => {
|
||||
const result = buildCreatePersonSchema(schemaCopy).safeParse({
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
department: "ENGINEERING",
|
||||
teamId: null,
|
||||
email: "not-an-email",
|
||||
})
|
||||
|
||||
@@ -52,7 +53,7 @@ describe("person schema validation", () => {
|
||||
const result = buildCreatePersonSchema(schemaCopy).safeParse({
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
department: "ENGINEERING",
|
||||
teamId: null,
|
||||
userId: "not-a-uuid",
|
||||
})
|
||||
|
||||
@@ -64,12 +65,27 @@ describe("person schema validation", () => {
|
||||
}
|
||||
})
|
||||
|
||||
it("rejects an invalid teamId", () => {
|
||||
const result = buildCreatePersonSchema(schemaCopy).safeParse({
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
teamId: "not-a-uuid",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.flatten().fieldErrors.teamId).toContain(
|
||||
schemaCopy.teamIdInvalid,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it("uses localized update identifier validation messages", () => {
|
||||
const result = buildUpdatePersonSchema(schemaCopy).safeParse({
|
||||
id: "",
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
department: "ENGINEERING",
|
||||
teamId: null,
|
||||
email: "ada@example.test",
|
||||
})
|
||||
|
||||
@@ -81,20 +97,20 @@ describe("person schema validation", () => {
|
||||
}
|
||||
})
|
||||
|
||||
it("preserves canonical department values and accepts optional userId UUID", () => {
|
||||
it("accepts a valid teamId UUID and optional userId UUID", () => {
|
||||
const result = buildCreatePersonSchema(schemaCopy).safeParse({
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
department: "ENGINEERING",
|
||||
teamId: validTeamId,
|
||||
email: "",
|
||||
userId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
userId: validTeamId,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.department).toBe("ENGINEERING")
|
||||
expect(result.data.teamId).toBe(validTeamId)
|
||||
expect(result.data.email).toBe("")
|
||||
expect(result.data.userId).toBe("550e8400-e29b-41d4-a716-446655440000")
|
||||
expect(result.data.userId).toBe(validTeamId)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -102,7 +118,7 @@ describe("person schema validation", () => {
|
||||
const result = buildCreatePersonSchema(schemaCopy).safeParse({
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
department: "ENGINEERING",
|
||||
teamId: null,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -6,34 +6,36 @@ import {
|
||||
unifiedFormRoleSchema,
|
||||
} from "@/schemas/user.schema"
|
||||
|
||||
const validTeamId = "550e8400-e29b-41d4-a716-446655440000"
|
||||
|
||||
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",
|
||||
teamIdInvalid: "Team must be a valid id",
|
||||
}
|
||||
|
||||
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",
|
||||
teamIdInvalid: "El equipo debe ser un id válido",
|
||||
}
|
||||
|
||||
const validPersonOnlyData = {
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
department: "IT",
|
||||
teamId: null,
|
||||
email: "john@example.test",
|
||||
phone: null,
|
||||
role: "NO_USER" as const,
|
||||
@@ -44,7 +46,7 @@ const validPersonOnlyData = {
|
||||
const validPersonWithUserData = {
|
||||
firstName: "Jane",
|
||||
lastName: "Smith",
|
||||
department: "ENGINEERING",
|
||||
teamId: validTeamId,
|
||||
email: "jane@example.test",
|
||||
phone: "1234567890",
|
||||
role: "ADMIN" as const,
|
||||
@@ -96,7 +98,7 @@ describe("buildUnifiedCreateSchema", () => {
|
||||
const result = schema.safeParse({
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
department: "",
|
||||
teamId: "not-a-uuid",
|
||||
email: "not-an-email",
|
||||
role: "NO_USER",
|
||||
phone: null,
|
||||
@@ -108,7 +110,7 @@ describe("buildUnifiedCreateSchema", () => {
|
||||
const errors = result.error.flatten().fieldErrors
|
||||
expect(errors.firstName).toContain(esCopy.firstNameRequired)
|
||||
expect(errors.lastName).toContain(esCopy.lastNameRequired)
|
||||
expect(errors.department).toContain(esCopy.departmentRequired)
|
||||
expect(errors.teamId).toContain(esCopy.teamIdInvalid)
|
||||
expect(errors.email).toContain(esCopy.emailInvalid)
|
||||
}
|
||||
})
|
||||
@@ -185,7 +187,7 @@ describe("buildUnifiedCreateSchema", () => {
|
||||
const result = schema.safeParse({
|
||||
firstName: "Jane",
|
||||
lastName: "Smith",
|
||||
department: "ENGINEERING",
|
||||
teamId: validTeamId,
|
||||
email: "jane@example.test",
|
||||
role: "ADMIN",
|
||||
password: "corta",
|
||||
@@ -228,36 +230,35 @@ describe("buildUnifiedCreateSchema", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("department validation", () => {
|
||||
it("rejects invalid department", () => {
|
||||
describe("teamId validation", () => {
|
||||
it("rejects invalid teamId", () => {
|
||||
const schema = buildUnifiedCreateSchema(enCopy)
|
||||
const result = schema.safeParse({
|
||||
...validPersonOnlyData,
|
||||
department: "INVALID_DEPT",
|
||||
teamId: "INVALID_TEAM",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("accepts valid departments", () => {
|
||||
it("accepts null teamId", () => {
|
||||
const schema = buildUnifiedCreateSchema(enCopy)
|
||||
const validDepartments = [
|
||||
"IT",
|
||||
"ENGINEERING",
|
||||
"TRAFFIC",
|
||||
"DRIVER",
|
||||
"LOGISTICS",
|
||||
"ADMINISTRATION",
|
||||
"SALES",
|
||||
"OTHER",
|
||||
]
|
||||
for (const dept of validDepartments) {
|
||||
const result = schema.safeParse({
|
||||
...validPersonOnlyData,
|
||||
department: dept,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
}
|
||||
const result = schema.safeParse({
|
||||
...validPersonOnlyData,
|
||||
teamId: null,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("accepts a valid teamId UUID", () => {
|
||||
const schema = buildUnifiedCreateSchema(enCopy)
|
||||
const result = schema.safeParse({
|
||||
...validPersonOnlyData,
|
||||
teamId: validTeamId,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,35 +5,37 @@ import {
|
||||
type UnifiedSchemaCopy,
|
||||
} from "@/schemas/user.schema"
|
||||
|
||||
const validTeamId = "550e8400-e29b-41d4-a716-446655440000"
|
||||
|
||||
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",
|
||||
teamIdInvalid: "Team must be a valid id",
|
||||
}
|
||||
|
||||
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",
|
||||
teamIdInvalid: "El equipo debe ser un id válido",
|
||||
}
|
||||
|
||||
const validPersonOnly = {
|
||||
id: "person-1",
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
department: "IT",
|
||||
teamId: null,
|
||||
email: "john@example.test",
|
||||
phone: null,
|
||||
}
|
||||
@@ -73,7 +75,7 @@ describe("buildUnifiedUpdateSchema", () => {
|
||||
id: "",
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
department: "IT",
|
||||
teamId: null,
|
||||
email: "john@example.test",
|
||||
phone: null,
|
||||
})
|
||||
@@ -85,6 +87,31 @@ describe("buildUnifiedUpdateSchema", () => {
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it("rejects invalid teamId", () => {
|
||||
const schema = buildUnifiedUpdateSchema(enCopy)
|
||||
const result = schema.safeParse({
|
||||
...validPersonOnly,
|
||||
teamId: "not-a-uuid",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.flatten().fieldErrors.teamId).toContain(
|
||||
enCopy.teamIdInvalid,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it("accepts a valid teamId UUID", () => {
|
||||
const schema = buildUnifiedUpdateSchema(enCopy)
|
||||
const result = schema.safeParse({
|
||||
...validPersonOnly,
|
||||
teamId: validTeamId,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("person+user update (when person has User linked)", () => {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
itemCount: vi.fn(),
|
||||
itemFindMany: vi.fn(),
|
||||
paginate: vi.fn(),
|
||||
itemFindUnique: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/lib/paginate", () => ({
|
||||
paginate: mocks.paginate,
|
||||
}))
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
default: {
|
||||
item: {
|
||||
count: mocks.itemCount,
|
||||
findMany: mocks.itemFindMany,
|
||||
findUnique: mocks.itemFindUnique,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
import { ItemService } from "@/services/item.service"
|
||||
|
||||
describe("ItemService stock policy selection", () => {
|
||||
it("selects stock policy fields when listing items", async () => {
|
||||
mocks.paginate.mockResolvedValue({ data: [], totalPages: 1 })
|
||||
|
||||
await ItemService.findAllWithAssetCount({
|
||||
page: 2,
|
||||
pageSize: 25,
|
||||
search: "lap",
|
||||
})
|
||||
|
||||
expect(mocks.paginate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
page: 2,
|
||||
pageSize: 25,
|
||||
where: expect.objectContaining({
|
||||
deletedAt: null,
|
||||
name: expect.objectContaining({
|
||||
contains: "lap",
|
||||
mode: "insensitive",
|
||||
}),
|
||||
}),
|
||||
select: expect.objectContaining({
|
||||
minStock: true,
|
||||
targetStock: true,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("selects stock policy fields when loading a single item", async () => {
|
||||
const findUnique = vi.fn().mockResolvedValue(null)
|
||||
const db = {
|
||||
item: {
|
||||
findUnique,
|
||||
},
|
||||
}
|
||||
|
||||
await ItemService.findByIdWithAssetCount("item-1", db as never)
|
||||
|
||||
expect(findUnique).toHaveBeenCalledWith({
|
||||
where: { id: "item-1" },
|
||||
select: expect.objectContaining({
|
||||
minStock: true,
|
||||
targetStock: true,
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it("keeps assignable-item filtering narrow and ordered", async () => {
|
||||
mocks.itemFindMany.mockResolvedValue([])
|
||||
|
||||
await ItemService.findAllAssignable()
|
||||
|
||||
expect(mocks.itemFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
deletedAt: null,
|
||||
OR: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
stock: expect.objectContaining({ gt: 0 }),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("remaps stock movement counts when loading an item summary", async () => {
|
||||
mocks.itemFindUnique.mockResolvedValue({
|
||||
id: "item-1",
|
||||
name: "Cable",
|
||||
stock: 3,
|
||||
trackingType: "QUANTITY",
|
||||
status: "ACTIVE",
|
||||
category: { id: "cat-1", name: "Accessories" },
|
||||
_count: { assets: 2, stockMovementLines: 4 },
|
||||
})
|
||||
|
||||
const item = await ItemService.findByIdWithAssetAndMovementCount("item-1")
|
||||
|
||||
expect(mocks.itemFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: "item-1" },
|
||||
include: {
|
||||
category: { select: { id: true, name: true } },
|
||||
_count: { select: { assets: true, stockMovementLines: true } },
|
||||
},
|
||||
})
|
||||
expect(item?._count).toEqual({ assets: 2, movements: 4 })
|
||||
})
|
||||
|
||||
it("includes stock movement relations when loading stocked items", async () => {
|
||||
mocks.itemFindMany.mockResolvedValue([])
|
||||
|
||||
await ItemService.findAllWithStock()
|
||||
|
||||
expect(mocks.itemFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
deletedAt: { equals: null },
|
||||
stock: { gt: 0 },
|
||||
}),
|
||||
include: expect.objectContaining({
|
||||
category: true,
|
||||
assets: {
|
||||
select: {
|
||||
id: true,
|
||||
serialNumber: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -5,8 +5,8 @@ vi.mock("@/lib/prisma", () => ({
|
||||
}))
|
||||
|
||||
import {
|
||||
getUserById,
|
||||
getUserByEmail,
|
||||
getUserById,
|
||||
getUserCredentialsByEmail,
|
||||
} from "@/services/user.service"
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export default defineConfig({
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts", "tests/**/*.test.tsx"],
|
||||
watch: false,
|
||||
allowOnly: !process.env.CI,
|
||||
globals: false,
|
||||
clearMocks: true,
|
||||
restoreMocks: true,
|
||||
|
||||
Reference in New Issue
Block a user