Facturas de cliente
This commit is contained in:
parent
b4fb4902dc
commit
b97d32b607
16
modules/core/src/common/helpers/dto-compare-helper.ts
Normal file
16
modules/core/src/common/helpers/dto-compare-helper.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import type { MoneyDTO, PercentageDTO, QuantityDTO } from "../dto";
|
||||
|
||||
/** MoneyDTO: { value, scale, currency_code } */
|
||||
export function areMoneyDTOEqual(a?: MoneyDTO | null, b?: MoneyDTO | null): boolean {
|
||||
return a?.value === b?.value && a?.scale === b?.scale && a?.currency_code === b?.currency_code;
|
||||
}
|
||||
|
||||
/** QuantityDTO: { value, scale } */
|
||||
export function areQuantityDTOEqual(a?: QuantityDTO | null, b?: QuantityDTO | null): boolean {
|
||||
return a?.value === b?.value && a?.scale === b?.scale;
|
||||
}
|
||||
|
||||
/** PercentageDTO: { value, scale } */
|
||||
export function arePercentageDTOEqual(a?: PercentageDTO | null, b?: PercentageDTO | null): boolean {
|
||||
return a?.value === b?.value && a?.scale === b?.scale;
|
||||
}
|
||||
@ -1 +1,2 @@
|
||||
export * from "./dto-compare-helper";
|
||||
export * from "./money-utils";
|
||||
|
||||
@ -19,7 +19,6 @@ export function useHookForm<TFields extends FieldValues = FieldValues, TContext
|
||||
onDirtyChange,
|
||||
...rest
|
||||
}: UseHookFormProps<TFields, TContext>): UseFormReturn<TFields> {
|
||||
|
||||
const form = useForm<TFields, TContext>({
|
||||
...rest,
|
||||
resolver: zodResolver(resolverSchema),
|
||||
|
||||
@ -225,11 +225,15 @@
|
||||
<td class="w-5"> </td>
|
||||
<td class="px-4 text-right">{{taxable_amount}}</td>
|
||||
</tr>
|
||||
{{#if taxes_amount }}
|
||||
<tr>
|
||||
<td class="px-4 text-right">IVA 21%</td>
|
||||
<td class="w-5"> </td>
|
||||
<td class="px-4 text-right">{{taxes_amount}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<!-- iva 0-->
|
||||
{{/if}}
|
||||
<tr class="">
|
||||
<td class="px-4 text-right accent-color">
|
||||
Total factura
|
||||
|
||||
@ -40,11 +40,12 @@ const taxesList = [
|
||||
interface CustomerInvoiceTaxesMultiSelect {
|
||||
value: string[];
|
||||
onChange: (selectedValues: string[]) => void;
|
||||
className?: string;
|
||||
[key: string]: any; // Allow other props to be passed
|
||||
}
|
||||
|
||||
export const CustomerInvoiceTaxesMultiSelect = (props: CustomerInvoiceTaxesMultiSelect) => {
|
||||
const { value, onChange, ...otherProps } = props;
|
||||
const { value, onChange, className, ...otherProps } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const taxCatalog = useMemo(() => SpainTaxCatalogProvider(), []);
|
||||
@ -78,6 +79,12 @@ export const CustomerInvoiceTaxesMultiSelect = (props: CustomerInvoiceTaxesMulti
|
||||
maxCount={3}
|
||||
autoFilter={true}
|
||||
filterSelected={filterSelectedByGroup}
|
||||
className={cn(
|
||||
"flex w-full -mt-0.5 px-1 py-0.5 rounded-md border min-h-8 h-auto items-center justify-between bg-background hover:bg-inherit [&_svg]:pointer-events-auto",
|
||||
"hover:border-ring hover:ring-ring/50 hover:ring-[2px]",
|
||||
className
|
||||
)}
|
||||
|
||||
{...otherProps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { PropsWithChildren } from "react";
|
||||
import { CustomerInvoicesProvider } from "../context";
|
||||
import { InvoiceProvider } from "../context";
|
||||
|
||||
export const CustomerInvoicesLayout = ({ children }: PropsWithChildren) => {
|
||||
return <CustomerInvoicesProvider>{children}</CustomerInvoicesProvider>;
|
||||
return <InvoiceProvider>{children}</InvoiceProvider>;
|
||||
};
|
||||
|
||||
@ -26,7 +26,7 @@ import { CustomerInvoiceStatusBadge } from "./customer-invoice-status-badge";
|
||||
export const CustomerInvoicesListGrid = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { format } = useMoney();
|
||||
const { formatCurrency } = useMoney();
|
||||
|
||||
const {
|
||||
data: invoices,
|
||||
@ -50,22 +50,26 @@ export const CustomerInvoicesListGrid = () => {
|
||||
{
|
||||
field: "invoice_number",
|
||||
headerName: t("pages.list.grid_columns.invoice_number"),
|
||||
cellClass: "tabular-nums",
|
||||
minWidth: 130,
|
||||
},
|
||||
{
|
||||
field: "series",
|
||||
headerName: t("pages.list.grid_columns.series"),
|
||||
cellClass: "tabular-nums",
|
||||
minWidth: 80,
|
||||
},
|
||||
{
|
||||
field: "invoice_date",
|
||||
headerName: t("pages.list.grid_columns.invoice_date"),
|
||||
valueFormatter: (p: ValueFormatterParams) => formatDate(p.value),
|
||||
cellClass: "tabular-nums",
|
||||
minWidth: 130,
|
||||
},
|
||||
{
|
||||
field: "recipient.tin",
|
||||
headerName: t("pages.list.grid_columns.recipient_tin"),
|
||||
cellClass: "tabular-nums",
|
||||
minWidth: 130,
|
||||
},
|
||||
{
|
||||
@ -94,7 +98,7 @@ export const CustomerInvoicesListGrid = () => {
|
||||
type: "rightAligned",
|
||||
valueFormatter: (params: ValueFormatterParams) => {
|
||||
const raw: MoneyDTO | null = params.value;
|
||||
return raw ? format(raw) : "—";
|
||||
return raw ? formatCurrency(raw) : "—";
|
||||
},
|
||||
cellClass: "tabular-nums",
|
||||
minWidth: 130,
|
||||
@ -105,7 +109,7 @@ export const CustomerInvoicesListGrid = () => {
|
||||
type: "rightAligned",
|
||||
valueFormatter: (params: ValueFormatterParams) => {
|
||||
const raw: MoneyDTO | null = params.value;
|
||||
return raw ? format(raw) : "—";
|
||||
return raw ? formatCurrency(raw) : "—";
|
||||
},
|
||||
cellClass: "tabular-nums",
|
||||
minWidth: 130,
|
||||
@ -116,7 +120,7 @@ export const CustomerInvoicesListGrid = () => {
|
||||
type: "rightAligned",
|
||||
valueFormatter: (params: ValueFormatterParams) => {
|
||||
const raw: MoneyDTO | null = params.value;
|
||||
return raw ? format(raw) : "—";
|
||||
return raw ? formatCurrency(raw) : "—";
|
||||
},
|
||||
cellClass: "tabular-nums font-semibold",
|
||||
minWidth: 140,
|
||||
|
||||
@ -108,6 +108,7 @@ export function AmountDTOInput({
|
||||
pattern={focused ? '[0-9]*[.,]?[0-9]*' : undefined}
|
||||
className={cn(
|
||||
"w-full bg-transparent p-0 text-right focus:outline-none tabular-nums focus:bg-background",
|
||||
"hover:border-ring hover:ring-ring/50 hover:ring-[2px]",
|
||||
className
|
||||
)}
|
||||
placeholder={emptyMode === "placeholder" && isEmptyMoneyDTO(value) ? emptyText : undefined}
|
||||
|
||||
@ -1,90 +1,122 @@
|
||||
import { useMoney } from '@erp/core/hooks';
|
||||
import { InvoiceItemTotals } from '@erp/customer-invoices/web/hooks/calcs';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,
|
||||
HoverCard, HoverCardContent, HoverCardTrigger
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
import { useTranslation } from "../../../i18n";
|
||||
|
||||
|
||||
export type HoverCardTotalsSummaryProps = PropsWithChildren & {
|
||||
totals: InvoiceItemTotals
|
||||
type HoverCardTotalsSummaryProps = PropsWithChildren & {
|
||||
rowIndex: number;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Muestra un desglose financiero del total de línea.
|
||||
* Lee directamente los importes del formulario vía react-hook-form.
|
||||
*/
|
||||
export const HoverCardTotalsSummary = ({
|
||||
children,
|
||||
totals
|
||||
rowIndex,
|
||||
}: HoverCardTotalsSummaryProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { formatCurrency } = useMoney();
|
||||
const { control } = useFormContext();
|
||||
|
||||
// 👀 Observar los valores actuales del formulario
|
||||
const [subtotal, discountPercentage, discountAmount, taxableBase, total] =
|
||||
useWatch({
|
||||
control,
|
||||
name: [
|
||||
`items.${rowIndex}.subtotal_amount`,
|
||||
`items.${rowIndex}.discount_percentage`,
|
||||
`items.${rowIndex}.discount_amount`,
|
||||
`items.${rowIndex}.taxable_base`,
|
||||
`items.${rowIndex}.total_amount`,
|
||||
],
|
||||
});
|
||||
|
||||
const SummaryBlock = () => (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold mb-3">{t("components.hover_card_totals_summary.label")}</h4>
|
||||
<h4 className="text-sm font-semibold mb-3">
|
||||
{t("components.hover_card_totals_summary.label")}
|
||||
</h4>
|
||||
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">{t("components.hover_card_totals_summary.fields.subtotal_amount")}:</span>
|
||||
<span className="font-mono">{formatCurrency(totals.subtotal)}</span>
|
||||
</div>
|
||||
|
||||
{(totals.discountPercent ?? 0) > 0 && (
|
||||
{/* Subtotal */}
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t("components.hover_card_totals_summary.fields.discount_percentage")} ({totals.discountPercent ?? 0}%):
|
||||
{t("components.hover_card_totals_summary.fields.subtotal_amount")}:
|
||||
</span>
|
||||
<span className="font-mono">{formatCurrency(subtotal)}</span>
|
||||
</div>
|
||||
|
||||
{/* Descuento (si aplica) */}
|
||||
{discountPercentage && Number(discountPercentage.value) > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t(
|
||||
"components.hover_card_totals_summary.fields.discount_percentage"
|
||||
)}{" "}
|
||||
({discountPercentage && discountPercentage.value
|
||||
? (Number(discountPercentage.value) /
|
||||
10 ** Number(discountPercentage.scale)) *
|
||||
100
|
||||
: 0}
|
||||
%):
|
||||
</span>
|
||||
<span className="font-mono text-destructive">
|
||||
-{formatCurrency(totals.discountAmount)}
|
||||
-{formatCurrency(discountAmount)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Base imponible */}
|
||||
<div className="flex justify-between text-sm border-t pt-2">
|
||||
<span className="text-muted-foreground">{t("components.hover_card_totals_summary.fields.taxable_amount")}:</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t("components.hover_card_totals_summary.fields.taxable_amount")}:
|
||||
</span>
|
||||
<span className="font-mono font-medium">
|
||||
{formatCurrency(totals.taxableBase)}
|
||||
{formatCurrency(taxableBase)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/*totals.taxesBreakdown.map((tax) => (
|
||||
<div key={tax.label} className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">{tax.label}:</span>
|
||||
<span className="font-mono">{formatCurrency(tax.amount)}</span>
|
||||
</div>
|
||||
))*/}
|
||||
|
||||
{/* Total final */}
|
||||
<div className="flex justify-between text-sm border-t pt-2 font-semibold">
|
||||
<span>{t("components.hover_card_totals_summary.fields.total_amount")}:</span>
|
||||
<span className="font-mono">{formatCurrency(totals.total)}</span>
|
||||
<span>
|
||||
{t("components.hover_card_totals_summary.fields.total_amount")}:
|
||||
</span>
|
||||
<span className="font-mono">{formatCurrency(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Variante móvil */}
|
||||
{/* Variante móvil (Dialog) */}
|
||||
<div className="md:hidden">
|
||||
<Dialog>
|
||||
<DialogTrigger>{children}</DialogTrigger>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Desglose del importe</DialogTitle>
|
||||
<DialogTitle>
|
||||
{t("components.hover_card_totals_summary.label")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<SummaryBlock />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Variante desktop */}
|
||||
{/* Variante escritorio (HoverCard) */}
|
||||
<div className="hidden md:block">
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>{children}</HoverCardTrigger>
|
||||
<HoverCardTrigger asChild>{children}</HoverCardTrigger>
|
||||
<HoverCardContent className="w-64" align="end">
|
||||
<SummaryBlock />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
import { Button, Checkbox, TableCell, TableRow, Tooltip, TooltipContent, TooltipTrigger } from "@repo/shadcn-ui/components";
|
||||
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, Trash2Icon } from "lucide-react";
|
||||
import { useEffect } from 'react';
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import { useCalcInvoiceItemTotals } from '../../../hooks';
|
||||
import { Control, Controller } from "react-hook-form";
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { CustomerInvoiceItemFormData } from '../../../schemas';
|
||||
import { CustomerInvoiceTaxesMultiSelect } from '../../customer-invoice-taxes-multi-select';
|
||||
@ -12,7 +10,7 @@ import { PercentageDTOInputField } from './percentage-dto-input-field';
|
||||
import { QuantityDTOInputField } from './quantity-dto-input-field';
|
||||
|
||||
export type ItemRowProps = {
|
||||
|
||||
control: Control,
|
||||
item: CustomerInvoiceItemFormData;
|
||||
rowIndex: number;
|
||||
isSelected: boolean;
|
||||
@ -27,7 +25,9 @@ export type ItemRowProps = {
|
||||
}
|
||||
|
||||
|
||||
export const ItemRow = ({ item,
|
||||
export const ItemRow = ({
|
||||
control,
|
||||
item,
|
||||
rowIndex,
|
||||
isSelected,
|
||||
isFirst,
|
||||
@ -40,19 +40,6 @@ export const ItemRow = ({ item,
|
||||
onRemove, }: ItemRowProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { control, setValue } = useFormContext();
|
||||
const totals = useCalcInvoiceItemTotals(item);
|
||||
|
||||
console.log(totals);
|
||||
|
||||
// sincroniza el total con el form
|
||||
useEffect(() => {
|
||||
if (totals?.totalDTO) {
|
||||
setValue?.(`items.${rowIndex}.total_amount`, totals.totalDTO);
|
||||
}
|
||||
}, [totals.totalDTO, control, rowIndex]);
|
||||
|
||||
|
||||
return (
|
||||
<TableRow data-row-index={rowIndex}>
|
||||
{/* selección */}
|
||||
@ -146,10 +133,9 @@ export const ItemRow = ({ item,
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
|
||||
{/* total (solo lectura) */}
|
||||
<TableCell className='text-right tabular-nums pt-[6px] leading-5'>
|
||||
<HoverCardTotalsSummary totals={totals}>
|
||||
<HoverCardTotalsSummary rowIndex={rowIndex} >
|
||||
<AmountDTOInputField
|
||||
control={control}
|
||||
name={`items.${rowIndex}.total_amount`}
|
||||
|
||||
@ -40,7 +40,6 @@ export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEdi
|
||||
const { control, watch } = form;
|
||||
|
||||
|
||||
|
||||
// Emitir cambios a quien consuma el componente
|
||||
React.useEffect(() => {
|
||||
const sub = watch((v) => onChange?.(v.items ?? []));
|
||||
@ -104,6 +103,7 @@ export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEdi
|
||||
{tableNav.fa.fields.map((f, rowIndex) => (
|
||||
<ItemRow
|
||||
key={f.id}
|
||||
control={control}
|
||||
item={form.watch(`items.${rowIndex}`)}
|
||||
rowIndex={rowIndex}
|
||||
isSelected={selectedRows.has(rowIndex)}
|
||||
|
||||
@ -99,7 +99,10 @@ export function PercentageDTOInput({
|
||||
aria-label={ariaLabel}
|
||||
inputMode="decimal"
|
||||
pattern={focused ? "[0-9]*[.,]?[0-9]*%?" : undefined}
|
||||
className={cn("w-full bg-transparent p-0 text-right tabular-nums", className)}
|
||||
className={cn(
|
||||
"w-full bg-transparent p-0 text-right tabular-nums h-8 focus:bg-background",
|
||||
"hover:border-ring hover:ring-ring/50 hover:ring-[2px]",
|
||||
className)}
|
||||
readOnly={readOnly}
|
||||
placeholder={emptyMode === "placeholder" && isEmptyDTO ? emptyText : undefined}
|
||||
value={raw}
|
||||
|
||||
@ -66,6 +66,119 @@ export function QuantityDTOInput({
|
||||
return suf ? `${numTxt}${nbspBeforeSuffix ? "\u00A0" : " "}${suf}` : numTxt;
|
||||
}, [value, toNumber, formatPlain, suffixFor, nbspBeforeSuffix, emptyMode, emptyText]);
|
||||
|
||||
const formatNumber = React.useCallback((value: number, locale: string, sc: number) => {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
maximumFractionDigits: sc,
|
||||
minimumFractionDigits: 0,
|
||||
useGrouping: false,
|
||||
}).format(value);
|
||||
}, []);
|
||||
|
||||
const numberFmt = React.useMemo(
|
||||
() => new Intl.NumberFormat(locale, { maximumFractionDigits: sc, minimumFractionDigits: 0, useGrouping: false }),
|
||||
[locale, sc]
|
||||
);
|
||||
|
||||
const formatDisplay = React.useCallback(
|
||||
(value: number) => {
|
||||
const numTxt = numberFmt.format(value);
|
||||
const suf = suffixFor(value);
|
||||
return suf
|
||||
? `${numTxt}${nbspBeforeSuffix ? "\u00A0" : " "}${suf}`
|
||||
: numTxt;
|
||||
},
|
||||
[numberFmt, suffixFor, nbspBeforeSuffix]
|
||||
);
|
||||
|
||||
const handleBlur = React.useCallback(
|
||||
(e: React.FocusEvent<HTMLInputElement>) => {
|
||||
setFocused(false);
|
||||
const txt = e.currentTarget.value.trim();
|
||||
|
||||
// Casos vacíos
|
||||
if (txt === "" || isShowingEmptyValue) {
|
||||
React.startTransition(() => {
|
||||
onChange(null);
|
||||
setRaw(emptyMode === "value" ? emptyText : "");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const n = parse(txt);
|
||||
if (n === null) {
|
||||
React.startTransition(() => {
|
||||
onChange(null);
|
||||
setRaw(emptyMode === "value" ? emptyText : "");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const rounded = roundToScale(n, sc);
|
||||
const formatted = formatDisplay(rounded);
|
||||
|
||||
// Actualiza en transición concurrente (no bloquea UI)
|
||||
React.startTransition(() => {
|
||||
onChange(fromNumber(rounded, sc));
|
||||
setRaw(formatted);
|
||||
});
|
||||
},
|
||||
[
|
||||
sc,
|
||||
parse,
|
||||
formatDisplay,
|
||||
roundToScale,
|
||||
fromNumber,
|
||||
onChange,
|
||||
emptyMode,
|
||||
emptyText,
|
||||
isShowingEmptyValue,
|
||||
]
|
||||
);
|
||||
|
||||
const handleFocus = React.useCallback(
|
||||
(e: React.FocusEvent<HTMLInputElement>) => {
|
||||
setFocused(true);
|
||||
const val = e.currentTarget.value;
|
||||
|
||||
// Si muestra el placeholder "vacío lógico", limpiar
|
||||
if (emptyMode === "value" && val === emptyText) {
|
||||
setRaw("");
|
||||
return;
|
||||
}
|
||||
|
||||
// Intenta parsear lo visible, o usa valor actual si no hay parse
|
||||
const parsed =
|
||||
parse(val) ??
|
||||
(!isEmptyQuantityDTO(value) ? toNumber(value!) : null);
|
||||
|
||||
setRaw(parsed !== null ? String(parsed) : "");
|
||||
},
|
||||
[emptyMode, emptyText, parse, value, toNumber]
|
||||
);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (readOnly) return;
|
||||
const { key, shiftKey } = e;
|
||||
|
||||
if (key !== "ArrowUp" && key !== "ArrowDown") return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
// Base numérica a partir del texto actual
|
||||
const base = parse(isShowingEmptyValue ? "" : raw) ?? 0;
|
||||
|
||||
// Cálculo de incremento/decremento
|
||||
const delta = (shiftKey ? 10 : 1) * step * (key === "ArrowUp" ? 1 : -1);
|
||||
const next = roundToScale(base + delta, sc);
|
||||
|
||||
React.startTransition(() => {
|
||||
onChange(fromNumber(next, sc));
|
||||
setRaw(String(next));
|
||||
});
|
||||
},
|
||||
[readOnly, parse, raw, isShowingEmptyValue, step, sc, onChange, fromNumber, roundToScale]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (focused) return;
|
||||
@ -99,52 +212,20 @@ export function QuantityDTOInput({
|
||||
id={id}
|
||||
inputMode="decimal"
|
||||
pattern={focused ? "[0-9]*[.,]?[0-9]*" : undefined}
|
||||
className={cn("w-full bg-transparent p-0 text-right tabular-nums h-8 leading-5 focus:bg-background", className)}
|
||||
className={cn(
|
||||
"w-full bg-transparent p-0 text-right tabular-nums h-8 px-1",
|
||||
"border-none",
|
||||
"focus:bg-background",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/20 focus-visible:ring-[2px]",
|
||||
"hover:border hover:ring-ring/20 hover:ring-[2px]",
|
||||
className
|
||||
)}
|
||||
placeholder={emptyMode === "placeholder" && isEmptyQuantityDTO(value) ? emptyText : undefined}
|
||||
value={raw}
|
||||
onChange={(e) => setRaw(e.currentTarget.value)}
|
||||
onFocus={(e) => {
|
||||
setFocused(true);
|
||||
// Pasar de visual (con posible sufijo) → crudo editable
|
||||
if (emptyMode === "value" && e.currentTarget.value === emptyText) { setRaw(""); return; }
|
||||
const n = parse(e.currentTarget.value) ?? (isEmptyQuantityDTO(value) ? null : toNumber(value!));
|
||||
setRaw(n !== null ? String(n) : "");
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
setFocused(false);
|
||||
const txt = e.currentTarget.value.trim();
|
||||
if (txt === "" || isShowingEmptyValue) {
|
||||
onChange(null);
|
||||
setRaw(emptyMode === "value" ? emptyText : "");
|
||||
return;
|
||||
}
|
||||
const n = parse(txt);
|
||||
if (n === null) {
|
||||
onChange(null);
|
||||
setRaw(emptyMode === "value" ? emptyText : "");
|
||||
return;
|
||||
}
|
||||
const rounded = roundToScale(n, sc);
|
||||
onChange(fromNumber(rounded, sc));
|
||||
// Visual con posible sufijo
|
||||
const numTxt = new Intl.NumberFormat(locale, {
|
||||
maximumFractionDigits: sc,
|
||||
minimumFractionDigits: Number.isInteger(rounded) ? 0 : 0,
|
||||
useGrouping: false,
|
||||
}).format(rounded);
|
||||
const suf = suffixFor(rounded);
|
||||
setRaw(suf ? `${numTxt}${nbspBeforeSuffix ? "\u00A0" : " "}${suf}` : numTxt);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (!readOnly && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
|
||||
e.preventDefault();
|
||||
const base = parse(isShowingEmptyValue ? "" : raw) ?? 0;
|
||||
const delta = (e.shiftKey ? 10 : 1) * step * (e.key === "ArrowUp" ? 1 : -1);
|
||||
const rounded = roundToScale(base + delta, sc);
|
||||
onChange(fromNumber(rounded, sc));
|
||||
setRaw(String(rounded)); // crudo mientras edita
|
||||
}
|
||||
}}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,55 +0,0 @@
|
||||
import { PropsWithChildren, createContext } from "react";
|
||||
|
||||
/**
|
||||
* ────────────────────────────────────────────────────────────────────────────────
|
||||
* 💡 Posibles usos del InvoicingContext
|
||||
* ────────────────────────────────────────────────────────────────────────────────
|
||||
* Este contexto se diseña para encapsular estado y lógica compartida dentro del
|
||||
* bounded context de facturación (facturas), proporcionando acceso global a datos
|
||||
* o funciones relevantes para múltiples vistas (listado, detalle, edición, etc).
|
||||
*
|
||||
* ✅ Usos recomendados:
|
||||
*
|
||||
* 1. 🔎 Gestión de filtros globales:
|
||||
* - Permite que los filtros aplicados en el listado de facturas se conserven
|
||||
* cuando el usuario navega a otras vistas (detalle, edición) y luego regresa.
|
||||
* - Mejora la experiencia de usuario evitando la necesidad de reestablecer filtros.
|
||||
*
|
||||
* 2. 🛡️ Gestión de permisos o configuración de acciones disponibles:
|
||||
* - Permite definir qué acciones están habilitadas para el usuario actual
|
||||
* (crear, editar, eliminar).
|
||||
* - Útil para mostrar u ocultar botones de acción en diferentes pantallas.
|
||||
*
|
||||
* 3. 🧭 Control del layout:
|
||||
* - Si el layout tiene elementos dinámicos (tabs, breadcrumb, loading global),
|
||||
* este contexto puede coordinar su estado desde componentes hijos.
|
||||
* - Ejemplo: seleccionar una pestaña activa que aplica en todas las subrutas.
|
||||
*
|
||||
* 4. 📦 Cacheo liviano de datos compartidos:
|
||||
* - Puede almacenar la última factura abierta, borradores de edición,
|
||||
* o referencias temporales para operaciones CRUD sin tener que usar la URL.
|
||||
*
|
||||
* 5. 🚀 Coordinación de side-effects:
|
||||
* - Permite exponer funciones comunes como `refetch`, `resetFilters`,
|
||||
* o `notifyInvoiceChanged`, usadas desde cualquier subcomponente del dominio.
|
||||
*
|
||||
* ⚠️ Alternativas:
|
||||
* - Si el estado compartido es muy mutable, grande o requiere persistencia,
|
||||
* podría ser preferible usar Zustand o Redux Toolkit.
|
||||
* - No usar contextos para valores que cambian frecuentemente en tiempo real,
|
||||
* ya que pueden causar renders innecesarios.
|
||||
*
|
||||
* ────────────────────────────────────────────────────────────────────────────────
|
||||
*/
|
||||
|
||||
export type CustomerInvoicesContextType = {};
|
||||
|
||||
export type CustomerInvoicesContextParamsType = {
|
||||
//service: CustomerInvoiceApplicationService;
|
||||
};
|
||||
|
||||
export const CustomerInvoicesContext = createContext<CustomerInvoicesContextType>({});
|
||||
|
||||
export const CustomerInvoicesProvider = ({ children }: PropsWithChildren) => {
|
||||
return <CustomerInvoicesContext.Provider value={{}}>{children}</CustomerInvoicesContext.Provider>;
|
||||
};
|
||||
@ -1 +1 @@
|
||||
export * from "./customer-invoices-context";
|
||||
export * from "./invoice-context";
|
||||
|
||||
@ -0,0 +1,62 @@
|
||||
import { PropsWithChildren, createContext, useCallback, useContext, useMemo, useState } from "react";
|
||||
|
||||
export type InvoiceContextValue = {
|
||||
company_id: string;
|
||||
currency_code: string;
|
||||
language_code: string;
|
||||
is_proforma: boolean;
|
||||
|
||||
changeLanguage: (lang: string) => void;
|
||||
changeCurrency: (currency: string) => void;
|
||||
changeIsProforma: (value: boolean) => void;
|
||||
};
|
||||
|
||||
const InvoiceContext = createContext<InvoiceContextValue | null>(null);
|
||||
|
||||
export interface InvoiceProviderParams {
|
||||
company_id: string;
|
||||
language_code?: string; // default "es"
|
||||
currency_code?: string; // default "EUR"
|
||||
is_proforma?: boolean; // default 'true'
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const InvoiceProvider = ({ company_id, language_code: initialLang = "es",
|
||||
currency_code: initialCurrency = "EUR",
|
||||
is_proforma: initialProforma = true, children }: PropsWithChildren<InvoiceProviderParams>) => {
|
||||
|
||||
// Estado interno local para campos dinámicos
|
||||
const [language_code, setLanguage] = useState(initialLang);
|
||||
const [currency_code, setCurrency] = useState(initialCurrency);
|
||||
const [is_proforma, setIsProforma] = useState(initialProforma);
|
||||
|
||||
// Callbacks memoizados
|
||||
const setLanguageMemo = useCallback((language_code: string) => setLanguage(language_code), []);
|
||||
const setCurrencyMemo = useCallback((currency_code: string) => setCurrency(currency_code), []);
|
||||
const setIsProformaMemo = useCallback((is_proforma: boolean) => setIsProforma(is_proforma), []);
|
||||
|
||||
const value = useMemo<InvoiceContextValue>(() => {
|
||||
|
||||
return {
|
||||
company_id,
|
||||
language_code,
|
||||
currency_code,
|
||||
is_proforma,
|
||||
|
||||
changeLanguage: setLanguageMemo,
|
||||
changeCurrency: setCurrencyMemo,
|
||||
changeIsProforma: setIsProformaMemo
|
||||
}
|
||||
}, [company_id, language_code, currency_code, is_proforma, setLanguageMemo, setCurrencyMemo, setIsProformaMemo]);
|
||||
|
||||
return <InvoiceContext.Provider value={value}>{children}</InvoiceContext.Provider>;
|
||||
};
|
||||
|
||||
|
||||
export function useInvoiceContext(): InvoiceContextValue {
|
||||
const context = useContext(InvoiceContext);
|
||||
if (!context) {
|
||||
throw new Error("useInvoiceContext must be used within <InvoiceProvider>");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@ -1,2 +1 @@
|
||||
export * from "./use-calc-invoice-items-totals";
|
||||
export * from "./use-calc-invoice-totals";
|
||||
export * from "./use-invoice-auto-recalc";
|
||||
|
||||
@ -0,0 +1,178 @@
|
||||
import { areMoneyDTOEqual } from "@erp/core";
|
||||
import { useMoney, usePercentage, useQuantity } from "@erp/core/hooks";
|
||||
import * as React from "react";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import { CustomerInvoiceFormData, CustomerInvoiceItemFormData } from "../../schemas";
|
||||
|
||||
/**
|
||||
* Hook que recalcula automáticamente los totales de cada línea
|
||||
* y los totales generales de la factura cuando cambian los valores relevantes.
|
||||
*/
|
||||
export function useInvoiceAutoRecalc(form: UseFormReturn<CustomerInvoiceFormData>) {
|
||||
const {
|
||||
watch,
|
||||
setValue,
|
||||
getValues,
|
||||
formState: { isDirty, isLoading, isSubmitting },
|
||||
} = form;
|
||||
|
||||
const moneyHelper = useMoney();
|
||||
const qtyHelper = useQuantity();
|
||||
const pctHelper = usePercentage();
|
||||
|
||||
// Cálculo de una línea
|
||||
const calculateItemTotals = React.useCallback(
|
||||
(item: CustomerInvoiceItemFormData) => {
|
||||
if (!item) {
|
||||
const zero = moneyHelper.fromNumber(0);
|
||||
return {
|
||||
subtotalDTO: zero,
|
||||
discountAmountDTO: zero,
|
||||
taxableBaseDTO: zero,
|
||||
taxesDTO: zero,
|
||||
totalDTO: zero,
|
||||
};
|
||||
}
|
||||
|
||||
// Subtotal = unit_amount × quantity
|
||||
const subtotalDTO = moneyHelper.multiply(item.unit_amount, qtyHelper.toNumber(item.quantity));
|
||||
|
||||
// Descuento = subtotal × (discount_percentage / 100)
|
||||
const discountDTO = moneyHelper.percentage(
|
||||
subtotalDTO,
|
||||
pctHelper.toNumber(item.discount_percentage)
|
||||
);
|
||||
|
||||
// Base imponible = subtotal − descuento
|
||||
const taxableBaseDTO = moneyHelper.sub(subtotalDTO, discountDTO);
|
||||
|
||||
// Impuestos (placeholder: se integrará con tax catalog)
|
||||
const taxesDTO = moneyHelper.fromNumber(0);
|
||||
|
||||
// Total = base imponible + impuestos
|
||||
const totalDTO = moneyHelper.add(taxableBaseDTO, taxesDTO);
|
||||
|
||||
return {
|
||||
subtotalDTO,
|
||||
discountAmountDTO: discountDTO,
|
||||
taxableBaseDTO,
|
||||
taxesDTO,
|
||||
totalDTO,
|
||||
};
|
||||
},
|
||||
[moneyHelper, qtyHelper, pctHelper]
|
||||
);
|
||||
|
||||
// Cálculo de los totales de la factura a partir de los conceptos
|
||||
const calculateInvoiceTotals = React.useCallback(
|
||||
(items: CustomerInvoiceItemFormData[]) => {
|
||||
let subtotalDTO = moneyHelper.fromNumber(0);
|
||||
let discountTotalDTO = moneyHelper.fromNumber(0);
|
||||
let taxableBaseDTO = moneyHelper.fromNumber(0);
|
||||
let taxesDTO = moneyHelper.fromNumber(0);
|
||||
let totalDTO = moneyHelper.fromNumber(0);
|
||||
|
||||
for (const item of items) {
|
||||
const t = calculateItemTotals(item);
|
||||
subtotalDTO = moneyHelper.add(subtotalDTO, t.subtotalDTO);
|
||||
discountTotalDTO = moneyHelper.add(discountTotalDTO, t.discountAmountDTO);
|
||||
taxableBaseDTO = moneyHelper.add(taxableBaseDTO, t.taxableBaseDTO);
|
||||
taxesDTO = moneyHelper.add(taxesDTO, t.taxesDTO);
|
||||
totalDTO = moneyHelper.add(totalDTO, t.totalDTO);
|
||||
}
|
||||
|
||||
return {
|
||||
subtotalDTO,
|
||||
discountTotalDTO,
|
||||
taxableBaseDTO,
|
||||
taxesDTO,
|
||||
totalDTO,
|
||||
};
|
||||
},
|
||||
[moneyHelper, calculateItemTotals]
|
||||
);
|
||||
|
||||
// Suscribirse a cambios del formulario
|
||||
React.useEffect(() => {
|
||||
if (!isDirty || isLoading || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = watch((formData, { name, type }) => {
|
||||
if (!formData?.items?.length) return;
|
||||
|
||||
// 1. Si cambia una línea completa (add/remove/move)
|
||||
if (name === "items" && type === "change") {
|
||||
formData.items.forEach((item, i) => {
|
||||
if (!item) return;
|
||||
|
||||
const typedItem = item as CustomerInvoiceItemFormData;
|
||||
const totals = calculateItemTotals(typedItem);
|
||||
const current = getValues(`items.${i}.total_amount`);
|
||||
|
||||
if (!areMoneyDTOEqual(current, totals.totalDTO)) {
|
||||
setValue(`items.${i}.total_amount`, totals.totalDTO, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Recalcular importes totales de la factura y
|
||||
// actualizar valores calculados.
|
||||
const typedItems = formData.items as CustomerInvoiceItemFormData[];
|
||||
const totalsGlobal = calculateInvoiceTotals(typedItems);
|
||||
|
||||
setValue("subtotal_amount", totalsGlobal.subtotalDTO);
|
||||
setValue("discount_amount", totalsGlobal.discountTotalDTO);
|
||||
setValue("taxable_amount", totalsGlobal.taxableBaseDTO);
|
||||
setValue("taxes_amount", totalsGlobal.taxesDTO);
|
||||
setValue("total_amount", totalsGlobal.totalDTO);
|
||||
}
|
||||
|
||||
// 2. Si cambia un campo dentro de un concepto
|
||||
if (name?.startsWith("items.") && type === "change") {
|
||||
const index = Number(name.split(".")[1]);
|
||||
const fieldName = name.split(".")[2];
|
||||
|
||||
if (["quantity", "unit_amount", "discount_percentage"].includes(fieldName)) {
|
||||
const typedItem = formData.items[index] as CustomerInvoiceItemFormData;
|
||||
if (!typedItem) return;
|
||||
|
||||
// Recalcular línea
|
||||
const totals = calculateItemTotals(typedItem);
|
||||
const current = getValues(`items.${index}.total_amount`);
|
||||
|
||||
if (!areMoneyDTOEqual(current, totals.totalDTO)) {
|
||||
setValue(`items.${index}.total_amount`, totals.totalDTO, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Recalcular importes totales de la factura y
|
||||
// actualizar valores calculados.
|
||||
const typedItems = formData.items as CustomerInvoiceItemFormData[];
|
||||
const totalsGlobal = calculateInvoiceTotals(typedItems);
|
||||
|
||||
setValue("subtotal_amount", totalsGlobal.subtotalDTO);
|
||||
setValue("discount_amount", totalsGlobal.discountTotalDTO);
|
||||
setValue("taxable_amount", totalsGlobal.taxableBaseDTO);
|
||||
setValue("taxes_amount", totalsGlobal.taxesDTO);
|
||||
setValue("total_amount", totalsGlobal.totalDTO);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, [
|
||||
watch,
|
||||
isDirty,
|
||||
isLoading,
|
||||
isSubmitting,
|
||||
setValue,
|
||||
getValues,
|
||||
calculateItemTotals,
|
||||
calculateInvoiceTotals,
|
||||
]);
|
||||
}
|
||||
@ -1,7 +1,6 @@
|
||||
export * from "./calcs";
|
||||
export * from "./use-create-customer-invoice-mutation";
|
||||
export * from "./use-customer-invoice-query";
|
||||
export * from "./use-customer-invoices-context";
|
||||
export * from "./use-customer-invoices-query";
|
||||
export * from "./use-detail-columns";
|
||||
export * from "./use-items-table-navigation";
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
import { useContext } from "react";
|
||||
import { CustomerInvoicesContext, CustomerInvoicesContextType } from "../context";
|
||||
|
||||
export const useCustomerInvoicesContext = (): CustomerInvoicesContextType => {
|
||||
const context = useContext(CustomerInvoicesContext);
|
||||
if (!context) {
|
||||
throw new Error("useCustomerInvoices must be used within a CustomerInvoicesProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
@ -16,7 +16,8 @@ import {
|
||||
CustomerInvoiceEditorSkeleton,
|
||||
PageHeader,
|
||||
} from "../../components";
|
||||
import { useCustomerInvoiceQuery, useUpdateCustomerInvoice } from "../../hooks";
|
||||
import { InvoiceProvider } from '../../context';
|
||||
import { useCustomerInvoiceQuery, useInvoiceAutoRecalc, useUpdateCustomerInvoice } from "../../hooks";
|
||||
import { useTranslation } from "../../i18n";
|
||||
import {
|
||||
CustomerInvoiceFormData,
|
||||
@ -46,13 +47,15 @@ export const CustomerInvoiceUpdatePage = () => {
|
||||
} = useUpdateCustomerInvoice();
|
||||
|
||||
// 3) Form hook
|
||||
|
||||
const form = useHookForm<CustomerInvoiceFormData>({
|
||||
resolverSchema: CustomerInvoiceFormSchema,
|
||||
initialValues: (invoiceData as unknown as CustomerInvoiceFormData) ?? defaultCustomerInvoiceFormData,
|
||||
disabled: isUpdating,
|
||||
});
|
||||
|
||||
// 4) Activa recálculo automático de los totales de la factura cuando hay algún cambio en importes
|
||||
useInvoiceAutoRecalc(form);
|
||||
|
||||
const handleSubmit = (formData: CustomerInvoiceFormData) => {
|
||||
const { dirtyFields } = form.formState;
|
||||
|
||||
@ -131,6 +134,11 @@ export const CustomerInvoiceUpdatePage = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<InvoiceProvider
|
||||
company_id={invoiceData.company_id}
|
||||
language_code={invoiceData.language_code}
|
||||
currency_code={invoiceData.currency_code}
|
||||
>
|
||||
<UnsavedChangesProvider isDirty={form.formState.isDirty}>
|
||||
<AppHeader>
|
||||
<AppBreadcrumb />
|
||||
@ -178,5 +186,6 @@ export const CustomerInvoiceUpdatePage = () => {
|
||||
</FormProvider>
|
||||
</AppContent>
|
||||
</UnsavedChangesProvider>
|
||||
</InvoiceProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { MoneySchema, PercentageSchema, QuantitySchema } from "@erp/core";
|
||||
import { ArrayElement } from "@repo/rdx-utils";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const CustomerInvoiceItemFormSchema = z.object({
|
||||
@ -79,7 +80,7 @@ export const CustomerInvoiceFormSchema = z.object({
|
||||
});
|
||||
|
||||
export type CustomerInvoiceFormData = z.infer<typeof CustomerInvoiceFormSchema>;
|
||||
export type CustomerInvoiceItemFormData = z.infer<typeof CustomerInvoiceItemFormSchema>;
|
||||
export type CustomerInvoiceItemFormData = ArrayElement<CustomerInvoiceFormData["items"]>;
|
||||
|
||||
export const defaultCustomerInvoiceItemFormData: CustomerInvoiceItemFormData = {
|
||||
description: "",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user