feat(i18n): localize assignment validation messages

This commit is contained in:
2026-06-15 01:15:24 +02:00
parent bfea2b77ab
commit 349559f4e0
12 changed files with 446 additions and 67 deletions
+42 -26
View File
@@ -1,12 +1,15 @@
"use server"
import { revalidatePath } from "next/cache"
import { flattenError } from "zod"
import { localizeAssignmentFieldErrors } from "@/actions/assignment.messages"
import { getI18n } from "@/i18n/server"
import {
assignmentSchema,
buildCreateAssignmentSchema,
buildUpdateAssignmentSchema,
type CreateAssignmentFormType,
type ReturnAssignmentFormType,
type UpdateAssignmentFormType,
updateAssignmentSchema,
} from "@/schemas/assignment.schema"
import { getAuthenticatedUserId } from "@/services/auth.service"
import {
@@ -16,52 +19,60 @@ import {
} from "@/use-cases/assignment.use-cases"
export async function createAssignment(formData: CreateAssignmentFormType) {
const createdBy = await getAuthenticatedUserId()
const { dictionary } = await getI18n()
const copy = dictionary.inventory.assignments
const validatedFields = assignmentSchema.safeParse({
...formData,
createdBy,
})
const validatedFields = buildCreateAssignmentSchema(copy.schema).safeParse(
formData,
)
if (!validatedFields.success) {
return {
success: false,
errors: validatedFields.error.flatten().fieldErrors,
errors: flattenError(validatedFields.error).fieldErrors,
}
}
try {
const createdBy = await getAuthenticatedUserId()
const result = await createAssignmentUseCase({
...validatedFields.data,
actorId: createdBy,
})
if (!result.success) {
return result
return {
...result,
errors: localizeAssignmentFieldErrors(result.errors, copy.actions),
}
}
revalidatePath("/assignments")
return {
success: true,
message: "Assignment created successfully",
success: true as const,
message: copy.actions.createSuccess,
}
} catch (error) {
console.error("Database error:", error)
return {
success: false,
errors: { error: ["Error creating assignment"] },
success: false as const,
message: copy.actions.createFailure,
}
}
}
export async function updateAssignment(formData: UpdateAssignmentFormType) {
const validatedFields = updateAssignmentSchema.safeParse(formData)
const { dictionary } = await getI18n()
const copy = dictionary.inventory.assignments
const validatedFields = buildUpdateAssignmentSchema(copy.schema).safeParse(
formData,
)
if (!validatedFields.success) {
return {
success: false,
errors: validatedFields.error.flatten().fieldErrors,
errors: flattenError(validatedFields.error).fieldErrors,
}
}
@@ -74,37 +85,42 @@ export async function updateAssignment(formData: UpdateAssignmentFormType) {
})
if (!result.success) {
return result
return {
...result,
errors: localizeAssignmentFieldErrors(result.errors, copy.actions),
}
}
revalidatePath("/assignments")
return {
success: true,
message: "Assignment updated successfully",
success: true as const,
message: copy.actions.updateSuccess,
}
} catch (error) {
console.error("Database error:", error)
return {
success: false,
errors: { error: ["Error updating assignment"] },
success: false as const,
message: copy.actions.updateFailure,
}
}
}
export async function returnAssignment(formData: ReturnAssignmentFormType) {
const { id } = formData
const { dictionary } = await getI18n()
const copy = dictionary.inventory.assignments
const userId = await getAuthenticatedUserId()
const result = await returnAssignmentUseCase({
id,
id: formData.id,
actorId: userId,
})
if (!result.success) {
return {
...result,
message: "Error returning assignment",
errors: localizeAssignmentFieldErrors(result.errors, copy.actions),
message: copy.actions.returnFailure,
}
}
@@ -112,6 +128,6 @@ export async function returnAssignment(formData: ReturnAssignmentFormType) {
return {
success: true as const,
message: "Assignment returned successfully",
message: copy.actions.returnSuccess,
}
}
+44
View File
@@ -0,0 +1,44 @@
import type { Dictionary } from "@/i18n/dictionaries"
type AssignmentActionCopy = Dictionary["inventory"]["assignments"]["actions"]
type FieldErrors = Record<string, string[]>
const assignmentErrorMessageKeys = {
"Item not found": "itemNotFound",
"Item does not have enough stock": "itemInsufficientStock",
"Asset not found": "assetNotFound",
"Asset does not belong to item": "assetItemMismatch",
"Assignment not found": "notFound",
"Assignment already returned": "assignmentAlreadyReturned",
"Invalid assignment data": "invalidData",
} as const satisfies Record<string, keyof AssignmentActionCopy>
function isAssignmentErrorMessage(
message: string,
): message is keyof typeof assignmentErrorMessageKeys {
return message in assignmentErrorMessageKeys
}
function localizeAssignmentMessage(
message: string,
copy: AssignmentActionCopy,
): string {
if (!isAssignmentErrorMessage(message)) return message
return copy[assignmentErrorMessageKeys[message]]
}
export function localizeAssignmentFieldErrors(
errors: FieldErrors | undefined,
copy: AssignmentActionCopy,
): FieldErrors | undefined {
if (!errors) return undefined
return Object.fromEntries(
Object.entries(errors).map(([field, messages]) => [
field,
messages.map((message) => localizeAssignmentMessage(message, copy)),
]),
)
}
@@ -42,6 +42,7 @@ export default async function EditAssignmentPage({
assets={assets}
initialData={assignment as UpdateAssignmentFormType}
formCopy={copy.form}
schemaCopy={copy.schema}
submitButtonCopy={dictionary.common.submitButton}
/>
</div>
@@ -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 { updateAssignment } from "@/actions/assignment.actions"
@@ -11,12 +12,13 @@ import {
} from "@/components/forms/submitButton"
import type { Dictionary } from "@/i18n/dictionaries"
import {
buildUpdateAssignmentSchema,
type UpdateAssignmentFormType,
updateAssignmentSchema,
} from "@/schemas/assignment.schema"
import type { Asset, Item, Recipient } from "@/types"
type AssignmentFormCopy = Dictionary["inventory"]["assignments"]["form"]
type AssignmentSchemaCopy = Dictionary["inventory"]["assignments"]["schema"]
interface Props {
recipients: Recipient[]
@@ -24,6 +26,7 @@ interface Props {
assets: Asset[]
initialData: UpdateAssignmentFormType
formCopy: AssignmentFormCopy
schemaCopy: AssignmentSchemaCopy
submitButtonCopy: SubmitButtonCopy
}
@@ -33,17 +36,23 @@ export default function EditAssignmentForm({
assets,
initialData,
formCopy,
schemaCopy,
submitButtonCopy,
}: Props) {
const router = useRouter()
const schema = useMemo(
() => buildUpdateAssignmentSchema(schemaCopy),
[schemaCopy],
)
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isSubmitSuccessful },
watch,
} = useForm<UpdateAssignmentFormType>({
resolver: zodResolver(updateAssignmentSchema),
resolver: zodResolver(schema),
defaultValues: {
...initialData,
id: initialData.id || undefined,
@@ -12,18 +12,20 @@ import {
} from "@/components/forms/submitButton"
import type { Dictionary } from "@/i18n/dictionaries"
import {
buildCreateAssignmentSchema,
type CreateAssignmentFormType,
createAssignmentSchema,
} from "@/schemas/assignment.schema"
import type { Asset, Item, Recipient } from "@/types"
type AssignmentFormCopy = Dictionary["inventory"]["assignments"]["form"]
type AssignmentSchemaCopy = Dictionary["inventory"]["assignments"]["schema"]
interface Props {
recipients: Recipient[]
items: Item[]
assets: Asset[]
formCopy: AssignmentFormCopy
schemaCopy: AssignmentSchemaCopy
submitButtonCopy: SubmitButtonCopy
}
@@ -32,17 +34,23 @@ export default function CreateAssignmentForm({
items,
assets,
formCopy,
schemaCopy,
submitButtonCopy,
}: Props) {
const router = useRouter()
const schema = useMemo(
() => buildCreateAssignmentSchema(schemaCopy),
[schemaCopy],
)
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isSubmitSuccessful },
watch,
} = useForm<CreateAssignmentFormType>({
resolver: zodResolver(createAssignmentSchema),
resolver: zodResolver(schema),
mode: "onSubmit",
})
@@ -22,6 +22,7 @@ export default async function NewAssignmentPage() {
items={items}
assets={assets}
formCopy={copy.form}
schemaCopy={copy.schema}
submitButtonCopy={dictionary.common.submitButton}
/>
</div>
+23
View File
@@ -294,6 +294,29 @@ export const en = {
fallback: {
missingValue: "N/A",
},
actions: {
createSuccess: "Assignment created successfully",
createFailure: "Error creating assignment",
updateSuccess: "Assignment updated successfully",
updateFailure: "Error updating assignment",
returnSuccess: "Assignment returned successfully",
returnFailure: "Error returning assignment",
notFound: "Assignment not found",
itemNotFound: "Item not found",
itemInsufficientStock: "Item does not have enough stock",
assetNotFound: "Asset not found",
assetItemMismatch: "Asset does not belong to item",
assignmentAlreadyReturned: "Assignment already returned",
invalidData: "Invalid assignment data",
genericFailure: "Error processing assignment",
},
schema: {
recipientRequired: "Recipient is required",
itemIdRequired: "Item is required",
quantityMinOne: "Quantity must be at least 1",
assetIdRequired: "Asset ID is required when item ID is provided",
idRequired: "Assignment ID is required",
},
},
recipients: {
list: {
+24
View File
@@ -298,6 +298,30 @@ export const es = {
fallback: {
missingValue: "No disponible",
},
actions: {
createSuccess: "Asignación creada correctamente",
createFailure: "Error al crear la asignación",
updateSuccess: "Asignación actualizada correctamente",
updateFailure: "Error al actualizar la asignación",
returnSuccess: "Asignación devuelta correctamente",
returnFailure: "Error al devolver la asignación",
notFound: "Asignación no encontrada",
itemNotFound: "Artículo no encontrado",
itemInsufficientStock: "El artículo no tiene stock suficiente",
assetNotFound: "Activo no encontrado",
assetItemMismatch: "El activo no pertenece al artículo",
assignmentAlreadyReturned: "La asignación ya fue devuelta",
invalidData: "Datos de asignación inválidos",
genericFailure: "Error al procesar la asignación",
},
schema: {
recipientRequired: "El destinatario es obligatorio",
itemIdRequired: "El artículo es obligatorio",
quantityMinOne: "La cantidad debe ser al menos 1",
assetIdRequired:
"El activo es obligatorio cuando se especifica el artículo",
idRequired: "El ID de asignación es obligatorio",
},
},
recipients: {
list: {
+72 -37
View File
@@ -1,52 +1,87 @@
import { z } from "zod"
export const assignmentSchema = z.object({
id: z.string().optional(),
quantity: z.coerce.number().int().nonnegative().min(1, {
error: "Quantity is required",
}),
notes: z.string().optional(),
itemId: z
.string()
.min(1, {
error: "Item is required",
})
.optional(),
assetId: z.string().optional(),
recipientId: z.string().min(1, {
error: "Recipient is required",
}),
assignmentDate: z.date().optional(),
returnDate: z.date().optional(),
})
import type { Dictionary } from "@/i18n/dictionaries"
export const createAssignmentSchema = assignmentSchema.omit({
id: true,
returnDate: true,
})
export type AssignmentSchemaCopy =
Dictionary["inventory"]["assignments"]["schema"]
const defaultAssignmentSchemaCopy: AssignmentSchemaCopy = {
recipientRequired: "Recipient is required",
itemIdRequired: "Item is required",
quantityMinOne: "Quantity must be at least 1",
assetIdRequired: "Asset ID is required when item ID is provided",
idRequired: "Assignment ID is required",
}
function buildAssignmentBaseSchema(copy: AssignmentSchemaCopy) {
return z.object({
id: z.string().optional(),
quantity: z.coerce.number().int().nonnegative().min(1, {
error: copy.quantityMinOne,
}),
notes: z.string().optional(),
itemId: z
.string()
.min(1, {
error: copy.itemIdRequired,
})
.optional(),
assetId: z.string().optional(),
recipientId: z.string().min(1, {
error: copy.recipientRequired,
}),
assignmentDate: z.date().optional(),
returnDate: z.date().optional(),
})
}
export const assignmentSchema = buildAssignmentBaseSchema(
defaultAssignmentSchemaCopy,
)
export function buildCreateAssignmentSchema(copy: AssignmentSchemaCopy) {
return buildAssignmentBaseSchema(copy).omit({
id: true,
returnDate: true,
})
}
export const createAssignmentSchema = buildCreateAssignmentSchema(
defaultAssignmentSchemaCopy,
)
export type CreateAssignmentFormType = z.input<typeof createAssignmentSchema>
export type CreateAssignmentData = z.output<typeof createAssignmentSchema>
export const updateAssignmentSchema = assignmentSchema
.omit({
returnDate: true,
})
.superRefine((data, ctx) => {
if (data.itemId && !data.assetId) {
ctx.addIssue({
code: "custom",
message: "Asset ID is required when item ID is provided",
path: ["assetId"],
})
}
})
export function buildUpdateAssignmentSchema(copy: AssignmentSchemaCopy) {
return buildAssignmentBaseSchema(copy)
.omit({
returnDate: true,
})
.extend({
id: z.string().min(1, {
error: copy.idRequired,
}),
})
.superRefine((data, ctx) => {
if (data.itemId && !data.assetId) {
ctx.addIssue({
code: "custom",
message: copy.assetIdRequired,
path: ["assetId"],
})
}
})
}
export const updateAssignmentSchema = buildUpdateAssignmentSchema(
defaultAssignmentSchemaCopy,
)
export type UpdateAssignmentFormType = z.input<typeof updateAssignmentSchema>
export type UpdateAssignmentData = z.output<typeof updateAssignmentSchema>
export const returnAssignmentSchema = z.object({
id: z.string().min(1, {
error: "Assignment ID is required",
error: defaultAssignmentSchemaCopy.idRequired,
}),
})
export type ReturnAssignmentFormType = z.infer<typeof returnAssignmentSchema>