Facturas de cliente - Arreglado recálculo de líneas y cabecera
This commit is contained in:
parent
13d24429c6
commit
54e23899c4
@ -17,7 +17,7 @@ export class CustomerInvoiceItemsFullPresenter extends Presenter {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: invoiceItem.id.toPrimitive(),
|
id: invoiceItem.id.toPrimitive(),
|
||||||
is_non_valued: String(invoiceItem.isNonValued),
|
is_valued: String(invoiceItem.isValued),
|
||||||
position: String(index),
|
position: String(index),
|
||||||
description: toEmptyString(invoiceItem.description, (value) => value.toPrimitive()),
|
description: toEmptyString(invoiceItem.description, (value) => value.toPrimitive()),
|
||||||
|
|
||||||
|
|||||||
@ -61,6 +61,8 @@ export class UpdateCustomerInvoiceUseCase {
|
|||||||
updatedInvoice.data,
|
updatedInvoice.data,
|
||||||
transaction
|
transaction
|
||||||
);
|
);
|
||||||
|
if (invoiceOrError.isFailure) return Result.fail(invoiceOrError.error);
|
||||||
|
|
||||||
const invoice = invoiceOrError.data;
|
const invoice = invoiceOrError.data;
|
||||||
const dto = presenter.toOutput(invoice);
|
const dto = presenter.toOutput(invoice);
|
||||||
return Result.ok(dto);
|
return Result.ok(dto);
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export interface CustomerInvoiceItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ICustomerInvoiceItem {
|
export interface ICustomerInvoiceItem {
|
||||||
isNonValued: boolean;
|
isValued: boolean;
|
||||||
|
|
||||||
description: Maybe<CustomerInvoiceItemDescription>;
|
description: Maybe<CustomerInvoiceItemDescription>;
|
||||||
|
|
||||||
@ -48,7 +48,7 @@ export class CustomerInvoiceItem
|
|||||||
extends DomainEntity<CustomerInvoiceItemProps>
|
extends DomainEntity<CustomerInvoiceItemProps>
|
||||||
implements ICustomerInvoiceItem
|
implements ICustomerInvoiceItem
|
||||||
{
|
{
|
||||||
protected _isNonValued!: boolean;
|
protected _isValued!: boolean;
|
||||||
|
|
||||||
public static create(
|
public static create(
|
||||||
props: CustomerInvoiceItemProps,
|
props: CustomerInvoiceItemProps,
|
||||||
@ -66,11 +66,11 @@ export class CustomerInvoiceItem
|
|||||||
protected constructor(props: CustomerInvoiceItemProps, id?: UniqueID) {
|
protected constructor(props: CustomerInvoiceItemProps, id?: UniqueID) {
|
||||||
super(props, id);
|
super(props, id);
|
||||||
|
|
||||||
this._isNonValued = this.quantity.isNone() || this.unitAmount.isNone();
|
this._isValued = this.quantity.isSome() || this.unitAmount.isSome();
|
||||||
}
|
}
|
||||||
|
|
||||||
get isNonValued(): boolean {
|
get isValued(): boolean {
|
||||||
return this._isNonValued;
|
return this._isValued;
|
||||||
}
|
}
|
||||||
|
|
||||||
get description(): Maybe<CustomerInvoiceItemDescription> {
|
get description(): Maybe<CustomerInvoiceItemDescription> {
|
||||||
|
|||||||
@ -95,37 +95,59 @@ export class CustomerInvoiceRepository
|
|||||||
* @param transaction - Transacción activa para la operación.
|
* @param transaction - Transacción activa para la operación.
|
||||||
* @returns Result<void, Error>
|
* @returns Result<void, Error>
|
||||||
*/
|
*/
|
||||||
async update(invoice: CustomerInvoice, transaction?: Transaction): Promise<Result<void, Error>> {
|
async update(invoice: CustomerInvoice, transaction: Transaction): Promise<Result<void, Error>> {
|
||||||
try {
|
try {
|
||||||
const mapper: ICustomerInvoiceDomainMapper = this._registry.getDomainMapper({
|
const mapper: ICustomerInvoiceDomainMapper = this._registry.getDomainMapper({
|
||||||
resource: "customer-invoice",
|
resource: "customer-invoice",
|
||||||
});
|
});
|
||||||
const dto = mapper.mapToPersistence(invoice);
|
const dtoResult = mapper.mapToPersistence(invoice);
|
||||||
|
|
||||||
if (dto.isFailure) {
|
if (dtoResult.isFailure) return Result.fail(dtoResult.error);
|
||||||
return Result.fail(dto.error);
|
|
||||||
}
|
|
||||||
const { id, ...updatePayload } = dto.data;
|
|
||||||
|
|
||||||
console.log(id);
|
const dto = dtoResult.data;
|
||||||
|
const { id, items, taxes, ...updatePayload } = dto;
|
||||||
|
|
||||||
const [affected] = await CustomerInvoiceModel.update(updatePayload, {
|
// 1. Actualizar cabecera
|
||||||
|
const [affectedCount] = await CustomerInvoiceModel.update(updatePayload, {
|
||||||
where: { id /*, version */ },
|
where: { id /*, version */ },
|
||||||
//fields: Object.keys(updatePayload),
|
|
||||||
transaction,
|
transaction,
|
||||||
individualHooks: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(affected);
|
console.log(affectedCount);
|
||||||
|
|
||||||
if (affected === 0) {
|
if (affectedCount === 0) {
|
||||||
return Result.fail(
|
return Result.fail(
|
||||||
new InfrastructureRepositoryError(
|
new InfrastructureRepositoryError(`Invoice ${id} not found or concurrency issue`)
|
||||||
"Concurrency conflict or not found update customer invoice"
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Borra items y taxes previos (simplifica sincronización)
|
||||||
|
await CustomerInvoiceItemModel.destroy({
|
||||||
|
where: { invoice_id: id },
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
await CustomerInvoiceTaxModel.destroy({
|
||||||
|
where: { invoice_id: id },
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Inserta taxes de cabecera
|
||||||
|
if (Array.isArray(taxes) && taxes.length > 0) {
|
||||||
|
await CustomerInvoiceTaxModel.bulkCreate(taxes, { transaction });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Inserta items + sus taxes
|
||||||
|
if (Array.isArray(items) && items.length > 0) {
|
||||||
|
for (const item of items) {
|
||||||
|
const { taxes: itemTaxes, ...itemData } = item;
|
||||||
|
await CustomerInvoiceItemModel.create(itemData, { transaction });
|
||||||
|
|
||||||
|
if (Array.isArray(itemTaxes) && itemTaxes.length > 0) {
|
||||||
|
await CustomerInvoiceItemTaxModel.bulkCreate(itemTaxes, { transaction });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
return Result.fail(translateSequelizeError(err));
|
return Result.fail(translateSequelizeError(err));
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { MoneySchema, PercentageSchema, QuantitySchema } from "@erp/core";
|
|||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
export const UpdateCustomerInvoiceByIdParamsRequestSchema = z.object({
|
export const UpdateCustomerInvoiceByIdParamsRequestSchema = z.object({
|
||||||
invoice_id: z.string(),
|
proforma_id: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UpdateCustomerInvoiceByIdRequestSchema = z.object({
|
export const UpdateCustomerInvoiceByIdRequestSchema = z.object({
|
||||||
@ -23,7 +23,7 @@ export const UpdateCustomerInvoiceByIdRequestSchema = z.object({
|
|||||||
items: z
|
items: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
is_non_valued: z.string().optional(),
|
is_valued: z.string().optional(),
|
||||||
|
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
quantity: QuantitySchema.optional(),
|
quantity: QuantitySchema.optional(),
|
||||||
|
|||||||
@ -59,7 +59,7 @@ export const GetCustomerInvoiceByIdResponseSchema = z.object({
|
|||||||
items: z.array(
|
items: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.uuid(),
|
id: z.uuid(),
|
||||||
is_non_valued: z.string(),
|
is_valued: z.string(),
|
||||||
position: z.string(),
|
position: z.string(),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
quantity: QuantitySchema,
|
quantity: QuantitySchema,
|
||||||
|
|||||||
@ -173,20 +173,20 @@
|
|||||||
"placeholder": "",
|
"placeholder": "",
|
||||||
"description": "Item unit price"
|
"description": "Item unit price"
|
||||||
},
|
},
|
||||||
"subtotal_amount": {
|
|
||||||
"label": "Subtotal",
|
|
||||||
"placeholder": "",
|
|
||||||
"description": ""
|
|
||||||
},
|
|
||||||
"discount_percentage": {
|
"discount_percentage": {
|
||||||
"label": "Dto (%)",
|
"label": "Dto (%)",
|
||||||
"placeholder": "",
|
"placeholder": "",
|
||||||
"description": "Percentage discount"
|
"description": "Percentage discount"
|
||||||
},
|
},
|
||||||
"discount_amount": {
|
"discount_amount": {
|
||||||
"label": "Discount price",
|
"label": "Discount amount",
|
||||||
"placeholder": "",
|
"placeholder": "",
|
||||||
"description": "Percentage discount price"
|
"description": "Percentage discount amount"
|
||||||
|
},
|
||||||
|
"taxable_amount": {
|
||||||
|
"label": "Taxable amount",
|
||||||
|
"placeholder": "",
|
||||||
|
"description": ""
|
||||||
},
|
},
|
||||||
"tax_codes": {
|
"tax_codes": {
|
||||||
"label": "Taxes",
|
"label": "Taxes",
|
||||||
@ -194,12 +194,12 @@
|
|||||||
"description": "Taxes"
|
"description": "Taxes"
|
||||||
},
|
},
|
||||||
"taxes_amount": {
|
"taxes_amount": {
|
||||||
"label": "Taxes price",
|
"label": "Taxes amount",
|
||||||
"placeholder": "",
|
"placeholder": "",
|
||||||
"description": "Percentage taxes price"
|
"description": "Percentage taxes amount"
|
||||||
},
|
},
|
||||||
"total_amount": {
|
"total_amount": {
|
||||||
"label": "Total",
|
"label": "Total amount",
|
||||||
"placeholder": "",
|
"placeholder": "",
|
||||||
"description": "Invoice line total"
|
"description": "Invoice line total"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -165,11 +165,6 @@
|
|||||||
"placeholder": "",
|
"placeholder": "",
|
||||||
"description": "Precio unitario del producto"
|
"description": "Precio unitario del producto"
|
||||||
},
|
},
|
||||||
"subtotal_amount": {
|
|
||||||
"label": "Subtotal",
|
|
||||||
"placeholder": "",
|
|
||||||
"description": ""
|
|
||||||
},
|
|
||||||
"discount_percentage": {
|
"discount_percentage": {
|
||||||
"label": "Dto (%)",
|
"label": "Dto (%)",
|
||||||
"placeholder": "",
|
"placeholder": "",
|
||||||
@ -180,6 +175,11 @@
|
|||||||
"placeholder": "",
|
"placeholder": "",
|
||||||
"description": "Importe del descuento porcentual"
|
"description": "Importe del descuento porcentual"
|
||||||
},
|
},
|
||||||
|
"taxable_amount": {
|
||||||
|
"label": "Subtotal",
|
||||||
|
"placeholder": "",
|
||||||
|
"description": ""
|
||||||
|
},
|
||||||
"tax_codes": {
|
"tax_codes": {
|
||||||
"label": "Impuestos",
|
"label": "Impuestos",
|
||||||
"placeholder": "",
|
"placeholder": "",
|
||||||
|
|||||||
@ -22,6 +22,11 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
|
|||||||
|
|
||||||
const displayTaxes = useWatch({ control, name: "taxes", defaultValue: [] });
|
const displayTaxes = useWatch({ control, name: "taxes", defaultValue: [] });
|
||||||
const subtotal_amount = useWatch({ control, name: "subtotal_amount", defaultValue: 0 });
|
const subtotal_amount = useWatch({ control, name: "subtotal_amount", defaultValue: 0 });
|
||||||
|
const items_discount_amount = useWatch({
|
||||||
|
control,
|
||||||
|
name: "items_discount_amount",
|
||||||
|
defaultValue: 0,
|
||||||
|
});
|
||||||
const discount_amount = useWatch({ control, name: "discount_amount", defaultValue: 0 });
|
const discount_amount = useWatch({ control, name: "discount_amount", defaultValue: 0 });
|
||||||
const taxable_amount = useWatch({ control, name: "taxable_amount", defaultValue: 0 });
|
const taxable_amount = useWatch({ control, name: "taxable_amount", defaultValue: 0 });
|
||||||
const taxes_amount = useWatch({ control, name: "taxes_amount", defaultValue: 0 });
|
const taxes_amount = useWatch({ control, name: "taxes_amount", defaultValue: 0 });
|
||||||
@ -39,12 +44,21 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
|
|||||||
<div className='space-y-1.5'>
|
<div className='space-y-1.5'>
|
||||||
{/* Sección: Subtotal y Descuentos */}
|
{/* Sección: Subtotal y Descuentos */}
|
||||||
<div className='flex justify-between text-sm'>
|
<div className='flex justify-between text-sm'>
|
||||||
<span className='text-muted-foreground'>Subtotal sin descuento</span>
|
<span className='text-muted-foreground'>Subtotal sin descuentos</span>
|
||||||
<span className='font-medium tabular-nums text-muted-foreground'>
|
<span className='font-medium tabular-nums text-muted-foreground'>
|
||||||
{formatCurrency(subtotal_amount, 2, currency_code, language_code)}
|
{formatCurrency(subtotal_amount, 2, currency_code, language_code)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className='flex justify-between text-sm'>
|
||||||
|
<div className='flex items-center gap-3'>
|
||||||
|
<span className='text-muted-foreground'>Descuento en líneas</span>
|
||||||
|
</div>
|
||||||
|
<span className='font-medium text-destructive tabular-nums'>
|
||||||
|
-{formatCurrency(items_discount_amount, 2, currency_code, language_code)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='flex justify-between text-sm'>
|
<div className='flex justify-between text-sm'>
|
||||||
<div className='flex items-center gap-3'>
|
<div className='flex items-center gap-3'>
|
||||||
<span className='text-muted-foreground'>Descuento global</span>
|
<span className='text-muted-foreground'>Descuento global</span>
|
||||||
@ -52,7 +66,7 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
|
|||||||
control={control}
|
control={control}
|
||||||
name={"discount_percentage"}
|
name={"discount_percentage"}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
inputId={"header-discount-percentage"}
|
inputId={"discount-percentage"}
|
||||||
showSuffix={true}
|
showSuffix={true}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-20 text-right tabular-nums bg-background",
|
"w-20 text-right tabular-nums bg-background",
|
||||||
|
|||||||
@ -1,32 +1,26 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
|
import { DataTableRowOps } from "@repo/rdx-ui/components";
|
||||||
|
import { Button, Tooltip, TooltipContent, TooltipTrigger } from "@repo/shadcn-ui/components";
|
||||||
import { Row, Table } from "@tanstack/react-table";
|
import { Row, Table } from "@tanstack/react-table";
|
||||||
|
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, PencilIcon, Trash2Icon } from "lucide-react";
|
||||||
|
|
||||||
import { DataTableRowOps } from '@repo/rdx-ui/components';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger
|
|
||||||
} from '@repo/shadcn-ui/components';
|
|
||||||
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, PencilIcon, Trash2Icon } from 'lucide-react';
|
|
||||||
|
|
||||||
interface DataTableRowActionsProps<TData> {
|
interface DataTableRowActionsProps<TData> {
|
||||||
row: Row<TData>,
|
row: Row<TData>;
|
||||||
table: Table<TData>
|
table: Table<TData>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ItemDataTableRowActions<TData>({
|
export function ItemDataTableRowActions<TData>({ row, table }: DataTableRowActionsProps<TData>) {
|
||||||
row, table
|
|
||||||
}: DataTableRowActionsProps<TData>) {
|
|
||||||
const ops = (table.options.meta as any)?.rowOps as DataTableRowOps<TData>;
|
const ops = (table.options.meta as any)?.rowOps as DataTableRowOps<TData>;
|
||||||
const openEditor = (table.options.meta as any)?.openEditor as (i: number, table: Table<TData>) => void;
|
const openEditor = (table.options.meta as any)?.openEditor as (
|
||||||
|
i: number,
|
||||||
|
table: Table<TData>
|
||||||
|
) => void;
|
||||||
const lastRow = table.getRowModel().rows.length - 1;
|
const lastRow = table.getRowModel().rows.length - 1;
|
||||||
const rowIndex = row.index;
|
const rowIndex = row.index;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="items-center gap-1 inline-flex">
|
<div className='items-center gap-1 inline-flex'>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
{openEditor && (
|
{openEditor && (
|
||||||
@ -81,17 +75,21 @@ export function ItemDataTableRowActions<TData>({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
{ops?.move && <Button
|
{ops?.move && (
|
||||||
type='button'
|
<Button
|
||||||
className='cursor-pointer'
|
type='button'
|
||||||
variant='ghost'
|
className='cursor-pointer'
|
||||||
size='icon-sm'
|
variant='ghost'
|
||||||
aria-label='Move down'
|
size='icon-sm'
|
||||||
disabled={ops?.canMoveDown ? !ops.canMoveDown(rowIndex, lastRow, table) : rowIndex === lastRow}
|
aria-label='Move down'
|
||||||
onClick={() => ops?.move?.(rowIndex, rowIndex + 1, table)}
|
disabled={
|
||||||
>
|
ops?.canMoveDown ? !ops.canMoveDown(rowIndex, lastRow, table) : rowIndex === lastRow
|
||||||
<ArrowDownIcon className='size-4 text-muted-foreground hover:cursor-pointer' />
|
}
|
||||||
</Button>}
|
onClick={() => ops?.move?.(rowIndex, rowIndex + 1, table)}
|
||||||
|
>
|
||||||
|
<ArrowDownIcon className='size-4 text-muted-foreground hover:cursor-pointer' />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Down</TooltipContent>
|
<TooltipContent>Down</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import { useInvoiceContext } from "../../../context";
|
|||||||
import { useInvoiceAutoRecalc } from "../../../hooks";
|
import { useInvoiceAutoRecalc } from "../../../hooks";
|
||||||
import { useTranslation } from "../../../i18n";
|
import { useTranslation } from "../../../i18n";
|
||||||
import { defaultCustomerInvoiceItemFormData, InvoiceFormData } from "../../../schemas";
|
import { defaultCustomerInvoiceItemFormData, InvoiceFormData } from "../../../schemas";
|
||||||
import { debugIdCol } from "./debug-id-col";
|
|
||||||
import { ItemRowEditor } from "./item-row-editor";
|
import { ItemRowEditor } from "./item-row-editor";
|
||||||
import { useItemsColumns } from "./use-items-columns";
|
import { useItemsColumns } from "./use-items-columns";
|
||||||
|
|
||||||
@ -15,7 +14,7 @@ export const ItemsEditor = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const context = useInvoiceContext();
|
const context = useInvoiceContext();
|
||||||
const form = useFormContext<InvoiceFormData>();
|
const form = useFormContext<InvoiceFormData>();
|
||||||
const { control, getValues } = form;
|
const { control } = form;
|
||||||
|
|
||||||
useInvoiceAutoRecalc(form, context);
|
useInvoiceAutoRecalc(form, context);
|
||||||
|
|
||||||
@ -25,7 +24,7 @@ export const ItemsEditor = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const baseColumns = useWithRowSelection(useItemsColumns(), true);
|
const baseColumns = useWithRowSelection(useItemsColumns(), true);
|
||||||
const columns = useMemo(() => [...baseColumns, debugIdCol], [baseColumns]);
|
const columns = useMemo(() => baseColumns, [baseColumns]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-0'>
|
<div className='space-y-0'>
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { useMoney } from "@erp/core/hooks";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Input,
|
Input,
|
||||||
@ -14,17 +15,14 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@repo/shadcn-ui/components";
|
} from "@repo/shadcn-ui/components";
|
||||||
import { ChevronDownIcon, ChevronUpIcon, CopyIcon, Plus, TrashIcon } from "lucide-react";
|
import { ChevronDownIcon, ChevronUpIcon, CopyIcon, Plus, TrashIcon } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useFormContext } from "react-hook-form";
|
||||||
import { useMoney } from '@erp/core/hooks';
|
import { useTranslation } from "../../../i18n";
|
||||||
import { useEffect, useState } from 'react';
|
import { InvoiceItemFormData } from "../../../schemas";
|
||||||
import { useFormContext } from 'react-hook-form';
|
import { HoverCardTotalsSummary } from "./hover-card-total-summary";
|
||||||
import { useTranslation } from '../../../i18n';
|
|
||||||
import { InvoiceItemFormData } from '../../../schemas';
|
|
||||||
import { HoverCardTotalsSummary } from './hover-card-total-summary';
|
|
||||||
import { CustomItemViewProps } from "./types";
|
import { CustomItemViewProps } from "./types";
|
||||||
|
|
||||||
export interface TableViewProps extends CustomItemViewProps { }
|
export interface TableViewProps extends CustomItemViewProps {}
|
||||||
|
|
||||||
export const TableView = ({ items, actions }: TableViewProps) => {
|
export const TableView = ({ items, actions }: TableViewProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -33,8 +31,8 @@ export const TableView = ({ items, actions }: TableViewProps) => {
|
|||||||
const [lines, setLines] = useState<InvoiceItemFormData[]>(items);
|
const [lines, setLines] = useState<InvoiceItemFormData[]>(items);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLines(items)
|
setLines(items);
|
||||||
}, [items])
|
}, [items]);
|
||||||
|
|
||||||
// Mantiene sincronía con el formulario padre
|
// Mantiene sincronía con el formulario padre
|
||||||
const updateItems = (updated: InvoiceItemFormData[]) => {
|
const updateItems = (updated: InvoiceItemFormData[]) => {
|
||||||
@ -52,10 +50,7 @@ export const TableView = ({ items, actions }: TableViewProps) => {
|
|||||||
|
|
||||||
/** 🔹 Mueve la fila hacia arriba o abajo */
|
/** 🔹 Mueve la fila hacia arriba o abajo */
|
||||||
const moveItem = (index: number, direction: "up" | "down") => {
|
const moveItem = (index: number, direction: "up" | "down") => {
|
||||||
if (
|
if ((direction === "up" && index === 0) || (direction === "down" && index === lines.length - 1))
|
||||||
(direction === "up" && index === 0) ||
|
|
||||||
(direction === "down" && index === lines.length - 1)
|
|
||||||
)
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const newItems = [...lines];
|
const newItems = [...lines];
|
||||||
@ -80,7 +75,6 @@ export const TableView = ({ items, actions }: TableViewProps) => {
|
|||||||
/** 🔹 Añade una nueva línea vacía */
|
/** 🔹 Añade una nueva línea vacía */
|
||||||
const addNewItem = () => {
|
const addNewItem = () => {
|
||||||
const newItem: InvoiceItemFormData = {
|
const newItem: InvoiceItemFormData = {
|
||||||
is_non_valued: false,
|
|
||||||
description: "",
|
description: "",
|
||||||
quantity: { value: "0", scale: "2" },
|
quantity: { value: "0", scale: "2" },
|
||||||
unit_amount: { value: "0", scale: "2", currency_code: "EUR" },
|
unit_amount: { value: "0", scale: "2", currency_code: "EUR" },
|
||||||
@ -96,60 +90,69 @@ export const TableView = ({ items, actions }: TableViewProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className='space-y-4'>
|
||||||
<div className="rounded-lg border border-border">
|
<div className='rounded-lg border border-border'>
|
||||||
<Table className="min-w-full">
|
<Table className='min-w-full'>
|
||||||
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
|
<TableHeader className='sticky top-0 z-20 bg-background shadow-sm'>
|
||||||
<TableRow className="bg-muted/30 text-xs text-muted-foreground">
|
<TableRow className='bg-muted/30 text-xs text-muted-foreground'>
|
||||||
<TableHead className="w-10 text-center">#</TableHead>
|
<TableHead className='w-10 text-center'>#</TableHead>
|
||||||
<TableHead>{t("form_fields.item.description.label")}</TableHead>
|
<TableHead>{t("form_fields.item.description.label")}</TableHead>
|
||||||
<TableHead className="text-right w-24">{t("form_fields.item.quantity.label")}</TableHead>
|
<TableHead className='text-right w-24'>
|
||||||
<TableHead className="text-right w-32">{t("form_fields.item.unit_amount.label")}</TableHead>
|
{t("form_fields.item.quantity.label")}
|
||||||
<TableHead className="text-right w-24">{t("form_fields.item.discount_percentage.label")}</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right w-32">{t("form_fields.item.tax_codes.label")}</TableHead>
|
<TableHead className='text-right w-32'>
|
||||||
<TableHead className="text-right w-32">{t("form_fields.item.total_amount.label")}</TableHead>
|
{t("form_fields.item.unit_amount.label")}
|
||||||
<TableHead className="w-44 text-center">{t("common.actions")}</TableHead>
|
</TableHead>
|
||||||
|
<TableHead className='text-right w-24'>
|
||||||
|
{t("form_fields.item.discount_percentage.label")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className='text-right w-32'>
|
||||||
|
{t("form_fields.item.tax_codes.label")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className='text-right w-32'>
|
||||||
|
{t("form_fields.item.total_amount.label")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className='w-44 text-center'>{t("common.actions")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{lines.map((item, i) => (
|
{lines.map((item, i) => (
|
||||||
<TableRow key={`item-${i}`} className="text-sm hover:bg-muted/40">
|
<TableRow key={`item-${i}`} className='text-sm hover:bg-muted/40'>
|
||||||
{/* ÍNDICE */}
|
{/* ÍNDICE */}
|
||||||
<TableCell className="text-center text-muted-foreground font-mono align-text-top">
|
<TableCell className='text-center text-muted-foreground font-mono align-text-top'>
|
||||||
{i + 1}
|
{i + 1}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* DESCRIPCIÓN */}
|
{/* DESCRIPCIÓN */}
|
||||||
|
|
||||||
<TableCell className="align-top">
|
<TableCell className='align-top'>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={item.description}
|
value={item.description}
|
||||||
onChange={(e) => updateItem(i, { description: e.target.value })}
|
onChange={(e) => updateItem(i, { description: e.target.value })}
|
||||||
placeholder="Descripción del producto o servicio…"
|
placeholder='Descripción del producto o servicio…'
|
||||||
className="min-h-[2.5rem] max-h-[10rem] resize-y bg-transparent border-none shadow-none focus:bg-background
|
className='min-h-[2.5rem] max-h-[10rem] resize-y bg-transparent border-none shadow-none focus:bg-background
|
||||||
px-2 py-0
|
px-2 py-0
|
||||||
leading-5 overflow-y-auto"
|
leading-5 overflow-y-auto'
|
||||||
aria-label={`Descripción línea ${i + 1}`}
|
aria-label={`Descripción línea ${i + 1}`}
|
||||||
spellCheck={true}
|
spellCheck={true}
|
||||||
autoComplete="off"
|
autoComplete='off'
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* CANTIDAD */}
|
{/* CANTIDAD */}
|
||||||
<TableCell className="text-right">
|
<TableCell className='text-right'>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type='number'
|
||||||
inputMode="decimal"
|
inputMode='decimal'
|
||||||
className="text-right border-0 bg-transparent focus-visible:ring-0 px-2 py-1"
|
className='text-right border-0 bg-transparent focus-visible:ring-0 px-2 py-1'
|
||||||
value={Number(item.quantity.value) / 10 ** Number(item.quantity.scale)}
|
value={Number(item.quantity.value) / 10 ** Number(item.quantity.scale)}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateItem(i, {
|
updateItem(i, {
|
||||||
quantity: {
|
quantity: {
|
||||||
value: (
|
value: (
|
||||||
Number(e.target.value) * 10 ** Number(item.quantity.scale)
|
Number(e.target.value) *
|
||||||
|
10 ** Number(item.quantity.scale)
|
||||||
).toString(),
|
).toString(),
|
||||||
scale: item.quantity.scale,
|
scale: item.quantity.scale,
|
||||||
},
|
},
|
||||||
@ -160,18 +163,19 @@ export const TableView = ({ items, actions }: TableViewProps) => {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* PRECIO UNITARIO */}
|
{/* PRECIO UNITARIO */}
|
||||||
<TableCell className="text-right">
|
<TableCell className='text-right'>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type='number'
|
||||||
inputMode="decimal"
|
inputMode='decimal'
|
||||||
className="text-right border-0 bg-transparent focus-visible:ring-0 px-2 py-1"
|
className='text-right border-0 bg-transparent focus-visible:ring-0 px-2 py-1'
|
||||||
value={Number(item.unit_amount.value) / 10 ** Number(item.unit_amount.scale)}
|
value={Number(item.unit_amount.value) / 10 ** Number(item.unit_amount.scale)}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateItem(i, {
|
updateItem(i, {
|
||||||
unit_amount: {
|
unit_amount: {
|
||||||
...item.unit_amount,
|
...item.unit_amount,
|
||||||
value: (
|
value: (
|
||||||
Number(e.target.value) * 10 ** Number(item.unit_amount.scale)
|
Number(e.target.value) *
|
||||||
|
10 ** Number(item.unit_amount.scale)
|
||||||
).toString(),
|
).toString(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -181,11 +185,11 @@ export const TableView = ({ items, actions }: TableViewProps) => {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* DESCUENTO */}
|
{/* DESCUENTO */}
|
||||||
<TableCell className="text-right">
|
<TableCell className='text-right'>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type='number'
|
||||||
inputMode="decimal"
|
inputMode='decimal'
|
||||||
className="text-right border-0 bg-transparent focus-visible:ring-0 px-2 py-1"
|
className='text-right border-0 bg-transparent focus-visible:ring-0 px-2 py-1'
|
||||||
value={
|
value={
|
||||||
Number(item.discount_percentage.value) /
|
Number(item.discount_percentage.value) /
|
||||||
10 ** Number(item.discount_percentage.scale)
|
10 ** Number(item.discount_percentage.scale)
|
||||||
@ -205,33 +209,31 @@ export const TableView = ({ items, actions }: TableViewProps) => {
|
|||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell className="text-right">
|
<TableCell className='text-right'></TableCell>
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
|
|
||||||
{/* TOTAL */}
|
{/* TOTAL */}
|
||||||
<TableCell className="text-right font-mono">
|
<TableCell className='text-right font-mono'>
|
||||||
<HoverCardTotalsSummary item={item}>
|
<HoverCardTotalsSummary item={item}>
|
||||||
<span className="cursor-help hover:text-primary transition-colors">
|
<span className='cursor-help hover:text-primary transition-colors'>
|
||||||
{format(item.total_amount)}
|
{format(item.total_amount)}
|
||||||
</span>
|
</span>
|
||||||
</HoverCardTotalsSummary>
|
</HoverCardTotalsSummary>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* ACCIONES */}
|
{/* ACCIONES */}
|
||||||
<TableCell className="text-center">
|
<TableCell className='text-center'>
|
||||||
<div className="flex items-center justify-center gap-1">
|
<div className='flex items-center justify-center gap-1'>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant='ghost'
|
||||||
size="icon"
|
size='icon'
|
||||||
onClick={() => moveItem(i, "up")}
|
onClick={() => moveItem(i, "up")}
|
||||||
disabled={i === 0}
|
disabled={i === 0}
|
||||||
className="h-7 w-7"
|
className='h-7 w-7'
|
||||||
>
|
>
|
||||||
<ChevronUpIcon className="size-3.5" />
|
<ChevronUpIcon className='size-3.5' />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Mover arriba</TooltipContent>
|
<TooltipContent>Mover arriba</TooltipContent>
|
||||||
@ -242,13 +244,13 @@ export const TableView = ({ items, actions }: TableViewProps) => {
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant='ghost'
|
||||||
size="icon"
|
size='icon'
|
||||||
onClick={() => moveItem(i, "down")}
|
onClick={() => moveItem(i, "down")}
|
||||||
disabled={i === lines.length - 1}
|
disabled={i === lines.length - 1}
|
||||||
className="h-7 w-7"
|
className='h-7 w-7'
|
||||||
>
|
>
|
||||||
<ChevronDownIcon className="size-3.5" />
|
<ChevronDownIcon className='size-3.5' />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Mover abajo</TooltipContent>
|
<TooltipContent>Mover abajo</TooltipContent>
|
||||||
@ -259,12 +261,12 @@ export const TableView = ({ items, actions }: TableViewProps) => {
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant='ghost'
|
||||||
size="icon"
|
size='icon'
|
||||||
onClick={() => duplicateItem(i)}
|
onClick={() => duplicateItem(i)}
|
||||||
className="h-7 w-7"
|
className='h-7 w-7'
|
||||||
>
|
>
|
||||||
<CopyIcon className="size-3.5" />
|
<CopyIcon className='size-3.5' />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Duplicar línea</TooltipContent>
|
<TooltipContent>Duplicar línea</TooltipContent>
|
||||||
@ -275,12 +277,12 @@ export const TableView = ({ items, actions }: TableViewProps) => {
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant='ghost'
|
||||||
size="icon"
|
size='icon'
|
||||||
onClick={() => removeItem(i)}
|
onClick={() => removeItem(i)}
|
||||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
className='h-7 w-7 text-destructive hover:text-destructive'
|
||||||
>
|
>
|
||||||
<TrashIcon className="size-3.5" />
|
<TrashIcon className='size-3.5' />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Eliminar línea</TooltipContent>
|
<TooltipContent>Eliminar línea</TooltipContent>
|
||||||
@ -296,13 +298,13 @@ export const TableView = ({ items, actions }: TableViewProps) => {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={addNewItem}
|
onClick={addNewItem}
|
||||||
variant="outline"
|
variant='outline'
|
||||||
className="w-full border-dashed bg-transparent"
|
className='w-full border-dashed bg-transparent'
|
||||||
aria-label="Agregar nueva línea"
|
aria-label='Agregar nueva línea'
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className='h-4 w-4 mr-2' />
|
||||||
Agregar línea
|
Agregar línea
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@ -18,7 +18,10 @@ export interface InvoiceItemFormData {
|
|||||||
quantity: number | "";
|
quantity: number | "";
|
||||||
unit_amount: number | "";
|
unit_amount: number | "";
|
||||||
discount_percentage: number | "";
|
discount_percentage: number | "";
|
||||||
|
discount_amount: number | "";
|
||||||
|
taxable_amount: number | "";
|
||||||
tax_codes: string[];
|
tax_codes: string[];
|
||||||
|
taxes_amount: number | "";
|
||||||
total_amount: number | ""; // readonly calculado
|
total_amount: number | ""; // readonly calculado
|
||||||
}
|
}
|
||||||
export interface InvoiceFormData {
|
export interface InvoiceFormData {
|
||||||
@ -70,7 +73,7 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
|
|||||||
el.style.height = `${el.scrollHeight}px`;
|
el.style.height = `${el.scrollHeight}px`;
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-[12rem] max-w-[46rem] w-full resize-none bg-transparent border-dashed transition",
|
"min-w-48 max-w-184 w-full resize-none bg-transparent border-dashed transition",
|
||||||
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-background focus-visible:border-solid",
|
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-background focus-visible:border-solid",
|
||||||
"focus:resize-y"
|
"focus:resize-y"
|
||||||
)}
|
)}
|
||||||
@ -179,6 +182,31 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
|
|||||||
size: 40,
|
size: 40,
|
||||||
minSize: 40,
|
minSize: 40,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "discount_amount",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader
|
||||||
|
column={column}
|
||||||
|
title={t("form_fields.item.discount_amount.label")}
|
||||||
|
className='text-right'
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<AmountInputField
|
||||||
|
control={control}
|
||||||
|
name={`items.${row.index}.discount_amount`}
|
||||||
|
readOnly
|
||||||
|
inputId={`discount_amount-${row.original.id}`}
|
||||||
|
currencyCode={currency_code}
|
||||||
|
languageCode={language_code}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
enableHiding: true,
|
||||||
|
enableSorting: false,
|
||||||
|
size: 120,
|
||||||
|
minSize: 100,
|
||||||
|
maxSize: 160,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "taxable_amount",
|
accessorKey: "taxable_amount",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
@ -192,17 +220,13 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
|
|||||||
<AmountInputField
|
<AmountInputField
|
||||||
control={control}
|
control={control}
|
||||||
name={`items.${row.index}.taxable_amount`}
|
name={`items.${row.index}.taxable_amount`}
|
||||||
readOnly={readOnly}
|
readOnly
|
||||||
inputId={`unit-${row.original.id}`}
|
inputId={`taxable_amount-${row.original.id}`}
|
||||||
scale={4}
|
|
||||||
currencyCode={currency_code}
|
currencyCode={currency_code}
|
||||||
languageCode={language_code}
|
languageCode={language_code}
|
||||||
data-row-index={row.index}
|
|
||||||
data-col-index={5}
|
|
||||||
data-cell-focus
|
|
||||||
className='font-base'
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
enableHiding: true,
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
size: 120,
|
size: 120,
|
||||||
minSize: 100,
|
minSize: 100,
|
||||||
@ -233,6 +257,30 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
|
|||||||
minSize: 130,
|
minSize: 130,
|
||||||
maxSize: 180,
|
maxSize: 180,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "taxes_amount",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader
|
||||||
|
column={column}
|
||||||
|
title={t("form_fields.item.taxes_amount.label")}
|
||||||
|
className='text-right'
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<AmountInputField
|
||||||
|
control={control}
|
||||||
|
name={`items.${row.index}.taxes_amount`}
|
||||||
|
readOnly
|
||||||
|
inputId={`taxes_amount-${row.original.id}`}
|
||||||
|
currencyCode={currency_code}
|
||||||
|
languageCode={language_code}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
size: 120,
|
||||||
|
minSize: 100,
|
||||||
|
maxSize: 160,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "total_amount",
|
accessorKey: "total_amount",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
@ -266,6 +314,10 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
|
|||||||
<DataTableColumnHeader column={column} title={t("components.datatable.actions")} />
|
<DataTableColumnHeader column={column} title={t("components.datatable.actions")} />
|
||||||
),
|
),
|
||||||
cell: ({ row, table }) => <ItemDataTableRowActions row={row} table={table} />,
|
cell: ({ row, table }) => <ItemDataTableRowActions row={row} table={table} />,
|
||||||
|
enableSorting: false,
|
||||||
|
size: 100,
|
||||||
|
minSize: 100,
|
||||||
|
maxSize: 100,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[t, readOnly, control, currency_code, language_code]
|
[t, readOnly, control, currency_code, language_code]
|
||||||
|
|||||||
@ -3,10 +3,9 @@ import type { Dinero } from "dinero.js";
|
|||||||
import { InvoiceItemTaxSummary } from "./calculate-invoice-item-amounts";
|
import { InvoiceItemTaxSummary } from "./calculate-invoice-item-amounts";
|
||||||
import { toDinero } from "./calculate-utils";
|
import { toDinero } from "./calculate-utils";
|
||||||
|
|
||||||
export interface InvoiceHeaderCalcInput {
|
export interface InvoiceItemsTotalsInput {
|
||||||
subtotal_amount: number;
|
subtotal_amount: number;
|
||||||
discount_amount: number;
|
discount_amount: number;
|
||||||
header_discount_amount: number;
|
|
||||||
taxable_amount: number;
|
taxable_amount: number;
|
||||||
taxes_amount: number;
|
taxes_amount: number;
|
||||||
taxes_summary: InvoiceItemTaxSummary[];
|
taxes_summary: InvoiceItemTaxSummary[];
|
||||||
@ -15,52 +14,54 @@ export interface InvoiceHeaderCalcInput {
|
|||||||
|
|
||||||
export interface InvoiceHeaderCalcResult {
|
export interface InvoiceHeaderCalcResult {
|
||||||
subtotal_amount: number;
|
subtotal_amount: number;
|
||||||
|
items_discount_amount: number;
|
||||||
discount_amount: number;
|
discount_amount: number;
|
||||||
header_discount_amount: number;
|
|
||||||
taxable_amount: number;
|
taxable_amount: number;
|
||||||
taxes_summary: InvoiceItemTaxSummary[];
|
taxes_summary: InvoiceItemTaxSummary[];
|
||||||
taxes_amount: number;
|
taxes_amount: number;
|
||||||
total_amount: number;
|
total_amount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toNum = (d: Dinero.Dinero) => d.toUnit();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agrega los importes de todas las líneas y recalcula los totales generales
|
* Agrega los importes de todas las líneas y recalcula los totales generales
|
||||||
* con precisión financiera (sumas exactas en céntimos).
|
* con precisión financiera (sumas exactas en céntimos).
|
||||||
*/
|
*/
|
||||||
export function calculateInvoiceHeaderAmounts(
|
export function calculateInvoiceHeaderAmounts(
|
||||||
items: InvoiceHeaderCalcInput[],
|
items: InvoiceItemsTotalsInput[],
|
||||||
|
discount_percentage: number,
|
||||||
currency: string
|
currency: string
|
||||||
): InvoiceHeaderCalcResult {
|
): InvoiceHeaderCalcResult {
|
||||||
const defaultScale = 2;
|
const defaultScale = 2;
|
||||||
|
|
||||||
let subtotal = toDinero(0, defaultScale, currency);
|
let items_subtotal = toDinero(0, defaultScale, currency);
|
||||||
let discount = toDinero(0, defaultScale, currency);
|
let items_discount = toDinero(0, defaultScale, currency);
|
||||||
let header_discount = toDinero(0, defaultScale, currency);
|
let items_taxable = toDinero(0, defaultScale, currency);
|
||||||
let taxable = toDinero(0, defaultScale, currency);
|
let items_taxes = toDinero(0, defaultScale, currency);
|
||||||
let taxes = toDinero(0, defaultScale, currency);
|
let items_total = toDinero(0, defaultScale, currency);
|
||||||
let total = toDinero(0, defaultScale, currency);
|
const items_taxes_summary: InvoiceItemTaxSummary[] = [];
|
||||||
const taxes_summary: InvoiceItemTaxSummary[] = [];
|
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
subtotal = subtotal.add(toDinero(item.subtotal_amount, defaultScale, currency));
|
items_subtotal = items_subtotal.add(toDinero(item.subtotal_amount, defaultScale, currency));
|
||||||
discount = discount.add(toDinero(item.discount_amount, defaultScale, currency));
|
items_discount = items_discount.add(toDinero(item.discount_amount, defaultScale, currency));
|
||||||
header_discount = header_discount.add(toDinero(item.discount_amount, defaultScale, currency));
|
items_taxable = items_taxable.add(toDinero(item.taxable_amount, defaultScale, currency));
|
||||||
taxable = taxable.add(toDinero(item.taxable_amount, defaultScale, currency));
|
items_taxes = items_taxes.add(toDinero(item.taxes_amount, defaultScale, currency));
|
||||||
taxes = taxes.add(toDinero(item.taxes_amount, defaultScale, currency));
|
items_total = items_total.add(toDinero(item.total_amount, defaultScale, currency));
|
||||||
total = total.add(toDinero(item.total_amount, defaultScale, currency));
|
items_taxes_summary.push(...item.taxes_summary);
|
||||||
taxes_summary.push(...item.taxes_summary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const toNum = (d: Dinero.Dinero) => d.toUnit();
|
// Descuento = subtotal × (item_pct / 100)
|
||||||
|
const discount_amount = items_taxable.percentage(discount_percentage);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subtotal_amount: toNum(subtotal),
|
subtotal_amount: toNum(items_subtotal),
|
||||||
discount_amount: toNum(discount),
|
items_discount_amount: toNum(items_discount),
|
||||||
header_discount_amount: toNum(header_discount),
|
discount_amount: toNum(discount_amount),
|
||||||
taxable_amount: toNum(taxable),
|
taxable_amount: toNum(items_taxable),
|
||||||
taxes_amount: toNum(taxes),
|
taxes_amount: toNum(items_taxes),
|
||||||
total_amount: toNum(total),
|
total_amount: toNum(items_total),
|
||||||
taxes_summary: calculateTaxesSummary(taxes_summary, currency),
|
taxes_summary: calculateTaxesSummary(items_taxes_summary, currency),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,6 @@ export interface InvoiceItemCalcInput {
|
|||||||
quantity?: string; // p.ej. "3.5"
|
quantity?: string; // p.ej. "3.5"
|
||||||
unit_amount?: string; // p.ej. "125.75"
|
unit_amount?: string; // p.ej. "125.75"
|
||||||
discount_percentage?: string; // p.ej. "10" (=> 10%)
|
discount_percentage?: string; // p.ej. "10" (=> 10%)
|
||||||
header_discount_percentage?: string; // p.ej. "5" (=> 5%)
|
|
||||||
tax_codes: string[]; // ["iva_21", ...]
|
tax_codes: string[]; // ["iva_21", ...]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -17,7 +16,6 @@ export type InvoiceItemTaxSummary = TaxItemType & {
|
|||||||
export interface InvoiceItemCalcResult {
|
export interface InvoiceItemCalcResult {
|
||||||
subtotal_amount: number;
|
subtotal_amount: number;
|
||||||
discount_amount: number;
|
discount_amount: number;
|
||||||
header_discount_amount: number;
|
|
||||||
taxable_amount: number;
|
taxable_amount: number;
|
||||||
taxes_amount: number;
|
taxes_amount: number;
|
||||||
taxes_summary: InvoiceItemTaxSummary[];
|
taxes_summary: InvoiceItemTaxSummary[];
|
||||||
@ -39,7 +37,6 @@ export function calculateInvoiceItemAmounts(
|
|||||||
const qty = Number.parseFloat(item.quantity || "0") || 0;
|
const qty = Number.parseFloat(item.quantity || "0") || 0;
|
||||||
const unit = Number.parseFloat(item.unit_amount || "0") || 0;
|
const unit = Number.parseFloat(item.unit_amount || "0") || 0;
|
||||||
const iten_pct = Number.parseFloat(item.discount_percentage || "0") || 0;
|
const iten_pct = Number.parseFloat(item.discount_percentage || "0") || 0;
|
||||||
const header_pct = Number.parseFloat(item.header_discount_percentage || "0") || 0;
|
|
||||||
|
|
||||||
// Subtotal = cantidad × precio unitario
|
// Subtotal = cantidad × precio unitario
|
||||||
const subtotal_amount = toDinero(unit, defaultScale, currency).multiply(qty);
|
const subtotal_amount = toDinero(unit, defaultScale, currency).multiply(qty);
|
||||||
@ -48,38 +45,36 @@ export function calculateInvoiceItemAmounts(
|
|||||||
const discount_amount = subtotal_amount.percentage(iten_pct);
|
const discount_amount = subtotal_amount.percentage(iten_pct);
|
||||||
const subtotal_w_discount_amount = subtotal_amount.subtract(discount_amount);
|
const subtotal_w_discount_amount = subtotal_amount.subtract(discount_amount);
|
||||||
|
|
||||||
// Descuento de la cabecera = subtotal con dto de línea × (header_pct / 100)
|
|
||||||
const header_discount = subtotal_w_discount_amount.percentage(header_pct);
|
|
||||||
|
|
||||||
// Base imponible
|
// Base imponible
|
||||||
const taxable_amount = subtotal_w_discount_amount.subtract(header_discount);
|
const taxable_amount = subtotal_w_discount_amount;
|
||||||
|
|
||||||
// Impuestos acumulados con signo
|
// Impuestos acumulados con signo
|
||||||
let taxes_amount = toDinero(0, defaultScale, currency);
|
let taxes_amount = toDinero(0, defaultScale, currency);
|
||||||
|
|
||||||
for (const code of item.tax_codes ?? []) {
|
for (const code of item.tax_codes ?? []) {
|
||||||
const tax = taxCatalog.findByCode(code);
|
const taxItemType = taxCatalog.findByCode(code);
|
||||||
if (tax.isNone()) continue;
|
if (taxItemType.isNone()) continue;
|
||||||
|
|
||||||
tax.map((taxItem) => {
|
const taxItem = taxItemType.unwrap();
|
||||||
const tax_pct_value =
|
|
||||||
Number.parseFloat(taxItem.value) / 10 ** Number.parseInt(taxItem.scale, 10);
|
|
||||||
const item_taxables_amount = taxable_amount.percentage(tax_pct_value);
|
|
||||||
|
|
||||||
// Sumar o restar según grupo
|
const tax_pct_value =
|
||||||
switch (taxItem.group.toLowerCase()) {
|
Number.parseFloat(taxItem.value) / 10 ** Number.parseInt(taxItem.scale, 10);
|
||||||
case "retención":
|
const item_taxables_amount = taxable_amount.percentage(tax_pct_value);
|
||||||
taxes_amount = taxes_amount.subtract(item_taxables_amount);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
taxes_amount = taxes_amount.add(item_taxables_amount);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
taxesSummary.push({
|
// Sumar o restar según grupo
|
||||||
...taxItem,
|
switch (taxItem.group.toLowerCase()) {
|
||||||
taxable_amount: toNum(taxable_amount),
|
case "retención":
|
||||||
taxes_amount: toNum(item_taxables_amount),
|
taxes_amount = taxes_amount.subtract(item_taxables_amount);
|
||||||
});
|
break;
|
||||||
|
default:
|
||||||
|
taxes_amount = taxes_amount.add(item_taxables_amount);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
taxesSummary.push({
|
||||||
|
...taxItem,
|
||||||
|
taxable_amount: toNum(taxable_amount),
|
||||||
|
taxes_amount: toNum(item_taxables_amount),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,7 +83,6 @@ export function calculateInvoiceItemAmounts(
|
|||||||
return {
|
return {
|
||||||
subtotal_amount: toNum(subtotal_amount),
|
subtotal_amount: toNum(subtotal_amount),
|
||||||
discount_amount: toNum(discount_amount),
|
discount_amount: toNum(discount_amount),
|
||||||
header_discount_amount: toNum(header_discount),
|
|
||||||
taxable_amount: toNum(taxable_amount),
|
taxable_amount: toNum(taxable_amount),
|
||||||
taxes_amount: toNum(taxes_amount),
|
taxes_amount: toNum(taxes_amount),
|
||||||
taxes_summary: taxesSummary,
|
taxes_summary: taxesSummary,
|
||||||
|
|||||||
@ -22,19 +22,20 @@ export type UseInvoiceAutoRecalcParams = {
|
|||||||
*/
|
*/
|
||||||
export function useInvoiceAutoRecalc(
|
export function useInvoiceAutoRecalc(
|
||||||
form: UseFormReturn<InvoiceFormData>,
|
form: UseFormReturn<InvoiceFormData>,
|
||||||
{ currency_code, taxCatalog, debug = false }: UseInvoiceAutoRecalcParams
|
{ currency_code, taxCatalog, debug = true }: UseInvoiceAutoRecalcParams
|
||||||
) {
|
) {
|
||||||
const { setValue, trigger, control } = form;
|
const { trigger, control } = form;
|
||||||
|
|
||||||
// Observa los ítems y el descuento global
|
// Observa los ítems y el descuento global
|
||||||
const watchedItems = useWatch({ control, name: "items" }) ?? [];
|
const watchedItems = useWatch({ control, name: "items" }) ?? [];
|
||||||
const watchedDiscount = useWatch({ control, name: "discount_percentage" }) ?? 0;
|
const watchedDiscount = useWatch({ control, name: "discount_percentage", defaultValue: 0 }); // <- descuento global
|
||||||
|
|
||||||
// Diferir valores pesados para reducir renders (React 19)
|
// Diferir valores pesados para reducir renders (React 19)
|
||||||
const deferredItems = React.useDeferredValue(watchedItems);
|
const deferredItems = React.useDeferredValue(watchedItems);
|
||||||
const deferredDiscount = React.useDeferredValue(watchedDiscount);
|
const deferredDiscount = React.useDeferredValue(watchedDiscount);
|
||||||
|
|
||||||
// Cache para evitar recálculos redundantes
|
// Cache para evitar recálculos redundantes
|
||||||
|
const [prevDiscount, setPrevDiscount] = React.useState(watchedDiscount);
|
||||||
const itemCache = React.useRef<Map<number, InvoiceItemCalcResult>>(new Map());
|
const itemCache = React.useRef<Map<number, InvoiceItemCalcResult>>(new Map());
|
||||||
|
|
||||||
// Debounce para agrupar recalculados rápidos
|
// Debounce para agrupar recalculados rápidos
|
||||||
@ -42,7 +43,7 @@ export function useInvoiceAutoRecalc(
|
|||||||
|
|
||||||
// Cálculo de una línea individual
|
// Cálculo de una línea individual
|
||||||
const calculateItemTotals = React.useCallback(
|
const calculateItemTotals = React.useCallback(
|
||||||
(item: InvoiceItemFormData, header_discount_percentage: number) => {
|
(item: InvoiceItemFormData) => {
|
||||||
const sanitize = (v?: number | string) => (v && !Number.isNaN(Number(v)) ? String(v) : "0");
|
const sanitize = (v?: number | string) => (v && !Number.isNaN(Number(v)) ? String(v) : "0");
|
||||||
|
|
||||||
return calculateInvoiceItemAmounts(
|
return calculateInvoiceItemAmounts(
|
||||||
@ -50,7 +51,6 @@ export function useInvoiceAutoRecalc(
|
|||||||
quantity: sanitize(item.quantity),
|
quantity: sanitize(item.quantity),
|
||||||
unit_amount: sanitize(item.unit_amount),
|
unit_amount: sanitize(item.unit_amount),
|
||||||
discount_percentage: sanitize(item.discount_percentage),
|
discount_percentage: sanitize(item.discount_percentage),
|
||||||
header_discount_percentage: sanitize(header_discount_percentage),
|
|
||||||
tax_codes: item.tax_codes,
|
tax_codes: item.tax_codes,
|
||||||
},
|
},
|
||||||
currency_code,
|
currency_code,
|
||||||
@ -64,13 +64,12 @@ export function useInvoiceAutoRecalc(
|
|||||||
const calculateInvoiceTotals = React.useCallback(
|
const calculateInvoiceTotals = React.useCallback(
|
||||||
(items: InvoiceItemFormData[], header_discount_percentage: number) => {
|
(items: InvoiceItemFormData[], header_discount_percentage: number) => {
|
||||||
const lines = items
|
const lines = items
|
||||||
.filter((i) => !i.is_non_valued)
|
//.filter((i) => i.is_valued)
|
||||||
.map((i) => {
|
.map((i) => {
|
||||||
const totals = calculateItemTotals(i, header_discount_percentage);
|
const totals = calculateItemTotals(i);
|
||||||
return {
|
return {
|
||||||
subtotal_amount: totals.subtotal_amount,
|
subtotal_amount: totals.subtotal_amount,
|
||||||
discount_amount: totals.discount_amount,
|
discount_amount: totals.discount_amount,
|
||||||
header_discount_amount: totals.header_discount_amount,
|
|
||||||
taxable_amount: totals.taxable_amount,
|
taxable_amount: totals.taxable_amount,
|
||||||
taxes_amount: totals.taxes_amount,
|
taxes_amount: totals.taxes_amount,
|
||||||
total_amount: totals.total_amount,
|
total_amount: totals.total_amount,
|
||||||
@ -78,7 +77,7 @@ export function useInvoiceAutoRecalc(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return calculateInvoiceHeaderAmounts(lines, currency_code);
|
return calculateInvoiceHeaderAmounts(lines, header_discount_percentage, currency_code);
|
||||||
},
|
},
|
||||||
[calculateItemTotals, currency_code]
|
[calculateItemTotals, currency_code]
|
||||||
);
|
);
|
||||||
@ -92,9 +91,14 @@ export function useInvoiceAutoRecalc(
|
|||||||
debounceTimer.current = setTimeout(() => {
|
debounceTimer.current = setTimeout(() => {
|
||||||
let shouldUpdateHeader = false;
|
let shouldUpdateHeader = false;
|
||||||
|
|
||||||
|
if (prevDiscount !== deferredDiscount) {
|
||||||
|
shouldUpdateHeader = true;
|
||||||
|
setPrevDiscount(deferredDiscount);
|
||||||
|
}
|
||||||
|
|
||||||
deferredItems.forEach((item, idx) => {
|
deferredItems.forEach((item, idx) => {
|
||||||
const prev = itemCache.current.get(idx);
|
const prev = itemCache.current.get(idx);
|
||||||
const next = calculateItemTotals(item, deferredDiscount);
|
const next = calculateItemTotals(item);
|
||||||
|
|
||||||
const itemHasChanges =
|
const itemHasChanges =
|
||||||
!prev ||
|
!prev ||
|
||||||
@ -107,14 +111,14 @@ export function useInvoiceAutoRecalc(
|
|||||||
shouldUpdateHeader = true;
|
shouldUpdateHeader = true;
|
||||||
itemCache.current.set(idx, next);
|
itemCache.current.set(idx, next);
|
||||||
setInvoiceItemTotals(form, idx, next);
|
setInvoiceItemTotals(form, idx, next);
|
||||||
if (debug) console.log(`💡 Recalc line ${idx + 1}`, next);
|
if (debug) console.log(`💡 Recalc line ${idx + 1}`, next.total_amount);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (shouldUpdateHeader) {
|
if (shouldUpdateHeader) {
|
||||||
const totals = calculateInvoiceTotals(deferredItems, deferredDiscount);
|
const totals = calculateInvoiceTotals(deferredItems, deferredDiscount);
|
||||||
setInvoiceTotals(form, totals);
|
setInvoiceTotals(form, totals);
|
||||||
if (debug) console.log("📊 Recalc invoice totals", totals);
|
if (debug) console.log("📊 Recalc invoice totals", totals.subtotal_amount);
|
||||||
|
|
||||||
void trigger([
|
void trigger([
|
||||||
"subtotal_amount",
|
"subtotal_amount",
|
||||||
@ -164,8 +168,11 @@ function setInvoiceTotals(
|
|||||||
const { setValue } = form;
|
const { setValue } = form;
|
||||||
const opts = { shouldDirty: true, shouldValidate: false } as const;
|
const opts = { shouldDirty: true, shouldValidate: false } as const;
|
||||||
|
|
||||||
|
console.log(totals);
|
||||||
|
|
||||||
setValue("subtotal_amount", totals.subtotal_amount, opts);
|
setValue("subtotal_amount", totals.subtotal_amount, opts);
|
||||||
setValue("discount_amount", totals.header_discount_amount, opts);
|
setValue("items_discount_amount", totals.items_discount_amount, opts);
|
||||||
|
setValue("discount_amount", totals.discount_amount, opts);
|
||||||
setValue("taxable_amount", totals.taxable_amount, opts);
|
setValue("taxable_amount", totals.taxable_amount, opts);
|
||||||
setValue("taxes_amount", totals.taxes_amount, opts);
|
setValue("taxes_amount", totals.taxes_amount, opts);
|
||||||
setValue("total_amount", totals.total_amount, opts);
|
setValue("total_amount", totals.total_amount, opts);
|
||||||
|
|||||||
@ -47,7 +47,6 @@ export const invoiceDtoToFormAdapter = {
|
|||||||
})),
|
})),
|
||||||
|
|
||||||
items: dto.items.map((item) => ({
|
items: dto.items.map((item) => ({
|
||||||
is_non_valued: item.is_non_valued === "true",
|
|
||||||
description: item.description ?? "",
|
description: item.description ?? "",
|
||||||
quantity: QuantityDTOHelper.toNumericString(item.quantity),
|
quantity: QuantityDTOHelper.toNumericString(item.quantity),
|
||||||
unit_amount: MoneyDTOHelper.toNumericString(item.unit_amount),
|
unit_amount: MoneyDTOHelper.toNumericString(item.unit_amount),
|
||||||
@ -80,7 +79,6 @@ export const invoiceDtoToFormAdapter = {
|
|||||||
currency_code: context.currency_code,
|
currency_code: context.currency_code,
|
||||||
|
|
||||||
items: form.items?.map((item) => ({
|
items: form.items?.map((item) => ({
|
||||||
is_non_valued: item.is_non_valued ? "true" : "false",
|
|
||||||
description: item.description,
|
description: item.description,
|
||||||
quantity: QuantityDTOHelper.fromNumericString(item.quantity, 4),
|
quantity: QuantityDTOHelper.fromNumericString(item.quantity, 4),
|
||||||
unit_amount: MoneyDTOHelper.fromNumericString(item.unit_amount, currency_code, 4),
|
unit_amount: MoneyDTOHelper.fromNumericString(item.unit_amount, currency_code, 4),
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
export const InvoiceItemFormSchema = z.object({
|
export const InvoiceItemFormSchema = z.object({
|
||||||
is_non_valued: z.boolean(),
|
|
||||||
|
|
||||||
description: z.string().max(2000).optional().default(""),
|
description: z.string().max(2000).optional().default(""),
|
||||||
quantity: z.any(), //NumericStringSchema.optional(),
|
quantity: z.any(), //NumericStringSchema.optional(),
|
||||||
unit_amount: z.any(), //NumericStringSchema.optional(),
|
unit_amount: z.any(), //NumericStringSchema.optional(),
|
||||||
@ -74,6 +72,7 @@ export const InvoiceFormSchema = z.object({
|
|||||||
items: z.array(InvoiceItemFormSchema).optional(),
|
items: z.array(InvoiceItemFormSchema).optional(),
|
||||||
|
|
||||||
subtotal_amount: z.number(),
|
subtotal_amount: z.number(),
|
||||||
|
items_discount_amount: z.number(),
|
||||||
discount_percentage: z.number(),
|
discount_percentage: z.number(),
|
||||||
discount_amount: z.number(),
|
discount_amount: z.number(),
|
||||||
taxable_amount: z.number(),
|
taxable_amount: z.number(),
|
||||||
@ -85,7 +84,6 @@ export type InvoiceFormData = z.infer<typeof InvoiceFormSchema>;
|
|||||||
export type InvoiceItemFormData = z.infer<typeof InvoiceItemFormSchema>;
|
export type InvoiceItemFormData = z.infer<typeof InvoiceItemFormSchema>;
|
||||||
|
|
||||||
export const defaultCustomerInvoiceItemFormData: InvoiceItemFormData = {
|
export const defaultCustomerInvoiceItemFormData: InvoiceItemFormData = {
|
||||||
is_non_valued: false,
|
|
||||||
description: "",
|
description: "",
|
||||||
quantity: "",
|
quantity: "",
|
||||||
unit_amount: "",
|
unit_amount: "",
|
||||||
@ -115,6 +113,7 @@ export const defaultCustomerInvoiceFormData: InvoiceFormData = {
|
|||||||
items: [],
|
items: [],
|
||||||
|
|
||||||
subtotal_amount: 0,
|
subtotal_amount: 0,
|
||||||
|
items_discount_amount: 0,
|
||||||
discount_amount: 0,
|
discount_amount: 0,
|
||||||
discount_percentage: 0,
|
discount_percentage: 0,
|
||||||
taxable_amount: 0,
|
taxable_amount: 0,
|
||||||
|
|||||||
@ -49,7 +49,7 @@ export const GetVerifactuRecordByIdResponseSchema = z.object({
|
|||||||
items: z.array(
|
items: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.uuid(),
|
id: z.uuid(),
|
||||||
is_non_valued: z.string(),
|
is_valued: z.string(),
|
||||||
position: z.string(),
|
position: z.string(),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
quantity: QuantitySchema,
|
quantity: QuantitySchema,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user