Editor de proformas

This commit is contained in:
David Arranz 2026-06-04 11:09:24 +02:00
parent 834d90f60c
commit 6e83958e30
22 changed files with 285 additions and 89 deletions

View File

@ -1,9 +1,11 @@
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
import { AppMain } from "./app-main";
export const AppFullscreenLayout = () => { export const AppFullscreenLayout = () => {
return ( return (
<main className="h-dvh w-dvw overflow-hidden bg-background"> <AppMain className="h-dvh w-dvw">
<Outlet /> <Outlet />
</main> </AppMain>
); );
}; };

View File

@ -32,7 +32,12 @@ export const PageFormHeader = ({
contentClassName, contentClassName,
}: PageFormHeaderProps) => { }: PageFormHeaderProps) => {
return ( return (
<header className={cn("sticky top-0 z-50 border-b bg-background", className)}> <header
className={cn(
"sticky top-0 z-50 ring-1 ring-foreground/10 bg-background shadow-xs",
className
)}
>
<div <div
className={cn( className={cn(
"flex h-14 items-center justify-between gap-6 border-b px-4 py-2 sm:px-6 md:h-18", "flex h-14 items-center justify-between gap-6 border-b px-4 py-2 sm:px-6 md:h-18",

View File

@ -51,7 +51,7 @@ export const PageHeader = ({
<div className="min-w-0 space-y-2"> <div className="min-w-0 space-y-2">
<div className="flex min-w-0 flex-wrap items-center gap-2"> <div className="flex min-w-0 flex-wrap items-center gap-2">
<h1 className="min-w-0 truncate text-xl font-bold tracking-tight text-foreground lg:text-2xl"> <h1 className="min-w-0 truncate text-2xl font-semibold tracking-tight text-foreground xl:text-3xl font-heading">
{title} {title}
</h1> </h1>

View File

@ -1,3 +1,5 @@
import { isNullishOrEmpty } from "@repo/rdx-utils";
import type { ProformaItemUpdateForm, ProformaLineInput } from "../entities"; import type { ProformaItemUpdateForm, ProformaLineInput } from "../entities";
/** /**
@ -16,16 +18,19 @@ export const mapProformaItemFormToProformaLineInputs = (
items: ProformaItemUpdateForm[], items: ProformaItemUpdateForm[],
params: MapProformaItemFormToProformaLineInputsParams params: MapProformaItemFormToProformaLineInputsParams
): ProformaLineInput[] => { ): ProformaLineInput[] => {
return items return (
.filter((item) => item.isValued) items
.map((item) => ({ // quitar tuplas no valoradas
quantity: item.quantity, .filter((item) => !(isNullishOrEmpty(item.quantity) || isNullishOrEmpty(item.unitAmount)))
unitAmount: item.unitAmount, .map((item) => ({
quantity: item.quantity,
unitAmount: item.unitAmount,
itemDiscountPercentage: item.itemDiscountPercentage, itemDiscountPercentage: item.itemDiscountPercentage,
globalDiscountPercentage: params.globalDiscountPercentage, globalDiscountPercentage: params.globalDiscountPercentage,
taxPercentage: item.taxPercentage, taxPercentage: item.taxPercentage,
recPercentage: item.recPercentage, recPercentage: item.recPercentage,
})); }))
);
}; };

View File

@ -14,7 +14,7 @@ export const mapProformaItemsToProformaItemsUpdateForm = (
return { return {
id: item.id, id: item.id,
position: item.position, position: item.position,
isValued: item.isValued, //isValued: item.isValued, <- campo calculado en frontend, no se mapea desde la API
description: item.description ?? null, description: item.description ?? null,

View File

@ -41,7 +41,8 @@ export const mapProformaToProformaUpdateForm = (proforma: Proforma): ProformaUpd
taxMode: fiscalDefaults.taxMode, taxMode: fiscalDefaults.taxMode,
taxRegimeCode: "01", //taxRegimeCode: proforma.taxRegimeCode ?? proformaDefaults.taxRegimeCode, // TODO: implementar en API taxRegimeCode: "01", //taxRegimeCode: proforma.taxRegimeCode ?? proformaDefaults.taxRegimeCode, // TODO: implementar en API
hasTaxPercentage: fiscalDefaults.defaultTaxPercentage !== null, hasTaxPercentage:
fiscalDefaults.taxMode === "single" && fiscalDefaults.defaultTaxPercentage !== null,
taxPercentage: fiscalDefaults.defaultTaxPercentage, taxPercentage: fiscalDefaults.defaultTaxPercentage,
hasRecPercentage: fiscalDefaults.defaultRecPercentage !== null, hasRecPercentage: fiscalDefaults.defaultRecPercentage !== null,

View File

@ -1,3 +1,4 @@
import { isNullishOrEmpty } from "@repo/rdx-utils";
import * as React from "react"; import * as React from "react";
import { import {
type FieldArrayWithId, type FieldArrayWithId,
@ -16,6 +17,8 @@ import { calculateProformaLinesTotals } from "../utils/calculations/calculate-pr
export interface ProformaItemAmounts { export interface ProformaItemAmounts {
subtotal: number; subtotal: number;
itemDiscountAmount: number; itemDiscountAmount: number;
subtotalTaxable: number;
taxAmount: number;
total: number; total: number;
} }
@ -35,6 +38,8 @@ export interface UseUpdateProformaItemsControllerResult {
hasItems: boolean; hasItems: boolean;
itemCount: number; itemCount: number;
getItemIsValued: (index: number) => boolean;
itemErrors: ProformaItemError[]; itemErrors: ProformaItemError[];
getItemError: (index: number) => ProformaItemError | undefined; getItemError: (index: number) => ProformaItemError | undefined;
getItemErrorMessage: (index: number) => string | undefined; getItemErrorMessage: (index: number) => string | undefined;
@ -96,9 +101,14 @@ export const useUpdateProformaItemsController = ({
const appendItem = React.useCallback(() => { const appendItem = React.useCallback(() => {
const nextPosition = getValues("items")?.length ?? 0; const nextPosition = getValues("items")?.length ?? 0;
append(buildProformaItemUpdateDefault(nextPosition), { append(
shouldFocus: false, buildProformaItemUpdateDefault(nextPosition, {
}); defaultTaxPercentage: getValues("taxPercentage") ?? undefined,
}),
{
shouldFocus: true,
}
);
}, [append, getValues]); }, [append, getValues]);
const removeItem = React.useCallback( const removeItem = React.useCallback(
@ -161,23 +171,6 @@ export const useUpdateProformaItemsController = ({
[getValues, replaceItems] [getValues, replaceItems]
); );
const getItemAmounts = React.useCallback(
(index: number): ProformaItemAmounts => {
const line = mapProformaItemFormToProformaLineInputs([items[index]], {
globalDiscountPercentage: null,
})[0];
const amounts = calculateProformaLineTotal(line);
return {
subtotal: amounts.subtotalBeforeDiscounts,
itemDiscountAmount: amounts.itemDiscountAmount,
total: amounts.subtotalBeforeGlobalDiscount,
};
},
[items]
);
const insertItemAt = React.useCallback( const insertItemAt = React.useCallback(
(index: number) => { (index: number) => {
const currentItems = getValues("items") ?? []; const currentItems = getValues("items") ?? [];
@ -223,6 +216,19 @@ export const useUpdateProformaItemsController = ({
}; };
}, [items]); }, [items]);
const itemsIsValued = React.useMemo(() => {
return items.map((item, _) => {
return !(isNullishOrEmpty(item.quantity) || isNullishOrEmpty(item.unitAmount));
});
}, [items]);
const getItemIsValued = React.useCallback(
(index: number): boolean => {
return itemsIsValued[index] ?? false;
},
[itemsIsValued]
);
const itemErrors = React.useMemo<ProformaItemError[]>(() => { const itemErrors = React.useMemo<ProformaItemError[]>(() => {
if (!Array.isArray(errors.items)) return []; if (!Array.isArray(errors.items)) return [];
@ -241,7 +247,6 @@ export const useUpdateProformaItemsController = ({
const error = itemErrors[index]; const error = itemErrors[index];
return ( return (
error?.isValued?.message ??
error?.quantity?.message ?? error?.quantity?.message ??
error?.unitAmount?.message ?? error?.unitAmount?.message ??
error?.description?.message ?? error?.description?.message ??
@ -251,6 +256,25 @@ export const useUpdateProformaItemsController = ({
[itemErrors] [itemErrors]
); );
const getItemAmounts = React.useCallback(
(index: number): ProformaItemAmounts => {
const line = mapProformaItemFormToProformaLineInputs([items[index]], {
globalDiscountPercentage: null,
})[0];
const amounts = calculateProformaLineTotal(line);
return {
subtotal: amounts.subtotalBeforeDiscounts,
itemDiscountAmount: amounts.itemDiscountAmount,
subtotalTaxable: amounts.subtotalTaxable,
taxAmount: amounts.taxAmount,
total: amounts.totalAmount,
};
},
[items]
);
return { return {
fields, fields,
items, items,
@ -258,6 +282,8 @@ export const useUpdateProformaItemsController = ({
hasItems: items.length > 0, hasItems: items.length > 0,
itemCount: items.length, itemCount: items.length,
getItemIsValued,
itemErrors, itemErrors,
getItemError, getItemError,
getItemErrorMessage, getItemErrorMessage,

View File

@ -98,6 +98,8 @@ export const useUpdateProformaTaxController = ({
}, [taxPercentage, getValues, hasRecPercentage, setValue, taxMode]); }, [taxPercentage, getValues, hasRecPercentage, setValue, taxMode]);
const enablePerLineTaxes = useCallback(() => { const enablePerLineTaxes = useCallback(() => {
// Activar impuestos por línea
setValue("taxMode", "perLine", { setValue("taxMode", "perLine", {
shouldDirty: true, shouldDirty: true,
shouldTouch: true, shouldTouch: true,
@ -106,6 +108,8 @@ export const useUpdateProformaTaxController = ({
}, [setValue]); }, [setValue]);
const disablePerLineTaxes = useCallback(() => { const disablePerLineTaxes = useCallback(() => {
// Desactivar impuestos por línea
setValue("taxMode", "single", { setValue("taxMode", "single", {
shouldDirty: true, shouldDirty: true,
shouldTouch: true, shouldTouch: true,

View File

@ -15,6 +15,9 @@ export interface ProformaLineTotal {
subtotalBeforeGlobalDiscount: number; subtotalBeforeGlobalDiscount: number;
globalDiscountAmount: number; globalDiscountAmount: number;
totalDiscountAmount: number; totalDiscountAmount: number;
subtotalTaxable: number;
taxAmount: number;
totalAmount: number;
} }
export interface ProformaTaxBreakdown { export interface ProformaTaxBreakdown {

View File

@ -1,7 +1,7 @@
export interface ProformaItemUpdateForm { export interface ProformaItemUpdateForm {
id: string; id: string;
position: number; position: number;
isValued: boolean; //isValued: boolean; <- campo calculado en frontend, no se mapea desde la API
description: string | null; description: string | null;

View File

@ -17,7 +17,7 @@ import { z } from "zod/v4";
export const ProformaItemUpdateFormSchema = z.object({ export const ProformaItemUpdateFormSchema = z.object({
id: z.string(), id: z.string(),
position: z.number(), position: z.number(),
isValued: z.boolean(), //isValued: z.boolean(), <- campo calculado en frontend, no se mapea desde la API
description: z.string().nullable(), description: z.string().nullable(),

View File

@ -63,6 +63,7 @@ export interface LineEditorProps<TLine> {
moveDownLabel: string; moveDownLabel: string;
removeLabel: string; removeLabel: string;
disabled?: boolean;
className?: string; className?: string;
} }
@ -96,6 +97,8 @@ export const LineEditor = <TLine,>({
moveDownLabel, moveDownLabel,
removeLabel, removeLabel,
disabled = false,
className, className,
}: LineEditorProps<TLine>) => { }: LineEditorProps<TLine>) => {
return ( return (
@ -104,12 +107,24 @@ export const LineEditor = <TLine,>({
<h3 className="text-lg font-semibold">{title}</h3> <h3 className="text-lg font-semibold">{title}</h3>
<div className="flex gap-2"> <div className="flex gap-2">
<Button onClick={onAddAtStart} size="sm" type="button" variant="outline"> <Button
disabled={disabled}
onClick={onAddAtStart}
size="sm"
type="button"
variant="outline"
>
<Plus className="mr-1 h-4 w-4" /> <Plus className="mr-1 h-4 w-4" />
{addAtStartLabel} {addAtStartLabel}
</Button> </Button>
<Button onClick={onAddAtEnd} size="sm" type="button" variant="default"> <Button
disabled={disabled}
onClick={onAddAtEnd}
size="sm"
type="button"
variant="default"
>
<Plus className="mr-1 h-4 w-4" /> <Plus className="mr-1 h-4 w-4" />
{addAtEndLabel} {addAtEndLabel}
</Button> </Button>
@ -132,7 +147,7 @@ export const LineEditor = <TLine,>({
))} ))}
<TableHead className="w-[50px] font-semibold text-muted-foreground text-center"> <TableHead className="w-[50px] font-semibold text-muted-foreground text-center">
<MoreHorizontalIcon className="size-4 text-muted-foreground mx-auto" /> Acciones
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@ -162,13 +177,13 @@ export const LineEditor = <TLine,>({
</TableCell> </TableCell>
))} ))}
<TableCell className="text-muted-foreground text-sm font-medium align-top"> <TableCell className="text-muted-foreground text-sm font-medium align-top text-center">
<DropdownMenu> <DropdownMenu disabled={disabled}>
<DropdownMenuTrigger <DropdownMenuTrigger
render={ render={
<Button <Button
aria-label={actionsLabel} aria-label={actionsLabel}
className="size-8 opacity-0 group-hover:opacity-100 focus:opacity-100" className="size-8"
size="icon" size="icon"
type="button" type="button"
variant="ghost" variant="ghost"

View File

@ -24,6 +24,7 @@ interface ProformaLineEditorProps {
showLineTaxes: boolean; showLineTaxes: boolean;
getItemIsValued: (index: number) => boolean;
getItemAmounts: (index: number) => ProformaItemAmounts; getItemAmounts: (index: number) => ProformaItemAmounts;
getItemErrorMessage: (index: number) => string | undefined; getItemErrorMessage: (index: number) => string | undefined;
@ -40,15 +41,16 @@ interface ProformaLineEditorProps {
removeItem: (index: number) => void; removeItem: (index: number) => void;
currency?: string; currency?: string;
disabled?: boolean;
className?: string; className?: string;
} }
2;
export const ProformaLineEditor = ({ export const ProformaLineEditor = ({
fields, fields,
showLineTaxes, showLineTaxes,
getItemIsValued,
getItemAmounts, getItemAmounts,
getItemErrorMessage, getItemErrorMessage,
@ -65,6 +67,7 @@ export const ProformaLineEditor = ({
removeItem, removeItem,
currency = "EUR", currency = "EUR",
disabled = false,
className, className,
}: ProformaLineEditorProps) => { }: ProformaLineEditorProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -109,6 +112,22 @@ export const ProformaLineEditor = ({
/> />
), ),
}, },
{
id: "subtotal",
header: t("form_fields.items.subtotal.label", "Subtotal"),
headClassName: "text-right",
className: "text-right font-semibold tabular-nums pt-4",
//cell: ({ index }) => MoneyHelper.formatCurrency(getItemAmounts(index).total, 2, currency),
cell: ({ index }) => {
const isValued = getItemIsValued(index);
if (!isValued) return "";
const { subtotal } = getItemAmounts(index);
return MoneyHelper.formatCurrency(subtotal, 2, currency);
},
},
{ {
id: "itemDiscountPercentage", id: "itemDiscountPercentage",
header: t("form_fields.items.discount_percentage.label", "Dto (%)"), header: t("form_fields.items.discount_percentage.label", "Dto (%)"),
@ -123,6 +142,21 @@ export const ProformaLineEditor = ({
), ),
}, },
{
id: "itemDiscountAmount",
header: t("form_fields.items.itemDiscountAmount.label", "Imp. dto."),
headClassName: "text-right",
className: "text-right font-semibold tabular-nums pt-4",
//cell: ({ index }) => MoneyHelper.formatCurrency(getItemAmounts(index).total, 2, currency),
cell: ({ index }) => {
const isValued = getItemIsValued(index);
if (!isValued) return "";
const { itemDiscountAmount } = getItemAmounts(index);
return MoneyHelper.formatCurrency(itemDiscountAmount, 2, currency);
},
},
...(showLineTaxes ...(showLineTaxes
? [ ? [
{ {
@ -142,13 +176,74 @@ export const ProformaLineEditor = ({
] ]
: []), : []),
{ ...(showLineTaxes
id: "total", ? [
header: t("form_fields.items.total.label", "Total"), {
headClassName: "w-[200px] text-right", id: "taxAmount",
className: "text-right font-semibold tabular-nums pt-4", header: t("form_fields.items.total.label", "Imp. IVA"),
cell: ({ index }) => MoneyHelper.formatCurrency(getItemAmounts(index).total, 2, currency), headClassName: "w-[200px] text-right",
}, className: "text-right font-semibold tabular-nums pt-4",
cell: ({ index }: { index: number }) => {
const isValued = getItemIsValued(index);
if (!isValued) return "";
const { taxAmount } = getItemAmounts(index);
return MoneyHelper.formatCurrency(taxAmount);
},
},
]
: []),
/*{
id: "isValued",
header: t("form_fields.items.is_valued.label", "Val."),
headClassName: "text-center",
className: "w-[60px] text-center",
cell: ({ index }) => {
const isValued = getItemIsValued(index);
return isValued ? (
<span className="size-5 rounded-full bg-green-500" />
) : (
<span className="size-5 rounded-full bg-muted-foreground/30" />
);
},
},*/
...(showLineTaxes
? [
{
id: "total",
header: t("form_fields.items.total.label", "Total"),
headClassName: "w-[200px] text-right",
className: "text-right font-semibold tabular-nums pt-4",
cell: ({ index }: { index: number }) => {
const isValued = getItemIsValued(index);
if (!isValued) return "";
const { total } = getItemAmounts(index);
return MoneyHelper.formatCurrency(total, 2, currency);
},
},
]
: []),
...(showLineTaxes
? []
: [
{
id: "subtotalTaxable",
header: t("form_fields.items.total.label", "Total"),
headClassName: "w-[200px] text-right",
className: "text-right font-semibold tabular-nums pt-4",
cell: ({ index }: { index: number }) => {
const isValued = getItemIsValued(index);
if (!isValued) return "";
const { subtotalTaxable } = getItemAmounts(index);
return MoneyHelper.formatCurrency(subtotalTaxable, 2, currency);
},
},
]),
]; ];
return ( return (
@ -158,6 +253,7 @@ export const ProformaLineEditor = ({
addAtStartLabel={t("common.add_at_start", "Al inicio")} addAtStartLabel={t("common.add_at_start", "Al inicio")}
className={className} className={className}
columns={columns} columns={columns}
disabled={disabled}
duplicateLabel={t("common.duplicate", "Duplicar")} duplicateLabel={t("common.duplicate", "Duplicar")}
getLineErrorMessage={(_, index) => { getLineErrorMessage={(_, index) => {
const message = getItemErrorMessage(index); const message = getItemErrorMessage(index);
@ -179,7 +275,12 @@ export const ProformaLineEditor = ({
onRemove={removeItem} onRemove={removeItem}
removeLabel={t("common.remove", "Eliminar")} removeLabel={t("common.remove", "Eliminar")}
renderFooter={() => ( renderFooter={() => (
<ProformaLineFooterEditor currency={currency} itemsTotals={itemsTotals} totals={totals} /> <ProformaLineFooterEditor
currency={currency}
disabled={disabled}
itemsTotals={itemsTotals}
totals={totals}
/>
)} )}
title={t("form_fields.items.title", "Líneas de detalle")} title={t("form_fields.items.title", "Líneas de detalle")}
/> />
@ -188,13 +289,14 @@ export const ProformaLineEditor = ({
type ProformaLineFooterEditorProps = Pick< type ProformaLineFooterEditorProps = Pick<
ProformaLineEditorProps, ProformaLineEditorProps,
"totals" | "itemsTotals" | "currency" "totals" | "itemsTotals" | "currency" | "disabled"
>; >;
export const ProformaLineFooterEditor = ({ export const ProformaLineFooterEditor = ({
totals, totals,
itemsTotals, itemsTotals,
currency, currency,
disabled,
}: ProformaLineFooterEditorProps) => { }: ProformaLineFooterEditorProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -205,6 +307,7 @@ export const ProformaLineFooterEditor = ({
<FormFieldLabel>Descuento global (%):</FormFieldLabel> <FormFieldLabel>Descuento global (%):</FormFieldLabel>
<div className="flex items-center gap-2 w-24"> <div className="flex items-center gap-2 w-24">
<PercentageField <PercentageField
disabled={disabled}
maxFractionDigits={2} maxFractionDigits={2}
minFractionDigits={0} minFractionDigits={0}
name={"globalDiscountPercentage"} name={"globalDiscountPercentage"}

View File

@ -5,11 +5,7 @@ import { PercentageField } from "@repo/rdx-ui/components";
import { preventEnterKeySubmitForm } from "@repo/rdx-ui/helpers"; import { preventEnterKeySubmitForm } from "@repo/rdx-ui/helpers";
import { cn } from "@repo/shadcn-ui/lib/utils"; import { cn } from "@repo/shadcn-ui/lib/utils";
import { import { ProformaUpdatePaymentEditor, ProformaUpdateRecipientEditor } from ".";
ProformaUpdatePaymentEditor,
ProformaUpdateRecipientEditor,
ProformaUpdateSettingsEditor,
} from ".";
import { useTranslation } from "../../../../i18n"; import { useTranslation } from "../../../../i18n";
import type { import type {
@ -113,7 +109,6 @@ export const ProformaUpdateEditorForm = ({
totals={totalsCtrl.totals} totals={totalsCtrl.totals}
/> />
<ProformaUpdateSettingsEditor className="w-full" />
<ProformaUpdatePaymentEditor <ProformaUpdatePaymentEditor
className="w-full" className="w-full"
disabled={isSubmitting || paymentCtrl.isLoading} disabled={isSubmitting || paymentCtrl.isLoading}

View File

@ -35,10 +35,12 @@ export const ProformaUpdateItemsEditor = ({
<ProformaLineEditor <ProformaLineEditor
addItemAtStart={itemsCtrl.addItemAtStart} addItemAtStart={itemsCtrl.addItemAtStart}
appendItem={itemsCtrl.appendItem} appendItem={itemsCtrl.appendItem}
disabled={disabled}
duplicateItem={itemsCtrl.duplicateItem} duplicateItem={itemsCtrl.duplicateItem}
fields={itemsCtrl.fields} fields={itemsCtrl.fields}
getItemAmounts={itemsCtrl.getItemAmounts} getItemAmounts={itemsCtrl.getItemAmounts}
getItemErrorMessage={itemsCtrl.getItemErrorMessage} getItemErrorMessage={itemsCtrl.getItemErrorMessage}
getItemIsValued={itemsCtrl.getItemIsValued}
insertItemAfter={itemsCtrl.insertItemAfter} insertItemAfter={itemsCtrl.insertItemAfter}
insertItemBefore={itemsCtrl.insertItemBefore} insertItemBefore={itemsCtrl.insertItemBefore}
itemsTotals={itemsCtrl.totals} itemsTotals={itemsCtrl.totals}

View File

@ -37,7 +37,7 @@ export const ProformaUpdateRecipientEditor = ({
}: ProformaUpdateRecipientEditorProps) => { }: ProformaUpdateRecipientEditorProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const showActions = !(readOnly || disabled); const showActions = !readOnly;
return ( return (
<Card className={className}> <Card className={className}>
@ -66,9 +66,10 @@ export const ProformaUpdateRecipientEditor = ({
</CardContent> </CardContent>
{showActions && ( {showActions && (
<CardFooter className="flex flex-col space-y-4"> <CardFooter className="flex gap-2 2xl:justify-between">
<Button <Button
className="w-full justify-center" className="justify-center"
disabled={disabled}
onClick={onCreateCustomerClick} onClick={onCreateCustomerClick}
type="button" type="button"
variant="outline" variant="outline"
@ -76,12 +77,12 @@ export const ProformaUpdateRecipientEditor = ({
<PlusIcon className="mr-2 size-4" /> <PlusIcon className="mr-2 size-4" />
{t("customers.selected_customer.new_customer", "Nuevo cliente")} {t("customers.selected_customer.new_customer", "Nuevo cliente")}
</Button> </Button>
<Button <Button
className="w-full justify-center" className="justify-center"
disabled={disabled}
onClick={onChangeCustomerClick} onClick={onChangeCustomerClick}
type="button" type="button"
variant={selectedCustomer ? "secondary" : "default"} variant={selectedCustomer ? "outline" : "default"}
> >
<RefreshCwIcon className="mr-2 size-4" /> <RefreshCwIcon className="mr-2 size-4" />
{selectedCustomer {selectedCustomer

View File

@ -110,11 +110,32 @@ export const ProformaUpdateTaxEditor = ({
"form_fields.proformas.tax_regime_code.placeholder", "form_fields.proformas.tax_regime_code.placeholder",
"Selecciona el régimen fiscal para esta proforma" "Selecciona el régimen fiscal para esta proforma"
)} )}
readOnly={readOnly || taxCtrl.usesPerLineTax} readOnly={readOnly}
/>
<Separator className="w-full col-start-1 col-span-full" />
<SwitchField
className="md:col-span-12 md:col-start-1 not-disabled:cursor-pointer"
disabled={disabled}
label="Mismo IVA en todas las líneas de la proforma"
name="hasTaxPercentage"
onCheckedChange={(checked) => {
checked ? taxCtrl.disablePerLineTaxes() : taxCtrl.enablePerLineTaxes();
}}
readOnly={readOnly}
/> />
<SelectField <SelectField
className="md:col-span-4 md:col-start-1" className="md:col-span-4 md:col-start-1 lg:col-span-2 2xl:col-span-6"
description={
taxCtrl.usesSingleTax
? t("form_fields.proformas.default_tax_percentage.description", "")
: t(
"form_fields.proformas.default_tax_percentage.description",
"Para las lineas de detalle nuevas"
)
}
deserialize={(value) => (value === null || value === "" ? null : Number(value))} deserialize={(value) => (value === null || value === "" ? null : Number(value))}
disabled={disabled} disabled={disabled}
inputClassName="bg-background" inputClassName="bg-background"
@ -131,23 +152,12 @@ export const ProformaUpdateTaxEditor = ({
"form_fields.proformas.default_tax_percentage.placeholder", "form_fields.proformas.default_tax_percentage.placeholder",
"Selecciona IVA" "Selecciona IVA"
)} )}
readOnly={readOnly || taxCtrl.usesPerLineTax} readOnly={readOnly}
serialize={(value) => (typeof value === "number" ? String(value) : "")} serialize={(value) => (typeof value === "number" ? String(value) : "")}
/> />
<Separator className="w-full col-start-1 col-span-full" /> <Separator className="w-full col-start-1 col-span-full" />
<SwitchField
checked={taxCtrl.usesSingleTax}
className="md:col-span-12 md:col-start-1 not-disabled:cursor-pointer"
disabled={disabled || readOnly}
label="Mismo IVA en todas las líneas de la proforma"
name=""
onCheckedChange={(checked) =>
checked ? taxCtrl.disablePerLineTaxes() : taxCtrl.enablePerLineTaxes()
}
/>
<SwitchField <SwitchField
className="md:col-span-12 md:col-start-1" className="md:col-span-12 md:col-start-1"
disabled={disabled} disabled={disabled}
@ -158,7 +168,7 @@ export const ProformaUpdateTaxEditor = ({
"Recargo de equivalencia" "Recargo de equivalencia"
)} )}
{taxCtrl.hasRecPercentage ? ( {taxCtrl.hasRecPercentage && !taxCtrl.usesPerLineTax ? (
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{PercentageHelper.formatPercent(taxCtrl.recPercentage ?? 0)} {PercentageHelper.formatPercent(taxCtrl.recPercentage ?? 0)}
</span> </span>
@ -179,7 +189,7 @@ export const ProformaUpdateTaxEditor = ({
/> />
<SelectField <SelectField
className="md:col-span-4 md:col-start-1" className="md:col-span-4 md:col-start-1 lg:col-span-2 2xl:col-span-6"
deserialize={(value) => (value === null || value === "" ? null : Number(value))} deserialize={(value) => (value === null || value === "" ? null : Number(value))}
disabled={disabled || !taxCtrl.hasRetentionPercentage} disabled={disabled || !taxCtrl.hasRetentionPercentage}
inputClassName="bg-background" inputClassName="bg-background"
@ -196,7 +206,7 @@ export const ProformaUpdateTaxEditor = ({
"form_fields.proformas.default_tax_percentage.placeholder", "form_fields.proformas.default_tax_percentage.placeholder",
"Selecciona IVA" "Selecciona IVA"
)} )}
readOnly={readOnly || taxCtrl.usesPerLineTax} readOnly={readOnly}
serialize={(value) => (typeof value === "number" ? String(value) : "")} serialize={(value) => (typeof value === "number" ? String(value) : "")}
/> />
</FormSectionGrid> </FormSectionGrid>

View File

@ -58,7 +58,7 @@ export const ProformaUpdatePage = () => {
); );
return ( return (
<div className="fixed inset-0 z-50 flex flex-col overflow-y-scroll bg-muted"> <div className="fixed inset-0 flex flex-col overflow-hidden">
<FormProvider {...updateCtrl.form}> <FormProvider {...updateCtrl.form}>
<UnsavedChangesProvider isDirty={updateCtrl.form.formState.isDirty}> <UnsavedChangesProvider isDirty={updateCtrl.form.formState.isDirty}>
<ProformaUpdateHeader <ProformaUpdateHeader

View File

@ -2,11 +2,18 @@ import { UniqueID } from "@repo/rdx-ddd";
import type { ProformaItemUpdateForm } from "../entities"; import type { ProformaItemUpdateForm } from "../entities";
export const buildProformaItemUpdateDefault = (position: number): ProformaItemUpdateForm => { export type buildProformaItemUpdateDefaultContext = {
defaultTaxPercentage?: number;
};
export const buildProformaItemUpdateDefault = (
position: number,
context?: buildProformaItemUpdateDefaultContext
): ProformaItemUpdateForm => {
return { return {
id: UniqueID.generateNewID().toString(), id: UniqueID.generateNewID().toString(),
position, position,
isValued: false, //isValued: false, <- campo calculado en frontend, no se mapea desde la API
description: null, description: null,
@ -14,7 +21,7 @@ export const buildProformaItemUpdateDefault = (position: number): ProformaItemUp
unitAmount: null, unitAmount: null,
itemDiscountPercentage: null, itemDiscountPercentage: null,
ivaPercentage: null, taxPercentage: context?.defaultTaxPercentage ?? null,
recPercentage: null, recPercentage: null,
}; };
}; };

View File

@ -31,6 +31,7 @@ export const buildProformaUpdateDefault = (): ProformaUpdateForm => {
retentionPercentage: 15, retentionPercentage: 15,
paymentMethodId: null, paymentMethodId: null,
paymentTermId: null,
items: [], items: [],
}; };

View File

@ -166,7 +166,6 @@ const resolveSingleRecCode = (formData: ProformaUpdateForm): string | null => {
if (!formData.hasRecPercentage) { if (!formData.hasRecPercentage) {
return null; return null;
} }
return getProformaRecCode(formData.recPercentage); return getProformaRecCode(formData.recPercentage);
}; };

View File

@ -10,6 +10,9 @@ export const calculateProformaLineTotal = (line?: ProformaLineInput): ProformaLi
subtotalBeforeGlobalDiscount: 0, subtotalBeforeGlobalDiscount: 0,
globalDiscountAmount: 0, globalDiscountAmount: 0,
totalDiscountAmount: 0, totalDiscountAmount: 0,
subtotalTaxable: 0,
taxAmount: 0,
totalAmount: 0,
}; };
} }
@ -33,6 +36,14 @@ export const calculateProformaLineTotal = (line?: ProformaLineInput): ProformaLi
const totalDiscountAmount = itemDiscountAmount + globalDiscountAmount; const totalDiscountAmount = itemDiscountAmount + globalDiscountAmount;
const subtotalTaxable = subtotalBeforeDiscounts - itemDiscountAmount;
const taxPercentage = toCalculationNumber(line.taxPercentage);
const taxAmount = percentageAmount(subtotalTaxable, taxPercentage);
const totalAmount = toCalculationNumber(subtotalTaxable + taxAmount);
return { return {
subtotalBeforeDiscounts: roundMoney(subtotalBeforeDiscounts), subtotalBeforeDiscounts: roundMoney(subtotalBeforeDiscounts),
@ -43,5 +54,11 @@ export const calculateProformaLineTotal = (line?: ProformaLineInput): ProformaLi
globalDiscountAmount: roundMoney(globalDiscountAmount), globalDiscountAmount: roundMoney(globalDiscountAmount),
totalDiscountAmount: roundMoney(totalDiscountAmount), totalDiscountAmount: roundMoney(totalDiscountAmount),
subtotalTaxable: roundMoney(subtotalTaxable),
taxAmount: roundMoney(taxAmount),
totalAmount: roundMoney(totalAmount),
}; };
}; };