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";
|
export * from "./money-utils";
|
||||||
|
|||||||
@ -19,7 +19,6 @@ export function useHookForm<TFields extends FieldValues = FieldValues, TContext
|
|||||||
onDirtyChange,
|
onDirtyChange,
|
||||||
...rest
|
...rest
|
||||||
}: UseHookFormProps<TFields, TContext>): UseFormReturn<TFields> {
|
}: UseHookFormProps<TFields, TContext>): UseFormReturn<TFields> {
|
||||||
|
|
||||||
const form = useForm<TFields, TContext>({
|
const form = useForm<TFields, TContext>({
|
||||||
...rest,
|
...rest,
|
||||||
resolver: zodResolver(resolverSchema),
|
resolver: zodResolver(resolverSchema),
|
||||||
|
|||||||
@ -225,11 +225,15 @@
|
|||||||
<td class="w-5"> </td>
|
<td class="w-5"> </td>
|
||||||
<td class="px-4 text-right">{{taxable_amount}}</td>
|
<td class="px-4 text-right">{{taxable_amount}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{{#if taxes_amount }}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-4 text-right">IVA 21%</td>
|
<td class="px-4 text-right">IVA 21%</td>
|
||||||
<td class="w-5"> </td>
|
<td class="w-5"> </td>
|
||||||
<td class="px-4 text-right">{{taxes_amount}}</td>
|
<td class="px-4 text-right">{{taxes_amount}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<!-- iva 0-->
|
||||||
|
{{/if}}
|
||||||
<tr class="">
|
<tr class="">
|
||||||
<td class="px-4 text-right accent-color">
|
<td class="px-4 text-right accent-color">
|
||||||
Total factura
|
Total factura
|
||||||
|
|||||||
@ -40,11 +40,12 @@ const taxesList = [
|
|||||||
interface CustomerInvoiceTaxesMultiSelect {
|
interface CustomerInvoiceTaxesMultiSelect {
|
||||||
value: string[];
|
value: string[];
|
||||||
onChange: (selectedValues: string[]) => void;
|
onChange: (selectedValues: string[]) => void;
|
||||||
|
className?: string;
|
||||||
[key: string]: any; // Allow other props to be passed
|
[key: string]: any; // Allow other props to be passed
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CustomerInvoiceTaxesMultiSelect = (props: CustomerInvoiceTaxesMultiSelect) => {
|
export const CustomerInvoiceTaxesMultiSelect = (props: CustomerInvoiceTaxesMultiSelect) => {
|
||||||
const { value, onChange, ...otherProps } = props;
|
const { value, onChange, className, ...otherProps } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const taxCatalog = useMemo(() => SpainTaxCatalogProvider(), []);
|
const taxCatalog = useMemo(() => SpainTaxCatalogProvider(), []);
|
||||||
@ -78,6 +79,12 @@ export const CustomerInvoiceTaxesMultiSelect = (props: CustomerInvoiceTaxesMulti
|
|||||||
maxCount={3}
|
maxCount={3}
|
||||||
autoFilter={true}
|
autoFilter={true}
|
||||||
filterSelected={filterSelectedByGroup}
|
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}
|
{...otherProps}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { PropsWithChildren } from "react";
|
import { PropsWithChildren } from "react";
|
||||||
import { CustomerInvoicesProvider } from "../context";
|
import { InvoiceProvider } from "../context";
|
||||||
|
|
||||||
export const CustomerInvoicesLayout = ({ children }: PropsWithChildren) => {
|
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 = () => {
|
export const CustomerInvoicesListGrid = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { format } = useMoney();
|
const { formatCurrency } = useMoney();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: invoices,
|
data: invoices,
|
||||||
@ -50,22 +50,26 @@ export const CustomerInvoicesListGrid = () => {
|
|||||||
{
|
{
|
||||||
field: "invoice_number",
|
field: "invoice_number",
|
||||||
headerName: t("pages.list.grid_columns.invoice_number"),
|
headerName: t("pages.list.grid_columns.invoice_number"),
|
||||||
|
cellClass: "tabular-nums",
|
||||||
minWidth: 130,
|
minWidth: 130,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "series",
|
field: "series",
|
||||||
headerName: t("pages.list.grid_columns.series"),
|
headerName: t("pages.list.grid_columns.series"),
|
||||||
|
cellClass: "tabular-nums",
|
||||||
minWidth: 80,
|
minWidth: 80,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "invoice_date",
|
field: "invoice_date",
|
||||||
headerName: t("pages.list.grid_columns.invoice_date"),
|
headerName: t("pages.list.grid_columns.invoice_date"),
|
||||||
valueFormatter: (p: ValueFormatterParams) => formatDate(p.value),
|
valueFormatter: (p: ValueFormatterParams) => formatDate(p.value),
|
||||||
|
cellClass: "tabular-nums",
|
||||||
minWidth: 130,
|
minWidth: 130,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "recipient.tin",
|
field: "recipient.tin",
|
||||||
headerName: t("pages.list.grid_columns.recipient_tin"),
|
headerName: t("pages.list.grid_columns.recipient_tin"),
|
||||||
|
cellClass: "tabular-nums",
|
||||||
minWidth: 130,
|
minWidth: 130,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -94,7 +98,7 @@ export const CustomerInvoicesListGrid = () => {
|
|||||||
type: "rightAligned",
|
type: "rightAligned",
|
||||||
valueFormatter: (params: ValueFormatterParams) => {
|
valueFormatter: (params: ValueFormatterParams) => {
|
||||||
const raw: MoneyDTO | null = params.value;
|
const raw: MoneyDTO | null = params.value;
|
||||||
return raw ? format(raw) : "—";
|
return raw ? formatCurrency(raw) : "—";
|
||||||
},
|
},
|
||||||
cellClass: "tabular-nums",
|
cellClass: "tabular-nums",
|
||||||
minWidth: 130,
|
minWidth: 130,
|
||||||
@ -105,7 +109,7 @@ export const CustomerInvoicesListGrid = () => {
|
|||||||
type: "rightAligned",
|
type: "rightAligned",
|
||||||
valueFormatter: (params: ValueFormatterParams) => {
|
valueFormatter: (params: ValueFormatterParams) => {
|
||||||
const raw: MoneyDTO | null = params.value;
|
const raw: MoneyDTO | null = params.value;
|
||||||
return raw ? format(raw) : "—";
|
return raw ? formatCurrency(raw) : "—";
|
||||||
},
|
},
|
||||||
cellClass: "tabular-nums",
|
cellClass: "tabular-nums",
|
||||||
minWidth: 130,
|
minWidth: 130,
|
||||||
@ -116,7 +120,7 @@ export const CustomerInvoicesListGrid = () => {
|
|||||||
type: "rightAligned",
|
type: "rightAligned",
|
||||||
valueFormatter: (params: ValueFormatterParams) => {
|
valueFormatter: (params: ValueFormatterParams) => {
|
||||||
const raw: MoneyDTO | null = params.value;
|
const raw: MoneyDTO | null = params.value;
|
||||||
return raw ? format(raw) : "—";
|
return raw ? formatCurrency(raw) : "—";
|
||||||
},
|
},
|
||||||
cellClass: "tabular-nums font-semibold",
|
cellClass: "tabular-nums font-semibold",
|
||||||
minWidth: 140,
|
minWidth: 140,
|
||||||
|
|||||||
@ -108,6 +108,7 @@ export function AmountDTOInput({
|
|||||||
pattern={focused ? '[0-9]*[.,]?[0-9]*' : undefined}
|
pattern={focused ? '[0-9]*[.,]?[0-9]*' : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full bg-transparent p-0 text-right focus:outline-none tabular-nums focus:bg-background",
|
"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
|
className
|
||||||
)}
|
)}
|
||||||
placeholder={emptyMode === "placeholder" && isEmptyMoneyDTO(value) ? emptyText : undefined}
|
placeholder={emptyMode === "placeholder" && isEmptyMoneyDTO(value) ? emptyText : undefined}
|
||||||
|
|||||||
@ -1,90 +1,122 @@
|
|||||||
import { useMoney } from '@erp/core/hooks';
|
import { useMoney } from '@erp/core/hooks';
|
||||||
import { InvoiceItemTotals } from '@erp/customer-invoices/web/hooks/calcs';
|
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,
|
||||||
HoverCard, HoverCardContent, HoverCardTrigger
|
HoverCard, HoverCardContent, HoverCardTrigger
|
||||||
} from "@repo/shadcn-ui/components";
|
} from "@repo/shadcn-ui/components";
|
||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
|
import { useFormContext, useWatch } from "react-hook-form";
|
||||||
import { useTranslation } from "../../../i18n";
|
import { useTranslation } from "../../../i18n";
|
||||||
|
|
||||||
|
|
||||||
export type HoverCardTotalsSummaryProps = PropsWithChildren & {
|
type HoverCardTotalsSummaryProps = PropsWithChildren & {
|
||||||
totals: InvoiceItemTotals
|
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 = ({
|
export const HoverCardTotalsSummary = ({
|
||||||
children,
|
children,
|
||||||
totals
|
rowIndex,
|
||||||
}: HoverCardTotalsSummaryProps) => {
|
}: HoverCardTotalsSummaryProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { formatCurrency } = useMoney();
|
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 = () => (
|
const SummaryBlock = () => (
|
||||||
<div className="space-y-2">
|
<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">
|
{/* Subtotal */}
|
||||||
<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 && (
|
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-muted-foreground">
|
<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>
|
||||||
<span className="font-mono text-destructive">
|
<span className="font-mono text-destructive">
|
||||||
-{formatCurrency(totals.discountAmount)}
|
-{formatCurrency(discountAmount)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Base imponible */}
|
||||||
<div className="flex justify-between text-sm border-t pt-2">
|
<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">
|
<span className="font-mono font-medium">
|
||||||
{formatCurrency(totals.taxableBase)}
|
{formatCurrency(taxableBase)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/*totals.taxesBreakdown.map((tax) => (
|
{/* Total final */}
|
||||||
<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>
|
|
||||||
))*/}
|
|
||||||
|
|
||||||
<div className="flex justify-between text-sm border-t pt-2 font-semibold">
|
<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>
|
||||||
<span className="font-mono">{formatCurrency(totals.total)}</span>
|
{t("components.hover_card_totals_summary.fields.total_amount")}:
|
||||||
|
</span>
|
||||||
|
<span className="font-mono">{formatCurrency(total)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Variante móvil */}
|
{/* Variante móvil (Dialog) */}
|
||||||
<div className="md:hidden">
|
<div className="md:hidden">
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger>{children}</DialogTrigger>
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Desglose del importe</DialogTitle>
|
<DialogTitle>
|
||||||
|
{t("components.hover_card_totals_summary.label")}
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<SummaryBlock />
|
<SummaryBlock />
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Variante desktop */}
|
{/* Variante escritorio (HoverCard) */}
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
<HoverCard>
|
<HoverCard>
|
||||||
<HoverCardTrigger>{children}</HoverCardTrigger>
|
<HoverCardTrigger asChild>{children}</HoverCardTrigger>
|
||||||
<HoverCardContent className="w-64" align="end">
|
<HoverCardContent className="w-64" align="end">
|
||||||
<SummaryBlock />
|
<SummaryBlock />
|
||||||
</HoverCardContent>
|
</HoverCardContent>
|
||||||
</HoverCard>
|
</HoverCard>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
import { Button, Checkbox, TableCell, TableRow, Tooltip, TooltipContent, TooltipTrigger } from "@repo/shadcn-ui/components";
|
import { Button, Checkbox, TableCell, TableRow, Tooltip, TooltipContent, TooltipTrigger } from "@repo/shadcn-ui/components";
|
||||||
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, Trash2Icon } from "lucide-react";
|
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, Trash2Icon } from "lucide-react";
|
||||||
import { useEffect } from 'react';
|
import { Control, Controller } from "react-hook-form";
|
||||||
import { Controller, useFormContext } from "react-hook-form";
|
|
||||||
import { useCalcInvoiceItemTotals } from '../../../hooks';
|
|
||||||
import { useTranslation } from '../../../i18n';
|
import { useTranslation } from '../../../i18n';
|
||||||
import { CustomerInvoiceItemFormData } from '../../../schemas';
|
import { CustomerInvoiceItemFormData } from '../../../schemas';
|
||||||
import { CustomerInvoiceTaxesMultiSelect } from '../../customer-invoice-taxes-multi-select';
|
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';
|
import { QuantityDTOInputField } from './quantity-dto-input-field';
|
||||||
|
|
||||||
export type ItemRowProps = {
|
export type ItemRowProps = {
|
||||||
|
control: Control,
|
||||||
item: CustomerInvoiceItemFormData;
|
item: CustomerInvoiceItemFormData;
|
||||||
rowIndex: number;
|
rowIndex: number;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
@ -27,7 +25,9 @@ export type ItemRowProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const ItemRow = ({ item,
|
export const ItemRow = ({
|
||||||
|
control,
|
||||||
|
item,
|
||||||
rowIndex,
|
rowIndex,
|
||||||
isSelected,
|
isSelected,
|
||||||
isFirst,
|
isFirst,
|
||||||
@ -40,19 +40,6 @@ export const ItemRow = ({ item,
|
|||||||
onRemove, }: ItemRowProps) => {
|
onRemove, }: ItemRowProps) => {
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<TableRow data-row-index={rowIndex}>
|
<TableRow data-row-index={rowIndex}>
|
||||||
{/* selección */}
|
{/* selección */}
|
||||||
@ -146,10 +133,9 @@ export const ItemRow = ({ item,
|
|||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
|
|
||||||
{/* total (solo lectura) */}
|
{/* total (solo lectura) */}
|
||||||
<TableCell className='text-right tabular-nums pt-[6px] leading-5'>
|
<TableCell className='text-right tabular-nums pt-[6px] leading-5'>
|
||||||
<HoverCardTotalsSummary totals={totals}>
|
<HoverCardTotalsSummary rowIndex={rowIndex} >
|
||||||
<AmountDTOInputField
|
<AmountDTOInputField
|
||||||
control={control}
|
control={control}
|
||||||
name={`items.${rowIndex}.total_amount`}
|
name={`items.${rowIndex}.total_amount`}
|
||||||
|
|||||||
@ -40,7 +40,6 @@ export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEdi
|
|||||||
const { control, watch } = form;
|
const { control, watch } = form;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Emitir cambios a quien consuma el componente
|
// Emitir cambios a quien consuma el componente
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const sub = watch((v) => onChange?.(v.items ?? []));
|
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) => (
|
{tableNav.fa.fields.map((f, rowIndex) => (
|
||||||
<ItemRow
|
<ItemRow
|
||||||
key={f.id}
|
key={f.id}
|
||||||
|
control={control}
|
||||||
item={form.watch(`items.${rowIndex}`)}
|
item={form.watch(`items.${rowIndex}`)}
|
||||||
rowIndex={rowIndex}
|
rowIndex={rowIndex}
|
||||||
isSelected={selectedRows.has(rowIndex)}
|
isSelected={selectedRows.has(rowIndex)}
|
||||||
|
|||||||
@ -99,7 +99,10 @@ export function PercentageDTOInput({
|
|||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
pattern={focused ? "[0-9]*[.,]?[0-9]*%?" : undefined}
|
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}
|
readOnly={readOnly}
|
||||||
placeholder={emptyMode === "placeholder" && isEmptyDTO ? emptyText : undefined}
|
placeholder={emptyMode === "placeholder" && isEmptyDTO ? emptyText : undefined}
|
||||||
value={raw}
|
value={raw}
|
||||||
|
|||||||
@ -66,6 +66,119 @@ export function QuantityDTOInput({
|
|||||||
return suf ? `${numTxt}${nbspBeforeSuffix ? "\u00A0" : " "}${suf}` : numTxt;
|
return suf ? `${numTxt}${nbspBeforeSuffix ? "\u00A0" : " "}${suf}` : numTxt;
|
||||||
}, [value, toNumber, formatPlain, suffixFor, nbspBeforeSuffix, emptyMode, emptyText]);
|
}, [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(() => {
|
React.useEffect(() => {
|
||||||
if (focused) return;
|
if (focused) return;
|
||||||
@ -99,52 +212,20 @@ export function QuantityDTOInput({
|
|||||||
id={id}
|
id={id}
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
pattern={focused ? "[0-9]*[.,]?[0-9]*" : undefined}
|
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}
|
placeholder={emptyMode === "placeholder" && isEmptyQuantityDTO(value) ? emptyText : undefined}
|
||||||
value={raw}
|
value={raw}
|
||||||
onChange={(e) => setRaw(e.currentTarget.value)}
|
onChange={(e) => setRaw(e.currentTarget.value)}
|
||||||
onFocus={(e) => {
|
onFocus={handleFocus}
|
||||||
setFocused(true);
|
onBlur={handleBlur}
|
||||||
// Pasar de visual (con posible sufijo) → crudo editable
|
onKeyDown={handleKeyDown}
|
||||||
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
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -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-invoice-auto-recalc";
|
||||||
export * from "./use-calc-invoice-totals";
|
|
||||||
|
|||||||
@ -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 "./calcs";
|
||||||
export * from "./use-create-customer-invoice-mutation";
|
export * from "./use-create-customer-invoice-mutation";
|
||||||
export * from "./use-customer-invoice-query";
|
export * from "./use-customer-invoice-query";
|
||||||
export * from "./use-customer-invoices-context";
|
|
||||||
export * from "./use-customer-invoices-query";
|
export * from "./use-customer-invoices-query";
|
||||||
export * from "./use-detail-columns";
|
export * from "./use-detail-columns";
|
||||||
export * from "./use-items-table-navigation";
|
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,
|
CustomerInvoiceEditorSkeleton,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
} from "../../components";
|
} from "../../components";
|
||||||
import { useCustomerInvoiceQuery, useUpdateCustomerInvoice } from "../../hooks";
|
import { InvoiceProvider } from '../../context';
|
||||||
|
import { useCustomerInvoiceQuery, useInvoiceAutoRecalc, useUpdateCustomerInvoice } from "../../hooks";
|
||||||
import { useTranslation } from "../../i18n";
|
import { useTranslation } from "../../i18n";
|
||||||
import {
|
import {
|
||||||
CustomerInvoiceFormData,
|
CustomerInvoiceFormData,
|
||||||
@ -46,13 +47,15 @@ export const CustomerInvoiceUpdatePage = () => {
|
|||||||
} = useUpdateCustomerInvoice();
|
} = useUpdateCustomerInvoice();
|
||||||
|
|
||||||
// 3) Form hook
|
// 3) Form hook
|
||||||
|
|
||||||
const form = useHookForm<CustomerInvoiceFormData>({
|
const form = useHookForm<CustomerInvoiceFormData>({
|
||||||
resolverSchema: CustomerInvoiceFormSchema,
|
resolverSchema: CustomerInvoiceFormSchema,
|
||||||
initialValues: (invoiceData as unknown as CustomerInvoiceFormData) ?? defaultCustomerInvoiceFormData,
|
initialValues: (invoiceData as unknown as CustomerInvoiceFormData) ?? defaultCustomerInvoiceFormData,
|
||||||
disabled: isUpdating,
|
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 handleSubmit = (formData: CustomerInvoiceFormData) => {
|
||||||
const { dirtyFields } = form.formState;
|
const { dirtyFields } = form.formState;
|
||||||
|
|
||||||
@ -131,6 +134,11 @@ export const CustomerInvoiceUpdatePage = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<InvoiceProvider
|
||||||
|
company_id={invoiceData.company_id}
|
||||||
|
language_code={invoiceData.language_code}
|
||||||
|
currency_code={invoiceData.currency_code}
|
||||||
|
>
|
||||||
<UnsavedChangesProvider isDirty={form.formState.isDirty}>
|
<UnsavedChangesProvider isDirty={form.formState.isDirty}>
|
||||||
<AppHeader>
|
<AppHeader>
|
||||||
<AppBreadcrumb />
|
<AppBreadcrumb />
|
||||||
@ -178,5 +186,6 @@ export const CustomerInvoiceUpdatePage = () => {
|
|||||||
</FormProvider>
|
</FormProvider>
|
||||||
</AppContent>
|
</AppContent>
|
||||||
</UnsavedChangesProvider>
|
</UnsavedChangesProvider>
|
||||||
|
</InvoiceProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { MoneySchema, PercentageSchema, QuantitySchema } from "@erp/core";
|
import { MoneySchema, PercentageSchema, QuantitySchema } from "@erp/core";
|
||||||
|
import { ArrayElement } from "@repo/rdx-utils";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
export const CustomerInvoiceItemFormSchema = z.object({
|
export const CustomerInvoiceItemFormSchema = z.object({
|
||||||
@ -79,7 +80,7 @@ export const CustomerInvoiceFormSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type CustomerInvoiceFormData = z.infer<typeof CustomerInvoiceFormSchema>;
|
export type CustomerInvoiceFormData = z.infer<typeof CustomerInvoiceFormSchema>;
|
||||||
export type CustomerInvoiceItemFormData = z.infer<typeof CustomerInvoiceItemFormSchema>;
|
export type CustomerInvoiceItemFormData = ArrayElement<CustomerInvoiceFormData["items"]>;
|
||||||
|
|
||||||
export const defaultCustomerInvoiceItemFormData: CustomerInvoiceItemFormData = {
|
export const defaultCustomerInvoiceItemFormData: CustomerInvoiceItemFormData = {
|
||||||
description: "",
|
description: "",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user