diff --git a/src/actions/asset.actions.ts b/src/actions/asset.actions.ts index 32fa50b..2031140 100644 --- a/src/actions/asset.actions.ts +++ b/src/actions/asset.actions.ts @@ -2,11 +2,13 @@ import { revalidatePath } from "next/cache" import { flattenError } from "zod" +import { localizeAssetFieldErrors } from "@/actions/asset.messages" +import { getI18n } from "@/i18n/server" import { + buildCreateAssetSchema, + buildUpdateAssetSchema, type CreateAssetFormType, - createAssetSchema, type UpdateAssetFormType, - updateAssetSchema, } from "@/schemas/asset.schema" import { getAuthenticatedUserId } from "@/services/auth.service" import { @@ -15,15 +17,19 @@ import { } from "@/use-cases/asset.use-cases" export async function createAssetAction(formData: CreateAssetFormType) { - try { - const validatedFields = createAssetSchema.safeParse(formData) + const { dictionary } = await getI18n() + const copy = dictionary.inventory.assets + const validatedFields = buildCreateAssetSchema(copy.schema).safeParse( + formData, + ) - if (!validatedFields.success) { - return { - errors: flattenError(validatedFields.error).fieldErrors, - } + if (!validatedFields.success) { + return { + errors: flattenError(validatedFields.error).fieldErrors, } + } + try { const userId = await getAuthenticatedUserId() const result = await createAssetUseCase({ @@ -32,7 +38,10 @@ export async function createAssetAction(formData: CreateAssetFormType) { }) if (!result.success) { - return result + return { + ...result, + errors: localizeAssetFieldErrors(result.errors, copy.actions), + } } revalidatePath("/inventory/assets") @@ -42,19 +51,23 @@ export async function createAssetAction(formData: CreateAssetFormType) { return { success: true, - message: "Asset created successfully", + message: copy.actions.createSuccess, } } catch (error) { console.error("Database error:", error) return { success: false, - message: "Error creating asset", + message: copy.actions.createFailure, } } } export async function updateAssetAction(formData: UpdateAssetFormType) { - const validatedFields = updateAssetSchema.safeParse(formData) + const { dictionary } = await getI18n() + const copy = dictionary.inventory.assets + const validatedFields = buildUpdateAssetSchema(copy.schema).safeParse( + formData, + ) if (!validatedFields.success) { return { @@ -71,7 +84,10 @@ export async function updateAssetAction(formData: UpdateAssetFormType) { }) if (!result.success) { - return result + return { + ...result, + errors: localizeAssetFieldErrors(result.errors, copy.actions), + } } revalidatePath("/inventory/assets") @@ -81,13 +97,13 @@ export async function updateAssetAction(formData: UpdateAssetFormType) { return { success: true, - message: "Asset updated successfully", + message: copy.actions.updateSuccess, } } catch (error) { console.error("Database error:", error) return { success: false, - message: "Error updating asset", + message: copy.actions.updateFailure, } } } diff --git a/src/actions/asset.messages.ts b/src/actions/asset.messages.ts new file mode 100644 index 0000000..ac8a3f8 --- /dev/null +++ b/src/actions/asset.messages.ts @@ -0,0 +1,40 @@ +import type { Dictionary } from "@/i18n/dictionaries" + +type AssetActionCopy = Dictionary["inventory"]["assets"]["actions"] + +type FieldErrors = Record + +const assetErrorMessageKeys = { + "Item not found": "itemNotFound", + "Asset not found": "notFound", + "This serial number already exists": "duplicateSerialNumber", + "Assignment already returned": "assignmentAlreadyReturned", + "Previous item not found for available asset": "previousItemNotFound", + "Item does not have enough stock": "insufficientStock", +} as const satisfies Record + +function isAssetErrorMessage( + message: string, +): message is keyof typeof assetErrorMessageKeys { + return message in assetErrorMessageKeys +} + +function localizeAssetMessage(message: string, copy: AssetActionCopy): string { + if (!isAssetErrorMessage(message)) return message + + return copy[assetErrorMessageKeys[message]] +} + +export function localizeAssetFieldErrors( + errors: FieldErrors | undefined, + copy: AssetActionCopy, +): FieldErrors | undefined { + if (!errors) return undefined + + return Object.fromEntries( + Object.entries(errors).map(([field, messages]) => [ + field, + messages.map((message) => localizeAssetMessage(message, copy)), + ]), + ) +} diff --git a/src/app/(dashboard)/inventory/assets/[assetId]/edit/page.tsx b/src/app/(dashboard)/inventory/assets/[assetId]/edit/page.tsx index 571233a..ec94198 100644 --- a/src/app/(dashboard)/inventory/assets/[assetId]/edit/page.tsx +++ b/src/app/(dashboard)/inventory/assets/[assetId]/edit/page.tsx @@ -34,6 +34,7 @@ export default async function EditAssetPage({ recipients={recipients} asset={asset as unknown as AssetWithAssignment} formCopy={copy.form} + schemaCopy={copy.schema} statusCopy={copy.status} submitButtonCopy={dictionary.common.submitButton} /> diff --git a/src/app/(dashboard)/inventory/assets/_components/asset.copy.ts b/src/app/(dashboard)/inventory/assets/_components/asset.copy.ts index 904f771..a166931 100644 --- a/src/app/(dashboard)/inventory/assets/_components/asset.copy.ts +++ b/src/app/(dashboard)/inventory/assets/_components/asset.copy.ts @@ -1,6 +1,8 @@ import type { Dictionary } from "@/i18n/dictionaries" +import type { AssetSchemaCopy } from "@/schemas/asset.schema" export type AssetListCopy = Dictionary["inventory"]["assets"]["list"] export type AssetFormCopy = Dictionary["inventory"]["assets"]["form"] export type AssetStatusCopy = Dictionary["inventory"]["assets"]["status"] export type AssetFallbackCopy = Dictionary["inventory"]["assets"]["fallback"] +export type { AssetSchemaCopy } diff --git a/src/app/(dashboard)/inventory/assets/_components/edit.asset.form.tsx b/src/app/(dashboard)/inventory/assets/_components/edit.asset.form.tsx index c2536de..ba49285 100644 --- a/src/app/(dashboard)/inventory/assets/_components/edit.asset.form.tsx +++ b/src/app/(dashboard)/inventory/assets/_components/edit.asset.form.tsx @@ -2,6 +2,7 @@ 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 { updateAssetAction } from "@/actions/asset.actions" @@ -11,8 +12,8 @@ import { } from "@/components/forms/submitButton" import { ITEM_STATUS } from "@/lib/constants" import { + buildUpdateAssetSchema, type UpdateAssetFormType, - updateAssetSchema, } from "@/schemas/asset.schema" import type { AssetWithAssignment, @@ -21,13 +22,18 @@ import type { UpdateAssetStatus, } from "@/types" -import type { AssetFormCopy, AssetStatusCopy } from "./asset.copy" +import type { + AssetFormCopy, + AssetSchemaCopy, + AssetStatusCopy, +} from "./asset.copy" interface EditAssetFormProps { asset: AssetWithAssignment items: Item[] recipients: Recipient[] formCopy: AssetFormCopy + schemaCopy: AssetSchemaCopy statusCopy: AssetStatusCopy submitButtonCopy: SubmitButtonCopy } @@ -37,10 +43,12 @@ export default function EditAssetForm({ items, recipients, formCopy, + schemaCopy, statusCopy, submitButtonCopy, }: EditAssetFormProps) { const router = useRouter() + const schema = useMemo(() => buildUpdateAssetSchema(schemaCopy), [schemaCopy]) const { register, @@ -49,7 +57,7 @@ export default function EditAssetForm({ formState: { errors, isSubmitting, isSubmitSuccessful }, watch, } = useForm({ - resolver: zodResolver(updateAssetSchema), + resolver: zodResolver(schema), defaultValues: { id: asset.id, itemId: asset.itemId ?? undefined, @@ -81,7 +89,7 @@ export default function EditAssetForm({ } if (response?.success) { - toast.success("Asset updated successfully") + toast.success(response.message) router.push(`/inventory/assets`) } } diff --git a/src/app/(dashboard)/inventory/assets/_components/new.asset.form.tsx b/src/app/(dashboard)/inventory/assets/_components/new.asset.form.tsx index 8f564a8..f7037b5 100644 --- a/src/app/(dashboard)/inventory/assets/_components/new.asset.form.tsx +++ b/src/app/(dashboard)/inventory/assets/_components/new.asset.form.tsx @@ -2,6 +2,7 @@ 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 { createAssetAction } from "@/actions/asset.actions" @@ -11,17 +12,22 @@ import { } from "@/components/forms/submitButton" import { ITEM_STATUS } from "@/lib/constants" import { + buildCreateAssetSchema, type CreateAssetFormType, - createAssetSchema, } from "@/schemas/asset.schema" import type { ItemWithoutStock, Recipient } from "@/types" -import type { AssetFormCopy, AssetStatusCopy } from "./asset.copy" +import type { + AssetFormCopy, + AssetSchemaCopy, + AssetStatusCopy, +} from "./asset.copy" interface NewAssetFormProps { items: ItemWithoutStock[] recipients: Recipient[] formCopy: AssetFormCopy + schemaCopy: AssetSchemaCopy statusCopy: AssetStatusCopy submitButtonCopy: SubmitButtonCopy } @@ -30,10 +36,12 @@ export default function NewAssetForm({ items, recipients, formCopy, + schemaCopy, statusCopy, submitButtonCopy, }: NewAssetFormProps) { const router = useRouter() + const schema = useMemo(() => buildCreateAssetSchema(schemaCopy), [schemaCopy]) const { register, @@ -42,7 +50,7 @@ export default function NewAssetForm({ formState: { errors, isSubmitting, isSubmitSuccessful }, watch, } = useForm({ - resolver: zodResolver(createAssetSchema), + resolver: zodResolver(schema), defaultValues: { status: "AVAILABLE", }, @@ -69,7 +77,7 @@ export default function NewAssetForm({ } if (response?.success) { - toast.success("Asset created successfully") + toast.success(response.message) router.push(`/inventory/assets`) } } diff --git a/src/app/(dashboard)/inventory/assets/new/page.tsx b/src/app/(dashboard)/inventory/assets/new/page.tsx index e2659c5..ea9c8ad 100644 --- a/src/app/(dashboard)/inventory/assets/new/page.tsx +++ b/src/app/(dashboard)/inventory/assets/new/page.tsx @@ -21,6 +21,7 @@ export default async function NewAssetPage() { items={items} recipients={recipients} formCopy={copy.form} + schemaCopy={copy.schema} statusCopy={copy.status} submitButtonCopy={dictionary.common.submitButton} /> diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index b1da838..f6ab73d 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -231,6 +231,29 @@ export const en = { fallback: { unknownStatus: "Unknown status", }, + actions: { + createSuccess: "Asset created successfully", + createFailure: "Error creating asset", + updateSuccess: "Asset updated successfully", + updateFailure: "Error updating asset", + duplicateSerialNumber: "This serial number already exists", + notFound: "Asset not found", + itemNotFound: "Item not found", + assignmentAlreadyReturned: "Assignment already returned", + previousItemNotFound: "Previous item not found for available asset", + insufficientStock: "Item does not have enough stock", + recipientRequired: "Recipient is required", + invalidStatus: "Invalid status", + genericFailure: "Error processing asset", + }, + schema: { + itemRequired: "Item is required", + serialNumberRequired: "Serial number is required", + idRequired: "ID is required", + statusRequired: "Status is required", + invalidCreateStatus: "Status must be Available or Assigned", + invalidUpdateStatus: "Invalid status", + }, }, }, login: { diff --git a/src/i18n/dictionaries/es.ts b/src/i18n/dictionaries/es.ts index 45fad1f..ee5c548 100644 --- a/src/i18n/dictionaries/es.ts +++ b/src/i18n/dictionaries/es.ts @@ -234,6 +234,30 @@ export const es = { fallback: { unknownStatus: "Estado desconocido", }, + actions: { + createSuccess: "Activo creado correctamente", + createFailure: "Error al crear el activo", + updateSuccess: "Activo actualizado correctamente", + updateFailure: "Error al actualizar el activo", + duplicateSerialNumber: "El número de serie ya existe", + notFound: "Activo no encontrado", + itemNotFound: "Artículo no encontrado", + assignmentAlreadyReturned: "La asignación ya fue devuelta", + previousItemNotFound: + "Artículo anterior no encontrado para el activo disponible", + insufficientStock: "El artículo no tiene stock suficiente", + recipientRequired: "El destinatario es obligatorio", + invalidStatus: "Estado inválido", + genericFailure: "Error al procesar el activo", + }, + schema: { + itemRequired: "El artículo es obligatorio", + serialNumberRequired: "El número de serie es obligatorio", + idRequired: "El activo es obligatorio", + statusRequired: "El estado es obligatorio", + invalidCreateStatus: "El estado inicial debe ser Disponible o Asignado", + invalidUpdateStatus: "Estado inválido", + }, }, }, login: { diff --git a/src/schemas/asset.schema.ts b/src/schemas/asset.schema.ts index 23adfe4..a9fb59f 100644 --- a/src/schemas/asset.schema.ts +++ b/src/schemas/asset.schema.ts @@ -1,37 +1,75 @@ import { z } from "zod" -export const assetSchema = z.object({ - id: z.string().optional(), - itemId: z.string().min(1, { - error: "Item is required", - }), - serialNumber: z.string().min(1, { - error: "Serial number is required", - }), - deliveryNote: z.string().optional(), - notes: z.string().optional(), - recipientId: z.string().optional(), -}) +import type { Dictionary } from "@/i18n/dictionaries" -export const createAssetSchema = assetSchema.extend({ - status: z.enum(["AVAILABLE", "ASSIGNED"]), -}) +export type AssetSchemaCopy = Dictionary["inventory"]["assets"]["schema"] + +const defaultAssetSchemaCopy: AssetSchemaCopy = { + itemRequired: "Item is required", + serialNumberRequired: "Serial number is required", + idRequired: "ID is required", + statusRequired: "Status is required", + invalidCreateStatus: "Status must be Available or Assigned", + invalidUpdateStatus: "Invalid status", +} + +const createAssetStatuses = ["AVAILABLE", "ASSIGNED"] as const +const updateAssetStatuses = [ + "AVAILABLE", + "ASSIGNED", + "RESERVED", + "IN_REPAIR", + "BROKEN", + "STOLEN", + "DISPOSED", +] as const + +function buildAssetBaseSchema(copy: AssetSchemaCopy) { + return z.object({ + id: z.string().optional(), + itemId: z.string().min(1, { + error: copy.itemRequired, + }), + serialNumber: z.string().min(1, { + error: copy.serialNumberRequired, + }), + deliveryNote: z.string().optional(), + notes: z.string().optional(), + recipientId: z.string().optional(), + }) +} + +export const assetSchema = buildAssetBaseSchema(defaultAssetSchemaCopy) + +export function buildCreateAssetSchema(copy: AssetSchemaCopy) { + return buildAssetBaseSchema(copy).extend({ + status: z.enum(createAssetStatuses, { + error: (issue) => + issue.input === undefined || issue.input === "" + ? copy.statusRequired + : copy.invalidCreateStatus, + }), + }) +} + +export const createAssetSchema = buildCreateAssetSchema(defaultAssetSchemaCopy) export type CreateAssetFormType = z.infer -export const updateAssetSchema = assetSchema.extend({ - id: z.string().min(1, { - error: "ID is required", - }), - status: z.enum([ - "AVAILABLE", - "ASSIGNED", - "RESERVED", - "IN_REPAIR", - "BROKEN", - "STOLEN", - "DISPOSED", - ]), -}) +export function buildUpdateAssetSchema(copy: AssetSchemaCopy) { + return buildAssetBaseSchema(copy).extend({ + id: z.string().min(1, { + error: copy.idRequired, + }), + status: z.enum(updateAssetStatuses, { + error: (issue) => + issue.input === undefined || issue.input === "" + ? copy.statusRequired + : copy.invalidUpdateStatus, + }), + }) +} + +export const updateAssetSchema = buildUpdateAssetSchema(defaultAssetSchemaCopy) export type UpdateAssetFormType = z.infer diff --git a/tests/unit/actions/asset.messages.test.ts b/tests/unit/actions/asset.messages.test.ts new file mode 100644 index 0000000..03a27e1 --- /dev/null +++ b/tests/unit/actions/asset.messages.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest" + +import { localizeAssetFieldErrors } from "@/actions/asset.messages" + +const actionCopy = { + createSuccess: "Activo creado correctamente", + createFailure: "Error al crear el activo", + updateSuccess: "Activo actualizado correctamente", + updateFailure: "Error al actualizar el activo", + duplicateSerialNumber: "El número de serie ya existe", + notFound: "Activo no encontrado", + itemNotFound: "Artículo no encontrado", + assignmentAlreadyReturned: "La asignación ya fue devuelta", + previousItemNotFound: "Artículo anterior no encontrado", + insufficientStock: "El artículo no tiene stock suficiente", + recipientRequired: "El destinatario es obligatorio", + invalidStatus: "Estado inválido", + genericFailure: "Error al procesar el activo", +} + +describe("asset action message localization", () => { + it("localizes known asset field errors", () => { + expect( + localizeAssetFieldErrors( + { + itemId: ["Item not found"], + id: ["Asset not found", "Assignment already returned"], + serialNumber: ["This serial number already exists"], + stock: ["Item does not have enough stock"], + previousItem: ["Previous item not found for available asset"], + }, + actionCopy, + ), + ).toEqual({ + itemId: [actionCopy.itemNotFound], + id: [actionCopy.notFound, actionCopy.assignmentAlreadyReturned], + serialNumber: [actionCopy.duplicateSerialNumber], + stock: [actionCopy.insufficientStock], + previousItem: [actionCopy.previousItemNotFound], + }) + }) + + it("keeps unknown messages unchanged", () => { + expect( + localizeAssetFieldErrors( + { serialNumber: ["Unexpected asset issue"] }, + actionCopy, + ), + ).toEqual({ serialNumber: ["Unexpected asset issue"] }) + }) +}) diff --git a/tests/unit/i18n/dictionaries.test.ts b/tests/unit/i18n/dictionaries.test.ts index cb77d1f..78e1b09 100644 --- a/tests/unit/i18n/dictionaries.test.ts +++ b/tests/unit/i18n/dictionaries.test.ts @@ -458,6 +458,29 @@ describe("i18n dictionaries", () => { fallback: { unknownStatus: "Unknown status", }, + actions: { + createSuccess: "Asset created successfully", + createFailure: "Error creating asset", + updateSuccess: "Asset updated successfully", + updateFailure: "Error updating asset", + duplicateSerialNumber: "This serial number already exists", + notFound: "Asset not found", + itemNotFound: "Item not found", + assignmentAlreadyReturned: "Assignment already returned", + previousItemNotFound: "Previous item not found for available asset", + insufficientStock: "Item does not have enough stock", + recipientRequired: "Recipient is required", + invalidStatus: "Invalid status", + genericFailure: "Error processing asset", + }, + schema: { + itemRequired: "Item is required", + serialNumberRequired: "Serial number is required", + idRequired: "ID is required", + statusRequired: "Status is required", + invalidCreateStatus: "Status must be Available or Assigned", + invalidUpdateStatus: "Invalid status", + }, }) expect(getDictionary("es").inventory.assets).toEqual({ @@ -509,6 +532,30 @@ describe("i18n dictionaries", () => { fallback: { unknownStatus: "Estado desconocido", }, + actions: { + createSuccess: "Activo creado correctamente", + createFailure: "Error al crear el activo", + updateSuccess: "Activo actualizado correctamente", + updateFailure: "Error al actualizar el activo", + duplicateSerialNumber: "El número de serie ya existe", + notFound: "Activo no encontrado", + itemNotFound: "Artículo no encontrado", + assignmentAlreadyReturned: "La asignación ya fue devuelta", + previousItemNotFound: + "Artículo anterior no encontrado para el activo disponible", + insufficientStock: "El artículo no tiene stock suficiente", + recipientRequired: "El destinatario es obligatorio", + invalidStatus: "Estado inválido", + genericFailure: "Error al procesar el activo", + }, + schema: { + itemRequired: "El artículo es obligatorio", + serialNumberRequired: "El número de serie es obligatorio", + idRequired: "El activo es obligatorio", + statusRequired: "El estado es obligatorio", + invalidCreateStatus: "El estado inicial debe ser Disponible o Asignado", + invalidUpdateStatus: "Estado inválido", + }, }) }) diff --git a/tests/unit/schemas/asset.schema.test.ts b/tests/unit/schemas/asset.schema.test.ts new file mode 100644 index 0000000..8086ef5 --- /dev/null +++ b/tests/unit/schemas/asset.schema.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest" + +import { + buildCreateAssetSchema, + buildUpdateAssetSchema, +} from "@/schemas/asset.schema" + +const schemaCopy = { + itemRequired: "El artículo es obligatorio", + serialNumberRequired: "El número de serie es obligatorio", + idRequired: "El activo es obligatorio", + statusRequired: "El estado es obligatorio", + invalidCreateStatus: "El estado inicial no es válido", + invalidUpdateStatus: "El estado no es válido", +} + +describe("asset schema localization", () => { + it("uses localized create validation messages", () => { + const result = buildCreateAssetSchema(schemaCopy).safeParse({ + itemId: "", + serialNumber: "", + status: "BROKEN", + }) + + expect(result.success).toBe(false) + if (!result.success) { + const errors = result.error.flatten().fieldErrors + + expect(errors.itemId).toContain(schemaCopy.itemRequired) + expect(errors.serialNumber).toContain(schemaCopy.serialNumberRequired) + expect(errors.status).toContain(schemaCopy.invalidCreateStatus) + } + }) + + it("uses localized update identifier and status validation messages", () => { + const result = buildUpdateAssetSchema(schemaCopy).safeParse({ + id: "", + itemId: "item-1", + serialNumber: "SERIAL-1", + status: "UNSUPPORTED", + }) + + expect(result.success).toBe(false) + if (!result.success) { + const errors = result.error.flatten().fieldErrors + + expect(errors.id).toContain(schemaCopy.idRequired) + expect(errors.status).toContain(schemaCopy.invalidUpdateStatus) + } + }) + + it("preserves raw status values for accepted create and update payloads", () => { + const createResult = buildCreateAssetSchema(schemaCopy).safeParse({ + itemId: "item-1", + serialNumber: "SERIAL-1", + status: "ASSIGNED", + recipientId: "recipient-1", + }) + + expect(createResult.success).toBe(true) + if (createResult.success) { + expect(createResult.data.status).toBe("ASSIGNED") + } + + const updateResult = buildUpdateAssetSchema(schemaCopy).safeParse({ + id: "asset-1", + itemId: "item-1", + serialNumber: "SERIAL-1", + status: "DISPOSED", + }) + + expect(updateResult.success).toBe(true) + if (updateResult.success) { + expect(updateResult.data.status).toBe("DISPOSED") + } + }) + + it("keeps optional asset fields optional", () => { + const result = buildCreateAssetSchema(schemaCopy).safeParse({ + itemId: "item-1", + serialNumber: "SERIAL-1", + status: "AVAILABLE", + }) + + expect(result.success).toBe(true) + }) +})