Facturas de cliente

This commit is contained in:
David Arranz 2025-10-12 12:43:06 +02:00
parent efcae31500
commit 78cc422d9a
53 changed files with 990 additions and 502 deletions

View File

@ -35,7 +35,9 @@
},
"style": {
"useImportType": "off",
"noNonNullAssertion": "info"
"noInferrableTypes": "off",
"noNonNullAssertion": "info",
"noUselessElse": "off"
},
"a11y": {
"useSemanticElements": "info"

View File

@ -1,2 +1,5 @@
export * from "./dto-compare-helper";
export * from "./money-utils";
export * from "./money-dto-helper";
export * from "./money-helper";
export * from "./percentage-dto-helpers";
export * from "./quantity-dto-helpers";

View File

@ -0,0 +1,145 @@
import type { MoneyDTO } from "@erp/core/common";
import Dinero from "dinero.js";
type DineroPlain = { amount: number; precision: number; currency: string };
const isEmptyMoneyDTO = (dto?: MoneyDTO | null) =>
!dto || dto.value?.trim?.() === "" || dto.scale?.trim?.() === "";
/**
* Convierte un MoneyDTO a número con precisión (sin moneda).
*/
const toNumber = (dto?: MoneyDTO | null, fallbackScale = 2): number => {
if (isEmptyMoneyDTO(dto)) {
return 0;
}
const scale = Number(dto!.scale || fallbackScale);
return Number(dto!.value || 0) / 10 ** scale;
};
/**
* Convierte un MoneyDTO a cadena numérica con precisión (sin moneda).
* Puede devolver cadena vacía
*/
const toNumericString = (dto?: MoneyDTO | null, fallbackScale = 2): string => {
if (isEmptyMoneyDTO(dto)) {
return "";
}
return toNumber(dto, fallbackScale).toString();
};
/**
* Convierte número a MoneyDTO.
*/
const fromNumber = (amount: number, currency: string = "EUR", scale = 2): MoneyDTO => {
return {
value: String(Math.round(amount * 10 ** scale)),
scale: String(scale),
currency_code: currency,
};
};
/**
* Convierte cadena numérica a MoneyDTO.
*/
const fromNumericString = (amount?: string, currency: string = "EUR", scale = 2): MoneyDTO => {
if (!amount || amount?.trim?.() === "") {
return {
value: "",
scale: "",
currency_code: currency,
};
}
return {
value: String(Math.round(Number(amount) * 10 ** scale)),
scale: String(scale),
currency_code: currency,
};
};
/**
* Normaliza un MoneyDTO incompleto o malformado.
*/
const normalizeDTO = (dto: MoneyDTO, fallbackCurrency = "EUR"): Required<MoneyDTO> => {
const v = /^-?\d+$/.test(dto?.value ?? "") ? dto.value : "0";
const s = /^\d+$/.test(dto?.scale ?? "") ? dto.scale : "2";
const c = (dto?.currency_code || fallbackCurrency) as string;
return { value: v, scale: s, currency_code: c };
};
/**
* Formatea un MoneyDTO según locale.
*/
const formatDTO = (dto: MoneyDTO, locale: string = "es-ES"): string => {
const { value, scale, currency_code } = normalizeDTO(dto);
const num = Number(value) / 10 ** Number(scale);
return new Intl.NumberFormat(locale, { style: "currency", currency: currency_code }).format(num);
};
export const MoneyDTOHelper = {
isEmpty: isEmptyMoneyDTO,
toNumber,
toNumericString,
fromNumber,
fromNumericString,
formatDTO,
};
/**
* Convierte un DTO a una instancia Dinero.js.
*/
function dineroFromDTO(dto: MoneyDTO, fallbackCurrency = "EUR"): Dinero.Dinero {
const n = normalizeDTO(dto, fallbackCurrency);
return Dinero({
amount: Number.parseInt(n.value, 10),
precision: Number.parseInt(n.scale, 10),
currency: n.currency_code as string,
});
}
/**
* Convierte una instancia Dinero a un DTO.
*/
function dtoFromDinero(d: Dinero.Dinero): MoneyDTO {
const { amount, precision, currency } = d.toObject() as DineroPlain;
return {
value: amount.toString(),
scale: precision.toString(),
currency_code: currency,
};
}
/**
* Suma una lista de MoneyDTO.
*/
function sumDTO(list: MoneyDTO[], fallbackCurrency = "EUR"): MoneyDTO {
if (list.length === 0) return { value: "0", scale: "2", currency_code: fallbackCurrency };
const sum = list.map((x) => dineroFromDTO(x, fallbackCurrency)).reduce((a, b) => a.add(b));
return dtoFromDinero(sum);
}
/**
* Multiplica un MoneyDTO por un número.
*/
function multiplyDTO(
dto: MoneyDTO,
multiplier: number,
rounding: Dinero.RoundingMode = "HALF_EVEN",
fallbackCurrency = "EUR"
): MoneyDTO {
const d = dineroFromDTO(dto, fallbackCurrency).multiply(multiplier, rounding);
return dtoFromDinero(d);
}
/**
* Calcula un porcentaje de un MoneyDTO.
*/
function percentageDTO(
dto: MoneyDTO,
percent: number,
rounding: Dinero.RoundingMode = "HALF_EVEN",
fallbackCurrency = "EUR"
): MoneyDTO {
const d = dineroFromDTO(dto, fallbackCurrency).percentage(percent, rounding);
return dtoFromDinero(d);
}

View File

@ -0,0 +1,56 @@
/**
* Funciones para manipular valores monetarios numéricos.
*/
/**
* Elimina símbolos de moneda y caracteres no numéricos.
* @param s Texto de entrada, e.g. "€ 1.234,56"
* @returns Solo dígitos, signos y separadores.
* @example stripCurrencySymbols("€ -1.234,56") // "-1.234,56"
*/
export const stripCurrencySymbols = (s: string): string =>
s
.replace(/[^\d.,\-]/g, "")
.replace(/\s+/g, " ")
.trim();
/**
* Parsea un número localizado a float (soporta "," y ".").
* @param raw Texto con número localizado.
* @returns número o null si no se puede parsear.
* @example parseLocaleNumber("1.234,56") // 1234.56
*/
export const parseLocaleNumber = (raw: string): number | null => {
if (!raw) return null;
const s = stripCurrencySymbols(raw);
if (!s) return null;
const lastComma = s.lastIndexOf(",");
const lastDot = s.lastIndexOf(".");
let normalized = s;
if (lastComma > -1 && lastDot > -1) {
if (lastComma > lastDot) normalized = s.replace(/\./g, "").replace(",", ".");
else normalized = s.replace(/,/g, "");
} else if (lastComma > -1) normalized = s.replace(/\s/g, "").replace(",", ".");
else normalized = s.replace(/\s/g, "");
const n = Number(normalized);
return Number.isFinite(n) ? n : null;
};
/**
* Redondea a una escala decimal determinada.
* @param n número base
* @param scale cantidad de decimales
* @returns número redondeado
* @example roundToScale(1.2345, 2) // 1.23
*/
export const roundToScale = (n: number, scale = 2): number => {
const f = 10 ** scale;
return Math.round(n * f) / f;
};
/**
* Suma o resta con step (para inputs numéricos).
* @example stepNumber(1.2, 0.1) // 1.3
*/
export const stepNumber = (base: number, step = 0.01, scale = 2): number =>
roundToScale(base + step, scale);

View File

@ -1,66 +0,0 @@
import type { MoneyDTO } from "@erp/core/common";
import Dinero, { Currency } from "dinero.js";
// Tipo compatible con API => MoneyDTO
// Snapshot mínimo de toObject() en v1
type DineroPlain = { amount: number; precision: number; currency: string };
// --- Helpers ---
function normalizeDTO(dto: MoneyDTO, fallbackCurrency: Currency = "EUR"): Required<MoneyDTO> {
const v = /^-?\d+$/.test(dto?.value ?? "") ? dto.value : "0";
const s = /^\d+$/.test(dto?.scale ?? "") ? dto.scale : "2";
const c = (dto?.currency_code || fallbackCurrency) as string;
return { value: v, scale: s, currency_code: c };
}
export function dineroFromDTO(dto: MoneyDTO, fallbackCurrency: Currency = "EUR"): Dinero.Dinero {
const n = normalizeDTO(dto, fallbackCurrency);
return Dinero({
amount: Number.parseInt(n.value, 10),
precision: Number.parseInt(n.scale, 10),
currency: n.currency_code as Currency,
});
}
export function dtoFromDinero(d: Dinero.Dinero): MoneyDTO {
const { amount, precision, currency } = d.toObject() as DineroPlain;
return {
value: amount.toString(),
scale: precision.toString(),
currency_code: currency,
};
}
export function sumDTO(list: MoneyDTO[], fallbackCurrency: Currency = "EUR"): MoneyDTO {
if (list.length === 0) return { value: "0", scale: "2", currency_code: fallbackCurrency };
const sum = list.map((x) => dineroFromDTO(x, fallbackCurrency)).reduce((a, b) => a.add(b));
return dtoFromDinero(sum);
}
export function multiplyDTO(
dto: MoneyDTO,
multiplier: number,
rounding: Dinero.RoundingMode = "HALF_EVEN",
fallbackCurrency: Currency = "EUR"
): MoneyDTO {
const d = dineroFromDTO(dto, fallbackCurrency).multiply(multiplier, rounding);
return dtoFromDinero(d);
}
export function percentageDTO(
dto: MoneyDTO,
percent: number, // 25 = 25%
rounding: Dinero.RoundingMode = "HALF_EVEN",
fallbackCurrency: Currency = "EUR"
): MoneyDTO {
const d = dineroFromDTO(dto, fallbackCurrency).percentage(percent, rounding);
return dtoFromDinero(d);
}
export function formatDTO(dto: MoneyDTO, locale = "es-ES"): string {
const { value, scale, currency_code } = normalizeDTO(dto);
const num = Number(value) / 10 ** Number(scale); // solo presentación
return new Intl.NumberFormat(locale, { style: "currency", currency: currency_code }).format(num);
}

View File

@ -0,0 +1,60 @@
import { PercentageDTO } from "../dto";
const isEmptyPercentageDTO = (dto?: PercentageDTO | null) =>
!dto || dto.value?.trim?.() === "" || dto.scale?.trim?.() === "";
/**
* Convierte un QuantityDTO a número con precisión.
*/
const toNumber = (dto?: PercentageDTO | null, fallbackScale = 2): number => {
if (isEmptyPercentageDTO(dto)) {
return 0;
}
const scale = Number(dto!.scale || fallbackScale);
return Number(dto!.value || 0) / 10 ** scale;
};
/**
* Convierte un QuantityDTO a cadena numérica con precisión.
* Puede devolver cadena vacía
*/
const toNumericString = (dto?: PercentageDTO | null, fallbackScale = 2): string => {
if (isEmptyPercentageDTO(dto)) {
return "";
}
return toNumber(dto, fallbackScale).toString();
};
/**
* Convierte número a QuantityDTO.
*/
const fromNumber = (amount: number, scale = 2): PercentageDTO => {
return {
value: String(Math.round(amount * 10 ** scale)),
scale: String(scale),
};
};
/**
* Convierte cadena numérica a QuantityDTO.
*/
const fromNumericString = (amount?: string, scale = 2): PercentageDTO => {
if (!amount || amount?.trim?.() === "") {
return {
value: "",
scale: "",
};
}
return {
value: String(Math.round(Number(amount) * 10 ** scale)),
scale: String(scale),
};
};
export const PercentageDTOHelper = {
isEmpty: isEmptyPercentageDTO,
toNumber,
toNumericString,
fromNumber,
fromNumericString,
};

View File

@ -0,0 +1,60 @@
import { QuantityDTO } from "../dto";
const isEmptyQuantityDTO = (dto?: QuantityDTO | null) =>
!dto || dto.value?.trim?.() === "" || dto.scale?.trim?.() === "";
/**
* Convierte un QuantityDTO a número con precisión.
*/
const toNumber = (dto?: QuantityDTO | null, fallbackScale = 2): number => {
if (isEmptyQuantityDTO(dto)) {
return 0;
}
const scale = Number(dto!.scale || fallbackScale);
return Number(dto!.value || 0) / 10 ** scale;
};
/**
* Convierte un QuantityDTO a cadena numérica con precisión.
* Puede devolver cadena vacía
*/
const toNumericString = (dto?: QuantityDTO | null, fallbackScale = 2): string => {
if (isEmptyQuantityDTO(dto)) {
return "";
}
return toNumber(dto, fallbackScale).toString();
};
/**
* Convierte número a QuantityDTO.
*/
const fromNumber = (amount: number, scale = 2): QuantityDTO => {
return {
value: String(Math.round(amount * 10 ** scale)),
scale: String(scale),
};
};
/**
* Convierte cadena numérica a QuantityDTO.
*/
const fromNumericString = (amount?: string, scale = 2): QuantityDTO => {
if (!amount || amount?.trim?.() === "") {
return {
value: "",
scale: "",
};
}
return {
value: String(Math.round(Number(amount) * 10 ** scale)),
scale: String(scale),
};
};
export const QuantityDTOHelper = {
isEmpty: isEmptyQuantityDTO,
toNumber,
toNumericString,
fromNumber,
fromNumericString,
};

View File

@ -8,13 +8,15 @@ type UseHookFormProps<TFields extends FieldValues = FieldValues, TContext = any>
TContext
> & {
resolverSchema: z4.$ZodType<TFields, any>;
initialValues: UseFormProps<TFields>["defaultValues"];
defaultValues: UseFormProps<TFields>["defaultValues"];
values: UseFormProps<TFields>["values"];
onDirtyChange?: (isDirty: boolean) => void;
};
export function useHookForm<TFields extends FieldValues = FieldValues, TContext = any>({
resolverSchema,
initialValues,
defaultValues,
values,
disabled,
onDirtyChange,
...rest
@ -22,7 +24,8 @@ export function useHookForm<TFields extends FieldValues = FieldValues, TContext
const form = useForm<TFields, TContext>({
...rest,
resolver: zodResolver(resolverSchema),
defaultValues: initialValues,
defaultValues,
values,
disabled,
});
@ -36,12 +39,12 @@ export function useHookForm<TFields extends FieldValues = FieldValues, TContext
useEffect(() => {
const applyReset = async () => {
const values = typeof initialValues === "function" ? await initialValues() : initialValues;
const values = typeof defaultValues === "function" ? await defaultValues() : defaultValues;
form.reset(values);
};
applyReset();
}, [initialValues, form]);
}, [defaultValues, form]);
return form;
}

View File

@ -0,0 +1,101 @@
import type { MoneyDTO } from "@erp/core/common";
import type { Currency } from "dinero.js";
import * as React from "react";
import { useTranslation } from "../i18n";
/**
* Hook para manipular valores MoneyDTO con operaciones,
* formato, parseo y conversión seguras.
*/
export function useMoneyDTO(overrides?: {
locale?: string;
fallbackCurrency?: Currency;
defaultScale?: number;
}) {
const { i18n } = useTranslation();
const locale = overrides?.locale || i18n.language || "es-ES";
const fallbackCurrency: Currency = overrides?.fallbackCurrency ?? "EUR";
const defaultScale = overrides?.defaultScale ?? 2;
// Conversión
const toNumber = React.useCallback(
(dto?: MoneyDTO | null) => toNumberUnsafe(dto, defaultScale),
[defaultScale]
);
const fromNumber = React.useCallback(
(n: number, currency: Currency = fallbackCurrency, scale: number = defaultScale): MoneyDTO =>
fromNumberUnsafe(n, currency, scale),
[fallbackCurrency, defaultScale]
);
// Operaciones
const add = React.useCallback(
(a: MoneyDTO, b: MoneyDTO): MoneyDTO => sumDTO([a, b], fallbackCurrency),
[fallbackCurrency]
);
const sub = React.useCallback(
(a: MoneyDTO, b: MoneyDTO): MoneyDTO => sumDTO([a, multiplyDTO(b, -1)], fallbackCurrency),
[fallbackCurrency]
);
const multiply = React.useCallback(
(dto: MoneyDTO, k: number, rounding: Dinero.RoundingMode = "HALF_EVEN") =>
multiplyDTO(dto, k, rounding, fallbackCurrency),
[fallbackCurrency]
);
const percentage = React.useCallback(
(dto: MoneyDTO, p: number, rounding: Dinero.RoundingMode = "HALF_EVEN") =>
percentageDTO(dto, p, rounding, fallbackCurrency),
[fallbackCurrency]
);
// Formatos
const formatCurrency = React.useCallback(
(dto: MoneyDTO, loc?: string) => formatDTO(dto, loc ?? locale),
[locale]
);
const parse = React.useCallback((text: string): number | null => parseLocaleNumber(text), []);
// Estado
const isZero = React.useCallback((dto?: MoneyDTO | null) => toNumber(dto) === 0, [toNumber]);
return React.useMemo(
() => ({
toNumber,
fromNumber,
add,
sub,
multiply,
percentage,
formatCurrency,
parse,
isZero,
roundToScale,
stepNumber,
stripCurrencySymbols,
toDinero: (dto: MoneyDTO) => dineroFromDTO(dto, fallbackCurrency),
fromDinero: dtoFromDinero,
locale,
fallbackCurrency,
defaultScale,
}),
[
toNumber,
fromNumber,
add,
sub,
multiply,
percentage,
formatCurrency,
parse,
isZero,
fallbackCurrency,
locale,
defaultScale,
]
);
}

View File

@ -1,14 +1,6 @@
import type { MoneyDTO } from "@erp/core/common";
import type { Currency } from "dinero.js";
import * as React from "react";
import {
dineroFromDTO,
dtoFromDinero,
formatDTO,
multiplyDTO,
percentageDTO,
sumDTO,
} from "../../common/helpers";
import { useTranslation } from "../i18n";
export type { Currency };
@ -131,7 +123,7 @@ export function useMoney(overrides?: {
[fallbackCurrency]
);
// Operaciones (dinero.js via helpers)
/* // Operaciones (dinero.js via helpers)
const add = React.useCallback(
(a: MoneyDTO, b: MoneyDTO): MoneyDTO => sumDTO([a, b], fallbackCurrency),
[fallbackCurrency]
@ -149,7 +141,7 @@ export function useMoney(overrides?: {
(dto: MoneyDTO, p: number, rounding: Dinero.RoundingMode = "HALF_EVEN") =>
percentageDTO(dto, p, rounding, fallbackCurrency),
[fallbackCurrency]
);
); */
// Estado/Comparaciones
const isZero = React.useCallback((dto?: MoneyDTO | null) => toNumber(dto) === 0, [toNumber]);
@ -183,10 +175,10 @@ export function useMoney(overrides?: {
toApi,
// Operaciones
add,
sub,
multiply,
percentage,
//add,
//sub,
//multiply,
//percentage,
// Estado/ayudas
isZero,
@ -202,8 +194,8 @@ export function useMoney(overrides?: {
fallbackCurrency,
defaultScale,
// Factory Dinero si se necesita en algún punto de bajo nivel:
toDinero: (dto: MoneyDTO) => dineroFromDTO(dto, fallbackCurrency),
fromDinero: dtoFromDinero,
//toDinero: (dto: MoneyDTO) => dineroFromDTO(dto, fallbackCurrency),
//fromDinero: dtoFromDinero,
}),
[
toNumber,
@ -214,10 +206,10 @@ export function useMoney(overrides?: {
parse,
fromApi,
toApi,
add,
sub,
multiply,
percentage,
//add,
//sub,
//multiply,
//percentage,
isZero,
sameCurrency,
stepNumber,

View File

@ -21,5 +21,6 @@ export function usePercentage() {
maximumFractionDigits: 2,
})}%`;
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
return useMemo(() => ({ toNumber, fromNumber, format }), []);
}

View File

@ -36,7 +36,6 @@ export class CustomerInvoiceFullPresenter extends Presenter<
);
const invoiceTaxes = invoice.getTaxes().map((taxItem) => {
console.log(taxItem);
return {
tax_code: taxItem.tax.code,
taxable_amount: taxItem.taxableAmount.toObjectString(),
@ -44,8 +43,6 @@ export class CustomerInvoiceFullPresenter extends Presenter<
};
});
console.log(invoiceTaxes);
return {
id: invoice.id.toString(),
company_id: invoice.companyId.toString(),

View File

@ -225,7 +225,12 @@ export class CustomerInvoice
const itemTaxes = this.items.getTaxesAmountByTaxes();
for (const taxItem of itemTaxes) {
amount = amount.add(taxItem.taxesAmount);
amount = amount.add(
InvoiceAmount.create({
value: taxItem.taxesAmount.convertScale(2).value,
currency_code: this.currencyCode.code,
}).data
);
}
return amount;
}

View File

@ -1,3 +1,4 @@
import { MoneySchema, PercentageSchema, QuantitySchema } from "@erp/core";
import { z } from "zod/v4";
export const UpdateCustomerInvoiceByIdParamsRequestSchema = z.object({
@ -18,6 +19,20 @@ export const UpdateCustomerInvoiceByIdRequestSchema = z.object({
language_code: z.string().optional(),
currency_code: z.string().optional(),
items: z.array(
z.object({
is_non_valued: z.string().optional(),
description: z.string().optional(),
quantity: QuantitySchema.optional(),
unit_amount: MoneySchema.optional(),
discount_percentage: PercentageSchema.optional(),
tax_codes: z.array(z.string()).default([]),
})
),
});
export type UpdateCustomerInvoiceByIdRequestDTO = Partial<

View File

@ -57,7 +57,7 @@ export const GetCustomerInvoiceByIdResponseSchema = z.object({
items: z.array(
z.object({
id: z.uuid(),
isNonValued: z.string(),
is_non_valued: z.string(),
position: z.string(),
description: z.string(),
quantity: QuantitySchema,

View File

@ -114,6 +114,11 @@
"placeholder": "Select a date",
"description": "Invoice operation date"
},
"reference": {
"label": "Reference",
"placeholder": "Reference of the invoice",
"description": "Reference of the invoice"
},
"description": {
"label": "Description",
"placeholder": "Description of the invoice",

View File

@ -106,6 +106,12 @@
"placeholder": "Selecciona una fecha",
"description": "Fecha de la operación de la factura"
},
"reference": {
"label": "Referencia",
"placeholder": "Referencia de la factura",
"description": "Referencia de la factura"
},
"description": {
"label": "Descripción",
"placeholder": "Descripción de la factura",

View File

@ -1,6 +1,5 @@
import { PropsWithChildren } from "react";
import { InvoiceProvider } from "../context";
export const CustomerInvoicesLayout = ({ children }: PropsWithChildren) => {
return <InvoiceProvider>{children}</InvoiceProvider>;
return <section>{children}</section>;
};

View File

@ -10,9 +10,7 @@ import {
import { useCallback, useMemo, useState } from "react";
import { MoneyDTO } from "@erp/core";
import { formatDate } from "@erp/core/client";
import { useMoney } from '@erp/core/hooks';
import { ErrorOverlay } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { AgGridReact } from "ag-grid-react";
@ -26,7 +24,7 @@ import { CustomerInvoiceStatusBadge } from "./customer-invoice-status-badge";
export const CustomerInvoicesListGrid = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { formatCurrency } = useMoney();
//const { formatCurrency } = useMoney();
const {
data: invoices,
@ -96,10 +94,10 @@ export const CustomerInvoicesListGrid = () => {
field: "taxable_amount",
headerName: t("pages.list.grid_columns.taxable_amount"),
type: "rightAligned",
valueFormatter: (params: ValueFormatterParams) => {
/*valueFormatter: (params: ValueFormatterParams) => {
const raw: MoneyDTO | null = params.value;
return raw ? formatCurrency(raw) : "—";
},
},*/
cellClass: "tabular-nums",
minWidth: 130,
},
@ -107,10 +105,10 @@ export const CustomerInvoicesListGrid = () => {
field: "taxes_amount",
headerName: t("pages.list.grid_columns.taxes_amount"),
type: "rightAligned",
valueFormatter: (params: ValueFormatterParams) => {
/*valueFormatter: (params: ValueFormatterParams) => {
const raw: MoneyDTO | null = params.value;
return raw ? formatCurrency(raw) : "—";
},
},*/
cellClass: "tabular-nums",
minWidth: 130,
},
@ -118,10 +116,10 @@ export const CustomerInvoicesListGrid = () => {
field: "total_amount",
headerName: t("pages.list.grid_columns.total_amount"),
type: "rightAligned",
valueFormatter: (params: ValueFormatterParams) => {
/*valueFormatter: (params: ValueFormatterParams) => {
const raw: MoneyDTO | null = params.value;
return raw ? formatCurrency(raw) : "—";
},
},*/
cellClass: "tabular-nums font-semibold",
minWidth: 140,
},

View File

@ -1,18 +1,19 @@
import { FieldErrors, useFormContext } from "react-hook-form";
import { FormDebug } from "@erp/core/components";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@repo/shadcn-ui/components';
import { useTranslation } from "../../i18n";
import { CustomerInvoiceFormData } from "../../schemas";
import { InvoiceFormData } from "../../schemas";
import { InvoiceBasicInfoFields } from "./invoice-basic-info-fields";
import { InvoiceItems } from "./invoice-items-editor";
import { InvoiceNotes } from './invoice-tax-notes';
import { InvoiceTaxSummary } from "./invoice-tax-summary";
import { InvoiceTotals } from "./invoice-totals";
import { InvoiceRecipient } from "./recipient";
interface CustomerInvoiceFormProps {
formId: string;
onSubmit: (data: CustomerInvoiceFormData) => void;
onError: (errors: FieldErrors<CustomerInvoiceFormData>) => void;
onSubmit: (data: InvoiceFormData) => void;
onError: (errors: FieldErrors<InvoiceFormData>) => void;
className: string;
}
@ -23,7 +24,7 @@ export const CustomerInvoiceEditForm = ({
className,
}: CustomerInvoiceFormProps) => {
const { t } = useTranslation();
const form = useFormContext<CustomerInvoiceFormData>();
const form = useFormContext<InvoiceFormData>();
return (
<form noValidate id={formId} onSubmit={form.handleSubmit(onSubmit, onError)}>
@ -31,24 +32,35 @@ export const CustomerInvoiceEditForm = ({
<div className='w-full'>
<FormDebug />
</div>
<div className='mx-auto grid w-full grid-cols-1 gap-6 lg:grid-flow-col-dense lg:grid-cols-2 items-stretch'>
<div className='lg:col-start-1 space-y-6'>
<InvoiceBasicInfoFields />
</div>
<div className="w-full gap-6 grid grid-cols-1 mx-auto">
<ResizablePanelGroup direction="horizontal" className="mx-auto grid w-full grid-cols-1 gap-6 lg:grid-cols-3 items-stretch">
<ResizablePanel className="lg:col-start-1 lg:col-span-2 h-full" defaultSize={65}>
<InvoiceBasicInfoFields className="h-full flex flex-col" />
<div className='space-y-6 '>
<InvoiceRecipient />
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel className="lg:col-end-4 h-full" defaultSize={35}>
<InvoiceRecipient className="h-full flex flex-col" />
<div className='lg:col-start-1 lg:col-span-2 space-y-6'>
<InvoiceItems />
</div>
</ResizablePanel>
</ResizablePanelGroup>
<div className='lg:col-start-1 space-y-6'>
<InvoiceTaxSummary />
</div>
<div className='space-y-6 '>
<InvoiceTotals />
<div className="mx-auto grid w-full grid-cols-1 gap-6 lg:grid-cols-3 items-stretch">
<div className="lg:col-start-1 lg:col-span-full h-full">
{/* <InvoiceItems className="h-full flex flex-col"/> */}
</div>
<div className="lg:col-start-1 h-full">
<InvoiceNotes className="h-full flex flex-col" />
</div>
<div className="h-full">
<InvoiceTaxSummary className="h-full flex flex-col" />
</div>
<div className="h-full">
<InvoiceTotals className="h-full flex flex-col" />
</div>
</div>
</div>
</section>

View File

@ -5,50 +5,38 @@ import {
FieldGroup,
Fieldset,
Legend,
TextAreaField,
TextField,
TextField
} from "@repo/rdx-ui/components";
import { FileTextIcon } from "lucide-react";
import { useFormContext, useWatch } from "react-hook-form";
import { ComponentProps } from 'react';
import { useFormContext } from "react-hook-form";
import { useTranslation } from "../../i18n";
import { CustomerInvoiceFormData } from "../../schemas";
import { InvoiceFormData } from "../../schemas";
export const InvoiceBasicInfoFields = () => {
export const InvoiceBasicInfoFields = (props: ComponentProps<"fieldset">) => {
const { t } = useTranslation();
const { control } = useFormContext<CustomerInvoiceFormData>();
const { control } = useFormContext<InvoiceFormData>();
const status = useWatch({
control,
name: "status",
defaultValue: "",
});
return (
<Fieldset>
<Fieldset {...props}>
<Legend className='flex items-center gap-2 text-foreground'>
<FileTextIcon className='h-5 w-5' /> {t("form_groups.basic_into.title")}
<FileTextIcon className='size-5' /> {t("form_groups.basic_into.title")}
</Legend>
<Description>{t("form_groups.basic_into.description")}</Description>
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-3'>
<TextField
control={control}
name='invoice_number'
readOnly
label={t("form_fields.invoice_number.label")}
placeholder={t("form_fields.invoice_number.placeholder")}
description={t("form_fields.invoice_number.description")}
/>
<TextField
typePreset='text'
control={control}
name='series'
label={t("form_fields.series.label")}
placeholder={t("form_fields.series.placeholder")}
description={t("form_fields.series.description")}
/>
<Field className='lg:col-span-2 2xl:col-span-1'>
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-4'>
<Field >
<TextField
control={control}
name='invoice_number'
readOnly
label={t("form_fields.invoice_number.label")}
placeholder={t("form_fields.invoice_number.placeholder")}
description={t("form_fields.invoice_number.description")}
/>
</Field>
<Field>
<DatePickerInputField
control={control}
name='invoice_date'
@ -60,7 +48,18 @@ export const InvoiceBasicInfoFields = () => {
/>
</Field>
<Field className='lg:col-span-2 lg:col-start-1 2xl:col-auto'>
<Field >
<TextField
typePreset='text'
control={control}
name='series'
label={t("form_fields.series.label")}
placeholder={t("form_fields.series.placeholder")}
description={t("form_fields.series.description")}
/>
</Field>
<Field>
<DatePickerInputField
control={control}
numberOfMonths={2}
@ -70,25 +69,30 @@ export const InvoiceBasicInfoFields = () => {
description={t("form_fields.operation_date.description")}
/>
</Field>
<TextField
typePreset='text'
maxLength={256}
className='lg:col-span-2'
control={control}
name='description'
label={t("form_fields.description.label")}
placeholder={t("form_fields.description.placeholder")}
description={t("form_fields.description.description")}
/>
<TextAreaField
maxLength={1024}
className='lg:col-span-full'
control={control}
name='notes'
label={t("form_fields.notes.label")}
placeholder={t("form_fields.notes.placeholder")}
description={t("form_fields.notes.description")}
/>
<Field className='lg:col-start-1 lg:col-span-1'>
<TextField
typePreset='text'
maxLength={256}
control={control}
name='reference'
label={t("form_fields.reference.label")}
placeholder={t("form_fields.reference.placeholder")}
description={t("form_fields.reference.description")}
/>
</Field>
<Field className='lg:col-span-3'>
<TextField
typePreset='text'
maxLength={256}
control={control}
name='description'
label={t("form_fields.description.label")}
placeholder={t("form_fields.description.placeholder")}
description={t("form_fields.description.description")}
/>
</Field>
</FieldGroup>
</Fieldset>
);

View File

@ -1,19 +1,21 @@
import { Card, CardContent, CardHeader, CardTitle } from "@repo/shadcn-ui/components";
import { Package } from "lucide-react";
import { cn } from '@repo/shadcn-ui/lib/utils';
import { ComponentProps } from 'react';
import { useTranslation } from '../../i18n';
import { ItemsEditor } from "./items";
export const InvoiceItems = () => {
export const InvoiceItems = ({ className, ...props }: ComponentProps<"div">) => {
const { t } = useTranslation();
return (
<Card className='border-none shadow-none'>
<Card className={cn("border-none shadow-none", className)} {...props}>
<CardHeader>
<div className='flex items-center justify-between'>
<CardTitle className='text-lg font-medium flex items-center gap-2'>
<Package className='h-5 w-5' />
<Package className='size-5' />
{t('form_groups.items.title')}
</CardTitle>
</div>

View File

@ -0,0 +1,32 @@
import { Description, FieldGroup, Fieldset, Legend, TextAreaField } from "@repo/rdx-ui/components";
import { StickyNoteIcon } from "lucide-react";
import { ComponentProps } from 'react';
import { useFormContext } from "react-hook-form";
import { useTranslation } from "../../i18n";
import { InvoiceFormData } from "../../schemas";
export const InvoiceNotes = (props: ComponentProps<"fieldset">) => {
const { t } = useTranslation();
const { control } = useFormContext<InvoiceFormData>();
return (
<Fieldset {...props}>
<Legend className='flex items-center gap-2 text-foreground'>
<StickyNoteIcon className='size-5' /> {t("form_groups.basic_into.title")}
</Legend>
<Description>{t("form_groups.basic_into.description")}</Description>
<FieldGroup className='grid grid-cols-1 gap-x-6 h-full min-h-0'>
<TextAreaField
maxLength={1024}
className='lg:col-span-full h-full'
control={control}
name='notes'
label={t("form_fields.notes.label")}
placeholder={t("form_fields.notes.placeholder")}
description={t("form_fields.notes.description")}
/>
</FieldGroup>
</Fieldset>
);
};

View File

@ -1,13 +1,14 @@
import { Description, FieldGroup, Fieldset, Legend } from "@repo/rdx-ui/components";
import { Badge } from "@repo/shadcn-ui/components";
import { ReceiptIcon } from "lucide-react";
import { ComponentProps } from 'react';
import { useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "../../i18n";
import { CustomerInvoiceFormData } from "../../schemas";
import { InvoiceFormData } from "../../schemas";
export const InvoiceTaxSummary = () => {
export const InvoiceTaxSummary = (props: ComponentProps<"fieldset">) => {
const { t } = useTranslation();
const { control } = useFormContext<CustomerInvoiceFormData>();
const { control, getValues } = useFormContext<InvoiceFormData>();
const taxes = useWatch({
control,
@ -15,79 +16,43 @@ export const InvoiceTaxSummary = () => {
defaultValue: [],
});
const formatCurrency = (amount: {
value: string;
scale: string;
currency_code: string;
}) => {
const { currency_code, value, scale } = amount;
console.log(getValues());
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("es-ES", {
style: "currency",
currency: currency_code,
minimumFractionDigits: Number(scale),
maximumFractionDigits: Number(scale),
compactDisplay: "short",
currencyDisplay: "symbol",
}).format(Number(value) / 10 ** Number(scale));
currency: "EUR",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
};
// Mock tax data
const mockTaxes = [
{
tax_code: "IVA 21%",
taxable_amount: {
value: "10000",
scale: "2",
currency_code: "EUR",
},
taxes_amount: {
value: "21000",
scale: "2",
currency_code: "EUR",
},
},
{
tax_code: "IVA 10%",
taxable_amount: {
value: "50000",
scale: "2",
currency_code: "EUR",
},
taxes_amount: {
value: "5000",
scale: "2",
currency_code: "EUR",
},
},
];
const displayTaxes = taxes ? taxes : mockTaxes;
const displayTaxes = taxes || [];
return (
<Fieldset>
<Fieldset {...props}>
<Legend className='flex items-center gap-2 text-foreground'>
<ReceiptIcon className='h-5 w-5' /> {t("form_groups.tax_resume.title")}
<ReceiptIcon className='size-5' /> {t("form_groups.tax_resume.title")}
</Legend>
<Description>{t("form_groups.tax_resume.description")}</Description>
<FieldGroup className='grid grid-cols-1'>
<div className='space-y-3'>
{displayTaxes.map((tax, index) => (
<div key={`${tax.tax_code}-${index}`} className='border rounded-lg p-3'>
<div className='flex items-center justify-between mb-2'>
<div key={`${tax.tax_code}-${index}`} className='border rounded-lg p-3 space-y-2'>
<div className='flex items-center justify-between mb-2 '>
<Badge variant='secondary' className='text-xs'>
{tax.tax_code}
{tax.tax_label}
</Badge>
</div>
<div className='space-y-1 text-sm'>
<div className='space-y-2 text-sm'>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Base Imponible:</span>
<span className='font-medium'>{formatCurrency(tax.taxable_amount)}</span>
<span className='text-muted-foreground'>Base para el impuesto:</span>
<span className='font-medium tabular-nums'>{formatCurrency(tax.taxable_amount)}</span>
</div>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Importe Impuesto:</span>
<span className='font-medium text-primary'>
<span className='text-muted-foreground'>Importe de impuesto:</span>
<span className='font-medium text-primary tabular-nums'>
{formatCurrency(tax.taxes_amount)}
</span>
</div>

View File

@ -1,18 +1,18 @@
import { Description, FieldGroup, Fieldset, Legend } from "@repo/rdx-ui/components";
import { Input, Label, Separator } from "@repo/shadcn-ui/components";
import { CalculatorIcon } from "lucide-react";
import { useState } from "react";
import { useFormContext } from "react-hook-form";
import { ComponentProps } from 'react';
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "../../i18n";
import { CustomerInvoiceFormData } from "../../schemas";
import { InvoiceFormData } from "../../schemas";
export const InvoiceTotals = () => {
export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
const { t } = useTranslation();
const { control } = useFormContext<CustomerInvoiceFormData>();
const { control, getValues } = useFormContext<InvoiceFormData>();
//const invoiceFormData = useWatch({ control });
const [invoice, setInvoice] = useState({
/*const [invoice, setInvoice] = useState({
items: [],
subtotal_amount: 0,
discount_percentage: 0,
@ -23,7 +23,7 @@ export const InvoiceTotals = () => {
});
const updateDiscount = (value: number) => {
const subtotal = invoice.items.reduce(
const subtotal = getValues('items.reduce(
(sum: number, item: any) => sum + item.subtotal_amount,
0
);
@ -41,7 +41,7 @@ export const InvoiceTotals = () => {
taxes_amount: taxesAmount,
total_amount: totalAmount,
});
};
};*/
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("es-ES", {
@ -53,9 +53,9 @@ export const InvoiceTotals = () => {
};
return (
<Fieldset>
<Fieldset {...props}>
<Legend className='flex items-center gap-2 text-foreground'>
<CalculatorIcon className='h-5 w-5' /> {t("form_groups.totals.title")}
<CalculatorIcon className='size-5' /> {t("form_groups.totals.title")}
</Legend>
<Description>{t("form_groups.totals.description")}</Description>
@ -63,48 +63,54 @@ export const InvoiceTotals = () => {
<div className='space-y-3'>
<div className='flex justify-between items-center'>
<Label className='text-sm text-muted-foreground'>Subtotal</Label>
<span className='font-medium'>{formatCurrency(invoice.subtotal_amount)}</span>
<span className='font-medium tabular-nums'>{formatCurrency(getValues('subtotal_amount'))}</span>
</div>
<div className='flex justify-between items-center gap-4'>
<Label className='text-sm text-muted-foreground'>Descuento Global</Label>
<Label className='text-sm text-muted-foreground'>Descuento (%)</Label>
<div className='flex items-center gap-2'>
<Input
type='number'
step='0.01'
value={invoice.discount_percentage}
onChange={(e) => updateDiscount(Number.parseFloat(e.target.value) || 0)}
className='w-20 text-right'
<Controller
control={control}
name={"discount_percentage"}
render={({
field, fieldState
}) => (<Input
readOnly={false}
value={field.value}
onChange={field.onChange}
disabled={fieldState.isValidating}
onBlur={field.onBlur}
className='w-20 text-right'
/>)}
/>
<span className='text-sm text-muted-foreground'>%</span>
</div>
</div>
<div className='flex justify-between items-center'>
<Label className='text-sm text-muted-foreground'>Importe Descuento</Label>
<span className='font-medium text-destructive'>
-{formatCurrency(invoice.discount_amount)}
<Label className='text-sm text-muted-foreground'>Importe del descuento</Label>
<span className='font-medium text-destructive tabular-nums'>
-{formatCurrency(getValues("discount_amount"))}
</span>
</div>
<Separator />
<div className='flex justify-between items-center'>
<Label className='text-sm text-muted-foreground'>Base Imponible</Label>
<span className='font-medium'>{formatCurrency(invoice.taxable_amount)}</span>
<Label className='text-sm text-muted-foreground'>Base imponible</Label>
<span className='font-medium tabular-nums'>{formatCurrency(getValues('taxable_amount'))}</span>
</div>
<div className='flex justify-between items-center'>
<Label className='text-sm text-muted-foreground'>Total Impuestos</Label>
<span className='font-medium'>{formatCurrency(invoice.taxes_amount)}</span>
<Label className='text-sm text-muted-foreground'>Total de impuestos</Label>
<span className='font-medium tabular-nums'>{formatCurrency(getValues('taxes_amount'))}</span>
</div>
<Separator />
<div className='flex justify-between items-center'>
<Label className='text-lg font-semibold'>Total Factura</Label>
<span className='text-xl font-bold text-primary'>
{formatCurrency(invoice.total_amount)}
<Label className='text-lg font-semibold'>Total de la factura</Label>
<span className='text-xl font-bold text-primary tabular-nums'>
{formatCurrency(getValues('total_amount'))}
</span>
</div>
</div>

View File

@ -3,7 +3,7 @@ import { Trash2 } from "lucide-react";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "../../../i18n";
import { CustomerInvoiceFormData } from "../../../schemas";
import { InvoiceFormData } from "../../../schemas";
import { CustomerInvoiceTaxesMultiSelect } from '../../customer-invoice-taxes-multi-select';
import { CustomItemViewProps } from "./types";
@ -20,7 +20,7 @@ const formatCurrency = (amount: number) => {
export const BlocksView = ({ items, removeItem, updateItem }: BlocksViewProps) => {
const { t } = useTranslation();
const { control } = useFormContext<CustomerInvoiceFormData>();
const { control } = useFormContext<InvoiceFormData>();
return (
<div className='space-y-4'>

View File

@ -2,7 +2,7 @@ import { Button, Checkbox, TableCell, TableRow, Tooltip, TooltipContent, Tooltip
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, Trash2Icon } from "lucide-react";
import { Control, Controller } from "react-hook-form";
import { useTranslation } from '../../../i18n';
import { CustomerInvoiceItemFormData } from '../../../schemas';
import { InvoiceItemFormData } from '../../../schemas';
import { CustomerInvoiceTaxesMultiSelect } from '../../customer-invoice-taxes-multi-select';
import { AmountDTOInputField } from './amount-dto-input-field';
import { HoverCardTotalsSummary } from './hover-card-total-summary';
@ -11,7 +11,7 @@ import { QuantityDTOInputField } from './quantity-dto-input-field';
export type ItemRowProps = {
control: Control,
item: CustomerInvoiceItemFormData;
item: InvoiceItemFormData;
rowIndex: number;
isSelected: boolean;
isFirst: boolean;
@ -47,7 +47,7 @@ export const ItemRow = ({
<div className='h-5'>
<Checkbox
aria-label={`Seleccionar fila ${rowIndex + 1}`}
className="block h-5 w-5 leading-none align-middle"
className="block size-5 leading-none align-middle"
checked={isSelected}
onCheckedChange={onToggleSelect}
disabled={readOnly}

View File

@ -4,14 +4,14 @@ import * as React from "react";
import { useFormContext } from "react-hook-form";
import { useItemsTableNavigation } from '../../../hooks';
import { useTranslation } from '../../../i18n';
import { CustomerInvoiceItemFormData, defaultCustomerInvoiceItemFormData } from '../../../schemas';
import { InvoiceItemFormData, defaultCustomerInvoiceItemFormData } from '../../../schemas';
import { ItemRow } from './item-row';
import { ItemsEditorToolbar } from './items-editor-toolbar';
import { LastCellTabHook } from './last-cell-tab-hook';
interface ItemsEditorProps {
value?: CustomerInvoiceItemFormData[];
onChange?: (items: CustomerInvoiceItemFormData[]) => void;
value?: InvoiceItemFormData[];
onChange?: (items: InvoiceItemFormData[]) => void;
readOnly?: boolean;
}

View File

@ -20,7 +20,7 @@ import { useMoney } from '@erp/core/hooks';
import { useEffect, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { useTranslation } from '../../../i18n';
import { CustomerInvoiceItemFormData } from '../../../schemas';
import { InvoiceItemFormData } from '../../../schemas';
import { HoverCardTotalsSummary } from './hover-card-total-summary';
import { CustomItemViewProps } from "./types";
@ -28,25 +28,25 @@ export interface TableViewProps extends CustomItemViewProps { }
export const TableView = ({ items, actions }: TableViewProps) => {
const { t } = useTranslation();
const { control } = useFormContext<CustomerInvoiceItemFormData>();
const { control } = useFormContext<InvoiceItemFormData>();
const { format } = useMoney();
const [lines, setLines] = useState<CustomerInvoiceItemFormData[]>(items);
const [lines, setLines] = useState<InvoiceItemFormData[]>(items);
useEffect(() => {
setLines(items)
}, [items])
// Mantiene sincronía con el formulario padre
const updateItems = (updated: CustomerInvoiceItemFormData[]) => {
const updateItems = (updated: InvoiceItemFormData[]) => {
setLines(updated);
onItemsChange(updated);
};
/** 🔹 Actualiza una fila con recalculo */
const updateItem = (index: number, patch: Partial<CustomerInvoiceItemFormData>) => {
const updateItem = (index: number, patch: Partial<InvoiceItemFormData>) => {
const newItems = [...lines];
const merged = { ...newItems[index], ...patch };
newItems[index] = calculateItemAmounts(merged as CustomerInvoiceItemFormData);
newItems[index] = calculateItemAmounts(merged as InvoiceItemFormData);
updateItems(newItems);
};
@ -79,8 +79,8 @@ export const TableView = ({ items, actions }: TableViewProps) => {
/** 🔹 Añade una nueva línea vacía */
const addNewItem = () => {
const newItem: CustomerInvoiceItemFormData = {
isNonValued: false,
const newItem: InvoiceItemFormData = {
is_non_valued: false,
description: "",
quantity: { value: "0", scale: "2" },
unit_amount: { value: "0", scale: "2", currency_code: "EUR" },

View File

@ -2,10 +2,11 @@ import { Description, FieldGroup, Fieldset, Legend } from "@repo/rdx-ui/componen
import { useFormContext } from "react-hook-form";
import { UserIcon } from "lucide-react";
import { ComponentProps } from 'react';
import { useTranslation } from "../../../i18n";
import { RecipientModalSelectorField } from "./recipient-modal-selector-field";
export const InvoiceRecipient = () => {
export const InvoiceRecipient = (props: ComponentProps<"fieldset">) => {
const { t } = useTranslation();
const { control, getValues } = useFormContext();
@ -13,7 +14,7 @@ export const InvoiceRecipient = () => {
const recipient = getValues('recipient');
return (
<Fieldset>
<Fieldset {...props}>
<Legend className='flex items-center gap-2 text-foreground'>
<UserIcon className='size-5' /> {t("form_groups.customer.title")}
</Legend>

View File

@ -2,6 +2,7 @@ import { PropsWithChildren, createContext, useCallback, useContext, useMemo, use
export type InvoiceContextValue = {
company_id: string;
status: string;
currency_code: string;
language_code: string;
is_proforma: boolean;
@ -15,13 +16,14 @@ const InvoiceContext = createContext<InvoiceContextValue | null>(null);
export interface InvoiceProviderParams {
company_id: string;
status: string; // default "draft"
language_code?: string; // default "es"
currency_code?: string; // default "EUR"
is_proforma?: boolean; // default 'true'
children: React.ReactNode;
}
export const InvoiceProvider = ({ company_id, language_code: initialLang = "es",
export const InvoiceProvider = ({ company_id, status: initialStatus = "draft", language_code: initialLang = "es",
currency_code: initialCurrency = "EUR",
is_proforma: initialProforma = true, children }: PropsWithChildren<InvoiceProviderParams>) => {
@ -29,6 +31,7 @@ export const InvoiceProvider = ({ company_id, language_code: initialLang = "es",
const [language_code, setLanguage] = useState(initialLang);
const [currency_code, setCurrency] = useState(initialCurrency);
const [is_proforma, setIsProforma] = useState(initialProforma);
const [status] = useState(initialStatus);
// Callbacks memoizados
const setLanguageMemo = useCallback((language_code: string) => setLanguage(language_code), []);
@ -39,6 +42,7 @@ export const InvoiceProvider = ({ company_id, language_code: initialLang = "es",
return {
company_id,
status,
language_code,
currency_code,
is_proforma,

View File

@ -15,7 +15,7 @@ const CustomerInvoiceAdd = lazy(() =>
import("./pages").then((m) => ({ default: m.CustomerInvoiceCreate }))
);
const CustomerInvoiceUpdate = lazy(() =>
import("./pages").then((m) => ({ default: m.CustomerInvoiceUpdatePage }))
import("./pages").then((m) => ({ default: m.InvoiceUpdatePage }))
);
export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[] => {

View File

@ -1,7 +1,7 @@
import { MoneyDTO } from "@erp/core";
import { useMoney, usePercentage, useQuantity } from "@erp/core/hooks";
import { useMemo } from "react";
import { CustomerInvoiceItemFormData } from "../../schemas";
import { InvoiceItemFormData } from "../../schemas";
/**
* Calcula totales derivados de un ítem de factura
@ -30,7 +30,7 @@ export type InvoiceItemTotals = Readonly<{
/**
* Calcula totales derivados de una línea de factura usando tus hooks de Money/Quantity/Percentage.
*/
export function useCalcInvoiceItemTotals(item?: CustomerInvoiceItemFormData): InvoiceItemTotals {
export function useCalcInvoiceItemTotals(item?: InvoiceItemFormData): InvoiceItemTotals {
const moneyHelper = useMoney();
const qtyHelper = useQuantity();
const pctHelper = usePercentage();

View File

@ -1,7 +1,7 @@
import { MoneyDTO } from "@erp/core";
import { useMoney, usePercentage, useQuantity } from "@erp/core/hooks";
import { useMemo } from "react";
import { CustomerInvoiceItemFormData } from "../../schemas";
import { InvoiceItemFormData } from "../../schemas";
export type InvoiceTotals = Readonly<{
subtotal: number;
@ -23,9 +23,7 @@ export type InvoiceTotals = Readonly<{
/**
* Calcula los totales generales de la factura a partir de sus líneas.
*/
export function useCalcInvoiceTotals(
items: CustomerInvoiceItemFormData[] | undefined
): InvoiceTotals {
export function useCalcInvoiceTotals(items: InvoiceItemFormData[] | undefined): InvoiceTotals {
const money = useMoney();
const qty = useQuantity();
const pct = usePercentage();

View File

@ -2,13 +2,13 @@ import { areMoneyDTOEqual } from "@erp/core";
import { useMoney, usePercentage, useQuantity } from "@erp/core/hooks";
import * as React from "react";
import { UseFormReturn } from "react-hook-form";
import { CustomerInvoiceFormData, CustomerInvoiceItemFormData } from "../../schemas";
import { InvoiceFormData, InvoiceItemFormData } from "../../schemas";
/**
* Hook que recalcula automáticamente los totales de cada línea
* y los totales generales de la factura cuando cambian los valores relevantes.
*/
export function useInvoiceAutoRecalc(form: UseFormReturn<CustomerInvoiceFormData>) {
export function useInvoiceAutoRecalc(form: UseFormReturn<InvoiceFormData>) {
const {
watch,
setValue,
@ -22,7 +22,7 @@ export function useInvoiceAutoRecalc(form: UseFormReturn<CustomerInvoiceFormData
// Cálculo de una línea
const calculateItemTotals = React.useCallback(
(item: CustomerInvoiceItemFormData) => {
(item: InvoiceItemFormData) => {
if (!item) {
const zero = moneyHelper.fromNumber(0);
return {
@ -65,7 +65,7 @@ export function useInvoiceAutoRecalc(form: UseFormReturn<CustomerInvoiceFormData
// Cálculo de los totales de la factura a partir de los conceptos
const calculateInvoiceTotals = React.useCallback(
(items: CustomerInvoiceItemFormData[]) => {
(items: InvoiceItemFormData[]) => {
let subtotalDTO = moneyHelper.fromNumber(0);
let discountTotalDTO = moneyHelper.fromNumber(0);
let taxableBaseDTO = moneyHelper.fromNumber(0);
@ -106,7 +106,7 @@ export function useInvoiceAutoRecalc(form: UseFormReturn<CustomerInvoiceFormData
formData.items.forEach((item, i) => {
if (!item) return;
const typedItem = item as CustomerInvoiceItemFormData;
const typedItem = item as InvoiceItemFormData;
const totals = calculateItemTotals(typedItem);
const current = getValues(`items.${i}.total_amount`);
@ -120,7 +120,7 @@ export function useInvoiceAutoRecalc(form: UseFormReturn<CustomerInvoiceFormData
// Recalcular importes totales de la factura y
// actualizar valores calculados.
const typedItems = formData.items as CustomerInvoiceItemFormData[];
const typedItems = formData.items as InvoiceItemFormData[];
const totalsGlobal = calculateInvoiceTotals(typedItems);
setValue("subtotal_amount", totalsGlobal.subtotalDTO);
@ -136,7 +136,7 @@ export function useInvoiceAutoRecalc(form: UseFormReturn<CustomerInvoiceFormData
const fieldName = name.split(".")[2];
if (["quantity", "unit_amount", "discount_percentage"].includes(fieldName)) {
const typedItem = formData.items[index] as CustomerInvoiceItemFormData;
const typedItem = formData.items[index] as InvoiceItemFormData;
if (!typedItem) return;
// Recalcular línea
@ -152,7 +152,7 @@ export function useInvoiceAutoRecalc(form: UseFormReturn<CustomerInvoiceFormData
// Recalcular importes totales de la factura y
// actualizar valores calculados.
const typedItems = formData.items as CustomerInvoiceItemFormData[];
const typedItems = formData.items as InvoiceItemFormData[];
const totalsGlobal = calculateInvoiceTotals(typedItems);
setValue("subtotal_amount", totalsGlobal.subtotalDTO);

View File

@ -1,7 +1,7 @@
export * from "./calcs";
export * from "./use-create-customer-invoice-mutation";
export * from "./use-customer-invoice-query";
export * from "./use-customer-invoices-query";
export * from "./use-detail-columns";
export * from "./use-invoice-query";
export * from "./use-items-table-navigation";
export * from "./use-update-customer-invoice-mutation";

View File

@ -2,10 +2,10 @@ import { useDataSource } from "@erp/core/hooks";
import { UniqueID, ValidationErrorCollection } from "@repo/rdx-ddd";
import { DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { CreateCustomerInvoiceRequestSchema } from "../../common";
import { CustomerInvoice, CustomerInvoiceFormData } from "../schemas";
import { CustomerInvoice, InvoiceFormData } from "../schemas";
type CreateCustomerInvoicePayload = {
data: CustomerInvoiceFormData;
data: InvoiceFormData;
};
export const useCreateCustomerInvoiceMutation = () => {

View File

@ -9,7 +9,7 @@ type CustomerInvoiceQueryOptions = {
enabled?: boolean;
};
export function useCustomerInvoiceQuery(invoiceId?: string, options?: CustomerInvoiceQueryOptions) {
export function useInvoiceQuery(invoiceId?: string, options?: CustomerInvoiceQueryOptions) {
const dataSource = useDataSource();
const enabled = (options?.enabled ?? true) && !!invoiceId;

View File

@ -5,8 +5,8 @@ import {
UpdateCustomerInvoiceByIdRequestDTO,
UpdateCustomerInvoiceByIdRequestSchema,
} from "../../common";
import { CustomerInvoiceFormData } from "../schemas";
import { CUSTOMER_INVOICE_QUERY_KEY } from "./use-customer-invoice-query";
import { InvoiceFormData } from "../schemas";
import { CUSTOMER_INVOICE_QUERY_KEY } from "./use-invoice-query";
export const CUSTOMER_INVOICES_LIST_KEY = ["customer-invoices"] as const;
@ -14,7 +14,7 @@ type UpdateCustomerInvoiceContext = {};
type UpdateCustomerInvoicePayload = {
id: string;
data: Partial<CustomerInvoiceFormData>;
data: Partial<InvoiceFormData>;
};
export function useUpdateCustomerInvoice() {
@ -23,7 +23,7 @@ export function useUpdateCustomerInvoice() {
const schema = UpdateCustomerInvoiceByIdRequestSchema;
return useMutation<
CustomerInvoiceFormData,
InvoiceFormData,
Error,
UpdateCustomerInvoicePayload,
UpdateCustomerInvoiceContext
@ -53,9 +53,9 @@ export function useUpdateCustomerInvoice() {
}
const updated = await dataSource.updateOne("customer-invoices", invoiceId, data);
return updated as CustomerInvoiceFormData;
return updated as InvoiceFormData;
},
onSuccess: (updated: CustomerInvoiceFormData, variables) => {
onSuccess: (updated: InvoiceFormData, variables) => {
const { id: invoiceId } = variables;
// Refresca inmediatamente el detalle

View File

@ -1 +1 @@
export * from "./customer-invoices-update-page";
export * from "./invoice-update-page";

View File

@ -17,15 +17,16 @@ import {
PageHeader,
} from "../../components";
import { InvoiceProvider } from '../../context';
import { useCustomerInvoiceQuery, useInvoiceAutoRecalc, useUpdateCustomerInvoice } from "../../hooks";
import { useInvoiceQuery, useUpdateCustomerInvoice } from "../../hooks";
import { useTranslation } from "../../i18n";
import {
CustomerInvoiceFormData,
CustomerInvoiceFormSchema,
defaultCustomerInvoiceFormData
InvoiceFormData,
InvoiceFormSchema,
defaultCustomerInvoiceFormData,
invoiceDtoToFormAdapter
} from "../../schemas";
export const CustomerInvoiceUpdatePage = () => {
export const InvoiceUpdatePage = () => {
const invoiceId = useUrlParamId();
const { t } = useTranslation();
const navigate = useNavigate();
@ -36,7 +37,7 @@ export const CustomerInvoiceUpdatePage = () => {
isLoading: isLoadingInvoice,
isError: isLoadError,
error: loadError,
} = useCustomerInvoiceQuery(invoiceId, { enabled: !!invoiceId });
} = useInvoiceQuery(invoiceId, { enabled: !!invoiceId });
// 2) Estado de actualización (mutación)
const {
@ -47,16 +48,17 @@ export const CustomerInvoiceUpdatePage = () => {
} = useUpdateCustomerInvoice();
// 3) Form hook
const form = useHookForm<CustomerInvoiceFormData>({
resolverSchema: CustomerInvoiceFormSchema,
initialValues: (invoiceData as unknown as CustomerInvoiceFormData) ?? defaultCustomerInvoiceFormData,
const form = useHookForm<InvoiceFormData>({
resolverSchema: InvoiceFormSchema,
defaultValues: defaultCustomerInvoiceFormData,
values: invoiceData ? invoiceDtoToFormAdapter.fromDto(invoiceData) : undefined,
disabled: isUpdating,
});
// 4) Activa recálculo automático de los totales de la factura cuando hay algún cambio en importes
useInvoiceAutoRecalc(form);
// useInvoiceAutoRecalc(form);
const handleSubmit = (formData: CustomerInvoiceFormData) => {
const handleSubmit = (formData: InvoiceFormData) => {
const { dirtyFields } = form.formState;
if (!formHasAnyDirty(dirtyFields)) {
@ -74,7 +76,7 @@ export const CustomerInvoiceUpdatePage = () => {
showSuccessToast(t("pages.update.successTitle"), t("pages.update.successMsg"));
// 🔹 limpiar el form e isDirty pasa a false
form.reset(data as unknown as CustomerInvoiceFormData);
form.reset(data as unknown as InvoiceFormData);
},
onError(error) {
showErrorToast(t("pages.update.errorTitle"), error.message);
@ -84,13 +86,13 @@ export const CustomerInvoiceUpdatePage = () => {
};
const handleReset = () =>
form.reset((invoiceData as unknown as CustomerInvoiceFormData) ?? defaultCustomerInvoiceFormData);
form.reset((invoiceData as unknown as InvoiceFormData) ?? defaultCustomerInvoiceFormData);
const handleBack = () => {
navigate(-1);
};
const handleError = (errors: FieldErrors<CustomerInvoiceFormData>) => {
const handleError = (errors: FieldErrors<InvoiceFormData>) => {
console.error("Errores en el formulario:", errors);
// Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
};
@ -136,6 +138,7 @@ export const CustomerInvoiceUpdatePage = () => {
return (
<InvoiceProvider
company_id={invoiceData.company_id}
status={invoiceData.status}
language_code={invoiceData.language_code}
currency_code={invoiceData.currency_code}
>

View File

@ -1,160 +0,0 @@
import { MoneySchema, PercentageSchema, QuantitySchema } from "@erp/core";
import { ArrayElement } from "@repo/rdx-utils";
import { z } from "zod/v4";
export const CustomerInvoiceItemFormSchema = z.object({
isNonValued: z.boolean().optional(),
description: z.string().optional(),
quantity: QuantitySchema.optional(),
unit_amount: MoneySchema.optional(),
subtotal_amount: MoneySchema.optional(),
discount_percentage: PercentageSchema.optional(),
discount_amount: MoneySchema.optional(),
taxable_amount: MoneySchema.optional(),
tax_codes: z.array(z.string()).default([]),
taxes: z
.array(
z.object({
label: z.string(),
percentage: z.number(),
amount: MoneySchema.optional(),
})
)
.optional(),
taxes_amount: MoneySchema.optional(),
total_amount: MoneySchema.optional(),
});
export const CustomerInvoiceFormSchema = z.object({
invoice_number: z.string().optional(),
status: z.string(),
series: z.string().optional(),
invoice_date: z.string().optional(),
operation_date: z.string().optional(),
customer_id: z.string().optional(),
description: z.string().optional(),
notes: z.string().optional(),
language_code: z
.string({
error: "El idioma es obligatorio",
})
.min(1, "Debe indicar un idioma")
.toUpperCase() // asegura mayúsculas
.default("es"),
currency_code: z
.string({
error: "La moneda es obligatoria",
})
.min(1, "La moneda no puede estar vacía")
.toUpperCase() // asegura mayúsculas
.default("EUR"),
/*taxes: z
.array(
z.object({
tax_code: z.string(),
taxable_amount: MoneySchema,
taxes_amount: MoneySchema,
})
)
.optional(),
*/
items: z.array(CustomerInvoiceItemFormSchema).optional(),
subtotal_amount: MoneySchema,
discount_percentage: PercentageSchema,
discount_amount: MoneySchema,
taxable_amount: MoneySchema,
taxes_amount: MoneySchema,
total_amount: MoneySchema,
});
export type CustomerInvoiceFormData = z.infer<typeof CustomerInvoiceFormSchema>;
export type CustomerInvoiceItemFormData = ArrayElement<CustomerInvoiceFormData["items"]>;
export const defaultCustomerInvoiceItemFormData: CustomerInvoiceItemFormData = {
description: "",
quantity: {
value: "",
scale: "2",
},
unit_amount: {
currency_code: "EUR",
value: "",
scale: "4",
},
discount_percentage: {
value: "",
scale: "2",
},
tax_codes: ["iva_21"],
total_amount: {
currency_code: "EUR",
value: "",
scale: "4",
},
};
export const defaultCustomerInvoiceFormData: CustomerInvoiceFormData = {
invoice_number: "",
status: "draft",
series: "",
invoice_date: "",
operation_date: "",
description: "",
notes: "",
language_code: "es",
currency_code: "EUR",
//taxes: [],
items: [],
subtotal_amount: {
currency_code: "EUR",
value: "0",
scale: "2",
},
discount_amount: {
currency_code: "EUR",
value: "0",
scale: "2",
},
discount_percentage: {
value: "0",
scale: "2",
},
taxable_amount: {
currency_code: "EUR",
value: "0",
scale: "2",
},
taxes_amount: {
currency_code: "EUR",
value: "0",
scale: "2",
},
total_amount: {
currency_code: "EUR",
value: "0",
scale: "2",
},
};

View File

@ -1,2 +1,3 @@
export * from "./customer-invoices.api.schema";
export * from "./customer-invoices.form.schema";
export * from "./invoice-dto.adapter";
export * from "./invoice.form.schema";

View File

@ -0,0 +1,96 @@
import {
MoneyDTOHelper,
PercentageDTOHelper,
QuantityDTOHelper,
SpainTaxCatalogProvider,
} from "@erp/core";
import {
GetCustomerInvoiceByIdResponseDTO,
UpdateCustomerInvoiceByIdRequestDTO,
} from "../../common";
import { InvoiceContextValue } from "../context";
import { InvoiceFormData } from "./invoice.form.schema";
/**
* Convierte el DTO completo de API a datos numéricos para el formulario.
*/
export const invoiceDtoToFormAdapter = {
fromDto(dto: GetCustomerInvoiceByIdResponseDTO): InvoiceFormData {
const taxCatalog = SpainTaxCatalogProvider();
return {
invoice_number: dto.invoice_number,
series: dto.series,
invoice_date: dto.invoice_date,
operation_date: dto.operation_date,
customer_id: dto.customer_id,
reference: dto.reference ?? "",
description: dto.description ?? "",
notes: dto.notes ?? "",
language_code: dto.language_code,
currency_code: dto.currency_code,
subtotal_amount: MoneyDTOHelper.toNumber(dto.subtotal_amount),
discount_percentage: PercentageDTOHelper.toNumber(dto.discount_percentage),
discount_amount: MoneyDTOHelper.toNumber(dto.discount_amount),
taxable_amount: MoneyDTOHelper.toNumber(dto.taxable_amount),
taxes_amount: MoneyDTOHelper.toNumber(dto.taxes_amount),
total_amount: MoneyDTOHelper.toNumber(dto.total_amount),
taxes: dto.taxes.map((taxItem) => ({
tax_code: taxItem.tax_code,
tax_label: taxCatalog.findByCode(taxItem.tax_code).match(
(tax) => tax.name,
() => ""
),
taxable_amount: MoneyDTOHelper.toNumber(taxItem.taxable_amount),
taxes_amount: MoneyDTOHelper.toNumber(taxItem.taxes_amount),
})),
items: dto.items.map((item) => ({
is_non_valued: item.is_non_valued === "true",
description: item.description ?? "",
quantity: QuantityDTOHelper.toNumericString(item.quantity),
unit_amount: MoneyDTOHelper.toNumericString(item.unit_amount),
subtotal_amount: MoneyDTOHelper.toNumber(item.subtotal_amount),
discount_percentage: PercentageDTOHelper.toNumericString(item.discount_percentage),
discount_amount: MoneyDTOHelper.toNumber(item.discount_amount),
taxable_amount: MoneyDTOHelper.toNumber(item.taxable_amount),
tax_codes: item.tax_codes ?? [],
taxes_amount: MoneyDTOHelper.toNumber(item.taxes_amount),
total_amount: MoneyDTOHelper.toNumber(item.total_amount),
})),
};
},
toDto(form: InvoiceFormData, context: InvoiceContextValue): UpdateCustomerInvoiceByIdRequestDTO {
return {
series: form.series,
invoice_date: form.invoice_date,
operation_date: form.operation_date,
customer_id: form.customer_id,
reference: form.reference,
description: form.description,
notes: form.notes,
language_code: context.language_code,
currency_code: context.currency_code,
items: form.items?.map((item) => ({
is_non_valued: item.is_non_valued ? "true" : "false",
description: item.description,
quantity: QuantityDTOHelper.fromNumericString(item.quantity, 4),
unit_amount: MoneyDTOHelper.fromNumericString(item.unit_amount, context.currency_code, 4),
discount_percentage: PercentageDTOHelper.fromNumericString(item.discount_percentage, 2),
tax_codes: item.tax_codes,
})),
};
},
};

View File

@ -0,0 +1,124 @@
import { NumericStringSchema } from "@erp/core";
import { z } from "zod/v4";
export const InvoiceItemFormSchema = z.object({
is_non_valued: z.boolean(),
description: z.string().max(2000).optional().default(""),
quantity: NumericStringSchema.optional(),
unit_amount: NumericStringSchema.optional(),
subtotal_amount: z.number(),
discount_percentage: NumericStringSchema.optional(),
discount_amount: z.number(),
taxable_amount: z.number(),
tax_codes: z.array(z.string()).default([]),
taxes_amount: z.number(),
total_amount: z.number(),
});
export const InvoiceFormSchema = z.object({
invoice_number: z.string().optional(),
series: z.string().optional(),
invoice_date: z.string().optional(),
operation_date: z.string().optional(),
customer_id: z.string().optional(),
recipient: z
.object({
id: z.string().optional(),
name: z.string().optional(),
tin: z.string().optional(),
street: z.string().optional(),
street2: z.string().optional(),
city: z.string().optional(),
province: z.string().optional(),
postal_code: z.string().optional(),
country: z.string().optional(),
})
.optional(),
reference: z.string().optional(),
description: z.string().optional(),
notes: z.string().optional(),
language_code: z
.string({
error: "El idioma es obligatorio",
})
.min(1, "Debe indicar un idioma")
.toUpperCase() // asegura mayúsculas
.default("es"),
currency_code: z
.string({
error: "La moneda es obligatoria",
})
.min(1, "La moneda no puede estar vacía")
.toUpperCase() // asegura mayúsculas
.default("EUR"),
taxes: z
.array(
z.object({
tax_code: z.string(),
tax_label: z.string(),
taxable_amount: z.number(),
taxes_amount: z.number(),
})
)
.optional(),
items: z.array(InvoiceItemFormSchema).optional(),
subtotal_amount: z.number(),
discount_percentage: z.number(),
discount_amount: z.number(),
taxable_amount: z.number(),
taxes_amount: z.number(),
total_amount: z.number(),
});
export type InvoiceFormData = z.infer<typeof InvoiceFormSchema>;
export type InvoiceItemFormData = z.infer<typeof InvoiceItemFormSchema>;
export const defaultCustomerInvoiceItemFormData: InvoiceItemFormData = {
is_non_valued: false,
description: "",
quantity: "",
unit_amount: "",
subtotal_amount: 0,
discount_percentage: "",
discount_amount: 0,
taxable_amount: 0,
tax_codes: ["iva_21"],
taxes_amount: 0,
total_amount: 0,
};
export const defaultCustomerInvoiceFormData: InvoiceFormData = {
invoice_number: "",
series: "",
invoice_date: "",
operation_date: "",
reference: "",
description: "",
notes: "",
language_code: "es",
currency_code: "EUR",
items: [],
subtotal_amount: 0,
discount_amount: 0,
discount_percentage: 0,
taxable_amount: 0,
taxes_amount: 0,
total_amount: 0,
};

View File

@ -222,7 +222,7 @@ export const ClientSelectorModal = () => {
<DialogContent className='max-w-md'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Plus className='h-5 w-5' />
<Plus className='size-5' />
Nuevo Cliente
</DialogTitle>
</DialogHeader>
@ -248,7 +248,7 @@ const CustomerCard = ({ customer }: { customer: Customer }) => (
<Card>
<CardContent className='p-4 space-y-2'>
<div className='flex items-center gap-2'>
<User className='h-5 w-5' />
<User className='size-5' />
<span className='font-semibold'>{customer.name}</span>
<Badge variant={customer.status === "Activo" ? "default" : "secondary"}>
{customer.status}

View File

@ -31,7 +31,7 @@ export const CreateCustomerFormDialog = ({
<DialogContent className='sm:max-w-[500px] bg-card border-border'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Plus className='h-5 w-5' /> Agregar Nuevo Cliente
<Plus className='size-5' /> Agregar Nuevo Cliente
</DialogTitle>
<DialogDescription>
Complete la información del cliente. Los campos marcados con * son obligatorios.

View File

@ -61,7 +61,7 @@ export const CustomerSearchDialog = ({
<DialogHeader className='px-6 pt-6 pb-4'>
<DialogTitle className='flex items-center justify-between'>
<span className='flex items-center gap-2'>
<User className='h-5 w-5' />
<User className='size-5' />
Seleccionar Cliente
</span>
</DialogTitle>

View File

@ -102,7 +102,7 @@ export const CustomerViewPage = () => {
<Card>
<CardHeader>
<CardTitle className='flex items-center gap-2 text-lg'>
<FileText className='h-5 w-5 text-primary' />
<FileText className='size-5 text-primary' />
Información Básica
</CardTitle>
</CardHeader>
@ -136,7 +136,7 @@ export const CustomerViewPage = () => {
<Card>
<CardHeader>
<CardTitle className='flex items-center gap-2 text-lg'>
<MapPin className='h-5 w-5 text-primary' />
<MapPin className='size-5 text-primary' />
Dirección
</CardTitle>
</CardHeader>
@ -180,7 +180,7 @@ export const CustomerViewPage = () => {
<Card className='md:col-span-2'>
<CardHeader>
<CardTitle className='flex items-center gap-2 text-lg'>
<Mail className='h-5 w-5 text-primary' />
<Mail className='size-5 text-primary' />
Información de Contacto
</CardTitle>
</CardHeader>
@ -306,7 +306,7 @@ export const CustomerViewPage = () => {
<Card className='md:col-span-2'>
<CardHeader>
<CardTitle className='flex items-center gap-2 text-lg'>
<Languages className='h-5 w-5 text-primary' />
<Languages className='size-5 text-primary' />
Preferencias
</CardTitle>
</CardHeader>

View File

@ -49,7 +49,7 @@ export const GetVerifactuRecordByIdResponseSchema = z.object({
items: z.array(
z.object({
id: z.uuid(),
isNonValued: z.string(),
is_non_valued: z.string(),
position: z.string(),
description: z.string(),
quantity: QuantitySchema,

View File

@ -28,6 +28,8 @@ type TextAreaFieldProps<TFormValues extends FieldValues> = CommonInputProps & {
/** Contador de caracteres (si usas maxLength) */
showCounter?: boolean;
maxLength?: number;
rows?: number;
};
export function TextAreaField<TFormValues extends FieldValues>({
@ -42,6 +44,7 @@ export function TextAreaField<TFormValues extends FieldValues>({
className,
showCounter = false,
maxLength,
rows = 3
}: TextAreaFieldProps<TFormValues>) {
const { t } = useTranslation();
const isDisabled = disabled || readOnly;
@ -57,7 +60,7 @@ export function TextAreaField<TFormValues extends FieldValues>({
control={control}
name={name}
render={({ field }) => (
<FormItem className={cn("space-y-0", className)}>
<FormItem className={cn("space-y-0 flex flex-col ", className)}>
{label && (
<div className='mb-1 flex justify-between gap-2'>
<div className='flex items-center gap-2'>
@ -81,8 +84,11 @@ export function TextAreaField<TFormValues extends FieldValues>({
<Textarea
disabled={isDisabled}
placeholder={placeholder}
className={"placeholder:font-normal placeholder:italic bg-background"}
className={"placeholder:font-normal placeholder:italic bg-background flex flex-1 min-h-0 h-full"}
maxLength={maxLength}
spellCheck={true}
rows={rows}
{...field}
/>
</FormControl>

View File

@ -407,7 +407,7 @@ export function DataTable({
Past Performance{" "}
<Badge
variant='secondary'
className='flex h-5 w-5 items-center justify-center rounded-full bg-muted-foreground/30'
className='flex size-5 items-center justify-center rounded-full bg-muted-foreground/30'
>
3
</Badge>
@ -416,7 +416,7 @@ export function DataTable({
Key Personnel{" "}
<Badge
variant='secondary'
className='flex h-5 w-5 items-center justify-center rounded-full bg-muted-foreground/30'
className='flex size-5 items-center justify-center rounded-full bg-muted-foreground/30'
>
2
</Badge>

View File

@ -24,6 +24,18 @@ export class Collection<T> {
this.totalItems = 0;
}
/**
* Agrega un nuevo elemento a la colección.
* @param item - Elemento a agregar.
*/
addCollection(collection: Collection<T>): boolean {
this.items.push(...collection.items);
if (this.totalItems !== null) {
this.totalItems = this.totalItems + collection.totalItems;
}
return true;
}
/**
* Agrega un nuevo elemento a la colección.
* @param item - Elemento a agregar.