Edición de Items de proformas

This commit is contained in:
David Arranz 2026-04-12 23:10:53 +02:00
parent 8ca2ef1fab
commit 65c3e1f324
22 changed files with 497 additions and 123 deletions

View File

@ -1,3 +1,2 @@
export * from "./items";
export * from "./proforma-basic-info-fields";
export * from "./proforma-totals";

View File

@ -1 +0,0 @@
export * from "./proforma-items-editor";

View File

@ -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 (
<FieldSet {...props}>
<FieldLegend className="hidden text-foreground" variant="label">
{t("form_groups.items.title")}
</FieldLegend>
<FieldDescription className="hidden">{t("form_groups.items.description")}</FieldDescription>
<FieldGroup className="grid grid-cols-1">
<ItemsEditor />
</FieldGroup>
</FieldSet>
);
};

View File

@ -1,2 +1,3 @@
export * from "./use-update-proforma-controller";
export * from "./use-update-proforma-items-controller";
export * from "./use-update-proforma-page-controller";

View File

@ -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,

View File

@ -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<ProformaUpdateForm, "items", "fieldId">;
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<ProformaUpdateForm>;
}
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<ProformaItemUpdateForm, "quantity" | "unitAmount" | "discountPercentage">
): 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<ProformaItemsTotals>(
(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,
};
};

View File

@ -1,9 +1,8 @@
export interface ProformaItemUpdateForm {
id: string;
position: string;
position: number;
description: string;
quantity: number;
unitAmount: number;
discountPercentage: number;
taxes: string;
}

View File

@ -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<typeof ProformaItemUpdateFormSchema>;

View File

@ -18,4 +18,6 @@ export const defaultProformaUpdateForm: ProformaUpdateForm = {
paymentMethod: "",
globalDiscountPercentage: 0,
items: [],
};

View File

@ -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[];
}

View File

@ -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<typeof ProformaUpdateFormSchema>;
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(),
});

View File

@ -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";

View File

@ -1,18 +1,3 @@
/** biome-ignore-all lint/complexity/noForEach: <explanation> */
/** biome-ignore-all lint/suspicious/useIterableCallbackReturn: <explanation> */
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 = () => {

View File

@ -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<HTMLFormElement>;
onSubmit: React.SubmitEventHandler<HTMLFormElement>;
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}
/>
<ProformaUpdateItemsEditor disabled={isSubmitting} itemsCtrl={itemsCtrl} />
<div className="flex flex-col-reverse gap-3 border-t pt-4 sm:flex-row sm:justify-end">
<Button disabled={isSubmitting} onClick={onReset} type="button" variant="outline">
{t("common.reset", "Restablecer")}

View File

@ -0,0 +1,107 @@
import { TextField } from "@repo/rdx-ui/components";
import { Button, Card, CardContent } from "@repo/shadcn-ui/components";
import { CopyIcon, MoveDownIcon, MoveUpIcon, Trash2Icon } from "lucide-react";
import { useTranslation } from "../../../../i18n";
import type { ProformaItemAmounts } from "../../controllers/use-update-proforma-items-controller";
interface ProformaUpdateItemRowEditorProps {
index: number;
amounts: ProformaItemAmounts;
canMoveUp: boolean;
canMoveDown: boolean;
onRemove: () => void;
onDuplicate: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
}
export const ProformaUpdateItemRowEditor = ({
index,
amounts,
canMoveUp,
canMoveDown,
onRemove,
onDuplicate,
onMoveUp,
onMoveDown,
}: ProformaUpdateItemRowEditorProps) => {
const { t } = useTranslation();
return (
<Card>
<CardContent className="space-y-4 pt-6">
<div className="grid grid-cols-12 gap-4">
<TextField
className="col-span-12"
label={t("form_fields.items.description.label", "Descripción")}
name={`items.${index}.description`}
required
/>
<TextField
className="col-span-12 md:col-span-3"
label={t("form_fields.items.quantity.label", "Cantidad")}
name={`items.${index}.quantity`}
typePreset="number"
/>
<TextField
className="col-span-12 md:col-span-3"
label={t("form_fields.items.unit_amount.label", "Importe unitario")}
name={`items.${index}.unitAmount`}
typePreset="number"
/>
<TextField
className="col-span-12 md:col-span-3"
label={t("form_fields.items.discount_percentage.label", "Descuento %")}
name={`items.${index}.discountPercentage`}
typePreset="number"
/>
<div className="col-span-12 md:col-span-3 rounded-md border p-3 text-sm">
<div className="flex justify-between gap-3">
<span>{t("form_fields.items.subtotal.label", "Subtotal")}</span>
<span>{amounts.subtotal}</span>
</div>
<div className="flex justify-between gap-3">
<span>{t("form_fields.items.discount_amount.label", "Descuento")}</span>
<span>{amounts.discountAmount}</span>
</div>
<div className="flex justify-between gap-3 font-medium">
<span>{t("form_fields.items.total.label", "Total")}</span>
<span>{amounts.total}</span>
</div>
</div>
</div>
<div className="flex flex-wrap justify-end gap-2">
<Button disabled={!canMoveUp} onClick={onMoveUp} type="button" variant="outline">
<MoveUpIcon />
{t("common.move_up", "Subir")}
</Button>
<Button disabled={!canMoveDown} onClick={onMoveDown} type="button" variant="outline">
<MoveDownIcon />
{t("common.move_down", "Bajar")}
</Button>
<Button onClick={onDuplicate} type="button" variant="outline">
<CopyIcon />
{t("common.duplicate", "Duplicar")}
</Button>
<Button onClick={onRemove} type="button" variant="destructive">
<Trash2Icon />
{t("common.remove", "Eliminar")}
</Button>
</div>
</CardContent>
</Card>
);
};

View File

@ -0,0 +1,62 @@
import { Button } from "@repo/shadcn-ui/components";
import { PlusIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useTranslation } from "../../../../i18n";
import type { UseUpdateProformaItemsControllerResult } from "../../controllers/use-update-proforma-items-controller";
import { ProformaSectionCard } from "../blocks";
import { ProformaUpdateItemRowEditor } from "./proforma-update-item-row-editor";
import { ProformaUpdateItemsTotals } from "./proforma-update-items-totals";
interface ProformaUpdateItemsEditorProps extends ComponentProps<"fieldset"> {
itemsCtrl: UseUpdateProformaItemsControllerResult;
}
export const ProformaUpdateItemsEditor = ({
itemsCtrl,
disabled,
...props
}: ProformaUpdateItemsEditorProps) => {
const { t } = useTranslation();
return (
<ProformaSectionCard
description={t("form_groups.items.description")}
title={t("form_groups.items.title")}
>
<fieldset className="space-y-4" disabled={disabled} {...props}>
<div className="flex justify-end">
<Button onClick={itemsCtrl.appendItem} type="button" variant="outline">
<PlusIcon />
{t("common.add", "Añadir")}
</Button>
</div>
{itemsCtrl.hasItems ? (
<div className="space-y-4">
{itemsCtrl.fields.map((field, index) => (
<ProformaUpdateItemRowEditor
amounts={itemsCtrl.getItemAmounts(index)}
canMoveDown={index < itemsCtrl.itemCount - 1}
canMoveUp={index > 0}
index={index}
key={field.fieldId}
onDuplicate={() => itemsCtrl.duplicateItem(index)}
onMoveDown={() => itemsCtrl.moveItemDown(index)}
onMoveUp={() => itemsCtrl.moveItemUp(index)}
onRemove={() => itemsCtrl.removeItem(index)}
/>
))}
</div>
) : (
<div className="text-sm text-muted-foreground">
{t("form_groups.items.empty", "Todavía no hay líneas en la proforma.")}
</div>
)}
<ProformaUpdateItemsTotals totals={itemsCtrl.totals} />
</fieldset>
</ProformaSectionCard>
);
};

View File

@ -0,0 +1,29 @@
import { useTranslation } from "../../../../i18n";
import type { ProformaItemsTotals } from "../../controllers";
interface ProformaUpdateItemsTotalsProps {
totals: ProformaItemsTotals;
}
export const ProformaUpdateItemsTotals = ({ totals }: ProformaUpdateItemsTotalsProps) => {
const { t } = useTranslation();
return (
<div className="ml-auto w-full max-w-md space-y-2 rounded-lg border p-4 text-sm">
<div className="flex justify-between gap-3">
<span>{t("form_groups.items.totals.subtotal", "Subtotal")}</span>
<span>{totals.subtotal}</span>
</div>
<div className="flex justify-between gap-3">
<span>{t("form_groups.items.totals.discount", "Descuento")}</span>
<span>{totals.discountAmount}</span>
</div>
<div className="flex justify-between gap-3 text-base font-semibold">
<span>{t("form_groups.items.totals.total", "Total")}</span>
<span>{totals.total}</span>
</div>
</div>
);
};

View File

@ -101,6 +101,7 @@ export const ProformaUpdatePage = () => {
<ProformaUpdateEditorForm
formId={updateCtrl.formId}
isSubmitting={updateCtrl.isUpdating}
itemsCtrl={updateCtrl.itemsCtrl}
onChangeCustomerClick={selectCustomerCtrl.selectCtrl.openDialog}
//onCreateCustomerClick={selectCustomerCtrl.createCtrl.openDialog}
onCreateCustomerClick={() => null}

View File

@ -0,0 +1,14 @@
import { UniqueID } from "@repo/rdx-ddd";
import type { ProformaItemUpdateForm } from "../entities";
export const buildProformaItemUpdateDefault = (position: number): ProformaItemUpdateForm => {
return {
id: UniqueID.generateNewID().toString(),
position,
description: "",
quantity: 1,
unitAmount: 0,
discountPercentage: 0,
};
};

View File

@ -1,3 +1,4 @@
export * from "./build-proforma-item-update-default";
export * from "./build-proforma-update-patch";
export * from "./build-update-proforma-by-id-params";
export * from "./focus-first-proforma-update-error";