Facturas de cliente
This commit is contained in:
parent
a946aa129a
commit
4e757c86d0
@ -11,6 +11,8 @@ import {
|
|||||||
} from "../../common/helpers";
|
} from "../../common/helpers";
|
||||||
import { useTranslation } from "../i18n";
|
import { useTranslation } from "../i18n";
|
||||||
|
|
||||||
|
export type { Currency };
|
||||||
|
|
||||||
// --- Utils locales (edición texto → número) ---
|
// --- Utils locales (edición texto → número) ---
|
||||||
|
|
||||||
// Quita símbolos de moneda/letras, conserva dígitos, signo y , .
|
// Quita símbolos de moneda/letras, conserva dígitos, signo y , .
|
||||||
|
|||||||
@ -1,31 +1,24 @@
|
|||||||
import { MoneyDTO, PercentageDTO, QuantityDTO } from '@erp/core';
|
|
||||||
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 { useInvoiceItemSummary } from '../../../hooks';
|
|
||||||
import { useTranslation } from "../../../i18n";
|
import { useTranslation } from "../../../i18n";
|
||||||
|
|
||||||
|
|
||||||
export type HoverCardTotalsSummaryProps = PropsWithChildren & {
|
export type HoverCardTotalsSummaryProps = PropsWithChildren & {
|
||||||
data: {
|
totals: InvoiceItemTotals
|
||||||
quantity: QuantityDTO | null | undefined;
|
|
||||||
unit_amount: MoneyDTO | null | undefined;
|
|
||||||
discount_percentage?: PercentageDTO | null;
|
|
||||||
tax_codes?: string[] | null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const HoverCardTotalsSummary = ({
|
export const HoverCardTotalsSummary = ({
|
||||||
children,
|
children,
|
||||||
data
|
totals
|
||||||
}: HoverCardTotalsSummaryProps) => {
|
}: HoverCardTotalsSummaryProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { formatCurrency } = useMoney();
|
const { formatCurrency } = useMoney();
|
||||||
const summary = useInvoiceItemSummary(data);
|
|
||||||
|
|
||||||
const SummaryBlock = () => (
|
const SummaryBlock = () => (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -33,16 +26,16 @@ export const HoverCardTotalsSummary = ({
|
|||||||
|
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-muted-foreground">{t("components.hover_card_totals_summary.fields.subtotal_amount")}:</span>
|
<span className="text-muted-foreground">{t("components.hover_card_totals_summary.fields.subtotal_amount")}:</span>
|
||||||
<span className="font-mono">{formatCurrency(summary.subtotal)}</span>
|
<span className="font-mono">{formatCurrency(totals.subtotal)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{Number(data.discount_percentage?.value ?? 0) > 0 && (
|
{(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")} ({data.discount_percentage?.value ?? 0}%):
|
{t("components.hover_card_totals_summary.fields.discount_percentage")} ({totals.discountPercent ?? 0}%):
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-destructive">
|
<span className="font-mono text-destructive">
|
||||||
-{formatCurrency(summary.discountAmount)}
|
-{formatCurrency(totals.discountAmount)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -50,20 +43,20 @@ export const HoverCardTotalsSummary = ({
|
|||||||
<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(summary.baseAmount)}
|
{formatCurrency(totals.taxableBase)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{summary.taxesBreakdown.map((tax) => (
|
{/*totals.taxesBreakdown.map((tax) => (
|
||||||
<div key={tax.label} className="flex justify-between text-sm">
|
<div key={tax.label} className="flex justify-between text-sm">
|
||||||
<span className="text-muted-foreground">{tax.label}:</span>
|
<span className="text-muted-foreground">{tax.label}:</span>
|
||||||
<span className="font-mono">{formatCurrency(tax.amount)}</span>
|
<span className="font-mono">{formatCurrency(tax.amount)}</span>
|
||||||
</div>
|
</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>{t("components.hover_card_totals_summary.fields.total_amount")}:</span>
|
||||||
<span className="font-mono">{formatCurrency(summary.total)}</span>
|
<span className="font-mono">{formatCurrency(totals.total)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { useTranslation } from '../../../i18n';
|
|||||||
|
|
||||||
export const ItemsEditorToolbar = ({
|
export const ItemsEditorToolbar = ({
|
||||||
readOnly,
|
readOnly,
|
||||||
selectedIdx,
|
selectedIndexes,
|
||||||
onAdd,
|
onAdd,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
onMoveUp,
|
onMoveUp,
|
||||||
@ -12,7 +12,7 @@ export const ItemsEditorToolbar = ({
|
|||||||
onRemove,
|
onRemove,
|
||||||
}: {
|
}: {
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
selectedIdx: number[];
|
selectedIndexes: number[];
|
||||||
onAdd?: () => void;
|
onAdd?: () => void;
|
||||||
onDuplicate?: () => void;
|
onDuplicate?: () => void;
|
||||||
onMoveUp?: () => void;
|
onMoveUp?: () => void;
|
||||||
@ -20,7 +20,7 @@ export const ItemsEditorToolbar = ({
|
|||||||
onRemove?: () => void;
|
onRemove?: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const hasSel = selectedIdx.length > 0;
|
const hasSel = selectedIndexes.length > 0;
|
||||||
return (
|
return (
|
||||||
<nav className="flex items-center justify-between h-12 py-1 px-2 text-muted-foreground bg-muted border-b">
|
<nav className="flex items-center justify-between h-12 py-1 px-2 text-muted-foreground bg-muted border-b">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -109,7 +109,7 @@ export const ItemsEditorToolbar = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<p className='text-sm font-normal'>
|
<p className='text-sm font-normal'>
|
||||||
{t("common.rows_selected", { count: selectedIdx.length })}
|
{t("common.rows_selected", { count: selectedIndexes.length })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { SpainTaxCatalogProvider } from '@erp/core';
|
import { useRowSelection } from '@repo/rdx-ui/hooks';
|
||||||
import { Button, Checkbox, Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, Tooltip, TooltipContent, TooltipTrigger } from "@repo/shadcn-ui/components";
|
import { Button, Checkbox, Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, Tooltip, TooltipContent, TooltipTrigger } from "@repo/shadcn-ui/components";
|
||||||
import { ArrowDown, ArrowUp, CopyIcon, Trash2 } from "lucide-react";
|
import { ArrowDown, ArrowUp, CopyIcon, Trash2 } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Controller, useFormContext } from "react-hook-form";
|
import { Controller, useFormContext } from "react-hook-form";
|
||||||
import { useItemsTableNavigation } from '../../../hooks';
|
import { useCalcInvoiceItemTotals, useItemsTableNavigation } from '../../../hooks';
|
||||||
import { useTranslation } from '../../../i18n';
|
import { useTranslation } from '../../../i18n';
|
||||||
import { CustomerInvoiceItemFormData, defaultCustomerInvoiceItemFormData } from '../../../schemas';
|
import { CustomerInvoiceItemFormData, defaultCustomerInvoiceItemFormData } from '../../../schemas';
|
||||||
import { CustomerInvoiceTaxesMultiSelect } from '../../customer-invoice-taxes-multi-select';
|
import { CustomerInvoiceTaxesMultiSelect } from '../../customer-invoice-taxes-multi-select';
|
||||||
@ -22,16 +22,10 @@ interface ItemsEditorProps {
|
|||||||
|
|
||||||
const createEmptyItem = () => defaultCustomerInvoiceItemFormData;
|
const createEmptyItem = () => defaultCustomerInvoiceItemFormData;
|
||||||
|
|
||||||
function getSelectAllState(totalRows: number, selectedCount: number): boolean | 'indeterminate' {
|
|
||||||
if (totalRows === 0 || selectedCount === 0) return false;
|
|
||||||
if (selectedCount === totalRows) return true;
|
|
||||||
return 'indeterminate';
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEditorProps) => {
|
export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEditorProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const form = useFormContext();
|
const form = useFormContext();
|
||||||
const taxCatalog = React.useMemo(() => SpainTaxCatalogProvider(), []);
|
|
||||||
|
|
||||||
const tableNav = useItemsTableNavigation(form, {
|
const tableNav = useItemsTableNavigation(form, {
|
||||||
name: "items",
|
name: "items",
|
||||||
@ -39,11 +33,17 @@ export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEdi
|
|||||||
firstEditableField: "description",
|
firstEditableField: "description",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
selectedRows,
|
||||||
|
selectedIndexes,
|
||||||
|
selectAllState,
|
||||||
|
toggleRow,
|
||||||
|
setSelectAll,
|
||||||
|
clearSelection,
|
||||||
|
} = useRowSelection(tableNav.fa.fields.length);
|
||||||
|
|
||||||
const { control, watch } = form;
|
const { control, watch } = form;
|
||||||
|
|
||||||
const [selection, setSelection] = React.useState<Set<number>>(new Set());
|
|
||||||
const selectedIdx = React.useMemo(() => [...selection].sort((a, b) => a - b), [selection]);
|
|
||||||
const resetSelection = () => setSelection(new Set());
|
|
||||||
|
|
||||||
|
|
||||||
// Emitir cambios a quien consuma el componente
|
// Emitir cambios a quien consuma el componente
|
||||||
@ -52,29 +52,21 @@ export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEdi
|
|||||||
return () => sub.unsubscribe();
|
return () => sub.unsubscribe();
|
||||||
}, [watch, onChange]);
|
}, [watch, onChange]);
|
||||||
|
|
||||||
const toggleSel = (i: number) =>
|
|
||||||
setSelection((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.has(i) ? next.delete(i) : next.add(i);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0">
|
<div className="space-y-0">
|
||||||
{/* Toolbar selección múltiple */}
|
{/* Toolbar selección múltiple */}
|
||||||
<ItemsEditorToolbar
|
<ItemsEditorToolbar
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
selectedIdx={selectedIdx}
|
selectedIndexes={selectedIndexes}
|
||||||
onAdd={() => tableNav.addEmpty(true)}
|
onAdd={() => tableNav.addEmpty(true)}
|
||||||
onDuplicate={() => selectedIdx.forEach((i) => tableNav.duplicate(i))}
|
onDuplicate={() => selectedIndexes.forEach((i) => tableNav.duplicate(i))}
|
||||||
onMoveUp={() => selectedIdx.forEach((i) => tableNav.moveUp(i))}
|
onMoveUp={() => selectedIndexes.forEach((i) => tableNav.moveUp(i))}
|
||||||
onMoveDown={() => [...selectedIdx].reverse().forEach((i) => tableNav.moveDown(i))}
|
onMoveDown={() => [...selectedIndexes].reverse().forEach((i) => tableNav.moveDown(i))}
|
||||||
onRemove={() => {
|
onRemove={() => {
|
||||||
[...selectedIdx].reverse().forEach((i) => tableNav.remove(i));
|
[...selectedIndexes].reverse().forEach((i) => tableNav.remove(i));
|
||||||
resetSelection();
|
clearSelection();
|
||||||
}}
|
}} />
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="bg-background">
|
<div className="bg-background">
|
||||||
<Table className="w-full border-collapse text-sm">
|
<Table className="w-full border-collapse text-sm">
|
||||||
@ -96,17 +88,9 @@ export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEdi
|
|||||||
<div className='h-5'>
|
<div className='h-5'>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
aria-label={t("common.select_all")}
|
aria-label={t("common.select_all")}
|
||||||
className='block h-5 w-5 leading-none align-middle'
|
checked={selectAllState}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
checked={getSelectAllState(tableNav.fa.fields.length, selection.size)}
|
onCheckedChange={(checked) => setSelectAll(checked)}
|
||||||
onCheckedChange={(next) => {
|
|
||||||
const shouldSelectAll = next !== false;
|
|
||||||
if (shouldSelectAll) {
|
|
||||||
setSelection(new Set(tableNav.fa.fields.map((_, i) => i)));
|
|
||||||
} else {
|
|
||||||
resetSelection();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
@ -122,11 +106,19 @@ export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEdi
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody className='text-sm'>
|
<TableBody className='text-sm'>
|
||||||
{tableNav.fa.fields.map((f, rowIndex) => {
|
{tableNav.fa.fields.map((f, rowIndex) => {
|
||||||
//const comp = calculateItemAmounts(f);
|
|
||||||
//console.log(comp);
|
|
||||||
const isFirst = rowIndex === 0;
|
const isFirst = rowIndex === 0;
|
||||||
const isLast = rowIndex === tableNav.fa.fields.length - 1;
|
const isLast = rowIndex === tableNav.fa.fields.length - 1;
|
||||||
|
|
||||||
|
const item = form.watch(`items.${rowIndex}`);
|
||||||
|
const totals = useCalcInvoiceItemTotals(item);
|
||||||
|
|
||||||
|
// sincronizar con react-hook-form
|
||||||
|
React.useEffect(() => {
|
||||||
|
form.setValue(`items.${rowIndex}.total_amount`, totals.totalDTO, { shouldDirty: true });
|
||||||
|
}, [totals.totalDTO, form, rowIndex]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={`row-${f.id}`} data-row-index={rowIndex}>
|
<TableRow key={`row-${f.id}`} data-row-index={rowIndex}>
|
||||||
{/* selección */}
|
{/* selección */}
|
||||||
@ -134,11 +126,11 @@ export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEdi
|
|||||||
<div className='h-5'>
|
<div className='h-5'>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
aria-label={t("common.select_row", { n: rowIndex + 1 })}
|
aria-label={t("common.select_row", { n: rowIndex + 1 })}
|
||||||
className='block h-5 w-5 leading-none align-middle'
|
checked={selectedRows.has(rowIndex)}
|
||||||
checked={selection.has(rowIndex)}
|
|
||||||
onCheckedChange={() => toggleSel(rowIndex)}
|
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
/> </div>
|
onCheckedChange={() => toggleRow(rowIndex)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* # */}
|
{/* # */}
|
||||||
@ -222,15 +214,13 @@ export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEdi
|
|||||||
|
|
||||||
{/* 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 data={{ ...f }}>
|
<HoverCardTotalsSummary totals={totals}>
|
||||||
<AmountDTOInputField
|
<AmountDTOInputField
|
||||||
control={control}
|
control={control}
|
||||||
name={`items.${rowIndex}.total_amount`}
|
name={`items.${rowIndex}.total_amount`}
|
||||||
readOnly
|
readOnly
|
||||||
inputId={`total-amount-${rowIndex}`}
|
inputId={`total-amount-${rowIndex}`}
|
||||||
// @ts-expect-error
|
locale="es"
|
||||||
readOnlyMode='textlike-input'
|
|
||||||
locale={"es"}
|
|
||||||
/>
|
/>
|
||||||
</HoverCardTotalsSummary>
|
</HoverCardTotalsSummary>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@ -298,7 +288,7 @@ export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEdi
|
|||||||
<TableCell colSpan={9}>
|
<TableCell colSpan={9}>
|
||||||
<ItemsEditorToolbar
|
<ItemsEditorToolbar
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
selectedIdx={selectedIdx}
|
selectedIndexes={selectedIndexes}
|
||||||
onAdd={() => tableNav.addEmpty(true)}
|
onAdd={() => tableNav.addEmpty(true)}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@ -1,48 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
import { parseNum } from './types.d';
|
|
||||||
|
|
||||||
type NumericInputProps = React.InputHTMLAttributes<HTMLInputElement> & {
|
|
||||||
value?: number;
|
|
||||||
onValueChange: (n: number | undefined) => void;
|
|
||||||
maxDecimals: 2 | 4;
|
|
||||||
readOnly?: boolean;
|
|
||||||
};
|
|
||||||
export function NumericInput({ value, onValueChange, maxDecimals, readOnly, ...rest }: NumericInputProps) {
|
|
||||||
const [raw, setRaw] = React.useState<string>(() => value ?? value === 0 ? String(value) : "");
|
|
||||||
React.useEffect(() => { if (document.activeElement !== ref.current) setRaw(value ?? value === 0 ? String(value) : ""); }, [value]);
|
|
||||||
const ref = React.useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
ref={ref}
|
|
||||||
data-slot='input'
|
|
||||||
inputMode="decimal"
|
|
||||||
pattern="[0-9]*[.,]?[0-9]*"
|
|
||||||
className="w-full bg-transparent focus:outline-none p-0 text-right tabular-nums font-medium"
|
|
||||||
aria-live="off"
|
|
||||||
readOnly={readOnly}
|
|
||||||
value={raw}
|
|
||||||
onChange={(e) => setRaw(e.target.value)}
|
|
||||||
onBlur={() => {
|
|
||||||
const n = parseNum(raw);
|
|
||||||
if (n === undefined) { onValueChange(undefined); setRaw(""); return; }
|
|
||||||
const factor = maxDecimals === 4 ? 1e4 : 1e2;
|
|
||||||
const rounded = Math.round(n * factor) / factor;
|
|
||||||
onValueChange(rounded);
|
|
||||||
// ocultar ceros a la derecha
|
|
||||||
setRaw(Intl.NumberFormat(undefined, { maximumFractionDigits: maxDecimals }).format(rounded));
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if ((e.key === "ArrowUp" || e.key === "ArrowDown") && !readOnly) {
|
|
||||||
e.preventDefault();
|
|
||||||
const n = parseNum(raw) ?? 0;
|
|
||||||
const delta = e.shiftKey ? 1 : 0.1;
|
|
||||||
const next = e.key === "ArrowUp" ? n + delta : n - delta;
|
|
||||||
onValueChange(Number(next.toFixed(maxDecimals)));
|
|
||||||
setRaw(String(Number(next.toFixed(maxDecimals))));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -19,7 +19,6 @@ import { ChevronDownIcon, ChevronUpIcon, CopyIcon, Plus, TrashIcon } from "lucid
|
|||||||
import { useMoney } from '@erp/core/hooks';
|
import { useMoney } from '@erp/core/hooks';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useFormContext } from 'react-hook-form';
|
import { useFormContext } from 'react-hook-form';
|
||||||
import { useCalculateItemAmounts } from '../../../hooks';
|
|
||||||
import { useTranslation } from '../../../i18n';
|
import { useTranslation } from '../../../i18n';
|
||||||
import { CustomerInvoiceItemFormData } from '../../../schemas';
|
import { CustomerInvoiceItemFormData } from '../../../schemas';
|
||||||
import { HoverCardTotalsSummary } from './hover-card-total-summary';
|
import { HoverCardTotalsSummary } from './hover-card-total-summary';
|
||||||
@ -31,7 +30,6 @@ export const TableView = ({ items, actions }: TableViewProps) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { control } = useFormContext<CustomerInvoiceItemFormData>();
|
const { control } = useFormContext<CustomerInvoiceItemFormData>();
|
||||||
const { format } = useMoney();
|
const { format } = useMoney();
|
||||||
const calculateItemAmounts = useCalculateItemAmounts();
|
|
||||||
const [lines, setLines] = useState<CustomerInvoiceItemFormData[]>(items);
|
const [lines, setLines] = useState<CustomerInvoiceItemFormData[]>(items);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
2
modules/customer-invoices/src/web/hooks/calcs/index.ts
Normal file
2
modules/customer-invoices/src/web/hooks/calcs/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./use-calc-invoice-items-totals";
|
||||||
|
export * from "./use-calc-invoice-totals";
|
||||||
@ -0,0 +1,77 @@
|
|||||||
|
import { MoneyDTO } from "@erp/core";
|
||||||
|
import { useMoney, usePercentage, useQuantity } from "@erp/core/hooks";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { CustomerInvoiceItemFormData } from "../../schemas";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula totales derivados de un ítem de factura
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type InvoiceItemTotals = Readonly<{
|
||||||
|
// valores base ya normalizados a number
|
||||||
|
quantity: number;
|
||||||
|
unitAmount: number;
|
||||||
|
discountPercent: number;
|
||||||
|
|
||||||
|
// desgloses numéricos
|
||||||
|
subtotal: number; // qty * unit
|
||||||
|
discountAmount: number; // subtotal * (discountPercent/100)
|
||||||
|
taxableBase: number; // subtotal - discountAmount
|
||||||
|
taxes: number; // por ahora 0 (o calcula según tax_codes si lo necesitas)
|
||||||
|
total: number; // taxableBase + taxes
|
||||||
|
|
||||||
|
// equivalentes en MoneyDTO (misma divisa/escala que useMoney)
|
||||||
|
subtotalDTO: MoneyDTO;
|
||||||
|
discountAmountDTO: MoneyDTO;
|
||||||
|
taxableBaseDTO: MoneyDTO;
|
||||||
|
taxesDTO: MoneyDTO;
|
||||||
|
totalDTO: MoneyDTO;
|
||||||
|
}>;
|
||||||
|
/**
|
||||||
|
* Calcula totales derivados de una línea de factura usando tus hooks de Money/Quantity/Percentage.
|
||||||
|
*/
|
||||||
|
export function useCalcInvoiceItemTotals(item?: CustomerInvoiceItemFormData): InvoiceItemTotals {
|
||||||
|
const moneyHelper = useMoney();
|
||||||
|
const qtyHelper = useQuantity();
|
||||||
|
const pctHelper = usePercentage();
|
||||||
|
|
||||||
|
return useMemo<InvoiceItemTotals>(() => {
|
||||||
|
// valores base
|
||||||
|
const quantity = item ? qtyHelper.toNumber(item.quantity) : 0;
|
||||||
|
const unitAmount = item ? moneyHelper.toNumber(item.unit_amount) : 0;
|
||||||
|
const discountPercent = item ? pctHelper.toNumber(item.discount_percentage) : 0;
|
||||||
|
|
||||||
|
// cálculos
|
||||||
|
const subtotal = quantity * unitAmount;
|
||||||
|
const discountAmount = subtotal * (discountPercent / 100);
|
||||||
|
const taxableBase = subtotal - discountAmount;
|
||||||
|
|
||||||
|
// impuestos (ajústalo si quieres aplicar tax_codes)
|
||||||
|
const taxes = 0;
|
||||||
|
|
||||||
|
const total = taxableBase + taxes;
|
||||||
|
|
||||||
|
// DTOs
|
||||||
|
const subtotalDTO = moneyHelper.fromNumber(subtotal);
|
||||||
|
const discountAmountDTO = moneyHelper.fromNumber(discountAmount);
|
||||||
|
const taxableBaseDTO = moneyHelper.fromNumber(taxableBase);
|
||||||
|
const taxesDTO = moneyHelper.fromNumber(taxes);
|
||||||
|
const totalDTO = moneyHelper.fromNumber(total);
|
||||||
|
|
||||||
|
return {
|
||||||
|
quantity,
|
||||||
|
unitAmount,
|
||||||
|
discountPercent,
|
||||||
|
subtotal,
|
||||||
|
discountAmount,
|
||||||
|
taxableBase,
|
||||||
|
taxes,
|
||||||
|
total,
|
||||||
|
subtotalDTO,
|
||||||
|
discountAmountDTO,
|
||||||
|
taxableBaseDTO,
|
||||||
|
taxesDTO,
|
||||||
|
totalDTO,
|
||||||
|
};
|
||||||
|
}, [item, moneyHelper, qtyHelper, pctHelper]);
|
||||||
|
}
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
import { MoneyDTO } from "@erp/core";
|
||||||
|
import { useMoney, usePercentage, useQuantity } from "@erp/core/hooks";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { CustomerInvoiceItemFormData } from "../../schemas";
|
||||||
|
|
||||||
|
export type InvoiceTotals = Readonly<{
|
||||||
|
subtotal: number;
|
||||||
|
discountTotal: number;
|
||||||
|
taxableBase: number;
|
||||||
|
taxes: number;
|
||||||
|
total: number;
|
||||||
|
|
||||||
|
subtotalDTO: MoneyDTO;
|
||||||
|
discountTotalDTO: MoneyDTO;
|
||||||
|
taxableBaseDTO: MoneyDTO;
|
||||||
|
taxesDTO: MoneyDTO;
|
||||||
|
totalDTO: MoneyDTO;
|
||||||
|
|
||||||
|
// número de líneas válidas consideradas
|
||||||
|
itemCount: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula los totales generales de la factura a partir de sus líneas.
|
||||||
|
*/
|
||||||
|
export function useCalcInvoiceTotals(
|
||||||
|
items: CustomerInvoiceItemFormData[] | undefined
|
||||||
|
): InvoiceTotals {
|
||||||
|
const money = useMoney();
|
||||||
|
const qty = useQuantity();
|
||||||
|
const pct = usePercentage();
|
||||||
|
|
||||||
|
return useMemo<InvoiceTotals>(() => {
|
||||||
|
if (!items?.length) {
|
||||||
|
const zero = money.fromNumber(0);
|
||||||
|
return {
|
||||||
|
subtotal: 0,
|
||||||
|
discountTotal: 0,
|
||||||
|
taxableBase: 0,
|
||||||
|
taxes: 0,
|
||||||
|
total: 0,
|
||||||
|
subtotalDTO: zero,
|
||||||
|
discountTotalDTO: zero,
|
||||||
|
taxableBaseDTO: zero,
|
||||||
|
taxesDTO: zero,
|
||||||
|
totalDTO: zero,
|
||||||
|
itemCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let subtotal = 0;
|
||||||
|
let discountTotal = 0;
|
||||||
|
let taxableBase = 0;
|
||||||
|
let taxes = 0;
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const quantity = qty.toNumber(item.quantity);
|
||||||
|
const unit = money.toNumber(item.unit_amount);
|
||||||
|
const discountPct = pct.toNumber(item.discount_percentage);
|
||||||
|
|
||||||
|
const lineSubtotal = quantity * unit;
|
||||||
|
const lineDiscount = lineSubtotal * (discountPct / 100);
|
||||||
|
const lineTaxable = lineSubtotal - lineDiscount;
|
||||||
|
const lineTaxes = 0; // ← ajusta si aplicas IVA o impuestos reales
|
||||||
|
const lineTotal = lineTaxable + lineTaxes;
|
||||||
|
|
||||||
|
subtotal += lineSubtotal;
|
||||||
|
discountTotal += lineDiscount;
|
||||||
|
taxableBase += lineTaxable;
|
||||||
|
taxes += lineTaxes;
|
||||||
|
total += lineTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subtotal,
|
||||||
|
discountTotal,
|
||||||
|
taxableBase,
|
||||||
|
taxes,
|
||||||
|
total,
|
||||||
|
subtotalDTO: money.fromNumber(subtotal),
|
||||||
|
discountTotalDTO: money.fromNumber(discountTotal),
|
||||||
|
taxableBaseDTO: money.fromNumber(taxableBase),
|
||||||
|
taxesDTO: money.fromNumber(taxes),
|
||||||
|
totalDTO: money.fromNumber(total),
|
||||||
|
itemCount: items.length,
|
||||||
|
};
|
||||||
|
}, [items, money, qty, pct]);
|
||||||
|
}
|
||||||
@ -1,6 +1,5 @@
|
|||||||
export * from "./use-calculate-item-amounts";
|
export * from "./calcs";
|
||||||
export * from "./use-create-customer-invoice-mutation";
|
export * from "./use-create-customer-invoice-mutation";
|
||||||
export * from "./use-customer-invoice-item-summary";
|
|
||||||
export * from "./use-customer-invoice-query";
|
export * from "./use-customer-invoice-query";
|
||||||
export * from "./use-customer-invoices-context";
|
export * from "./use-customer-invoices-context";
|
||||||
export * from "./use-customer-invoices-query";
|
export * from "./use-customer-invoices-query";
|
||||||
|
|||||||
@ -1,127 +0,0 @@
|
|||||||
import { MoneyDTO, PercentageDTO, TaxCatalogProvider } from "@erp/core";
|
|
||||||
import { useMoney, usePercentage, useQuantity } from "@erp/core/hooks";
|
|
||||||
import * as React from "react";
|
|
||||||
import { CustomerInvoiceItem } from "../schemas";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recalcula todos los importes de una línea usando los hooks de escala.
|
|
||||||
*/
|
|
||||||
|
|
||||||
type UseCalculateItemAmountsParams = {
|
|
||||||
locale: string;
|
|
||||||
currencyCode: string;
|
|
||||||
keepNullWhenEmpty?: boolean; // Mantener todos los importes a null cuando la línea está “vacía” (qty+unit vacíos)
|
|
||||||
taxCatalog: TaxCatalogProvider; // Catálogo de impuestos (inyectable para test)
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useCalculateItemAmounts(params: UseCalculateItemAmountsParams) {
|
|
||||||
const { locale, currencyCode, taxCatalog, keepNullWhenEmpty } = params;
|
|
||||||
|
|
||||||
const {
|
|
||||||
add,
|
|
||||||
sub,
|
|
||||||
multiply,
|
|
||||||
percentage: moneyPct,
|
|
||||||
fromNumber,
|
|
||||||
toNumber,
|
|
||||||
isEmptyMoneyDTO,
|
|
||||||
fallbackCurrency,
|
|
||||||
} = useMoney();
|
|
||||||
const { toNumber: qtyToNumber } = useQuantity();
|
|
||||||
const { toNumber: pctToNumber } = usePercentage();
|
|
||||||
|
|
||||||
// Crea un MoneyDTO "cero" con la misma divisa/escala que unit_amount
|
|
||||||
const zeroOf = React.useCallback(
|
|
||||||
(unit?: MoneyDTO | null): MoneyDTO => {
|
|
||||||
const cur = unit?.currency_code ?? fallbackCurrency;
|
|
||||||
const sc = Number(unit?.scale ?? 2);
|
|
||||||
return fromNumber(0, cur as any, sc);
|
|
||||||
},
|
|
||||||
[fromNumber, fallbackCurrency]
|
|
||||||
);
|
|
||||||
|
|
||||||
const emptyAmountDTO = React.useMemo(
|
|
||||||
() => ({
|
|
||||||
value: "",
|
|
||||||
scale: "4",
|
|
||||||
currency_code: currencyCode,
|
|
||||||
}),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
return React.useCallback(
|
|
||||||
(item: CustomerInvoiceItem): CustomerInvoiceItem => {
|
|
||||||
const qty = qtyToNumber(item.quantity); // 0 si vacío
|
|
||||||
const unit = item.unit_amount && !isEmptyMoneyDTO(item.unit_amount) ? item.unit_amount : null;
|
|
||||||
const zero = zeroOf(unit ?? undefined);
|
|
||||||
|
|
||||||
// Línea “vacía”: mantener null si se pide y no hay datos
|
|
||||||
const isEmptyLine = qty === 0 && (!unit || toNumber(unit) === 0);
|
|
||||||
if (isEmptyLine && keepNullWhenEmpty) {
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
subtotal_amount: emptyAmountDTO,
|
|
||||||
discount_amount: emptyAmountDTO,
|
|
||||||
taxable_amount: emptyAmountDTO,
|
|
||||||
taxes_amount: emptyAmountDTO,
|
|
||||||
total_amount: emptyAmountDTO,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1) Subtotal = qty × unit
|
|
||||||
const subtotal = unit ? multiply(unit, qty) : zero;
|
|
||||||
|
|
||||||
// 2) Descuento = subtotal × (discount_percentage / 100)
|
|
||||||
const pctDTO = item.discount_percentage ?? ({ value: "", scale: "" } as PercentageDTO);
|
|
||||||
const pct = pctToNumber(pctDTO); // 0 si vacío
|
|
||||||
const discountAmount = pct !== 0 ? moneyPct(subtotal, Math.abs(pct)) : zero;
|
|
||||||
|
|
||||||
// 3) Base imponible = subtotal - descuento
|
|
||||||
const baseAmount = sub(subtotal, discountAmount);
|
|
||||||
|
|
||||||
// 4) Impuestos
|
|
||||||
const taxesBreakdown =
|
|
||||||
(item.tax_codes
|
|
||||||
?.map((code) => {
|
|
||||||
const maybe = taxCatalog.findByCode(code);
|
|
||||||
if (maybe.isNone()) {
|
|
||||||
console.warn(`[useCalculateItemAmounts] Tax code not found: "${code}"`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const tax = maybe.unwrap()!; // { name, value, scale }
|
|
||||||
const p = pctToNumber({ value: tax.value, scale: tax.scale }); // ej. 21
|
|
||||||
return moneyPct(baseAmount, p);
|
|
||||||
})
|
|
||||||
.filter(Boolean) as MoneyDTO[]) ?? [];
|
|
||||||
|
|
||||||
const taxesTotal = taxesBreakdown.length
|
|
||||||
? taxesBreakdown.reduce((acc, m) => add(acc, m), zero)
|
|
||||||
: zero;
|
|
||||||
|
|
||||||
// 5) Total = base + impuestos
|
|
||||||
const total = add(baseAmount, taxesTotal);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
subtotal_amount: subtotal,
|
|
||||||
discount_amount: discountAmount,
|
|
||||||
taxable_amount: baseAmount,
|
|
||||||
taxes_amount: taxesTotal,
|
|
||||||
total_amount: total,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[
|
|
||||||
qtyToNumber,
|
|
||||||
pctToNumber,
|
|
||||||
multiply,
|
|
||||||
moneyPct,
|
|
||||||
add,
|
|
||||||
sub,
|
|
||||||
isEmptyMoneyDTO,
|
|
||||||
zeroOf,
|
|
||||||
toNumber,
|
|
||||||
keepNullWhenEmpty,
|
|
||||||
taxCatalog,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,75 +0,0 @@
|
|||||||
import { useDataSource, useQueryKey } from "@erp/core/hooks";
|
|
||||||
import { IListCustomerInvoicesResponseDTO } from "@erp/customerInvoices/common/dto";
|
|
||||||
|
|
||||||
export type UseCustomerInvoicesListParams = Omit<IGetListDataProviderParams, "filters" | "resource"> & {
|
|
||||||
status?: string;
|
|
||||||
enabled?: boolean;
|
|
||||||
queryOptions?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UseCustomerInvoicesListResponse = UseListQueryResult<
|
|
||||||
IListResponseDTO<IListCustomerInvoicesResponseDTO>,
|
|
||||||
unknown
|
|
||||||
>;
|
|
||||||
|
|
||||||
export type UseCustomerInvoicesGetParamsType = {
|
|
||||||
enabled?: boolean;
|
|
||||||
queryOptions?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UseCustomerInvoicesReportParamsType = {
|
|
||||||
enabled?: boolean;
|
|
||||||
queryOptions?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useCustomerInvoices = () => {
|
|
||||||
const actions = {
|
|
||||||
/**
|
|
||||||
* Hook para obtener la lista de facturas
|
|
||||||
* @param params - Parámetros para la consulta de la lista de facturas
|
|
||||||
* @returns - Respuesta de la consulta de la lista de facturas
|
|
||||||
*/
|
|
||||||
useList: (params: UseCustomerInvoicesListParams): UseCustomerInvoicesListResponse => {
|
|
||||||
const dataSource = useDataSource();
|
|
||||||
const keys = useQueryKey();
|
|
||||||
|
|
||||||
const {
|
|
||||||
pagination,
|
|
||||||
status = "draft",
|
|
||||||
quickSearchTerm = undefined,
|
|
||||||
enabled = true,
|
|
||||||
queryOptions,
|
|
||||||
} = params;
|
|
||||||
|
|
||||||
return useList({
|
|
||||||
queryKey: keys().data().resource("customerInvoices").action("list").params(params).get(),
|
|
||||||
queryFn: () => {
|
|
||||||
return dataSource.getList({
|
|
||||||
resource: "customerInvoices",
|
|
||||||
quickSearchTerm,
|
|
||||||
filters:
|
|
||||||
status !== "all"
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
field: "status",
|
|
||||||
operator: "eq",
|
|
||||||
value: status,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
field: "status",
|
|
||||||
operator: "ne",
|
|
||||||
value: "archived",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
pagination,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
enabled,
|
|
||||||
queryOptions,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return actions;
|
|
||||||
};
|
|
||||||
@ -13,7 +13,7 @@
|
|||||||
"./components": "./src/components/index.tsx",
|
"./components": "./src/components/index.tsx",
|
||||||
"./components/*": "./src/components/*.tsx",
|
"./components/*": "./src/components/*.tsx",
|
||||||
"./locales/*": "./src/locales/*",
|
"./locales/*": "./src/locales/*",
|
||||||
"./hooks/*": ["./src/hooks/*.tsx", "./src/hooks/*.ts"]
|
"./hooks": ["./src/hooks/index.ts"]
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
|||||||
1
packages/rdx-ui/src/hooks/index.ts
Normal file
1
packages/rdx-ui/src/hooks/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./use-row-selection.ts";
|
||||||
55
packages/rdx-ui/src/hooks/use-row-selection.ts
Normal file
55
packages/rdx-ui/src/hooks/use-row-selection.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
export type CheckedState = boolean | "indeterminate";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook para manejar selección múltiple con estado "seleccionar todo".
|
||||||
|
*/
|
||||||
|
export function useRowSelection(totalRows: number) {
|
||||||
|
const [selectedRows, setSelection] = React.useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
// Deriva array de índices seleccionados
|
||||||
|
const selectedIndexes = React.useMemo(
|
||||||
|
() => [...selectedRows].sort((a, b) => a - b),
|
||||||
|
[selectedRows]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Estado visual del checkbox maestro
|
||||||
|
const selectAllState: CheckedState = React.useMemo(() => {
|
||||||
|
if (totalRows === 0 || selectedRows.size === 0) return false;
|
||||||
|
if (selectedRows.size === totalRows) return true;
|
||||||
|
return "indeterminate";
|
||||||
|
}, [selectedRows, totalRows]);
|
||||||
|
|
||||||
|
// Seleccionar/deseleccionar una fila
|
||||||
|
const toggleRow = React.useCallback((index: number) => {
|
||||||
|
setSelection((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(index) ? next.delete(index) : next.add(index);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Seleccionar todas o limpiar
|
||||||
|
const setSelectAll = React.useCallback(
|
||||||
|
(checked: CheckedState) => {
|
||||||
|
if (checked === false) {
|
||||||
|
setSelection(new Set());
|
||||||
|
} else {
|
||||||
|
setSelection(new Set(Array.from({ length: totalRows }, (_, i) => i)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[totalRows]
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearSelection = React.useCallback(() => setSelection(new Set()), []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedRows,
|
||||||
|
selectedIndexes,
|
||||||
|
selectAllState,
|
||||||
|
toggleRow,
|
||||||
|
setSelectAll,
|
||||||
|
clearSelection,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -2,3 +2,4 @@ export const PACKAGE_NAME = "rdx-ui";
|
|||||||
|
|
||||||
export * from "./components/index.tsx";
|
export * from "./components/index.tsx";
|
||||||
export * from "./helpers/index.ts";
|
export * from "./helpers/index.ts";
|
||||||
|
export * from "./hooks/index.ts";
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user