diff --git a/modules/customer-invoices/src/web/proformas/pages/update/ui/blocks/index.ts b/modules/customer-invoices/src/web/proformas/pages/update/ui/blocks/index.ts
index c9c839bc..b4cd11db 100644
--- a/modules/customer-invoices/src/web/proformas/pages/update/ui/blocks/index.ts
+++ b/modules/customer-invoices/src/web/proformas/pages/update/ui/blocks/index.ts
@@ -1,3 +1,2 @@
-export * from "./items";
export * from "./proforma-basic-info-fields";
export * from "./proforma-totals";
diff --git a/modules/customer-invoices/src/web/proformas/pages/update/ui/blocks/items/index.ts b/modules/customer-invoices/src/web/proformas/pages/update/ui/blocks/items/index.ts
deleted file mode 100644
index d183d2e4..00000000
--- a/modules/customer-invoices/src/web/proformas/pages/update/ui/blocks/items/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from "./proforma-items-editor";
diff --git a/modules/customer-invoices/src/web/proformas/pages/update/ui/blocks/items/proforma-items-editor.tsx b/modules/customer-invoices/src/web/proformas/pages/update/ui/blocks/items/proforma-items-editor.tsx
deleted file mode 100644
index 2e42d5cb..00000000
--- a/modules/customer-invoices/src/web/proformas/pages/update/ui/blocks/items/proforma-items-editor.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { FieldDescription, FieldGroup, FieldLegend, FieldSet } from "@repo/shadcn-ui/components";
-import type { ComponentProps } from "react";
-
-import { useTranslation } from "../../../../../../i18n";
-import { ItemsEditor } from "../../components";
-
-export const ProformaItems = (props: ComponentProps<"fieldset">) => {
- const { t } = useTranslation();
-
- return (
-
- );
-};
diff --git a/modules/customer-invoices/src/web/proformas/update/controllers/index.ts b/modules/customer-invoices/src/web/proformas/update/controllers/index.ts
index f8022c89..2fcdad69 100644
--- a/modules/customer-invoices/src/web/proformas/update/controllers/index.ts
+++ b/modules/customer-invoices/src/web/proformas/update/controllers/index.ts
@@ -1,2 +1,3 @@
export * from "./use-update-proforma-controller";
+export * from "./use-update-proforma-items-controller";
export * from "./use-update-proforma-page-controller";
diff --git a/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-controller.ts b/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-controller.ts
index 9238fe50..1251f5ba 100644
--- a/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-controller.ts
+++ b/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-controller.ts
@@ -20,6 +20,8 @@ import {
focusFirstProformaUpdateError,
} from "../utils";
+import { useUpdateProformaItemsController } from "./use-update-proforma-items-controller";
+
export interface UseUpdateProformaControllerOptions {
onUpdated?(updated: Proforma): void;
successToasts?: boolean;
@@ -172,11 +174,17 @@ export const useUpdateProformaController = (
submitHandler(event);
};
+ const itemsCtrl = useUpdateProformaItemsController({
+ form,
+ });
+
return {
// form
formId,
form,
+ itemsCtrl,
+
// handlers del form
onSubmit,
resetForm,
diff --git a/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-items-controller.ts b/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-items-controller.ts
new file mode 100644
index 00000000..5fed0f57
--- /dev/null
+++ b/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-items-controller.ts
@@ -0,0 +1,229 @@
+import * as React from "react";
+import {
+ type FieldArrayWithId,
+ type UseFormReturn,
+ useFieldArray,
+ useWatch,
+} from "react-hook-form";
+
+import type { ProformaItemUpdateForm, ProformaUpdateForm } from "../entities";
+import { buildProformaItemUpdateDefault } from "../utils/build-proforma-item-update-default";
+
+export interface ProformaItemAmounts {
+ subtotal: number;
+ discountAmount: number;
+ total: number;
+}
+
+export interface ProformaItemsTotals {
+ subtotal: number;
+ discountAmount: number;
+ total: number;
+}
+
+export type ProformaItemField = FieldArrayWithId;
+
+export interface UseUpdateProformaItemsControllerResult {
+ fields: ProformaItemField[];
+ items: ProformaItemUpdateForm[];
+
+ hasItems: boolean;
+ itemCount: number;
+
+ appendItem: () => void;
+ removeItem: (index: number) => void;
+ duplicateItem: (index: number) => void;
+ moveItemUp: (index: number) => void;
+ moveItemDown: (index: number) => void;
+
+ getItemAmounts: (index: number) => ProformaItemAmounts;
+ totals: ProformaItemsTotals;
+}
+
+interface UseUpdateProformaItemsControllerParams {
+ form: UseFormReturn;
+}
+
+const roundCurrency = (value: number): number => {
+ return Math.round(value * 100) / 100;
+};
+
+const normalizeItemPositions = (items: ProformaItemUpdateForm[]): ProformaItemUpdateForm[] => {
+ return items.map((item, index) => ({
+ ...item,
+ position: index,
+ }));
+};
+
+const calculateItemAmounts = (
+ item?: Pick
+): ProformaItemAmounts => {
+ if (!item) {
+ return {
+ subtotal: 0,
+ discountAmount: 0,
+ total: 0,
+ };
+ }
+
+ const subtotal = roundCurrency(item.quantity * item.unitAmount);
+ const discountAmount = roundCurrency(subtotal * (item.discountPercentage / 100));
+ const total = roundCurrency(subtotal - discountAmount);
+
+ return {
+ subtotal,
+ discountAmount,
+ total,
+ };
+};
+
+const calculateItemsTotals = (items: ProformaItemUpdateForm[]): ProformaItemsTotals => {
+ return items.reduce(
+ (acc, item) => {
+ const amounts = calculateItemAmounts(item);
+
+ return {
+ subtotal: roundCurrency(acc.subtotal + amounts.subtotal),
+ discountAmount: roundCurrency(acc.discountAmount + amounts.discountAmount),
+ total: roundCurrency(acc.total + amounts.total),
+ };
+ },
+ {
+ subtotal: 0,
+ discountAmount: 0,
+ total: 0,
+ }
+ );
+};
+
+export const useUpdateProformaItemsController = ({
+ form,
+}: UseUpdateProformaItemsControllerParams): UseUpdateProformaItemsControllerResult => {
+ const { control, getValues, setValue } = form;
+
+ const { fields, append } = useFieldArray({
+ control,
+ name: "items",
+ keyName: "fieldId",
+ });
+
+ const watchedItems = useWatch({
+ control,
+ name: "items",
+ });
+
+ const items = React.useMemo(() => watchedItems ?? [], [watchedItems]);
+
+ const replaceItems = React.useCallback(
+ (nextItems: ProformaItemUpdateForm[]) => {
+ setValue("items", normalizeItemPositions(nextItems), {
+ shouldDirty: true,
+ shouldTouch: true,
+ shouldValidate: true,
+ });
+ },
+ [setValue]
+ );
+
+ const appendItem = React.useCallback(() => {
+ const nextPosition = getValues("items")?.length ?? 0;
+
+ append(buildProformaItemUpdateDefault(nextPosition), {
+ shouldFocus: false,
+ });
+ }, [append, getValues]);
+
+ const removeItem = React.useCallback(
+ (index: number) => {
+ const currentItems = getValues("items") ?? [];
+
+ if (index < 0 || index >= currentItems.length) {
+ return;
+ }
+
+ replaceItems(currentItems.filter((_, currentIndex) => currentIndex !== index));
+ },
+ [getValues, replaceItems]
+ );
+
+ const duplicateItem = React.useCallback(
+ (index: number) => {
+ const currentItems = getValues("items") ?? [];
+ const item = currentItems[index];
+
+ if (!item) {
+ return;
+ }
+
+ const duplicatedItem: ProformaItemUpdateForm = {
+ ...item,
+ id: buildProformaItemUpdateDefault(index + 1).id,
+ position: index + 1,
+ };
+
+ const nextItems = [...currentItems];
+ nextItems.splice(index + 1, 0, duplicatedItem);
+
+ replaceItems(nextItems);
+ },
+ [getValues, replaceItems]
+ );
+
+ const moveItemUp = React.useCallback(
+ (index: number) => {
+ const currentItems = getValues("items") ?? [];
+
+ if (index <= 0 || index >= currentItems.length) {
+ return;
+ }
+
+ const nextItems = [...currentItems];
+ [nextItems[index - 1], nextItems[index]] = [nextItems[index], nextItems[index - 1]];
+
+ replaceItems(nextItems);
+ },
+ [getValues, replaceItems]
+ );
+
+ const moveItemDown = React.useCallback(
+ (index: number) => {
+ const currentItems = getValues("items") ?? [];
+
+ if (index < 0 || index >= currentItems.length - 1) {
+ return;
+ }
+
+ const nextItems = [...currentItems];
+ [nextItems[index], nextItems[index + 1]] = [nextItems[index + 1], nextItems[index]];
+
+ replaceItems(nextItems);
+ },
+ [getValues, replaceItems]
+ );
+
+ const getItemAmounts = React.useCallback(
+ (index: number): ProformaItemAmounts => {
+ return calculateItemAmounts(items[index]);
+ },
+ [items]
+ );
+
+ const totals = React.useMemo(() => calculateItemsTotals(items), [items]);
+
+ return {
+ fields,
+ items,
+
+ hasItems: items.length > 0,
+ itemCount: items.length,
+
+ appendItem,
+ removeItem,
+ duplicateItem,
+ moveItemUp,
+ moveItemDown,
+
+ getItemAmounts,
+ totals,
+ };
+};
diff --git a/modules/customer-invoices/src/web/proformas/update/entities/proforma-item-update-form.entity.ts b/modules/customer-invoices/src/web/proformas/update/entities/proforma-item-update-form.entity.ts
index 9c8cc110..5bd9406e 100644
--- a/modules/customer-invoices/src/web/proformas/update/entities/proforma-item-update-form.entity.ts
+++ b/modules/customer-invoices/src/web/proformas/update/entities/proforma-item-update-form.entity.ts
@@ -1,9 +1,8 @@
export interface ProformaItemUpdateForm {
id: string;
- position: string;
+ position: number;
description: string;
quantity: number;
unitAmount: number;
discountPercentage: number;
- taxes: string;
}
diff --git a/modules/customer-invoices/src/web/proformas/update/entities/proforma-item-update-form.schema.ts b/modules/customer-invoices/src/web/proformas/update/entities/proforma-item-update-form.schema.ts
new file mode 100644
index 00000000..1a07d100
--- /dev/null
+++ b/modules/customer-invoices/src/web/proformas/update/entities/proforma-item-update-form.schema.ts
@@ -0,0 +1,26 @@
+import { z } from "zod/v4";
+
+/**
+ * Este esquema es para validar los datos de los items de proformas.
+ * No tiene por qué coincidir con el shape de la entidad ni con el de la API.
+ * Solo define los campos que se muestran en el editor y sus validaciones.
+ *
+ * Reglas:
+ * - no meter transformaciones silenciosas raras en el esquema (ej: .toUpperCase())
+ * - nombres en camelCase
+ * - tipos orientados a UI/form
+ * - sin campos de solo lectura que no se editen
+ * - sin shape DTO
+ * - sin detalles impuestos por el widget
+ */
+
+export const ProformaItemUpdateFormSchema = z.object({
+ id: z.uuid(),
+ position: z.number().int().nonnegative(),
+ description: z.string().trim(),
+ quantity: z.number().positive(),
+ unitAmount: z.number().nonnegative(),
+ discountPercentage: z.number().min(0).max(100),
+});
+
+export type ProformaItemUpdateForm = z.infer;
diff --git a/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-form-default.entity.ts b/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-form-default.entity.ts
index e3ec1aa1..a53581dd 100644
--- a/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-form-default.entity.ts
+++ b/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-form-default.entity.ts
@@ -18,4 +18,6 @@ export const defaultProformaUpdateForm: ProformaUpdateForm = {
paymentMethod: "",
globalDiscountPercentage: 0,
+
+ items: [],
};
diff --git a/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-form.entity.ts b/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-form.entity.ts
index 610b3eb5..f2d28221 100644
--- a/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-form.entity.ts
+++ b/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-form.entity.ts
@@ -13,6 +13,8 @@
* - sin detalles impuestos por el widget
*/
+import type { ProformaItemUpdateForm } from "./proforma-item-update-form.schema";
+
export interface ProformaUpdateForm {
series: string;
@@ -32,5 +34,5 @@ export interface ProformaUpdateForm {
paymentMethod: string;
- //items: ProformaItemForm[];
+ items: ProformaItemUpdateForm[];
}
diff --git a/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-form.schema.ts b/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-form.schema.ts
index 4b6b7156..295e66bc 100644
--- a/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-form.schema.ts
+++ b/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-form.schema.ts
@@ -1,5 +1,7 @@
import { z } from "zod/v4";
+import { ProformaItemUpdateFormSchema } from "./proforma-item-update-form.schema";
+
/**
* Este esquema es para validar los datos del formulario de actualización de proformas.
* No tiene por qué coincidir con el shape de la entidad ni con el de la API.
@@ -32,86 +34,8 @@ export const ProformaUpdateFormSchema = z.object({
globalDiscountPercentage: z.number().default(0),
paymentMethod: z.string().default(""),
+
+ items: z.array(ProformaItemUpdateFormSchema),
});
export type ProformaUpdateFormSchemaType = z.infer;
-
-const ProformaItemFormSchema = z.object({
- description: z.string().max(2000).optional().default(""),
- quantity: z.any(), //NumericStringSchema.optional(),
- unit_amount: z.any(), //NumericStringSchema.optional(),
-
- subtotal_amount: z.any(), //z.number(),
- discount_percentage: z.any(), //NumericStringSchema.optional(),
- discount_amount: z.number(),
- taxable_amount: z.number(),
-
- tax_codes: z.array(z.string()).default([]),
-
- taxes_amount: z.number(),
- total_amount: z.number(),
-});
-
-const ProformaFormSchema = z.object({
- invoice_number: z.string().optional(),
- series: z.string().optional(),
-
- invoice_date: z.string().optional(),
- operation_date: z.string().optional(),
-
- customer_id: z.string().optional(),
- recipient: z
- .object({
- id: z.string().optional(),
- name: z.string().optional(),
- tin: z.string().optional(),
- street: z.string().optional(),
- street2: z.string().optional(),
- city: z.string().optional(),
- province: z.string().optional(),
- postal_code: z.string().optional(),
- country: z.string().optional(),
- })
- .optional(),
-
- reference: z.string().optional(),
- description: z.string().optional(),
- notes: z.string().optional(),
-
- language_code: z
- .string({
- error: "El idioma es obligatorio",
- })
- .min(1, "Debe indicar un idioma")
- .toUpperCase() // asegura mayúsculas
- .default("es"),
-
- currency_code: z
- .string({
- error: "La moneda es obligatoria",
- })
- .min(1, "La moneda no puede estar vacía")
- .toUpperCase() // asegura mayúsculas
- .default("EUR"),
-
- taxes: z
- .array(
- z.object({
- tax_code: z.string(),
- tax_label: z.string(),
- taxable_amount: z.number(),
- taxes_amount: z.number(),
- })
- )
- .optional(),
-
- items: z.array(ProformaItemFormSchema).optional(),
-
- subtotal_amount: z.number(),
- items_discount_amount: z.number(),
- discount_percentage: z.number(),
- discount_amount: z.number(),
- taxable_amount: z.number(),
- taxes_amount: z.number(),
- total_amount: z.number(),
-});
diff --git a/modules/customer-invoices/src/web/proformas/update/ui/blocks/index.ts b/modules/customer-invoices/src/web/proformas/update/ui/blocks/index.ts
index c3c8cd30..f7577927 100644
--- a/modules/customer-invoices/src/web/proformas/update/ui/blocks/index.ts
+++ b/modules/customer-invoices/src/web/proformas/update/ui/blocks/index.ts
@@ -1,3 +1,4 @@
+export * from "./items-editor";
export * from "./proforma-basic-info-fields";
export * from "./proforma-form-field-shell";
export * from "./proforma-header-fields-card";
diff --git a/modules/customer-invoices/src/web/proformas/pages/update/ui/components/items-editor/index.ts b/modules/customer-invoices/src/web/proformas/update/ui/blocks/items-editor/index.ts
similarity index 100%
rename from modules/customer-invoices/src/web/proformas/pages/update/ui/components/items-editor/index.ts
rename to modules/customer-invoices/src/web/proformas/update/ui/blocks/items-editor/index.ts
diff --git a/modules/customer-invoices/src/web/proformas/pages/update/ui/components/items-editor/item-row-editor.tsx b/modules/customer-invoices/src/web/proformas/update/ui/blocks/items-editor/item-row-editor.tsx
similarity index 100%
rename from modules/customer-invoices/src/web/proformas/pages/update/ui/components/items-editor/item-row-editor.tsx
rename to modules/customer-invoices/src/web/proformas/update/ui/blocks/items-editor/item-row-editor.tsx
diff --git a/modules/customer-invoices/src/web/proformas/pages/update/ui/components/items-editor/items-editor.tsx b/modules/customer-invoices/src/web/proformas/update/ui/blocks/items-editor/items-editor.tsx
similarity index 79%
rename from modules/customer-invoices/src/web/proformas/pages/update/ui/components/items-editor/items-editor.tsx
rename to modules/customer-invoices/src/web/proformas/update/ui/blocks/items-editor/items-editor.tsx
index ad5df7c4..cf5430b8 100644
--- a/modules/customer-invoices/src/web/proformas/pages/update/ui/components/items-editor/items-editor.tsx
+++ b/modules/customer-invoices/src/web/proformas/update/ui/blocks/items-editor/items-editor.tsx
@@ -1,18 +1,3 @@
-/** biome-ignore-all lint/complexity/noForEach: */
-/** biome-ignore-all lint/suspicious/useIterableCallbackReturn: */
-
-import { useProformaGridColumns } from "@erp/customer-invoices/web/proformas/hooks";
-import { DataTable, useWithRowSelection } from "@repo/rdx-ui/components";
-import { useMemo } from "react";
-import { useFieldArray, useFormContext } from "react-hook-form";
-
-import { useProformaAutoRecalc } from "../../../../../../hooks";
-import { useTranslation } from "../../../../../../i18n";
-import { type ProformaFormData, defaultProformaItemFormData } from "../../../../../types";
-import { useProformaContext } from "../../../context";
-
-import { ItemRowEditor } from "./item-row-editor";
-
const createEmptyItem = () => defaultProformaItemFormData;
export const ItemsEditor = () => {
diff --git a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-editor-form.tsx b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-editor-form.tsx
index 223629c5..31ce0fd8 100644
--- a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-editor-form.tsx
+++ b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-editor-form.tsx
@@ -7,20 +7,24 @@ import { ProformaUpdateRecipientEditor } from ".";
import { useTranslation } from "../../../../i18n";
import type { Proforma } from "../../../shared/entities";
+import type { UseUpdateProformaItemsControllerResult } from "../../controllers";
import { ProformaUpdateHeaderEditor } from "./proforma-update-header-editor";
+import { ProformaUpdateItemsEditor } from "./proforma-update-items-editor";
type ProformaUpdateEditorProps = {
formId: string;
proforma?: Proforma;
isSubmitting: boolean;
- onSubmit: React.FormEventHandler;
+ onSubmit: React.SubmitEventHandler;
onReset: () => void;
selectedCustomer?: CustomerSelectionOption | null;
onChangeCustomerClick: () => void;
onCreateCustomerClick: () => void;
+ itemsCtrl: UseUpdateProformaItemsControllerResult;
+
className?: string;
};
@@ -32,6 +36,7 @@ export const ProformaUpdateEditorForm = ({
selectedCustomer,
onChangeCustomerClick,
onCreateCustomerClick,
+ itemsCtrl,
className,
}: ProformaUpdateEditorProps) => {
const { t } = useTranslation();
@@ -47,6 +52,8 @@ export const ProformaUpdateEditorForm = ({
selectedCustomer={selectedCustomer}
/>
+
+