Facturas de cliente
This commit is contained in:
parent
a946aa129a
commit
4e757c86d0
@ -11,6 +11,8 @@ import {
|
||||
} from "../../common/helpers";
|
||||
import { useTranslation } from "../i18n";
|
||||
|
||||
export type { Currency };
|
||||
|
||||
// --- Utils locales (edición texto → número) ---
|
||||
|
||||
// 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 { 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 { useInvoiceItemSummary } from '../../../hooks';
|
||||
import { useTranslation } from "../../../i18n";
|
||||
|
||||
|
||||
export type HoverCardTotalsSummaryProps = PropsWithChildren & {
|
||||
data: {
|
||||
quantity: QuantityDTO | null | undefined;
|
||||
unit_amount: MoneyDTO | null | undefined;
|
||||
discount_percentage?: PercentageDTO | null;
|
||||
tax_codes?: string[] | null;
|
||||
}
|
||||
totals: InvoiceItemTotals
|
||||
}
|
||||
|
||||
|
||||
export const HoverCardTotalsSummary = ({
|
||||
children,
|
||||
data
|
||||
totals
|
||||
}: HoverCardTotalsSummaryProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { formatCurrency } = useMoney();
|
||||
const summary = useInvoiceItemSummary(data);
|
||||
|
||||
const SummaryBlock = () => (
|
||||
<div className="space-y-2">
|
||||
@ -33,16 +26,16 @@ export const HoverCardTotalsSummary = ({
|
||||
|
||||
<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(summary.subtotal)}</span>
|
||||
<span className="font-mono">{formatCurrency(totals.subtotal)}</span>
|
||||
</div>
|
||||
|
||||
{Number(data.discount_percentage?.value ?? 0) > 0 && (
|
||||
{(totals.discountPercent ?? 0) > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<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 className="font-mono text-destructive">
|
||||
-{formatCurrency(summary.discountAmount)}
|
||||
-{formatCurrency(totals.discountAmount)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@ -50,20 +43,20 @@ export const HoverCardTotalsSummary = ({
|
||||
<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="font-mono font-medium">
|
||||
{formatCurrency(summary.baseAmount)}
|
||||
{formatCurrency(totals.taxableBase)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{summary.taxesBreakdown.map((tax) => (
|
||||
{/*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>
|
||||
))}
|
||||
))*/}
|
||||
|
||||
<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(summary.total)}</span>
|
||||
<span className="font-mono">{formatCurrency(totals.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -4,7 +4,7 @@ import { useTranslation } from '../../../i18n';
|
||||
|
||||
export const ItemsEditorToolbar = ({
|
||||
readOnly,
|
||||
selectedIdx,
|
||||
selectedIndexes,
|
||||
onAdd,
|
||||
onDuplicate,
|
||||
onMoveUp,
|
||||
@ -12,7 +12,7 @@ export const ItemsEditorToolbar = ({
|
||||
onRemove,
|
||||
}: {
|
||||
readOnly: boolean;
|
||||
selectedIdx: number[];
|
||||
selectedIndexes: number[];
|
||||
onAdd?: () => void;
|
||||
onDuplicate?: () => void;
|
||||
onMoveUp?: () => void;
|
||||
@ -20,7 +20,7 @@ export const ItemsEditorToolbar = ({
|
||||
onRemove?: () => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const hasSel = selectedIdx.length > 0;
|
||||
const hasSel = selectedIndexes.length > 0;
|
||||
return (
|
||||
<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">
|
||||
@ -109,7 +109,7 @@ export const ItemsEditorToolbar = ({
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className='text-sm font-normal'>
|
||||
{t("common.rows_selected", { count: selectedIdx.length })}
|
||||
{t("common.rows_selected", { count: selectedIndexes.length })}
|
||||
</p>
|
||||
</div>
|
||||
</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 { ArrowDown, ArrowUp, CopyIcon, Trash2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import { useItemsTableNavigation } from '../../../hooks';
|
||||
import { useCalcInvoiceItemTotals, useItemsTableNavigation } from '../../../hooks';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { CustomerInvoiceItemFormData, defaultCustomerInvoiceItemFormData } from '../../../schemas';
|
||||
import { CustomerInvoiceTaxesMultiSelect } from '../../customer-invoice-taxes-multi-select';
|
||||
@ -22,16 +22,10 @@ interface ItemsEditorProps {
|
||||
|
||||
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) => {
|
||||
const { t } = useTranslation();
|
||||
const form = useFormContext();
|
||||
const taxCatalog = React.useMemo(() => SpainTaxCatalogProvider(), []);
|
||||
|
||||
|
||||
const tableNav = useItemsTableNavigation(form, {
|
||||
name: "items",
|
||||
@ -39,11 +33,17 @@ export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEdi
|
||||
firstEditableField: "description",
|
||||
});
|
||||
|
||||
const {
|
||||
selectedRows,
|
||||
selectedIndexes,
|
||||
selectAllState,
|
||||
toggleRow,
|
||||
setSelectAll,
|
||||
clearSelection,
|
||||
} = useRowSelection(tableNav.fa.fields.length);
|
||||
|
||||
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
|
||||
@ -52,29 +52,21 @@ export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEdi
|
||||
return () => sub.unsubscribe();
|
||||
}, [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 (
|
||||
<div className="space-y-0">
|
||||
{/* Toolbar selección múltiple */}
|
||||
<ItemsEditorToolbar
|
||||
readOnly={readOnly}
|
||||
selectedIdx={selectedIdx}
|
||||
selectedIndexes={selectedIndexes}
|
||||
onAdd={() => tableNav.addEmpty(true)}
|
||||
onDuplicate={() => selectedIdx.forEach((i) => tableNav.duplicate(i))}
|
||||
onMoveUp={() => selectedIdx.forEach((i) => tableNav.moveUp(i))}
|
||||
onMoveDown={() => [...selectedIdx].reverse().forEach((i) => tableNav.moveDown(i))}
|
||||
onDuplicate={() => selectedIndexes.forEach((i) => tableNav.duplicate(i))}
|
||||
onMoveUp={() => selectedIndexes.forEach((i) => tableNav.moveUp(i))}
|
||||
onMoveDown={() => [...selectedIndexes].reverse().forEach((i) => tableNav.moveDown(i))}
|
||||
onRemove={() => {
|
||||
[...selectedIdx].reverse().forEach((i) => tableNav.remove(i));
|
||||
resetSelection();
|
||||
}}
|
||||
/>
|
||||
[...selectedIndexes].reverse().forEach((i) => tableNav.remove(i));
|
||||
clearSelection();
|
||||
}} />
|
||||
|
||||
<div className="bg-background">
|
||||
<Table className="w-full border-collapse text-sm">
|
||||
@ -96,17 +88,9 @@ export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEdi
|
||||
<div className='h-5'>
|
||||
<Checkbox
|
||||
aria-label={t("common.select_all")}
|
||||
className='block h-5 w-5 leading-none align-middle'
|
||||
checked={selectAllState}
|
||||
disabled={readOnly}
|
||||
checked={getSelectAllState(tableNav.fa.fields.length, selection.size)}
|
||||
onCheckedChange={(next) => {
|
||||
const shouldSelectAll = next !== false;
|
||||
if (shouldSelectAll) {
|
||||
setSelection(new Set(tableNav.fa.fields.map((_, i) => i)));
|
||||
} else {
|
||||
resetSelection();
|
||||
}
|
||||
}}
|
||||
onCheckedChange={(checked) => setSelectAll(checked)}
|
||||
/>
|
||||
</div>
|
||||
</TableHead>
|
||||
@ -122,11 +106,19 @@ export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEdi
|
||||
</TableHeader>
|
||||
<TableBody className='text-sm'>
|
||||
{tableNav.fa.fields.map((f, rowIndex) => {
|
||||
//const comp = calculateItemAmounts(f);
|
||||
//console.log(comp);
|
||||
|
||||
const isFirst = rowIndex === 0;
|
||||
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 (
|
||||
<TableRow key={`row-${f.id}`} data-row-index={rowIndex}>
|
||||
{/* selección */}
|
||||
@ -134,11 +126,11 @@ export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEdi
|
||||
<div className='h-5'>
|
||||
<Checkbox
|
||||
aria-label={t("common.select_row", { n: rowIndex + 1 })}
|
||||
className='block h-5 w-5 leading-none align-middle'
|
||||
checked={selection.has(rowIndex)}
|
||||
onCheckedChange={() => toggleSel(rowIndex)}
|
||||
checked={selectedRows.has(rowIndex)}
|
||||
disabled={readOnly}
|
||||
/> </div>
|
||||
onCheckedChange={() => toggleRow(rowIndex)}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* # */}
|
||||
@ -222,15 +214,13 @@ export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEdi
|
||||
|
||||
{/* total (solo lectura) */}
|
||||
<TableCell className='text-right tabular-nums pt-[6px] leading-5'>
|
||||
<HoverCardTotalsSummary data={{ ...f }}>
|
||||
<HoverCardTotalsSummary totals={totals}>
|
||||
<AmountDTOInputField
|
||||
control={control}
|
||||
name={`items.${rowIndex}.total_amount`}
|
||||
readOnly
|
||||
inputId={`total-amount-${rowIndex}`}
|
||||
// @ts-expect-error
|
||||
readOnlyMode='textlike-input'
|
||||
locale={"es"}
|
||||
locale="es"
|
||||
/>
|
||||
</HoverCardTotalsSummary>
|
||||
</TableCell>
|
||||
@ -298,7 +288,7 @@ export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEdi
|
||||
<TableCell colSpan={9}>
|
||||
<ItemsEditorToolbar
|
||||
readOnly={readOnly}
|
||||
selectedIdx={selectedIdx}
|
||||
selectedIndexes={selectedIndexes}
|
||||
onAdd={() => tableNav.addEmpty(true)}
|
||||
/>
|
||||
</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 { useEffect, useState } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { useCalculateItemAmounts } from '../../../hooks';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { CustomerInvoiceItemFormData } from '../../../schemas';
|
||||
import { HoverCardTotalsSummary } from './hover-card-total-summary';
|
||||
@ -31,7 +30,6 @@ export const TableView = ({ items, actions }: TableViewProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { control } = useFormContext<CustomerInvoiceItemFormData>();
|
||||
const { format } = useMoney();
|
||||
const calculateItemAmounts = useCalculateItemAmounts();
|
||||
const [lines, setLines] = useState<CustomerInvoiceItemFormData[]>(items);
|
||||
|
||||
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-customer-invoice-item-summary";
|
||||
export * from "./use-customer-invoice-query";
|
||||
export * from "./use-customer-invoices-context";
|
||||
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/*.tsx",
|
||||
"./locales/*": "./src/locales/*",
|
||||
"./hooks/*": ["./src/hooks/*.tsx", "./src/hooks/*.ts"]
|
||||
"./hooks": ["./src/hooks/index.ts"]
|
||||
},
|
||||
"peerDependencies": {
|
||||
"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 "./helpers/index.ts";
|
||||
export * from "./hooks/index.ts";
|
||||
|
||||
Loading…
Reference in New Issue
Block a user