Edición de detalles de factura

This commit is contained in:
David Arranz 2026-04-29 17:02:08 +02:00
parent 4361df07a0
commit 79e90ec00f
113 changed files with 1387 additions and 4511 deletions

View File

@ -16,7 +16,7 @@
"@repo/typescript-config": "workspace:*",
"@types/bcrypt": "^6.0.0",
"@types/cors": "^2.8.19",
"@types/dinero.js": "^2.0.0",
"@types/dinero.js": "^1.9.1",
"@types/express": "^4.17.21",
"@types/glob": "^9.0.0",
"@types/jsonwebtoken": "^9.0.10",

View File

@ -18,7 +18,7 @@
"@biomejs/biome": "^2.4.11",
"@tanstack/react-query-devtools": "^5.98.0",
"@tailwindcss/postcss": "^4.1.5",
"@types/dinero.js": "^2.0.0",
"@types/dinero.js": "1.9.1",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",

View File

@ -23,7 +23,7 @@
},
"devDependencies": {
"@hookform/devtools": "^4.4.0",
"@types/dinero.js": "^2.0.0",
"@types/dinero.js": "1.9.1",
"@types/express": "^4.17.21",
"@types/mime-types": "^3.0.1",
"@types/react": "^19.2.14",

View File

@ -1,16 +0,0 @@
import type { MoneyDTO, PercentageDTO, QuantityDTO } from "../dto";
/** MoneyDTO: { value, scale, currency_code } */
export function areMoneyDTOEqual(a?: MoneyDTO | null, b?: MoneyDTO | null): boolean {
return a?.value === b?.value && a?.scale === b?.scale && a?.currency_code === b?.currency_code;
}
/** QuantityDTO: { value, scale } */
export function areQuantityDTOEqual(a?: QuantityDTO | null, b?: QuantityDTO | null): boolean {
return a?.value === b?.value && a?.scale === b?.scale;
}
/** PercentageDTO: { value, scale } */
export function arePercentageDTOEqual(a?: PercentageDTO | null, b?: PercentageDTO | null): boolean {
return a?.value === b?.value && a?.scale === b?.scale;
}

View File

@ -1,8 +1,4 @@
export * from "./apply-validation-error-collection";
export * from "./date-helper";
export * from "./dto-compare-helper";
export * from "./money-dto-helper";
export * from "./money-helper";
export * from "./number-helper";
export * from "./percentage-dto-helper";
export * from "./quantity-dto-helper";

View File

@ -1,66 +0,0 @@
/**
* Funciones para manipular valores monetarios numéricos.
*/
export const formatCurrency = (amount: number, scale = 2, currency = "EUR", locale = "es-ES") => {
return new Intl.NumberFormat(locale, {
style: "currency",
currency,
maximumFractionDigits: scale,
minimumFractionDigits: Number.isInteger(amount) ? 0 : scale,
useGrouping: true,
}).format(amount);
};
/**
* 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,7 +0,0 @@
const toSafeNumber = (value: string | number | null | undefined): number => {
return Number(value ?? 0);
};
export const NumberHelper = {
toSafeNumber,
};

View File

@ -1,11 +1,5 @@
export * from "./use-datasource";
export * from "./use-hook-form";
export * from "./use-money";
export * from "./use-pagination";
export * from "./use-percentage";
export * from "./use-quantity";
export * from "./use-query-key";
export * from "./use-rhf-error-focus";
export * from "./use-toggle";
export * from "./use-unsaved-changes-notifier";
export * from "./use-url-param-id";

View File

@ -1,5 +1,6 @@
import { createContext, useContext } from "react";
import { IDataSource } from "../../lib/data-source/datasource.interface";
import type { IDataSource } from "../../lib/data-source/datasource.interface";
const DataSourceContext = createContext<IDataSource | null>(null);

View File

@ -1,87 +0,0 @@
import {
QueryFunctionContext,
QueryKey,
UseQueryOptions,
UseQueryResult,
keepPreviousData,
useQuery,
} from "@tanstack/react-query";
import { isResponseAListDTO } from "../../../common/dto";
import {
UseLoadingOvertimeOptionsProps,
UseLoadingOvertimeReturnType,
useLoadingOvertime,
} from "./use-loading-overtime";
const DEFAULT_REFETCH_INTERVAL = 2 * 60 * 1000; // 2 minutes
const DEFAULT_STALE_TIME = 60 * 1000; // 1 minute
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export type UseListQueryOptions<TUseListQueryData, TUseListQueryError> = {
queryKey: QueryKey;
queryFn: (context: QueryFunctionContext) => Promise<TUseListQueryData>;
enabled?: boolean;
refetchInterval?: number | false;
select?: (data: TUseListQueryData) => TUseListQueryData;
queryOptions?: Partial<UseQueryOptions<TUseListQueryData, TUseListQueryError>>;
} & UseLoadingOvertimeOptionsProps;
export type UseListQueryResult<TUseListQueryData, TUseListQueryError> = UseQueryResult<
TUseListQueryData,
TUseListQueryError
> & {
isEmpty: boolean;
} & UseLoadingOvertimeReturnType;
/**
* Hook para manejar consultas de listas con React Query,
* incluye detección de listas vacías y control de sobretiempo de carga.
*/
export const useList = <TUseListQueryData, TUseListQueryError>({
queryKey,
queryFn,
enabled,
refetchInterval,
select,
queryOptions = {},
overtimeOptions,
}: UseListQueryOptions<TUseListQueryData, TUseListQueryError>): UseListQueryResult<
TUseListQueryData,
TUseListQueryError
> => {
if (!queryFn) {
console.error("queryFn es requerido en useList");
throw new Error("queryFn es requerido en useList");
}
const queryResponse = useQuery<TUseListQueryData, TUseListQueryError>({
queryKey,
queryFn,
placeholderData: keepPreviousData,
staleTime: DEFAULT_STALE_TIME,
refetchInterval: refetchInterval ?? DEFAULT_REFETCH_INTERVAL,
refetchOnWindowFocus: true,
enabled: enabled && !!queryFn,
select,
...queryOptions,
});
const { elapsedTime } = useLoadingOvertime({
isPending: queryResponse.isFetching,
interval: overtimeOptions?.interval,
onInterval: overtimeOptions?.onInterval,
});
const isEmpty =
queryResponse.isSuccess &&
isResponseAListDTO(queryResponse.data) &&
queryResponse.data.total_items === 0;
const result = {
...queryResponse,
overtime: { elapsedTime },
isEmpty,
};
return result;
};

View File

@ -1,101 +0,0 @@
import { useEffect, useState } from "react";
export type UseLoadingOvertimeRefineContext = Omit<
UseLoadingOvertimeCoreProps,
"isPending" | "interval"
> &
Required<Pick<UseLoadingOvertimeCoreProps, "interval">>;
export type UseLoadingOvertimeOptionsProps = {
overtimeOptions?: UseLoadingOvertimeCoreOptions;
};
export type UseLoadingOvertimeReturnType = {
overtime: {
elapsedTime?: number;
};
};
type UseLoadingOvertimeCoreOptions = Omit<UseLoadingOvertimeCoreProps, "isPending">;
type UseLoadingOvertimeCoreReturnType = {
elapsedTime?: number;
};
export type UseLoadingOvertimeCoreProps = {
/**
* The pengind state. If true, the elapsed time will be calculated.
*/
isPending: boolean;
/**
* The interval in milliseconds. If the pending time exceeds this time, the `onInterval` callback will be called.
* If not specified, the `interval` value from the `overtime` option of the `RefineProvider` will be used.
*
* @default: 1000 (1 second)
*/
interval?: number;
/**
* The callback function that will be called when the pending time exceeds the specified time.
* If not specified, the `onInterval` value from the `overtime` option of the `RefineProvider` will be used.
*
* @param elapsedInterval The elapsed time in milliseconds.
*/
onInterval?: (elapsedInterval: number) => void;
};
/**
* if you need to do something when the loading time exceeds the specified time, refine provides the `useLoadingOvertime` hook.
* It returns the elapsed time in milliseconds.
*
* @example
* const { elapsedTime } = useLoadingOvertime({
* isLoading,
* interval: 1000,
* onInterval(elapsedInterval) {
* console.log("loading overtime", elapsedInterval);
* },
* });
*/
export const useLoadingOvertime = ({
isPending,
interval = 1000,
onInterval,
}: UseLoadingOvertimeCoreProps): UseLoadingOvertimeCoreReturnType => {
const [elapsedTime, setElapsedTime] = useState<number | undefined>(undefined);
useEffect(() => {
let intervalFn: ReturnType<typeof setInterval>;
if (isPending) {
intervalFn = setInterval(() => {
// increase elapsed time
setElapsedTime((prevElapsedTime) => {
if (prevElapsedTime === undefined) {
return interval;
}
return prevElapsedTime + interval;
});
}, interval);
}
return () => {
clearInterval(intervalFn);
// reset elapsed time
setElapsedTime(undefined);
};
}, [isPending, interval]);
useEffect(() => {
// call onInterval callback
if (onInterval && elapsedTime) {
onInterval(elapsedTime);
}
}, [onInterval, elapsedTime]);
return {
elapsedTime,
};
};

View File

@ -1,101 +0,0 @@
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,226 +0,0 @@
import { type MoneyDTO, MoneyDTOHelper } from "@erp/core/common";
import type { Currency } from "dinero.js";
import * as React from "react";
import { useTranslation } from "../i18n";
export type { Currency };
// --- Utils locales (edición texto → número) ---
// Quita símbolos de moneda/letras, conserva dígitos, signo y , .
const stripCurrencySymbols = (s: string) =>
s
.replace(/[^\d.,-]/g, "")
.replace(/\s+/g, " ")
.trim();
// Heurística robusta: determina decimal por última ocurrencia de , o .
const parseLocaleNumber = (raw: string, locale = "es-ES"): number | null => {
if (!raw) return null;
const s = stripCurrencySymbols(raw);
if (!s) return null;
// Obtener formato local
const example = Intl.NumberFormat(locale).format(1000.1);
const group = example.charAt(1); // normalmente "." o ","
const decimal = example.charAt(example.length - 2); // normalmente "," o "."
// Normalizar: eliminar separador de miles y reemplazar decimal por "."
const normalized = s
.replace(new RegExp(`\\${group}`, "g"), "")
.replace(new RegExp(`\\${decimal}`), ".");
const n = Number(normalized);
return Number.isFinite(n) ? n : null;
};
// Redondeo a escala (por defecto 2)
const roundToScale = (n: number, scale = 2) => {
const f = 10 ** scale;
return Math.round(n * f) / f;
};
// DTO vacío (API puede mandar "", "")
const isEmptyMoneyDTO = (m?: MoneyDTO | null) =>
!m || m.value?.trim?.() === "" || m.scale?.trim?.() === "";
// Convierte DTO→número sin instanciar dinero.js (solo lectura)
const toNumberUnsafe = (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 número→DTO (sin costo extra)
const fromNumberUnsafe = (n: number, currency: Currency = "EUR", scale = 2): MoneyDTO => ({
value: String(Math.round(n * 10 ** scale)),
scale: String(scale),
currency_code: currency,
});
// --- Hook ---
export function useMoney(overrides?: {
locale?: string; // e.g. "es-ES" (si no, i18n.language)
fallbackCurrency?: Currency; // por defecto "EUR"
defaultScale?: number; // por defecto 2
}) {
const { i18n } = useTranslation();
const locale = overrides?.locale || i18n.language || "es-ES";
const fallbackCurrency: Currency = overrides?.fallbackCurrency ?? "EUR";
const defaultScale = overrides?.defaultScale ?? 2;
// Conversión básica
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]
);
// Reescala manteniendo magnitud
const withScale = React.useCallback(
(dto: MoneyDTO, scale: number) => {
const curr = toNumber(dto);
return fromNumber(curr, (dto.currency_code as Currency) || fallbackCurrency, scale);
},
[toNumber, fromNumber, fallbackCurrency]
);
// Formateos
const formatCurrency = React.useCallback(
(dto: MoneyDTO, loc?: string) => MoneyDTOHelper.format(dto, loc ?? locale),
[locale]
);
const formatPlain = React.useCallback(
(dto: MoneyDTO, loc?: string) => {
const n = toNumber(dto);
const dec = Number(dto?.scale || defaultScale);
return new Intl.NumberFormat(loc ?? locale, {
maximumFractionDigits: dec,
minimumFractionDigits: Number.isInteger(n) ? 0 : 0,
useGrouping: true,
}).format(n);
},
[locale, toNumber, defaultScale]
);
const parse = React.useCallback(
(text: string): number | null => parseLocaleNumber(text, locale),
[locale]
);
// Adaptadores API
const fromApi = React.useCallback(
(m?: MoneyDTO | null): MoneyDTO | null => (m == null || isEmptyMoneyDTO(m) ? null : m),
[]
);
const toApi = React.useCallback(
(m: MoneyDTO | null, currency: Currency = fallbackCurrency): MoneyDTO =>
m ? m : { value: "", scale: "", currency_code: currency },
[fallbackCurrency]
);
/* // Operaciones (dinero.js via helpers)
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]
); */
// Estado/Comparaciones
const isZero = React.useCallback((dto?: MoneyDTO | null) => toNumber(dto) === 0, [toNumber]);
const sameCurrency = React.useCallback(
(a?: MoneyDTO | null, b?: MoneyDTO | null) =>
(a?.currency_code || fallbackCurrency) === (b?.currency_code || fallbackCurrency),
[fallbackCurrency]
);
// Stepping teclado con redondeo a escala
const stepNumber = React.useCallback(
(base: number, step = 0.01, scale = defaultScale) => roundToScale(base + step, scale),
[defaultScale]
);
return React.useMemo(
() => ({
// Conversión
toNumber,
fromNumber,
withScale,
// Formateo/parseo
formatCurrency,
formatPlain,
parse,
// DTO vacío / adaptadores
isEmptyMoneyDTO,
fromApi,
toApi,
// Operaciones
//add,
//sub,
//multiply,
//percentage,
// Estado/ayudas
isZero,
sameCurrency,
stepNumber,
roundToScale,
// Utils UI
stripCurrencySymbols,
// Config efectiva
locale,
fallbackCurrency,
defaultScale,
// Factory Dinero si se necesita en algún punto de bajo nivel:
//toDinero: (dto: MoneyDTO) => dineroFromDTO(dto, fallbackCurrency),
//fromDinero: dtoFromDinero,
}),
[
toNumber,
fromNumber,
withScale,
formatCurrency,
formatPlain,
parse,
fromApi,
toApi,
//add,
//sub,
//multiply,
//percentage,
isZero,
sameCurrency,
stepNumber,
locale,
fallbackCurrency,
defaultScale,
]
);
}

View File

@ -1,2 +0,0 @@
export * from "./use-pagination";
export * from "./use-pagination-sync-with-location";

View File

@ -1,54 +0,0 @@
import {
INITIAL_PAGE_INDEX,
INITIAL_PAGE_SIZE,
MAX_PAGE_SIZE,
MIN_PAGE_SIZE,
} from "@repo/rdx-criteria";
import { useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import { usePagination } from "./use-pagination";
export const usePaginationSyncWithLocation = (
initialPageIndex: number = INITIAL_PAGE_INDEX,
initialPageSize: number = INITIAL_PAGE_SIZE
) => {
const [urlSearchParams, setUrlSearchParams] = useSearchParams();
const urlParamPageIndex: string | null = urlSearchParams.get("page_index");
const urlParamPageSize: string | null = urlSearchParams.get("page_size");
const calculatedPageIndex = useMemo(() => {
const parsedPageIndex = Number.parseInt(urlParamPageIndex ?? "", 10);
let result = !Number.isNaN(parsedPageIndex) ? parsedPageIndex : initialPageIndex;
if (result < initialPageIndex) {
result = initialPageIndex;
}
return result;
}, [urlParamPageIndex, initialPageIndex]);
const calculatedPageSize = useMemo(() => {
const parsedPageSize = Number.parseInt(urlParamPageSize ?? "", 10);
let result = !Number.isNaN(parsedPageSize) ? parsedPageSize : initialPageSize;
if (result < MIN_PAGE_SIZE || result > MAX_PAGE_SIZE) {
result = initialPageSize;
}
return result;
}, [urlParamPageSize, initialPageSize]);
const [pagination, setPagination] = usePagination(calculatedPageIndex, calculatedPageSize);
const updatePagination = (newPagination: any) => {
const _validatedPagination = setPagination(newPagination);
setUrlSearchParams({
//...actualSearchParam,
page_index: String(_validatedPagination.pageIndex),
page_size: String(_validatedPagination.pageSize),
});
};
return [pagination, updatePagination] as const;
};

View File

@ -1,76 +0,0 @@
import {
INITIAL_PAGE_INDEX,
INITIAL_PAGE_SIZE,
MAX_PAGE_SIZE,
MIN_PAGE_SIZE,
} from "@repo/rdx-criteria";
import { act, renderHook } from "@testing-library/react-hooks";
import { PaginationState, usePagination } from "./use-pagination";
describe("usePagination", () => {
it("should initialize with default values", () => {
const { result } = renderHook(() => usePagination());
const [pagination] = result.current;
expect(pagination.pageIndex).toBe(INITIAL_PAGE_INDEX);
expect(pagination.pageSize).toBe(INITIAL_PAGE_SIZE);
});
it("should initialize with custom values", () => {
const customPageIndex = 2;
const customPageSize = 20;
const { result } = renderHook(() => usePagination(customPageIndex, customPageSize));
const [pagination] = result.current;
expect(pagination.pageIndex).toBe(customPageIndex);
expect(pagination.pageSize).toBe(customPageSize);
});
it("should update pagination state correctly", () => {
const { result } = renderHook(() => usePagination());
const [pagination, updatePagination] = result.current;
const newPagination: PaginationState = { pageIndex: 1, pageSize: 25 };
act(() => {
updatePagination(newPagination);
});
expect(pagination.pageIndex).toBe(newPagination.pageIndex);
expect(pagination.pageSize).toBe(newPagination.pageSize);
});
it("should not update pageIndex below INITIAL_PAGE_INDEX", () => {
const { result } = renderHook(() => usePagination());
const [, updatePagination] = result.current;
act(() => {
updatePagination({ pageIndex: -1, pageSize: INITIAL_PAGE_SIZE });
});
const [pagination] = result.current;
expect(pagination.pageIndex).toBe(INITIAL_PAGE_INDEX);
});
it("should not update pageSize below MIN_PAGE_SIZE or above MAX_PAGE_SIZE", () => {
const { result } = renderHook(() => usePagination());
const [, updatePagination] = result.current;
act(() => {
updatePagination({ pageIndex: INITIAL_PAGE_INDEX, pageSize: MIN_PAGE_SIZE - 1 });
});
let [pagination] = result.current;
expect(pagination.pageSize).toBe(INITIAL_PAGE_SIZE); // No cambia porque pageSize es menor que el mínimo
act(() => {
updatePagination({ pageIndex: INITIAL_PAGE_INDEX, pageSize: MAX_PAGE_SIZE + 1 });
});
[pagination] = result.current;
expect(pagination.pageSize).toBe(INITIAL_PAGE_SIZE); // No cambia porque pageSize es mayor que el máximo
});
});

View File

@ -1,52 +0,0 @@
import {
INITIAL_PAGE_INDEX,
INITIAL_PAGE_SIZE,
MAX_PAGE_SIZE,
MIN_PAGE_SIZE,
} from "@repo/rdx-criteria";
import { useState } from "react";
export const DEFAULT_PAGE_SIZES = [5, 10, 15, 30, 50, 75, 100];
export interface PaginationState {
pageIndex: number;
pageSize: number;
}
export const defaultPaginationState = {
pageIndex: INITIAL_PAGE_INDEX,
pageSize: INITIAL_PAGE_SIZE,
};
export const usePagination = (
initialPageIndex: number = INITIAL_PAGE_INDEX,
initialPageSize: number = INITIAL_PAGE_SIZE
) => {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: initialPageIndex,
pageSize: initialPageSize,
});
const updatePagination = (newPagination: PaginationState) => {
// Realiza comprobaciones antes de actualizar el estado
const validatedPagination = newPagination;
if (validatedPagination.pageIndex < INITIAL_PAGE_INDEX) {
validatedPagination.pageIndex = INITIAL_PAGE_INDEX;
}
if (newPagination.pageSize < MIN_PAGE_SIZE || newPagination.pageSize > MAX_PAGE_SIZE) {
validatedPagination.pageSize = MIN_PAGE_SIZE;
}
setPagination((oldPagination) => ({
...oldPagination,
pageIndex: newPagination.pageIndex,
pageSize: newPagination.pageSize,
}));
return validatedPagination;
};
return [pagination, updatePagination] as const;
};

View File

@ -1,26 +0,0 @@
import { useMemo } from "react";
import { PercentageDTO } from "../../common";
/**
* Hook para porcentajes escalados (value+scale).
*/
export function usePercentage() {
const toNumber = (p?: PercentageDTO | null): number => {
if (!p?.value || !p.scale) return 0;
return Number(p.value) / 10 ** Number(p.scale);
};
const fromNumber = (num: number, scale = 2): PercentageDTO => ({
value: Math.round(num * 10 ** scale).toString(),
scale: scale.toString(),
});
const format = (p: PercentageDTO): string =>
`${toNumber(p).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}%`;
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
return useMemo(() => ({ toNumber, fromNumber, format }), []);
}

View File

@ -1,206 +0,0 @@
import * as React from "react";
import { QuantityDTO } from "../../common";
/**
* Hook para manipular cantidades escaladas (value+scale).
* Ejemplo: { value:"1500", scale:"2" } 15.00 unidades
*/
export const isEmptyQuantityDTO = (q?: QuantityDTO | null) =>
!q || q.value.trim() === "" || q.scale.trim() === "";
// Redondeo a escala (por defecto 2)
const roundToScale = (n: number, scale = 2) => {
const f = 10 ** scale;
return Math.round(n * f) / f;
};
// Quita caracteres no numéricos salvo signos y separadores
const stripNumberish = (s: string) => s.replace(/[^\d.,\-]/g, "").trim();
// Parse tolerante: “1.234,5” | “1,234.5” | “1234.5” → número JS
const parseLocaleNumber = (raw: string): number | null => {
if (!raw) return null;
const s = stripNumberish(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(",", ".");
const n = Number(normalized);
return Number.isFinite(n) ? n : null;
};
export function useQuantity(overrides?: {
defaultScale?: number; // por defecto 2
min?: number; // clamp opcional (ej. 0)
max?: number; // clamp opcional
}) {
const defaultScale = overrides?.defaultScale ?? 2;
const min = overrides?.min;
const max = overrides?.max;
// DTO → número (ej. {100, "2"} → 1)
const toNumber = React.useCallback(
(q?: QuantityDTO | null): number => {
if (isEmptyQuantityDTO(q)) return 0;
const scale = Number(q!.scale || defaultScale);
return Number(q!.value || 0) / 10 ** scale;
},
[defaultScale]
);
// número → DTO (manteniendo escala deseada)
const fromNumber = React.useCallback(
(n: number, scale: number = defaultScale): QuantityDTO => ({
value: String(Math.round(n * 10 ** scale)),
scale: String(scale),
}),
[defaultScale]
);
// Reescala manteniendo magnitud
const withScale = React.useCallback(
(q: QuantityDTO, scale: number) => {
const curr = toNumber(q);
return fromNumber(curr, scale);
},
[toNumber, fromNumber]
);
// Formateo sin relleno de ceros (máx. escala)
const formatPlain = React.useCallback(
(q: QuantityDTO) => {
const n = toNumber(q);
const dec = Number(q.scale || defaultScale);
return new Intl.NumberFormat(undefined, {
maximumFractionDigits: dec,
minimumFractionDigits: Number.isInteger(n) ? 0 : 0,
useGrouping: false,
}).format(n);
},
[toNumber, defaultScale]
);
// Parse texto → número (tolerante ,/.)
const parse = React.useCallback((text: string): number | null => parseLocaleNumber(text), []);
// DTO vacío ↔ null (adaptadores)
const fromApi = React.useCallback(
(q?: QuantityDTO | null): QuantityDTO | null => (q && !isEmptyQuantityDTO(q) ? q : null),
[]
);
const toApi = React.useCallback(
(q: QuantityDTO | null): QuantityDTO => (q ? q : { value: "", scale: "" }),
[]
);
// Operaciones aritméticas simples (misma escala resultado)
const add = React.useCallback(
(a: QuantityDTO, b: QuantityDTO): QuantityDTO => {
const scale = Math.max(Number(a.scale || defaultScale), Number(b.scale || defaultScale));
const av = withScale(a, scale);
const bv = withScale(b, scale);
return { value: String(Number(av.value) + Number(bv.value)), scale: String(scale) };
},
[withScale, defaultScale]
);
const sub = React.useCallback(
(a: QuantityDTO, b: QuantityDTO): QuantityDTO => {
const scale = Math.max(Number(a.scale || defaultScale), Number(b.scale || defaultScale));
const av = withScale(a, scale);
const bv = withScale(b, scale);
return { value: String(Number(av.value) - Number(bv.value)), scale: String(scale) };
},
[withScale, defaultScale]
);
const multiply = React.useCallback(
(q: QuantityDTO, k: number, outScale?: number): QuantityDTO => {
const sc = outScale ?? Number(q.scale || defaultScale);
const n = toNumber(q) * k;
return fromNumber(roundToScale(n, sc), sc);
},
[toNumber, fromNumber, defaultScale]
);
// Stepping teclado (p.ej. ArrowUp/Down)
const stepNumber = React.useCallback(
(base: number, step = 1, scale: number = defaultScale) => {
let next = base + step;
if (min !== undefined) next = Math.max(next, min);
if (max !== undefined) next = Math.min(next, max);
return roundToScale(next, scale);
},
[defaultScale, min, max]
);
// Estado/ayudas
const clamp = React.useCallback(
(n: number, s: number = defaultScale) => {
let v = n;
if (min !== undefined) v = Math.max(v, min);
if (max !== undefined) v = Math.min(v, max);
return roundToScale(v, s);
},
[min, max, defaultScale]
);
const isZero = React.useCallback((q?: QuantityDTO | null) => toNumber(q) === 0, [toNumber]);
return React.useMemo(
() => ({
// Conversión
toNumber,
fromNumber,
withScale,
// Formateo/parseo
formatPlain,
parse,
// DTO vacío / adaptadores
isEmptyQuantityDTO,
fromApi,
toApi,
// Operaciones
add,
sub,
multiply,
// Teclado/estado
stepNumber,
roundToScale,
clamp,
isZero,
// Utils UI
stripNumberish,
// Config efectiva
defaultScale,
min,
max,
}),
[
toNumber,
fromNumber,
withScale,
formatPlain,
parse,
add,
sub,
multiply,
stepNumber,
clamp,
isZero,
defaultScale,
min,
max,
]
);
}

View File

@ -1,5 +0,0 @@
import { keys } from "./key-builder";
export const useQueryKey = () => {
return keys;
};

View File

@ -1,181 +0,0 @@
type BaseKey = string | number;
type ParametrizedDataActions = "list" | "infinite";
type IdRequiredDataActions = "one" | "report" | "upload";
type IdsRequiredDataActions = "many";
type DataMutationActions =
| "custom"
| "customMutation"
| "create"
| "createMany"
| "update"
| "updateMany"
| "delete"
| "deleteMany";
type AuthActionType =
| "login"
| "logout"
| "profile"
| "register"
| "forgotPassword"
| "check"
| "onError"
| "permissions"
| "updatePassword";
type AuditActionType = "list" | "log" | "rename";
type IdType = BaseKey;
type IdsType = IdType[];
type ParamsType = any;
type KeySegment = string | IdType | IdsType | ParamsType;
export function arrayFindIndex<T>(array: T[], slice: T[]): number {
return array.findIndex(
(_, index) =>
index <= array.length - slice.length &&
slice.every((sliceItem, sliceIndex) => array[index + sliceIndex] === sliceItem)
);
}
export function arrayReplace<T>(array: T[], partToBeReplaced: T[], newPart: T[]): T[] {
const newArray: T[] = [...array];
const startIndex = arrayFindIndex(array, partToBeReplaced);
if (startIndex !== -1) {
newArray.splice(startIndex, partToBeReplaced.length, ...newPart);
}
return newArray;
}
export function stripUndefined(segments: KeySegment[]) {
return segments.filter((segment) => segment !== undefined);
}
class BaseKeyBuilder {
segments: KeySegment[] = [];
constructor(segments: KeySegment[] = []) {
this.segments = segments;
}
key() {
return this.segments;
}
get() {
return this.segments;
}
}
class ParamsKeyBuilder extends BaseKeyBuilder {
params(paramsValue?: ParamsType) {
return new BaseKeyBuilder([...this.segments, paramsValue]);
}
}
class DataIdRequiringKeyBuilder extends BaseKeyBuilder {
id(idValue?: IdType) {
return new ParamsKeyBuilder([...this.segments, idValue ? String(idValue) : undefined]);
}
}
class DataIdsRequiringKeyBuilder extends BaseKeyBuilder {
ids(...idsValue: IdsType) {
return new ParamsKeyBuilder([
...this.segments,
...(idsValue.length ? [idsValue.map((el) => String(el))] : []),
]);
}
}
class DataResourceKeyBuilder extends BaseKeyBuilder {
action(actionType: ParametrizedDataActions): ParamsKeyBuilder;
action(actionType: IdRequiredDataActions): DataIdRequiringKeyBuilder;
action(actionType: IdsRequiredDataActions): DataIdsRequiringKeyBuilder;
action(
actionType: ParametrizedDataActions | IdRequiredDataActions | IdsRequiredDataActions
): ParamsKeyBuilder | DataIdRequiringKeyBuilder | DataIdsRequiringKeyBuilder {
if (["one", "report"].includes(actionType)) {
return new DataIdRequiringKeyBuilder([...this.segments, actionType]);
}
if (actionType === "many") {
return new DataIdsRequiringKeyBuilder([...this.segments, actionType]);
}
if (["list", "infinite"].includes(actionType)) {
return new ParamsKeyBuilder([...this.segments, actionType]);
}
throw new Error("Invalid action type");
}
}
class DataKeyBuilder extends BaseKeyBuilder {
resource(resourceName?: string) {
return new DataResourceKeyBuilder([...this.segments, resourceName]);
}
mutation(mutationName: DataMutationActions) {
return new ParamsKeyBuilder([
...(mutationName === "custom" ? this.segments : [this.segments[0]]),
mutationName,
]);
}
}
class AuthKeyBuilder extends BaseKeyBuilder {
action(actionType: AuthActionType) {
return new ParamsKeyBuilder([...this.segments, actionType]);
}
}
class AccessResourceKeyBuilder extends BaseKeyBuilder {
action(resourceName: string) {
return new ParamsKeyBuilder([...this.segments, resourceName]);
}
}
class AccessKeyBuilder extends BaseKeyBuilder {
resource(resourceName?: string) {
return new AccessResourceKeyBuilder([...this.segments, resourceName]);
}
}
class AuditActionKeyBuilder extends BaseKeyBuilder {
action(actionType: Extract<AuditActionType, "list">) {
return new ParamsKeyBuilder([...this.segments, actionType]);
}
}
class AuditKeyBuilder extends BaseKeyBuilder {
resource(resourceName?: string) {
return new AuditActionKeyBuilder([...this.segments, resourceName]);
}
action(actionType: Extract<AuditActionType, "rename" | "log">) {
return new ParamsKeyBuilder([...this.segments, actionType]);
}
}
export class KeyBuilder extends BaseKeyBuilder {
data(name?: string) {
return new DataKeyBuilder(["data", name || "default"]);
}
auth() {
return new AuthKeyBuilder(["auth"]);
}
access() {
return new AccessKeyBuilder(["access"]);
}
audit() {
return new AuditKeyBuilder(["audit"]);
}
}
export const keys = () => new KeyBuilder([]);

View File

@ -1,15 +0,0 @@
import { useState } from "react";
// https://github.com/wojtekmaj/react-hooks/blob/main/src/useToggle.ts
/**
* Returns a flag and a function to toggle it.
*
* @param {boolean} defaultValue Default value
* @returns {[boolean, () => void]} Flag and toggle function
*/
export default function useToggle(defaultValue = false): [boolean, () => void] {
const [value, setValue] = useState(defaultValue);
const toggleValue = () => setValue((prevValue) => !prevValue);
return [value, toggleValue];
}

View File

@ -22,7 +22,7 @@
},
"devDependencies": {
"@hookform/devtools": "^4.4.0",
"@types/dinero.js": "^2.0.0",
"@types/dinero.js": "1.9.1",
"@types/express": "^4.17.21",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",

View File

@ -7,12 +7,12 @@ import {
import { GetIssuedInvoiceByIdResponseSchema } from "../../../../../common/index.ts";
import type { GetIssuedInvoiceByIdUseCase } from "../../../../application/issued-invoices/index.ts";
import { proformasApiErrorMapper } from "../../../proformas/express/proformas-api-error-mapper.ts";
import { issuedInvoicesApiErrorMapper } from "../issued-invoices-api-error-mapper.ts";
export class GetIssuedInvoiceByIdController extends ExpressController {
public constructor(private readonly useCase: GetIssuedInvoiceByIdUseCase) {
super();
this.errorMapper = proformasApiErrorMapper;
this.errorMapper = issuedInvoicesApiErrorMapper;
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
this.registerGuards(

View File

@ -8,12 +8,12 @@ import { Criteria } from "@repo/rdx-criteria/server";
import { ListIssuedInvoicesResponseSchema } from "../../../../../common/index.ts";
import type { ListIssuedInvoicesUseCase } from "../../../../application/issued-invoices/index.ts";
import { proformasApiErrorMapper } from "../../../proformas/express/proformas-api-error-mapper.ts";
import { issuedInvoicesApiErrorMapper } from "../issued-invoices-api-error-mapper.ts";
export class ListIssuedInvoicesController extends ExpressController {
public constructor(private readonly useCase: ListIssuedInvoicesUseCase) {
super();
this.errorMapper = proformasApiErrorMapper;
this.errorMapper = issuedInvoicesApiErrorMapper;
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
this.registerGuards(

View File

@ -8,12 +8,12 @@ import {
import type { ReportIssueInvoiceByIdQueryRequestDTO } from "../../../../../common";
import type { ReportIssuedInvoiceUseCase } from "../../../../application/index.ts";
import { proformasApiErrorMapper } from "../../../proformas/express/proformas-api-error-mapper.ts";
import { issuedInvoicesApiErrorMapper } from "../issued-invoices-api-error-mapper.ts";
export class ReportIssuedInvoiceController extends ExpressController {
public constructor(private readonly useCase: ReportIssuedInvoiceUseCase) {
super();
this.errorMapper = proformasApiErrorMapper;
this.errorMapper = issuedInvoicesApiErrorMapper;
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
this.registerGuards(

View File

@ -37,6 +37,6 @@ const issuedinvoiceItemMismatchError: ErrorToApiRule = {
};
// Cómo aplicarla: crea una nueva instancia del mapper con la regla extra
export const customerInvoicesApiErrorMapper: ApiErrorMapper = ApiErrorMapper.default()
export const issuedInvoicesApiErrorMapper: ApiErrorMapper = ApiErrorMapper.default()
.register(invoiceDuplicateRule)
.register(issuedinvoiceItemMismatchError);

View File

@ -9,34 +9,19 @@ import { z } from "zod/v4";
import { ItemPositionSchema, TaxCombinationCodeSchema } from "../../shared";
export const UpdateProformaItemRequestSchema = z
.object({
position: ItemPositionSchema,
is_valued: z.boolean(),
export const UpdateProformaItemRequestSchema = z.object({
position: ItemPositionSchema,
is_valued: z.boolean(),
description: z.string().nullable(),
description: z.string().nullable(),
quantity: NumericStringSchema.nullable(),
unit_amount: NumericStringSchema.nullable(),
quantity: NumericStringSchema.nullable(),
unit_amount: NumericStringSchema.nullable(),
item_discount_percentage: PercentageSchema.nullable(),
item_discount_percentage: PercentageSchema.nullable(),
taxes: TaxCombinationCodeSchema,
})
.refine(
(item) => {
if (!item.is_valued) {
return item.quantity === null && item.unit_amount === null;
}
return item.quantity !== null && item.unit_amount !== null;
},
{
message:
"quantity and unit_amount must be null when is_valued is false and non-null when is_valued is true",
path: ["is_valued"],
}
);
taxes: TaxCombinationCodeSchema,
});
export const UpdateProformaByIdParamsRequestSchema = z.object({
proforma_id: z.uuid(),

View File

@ -1 +0,0 @@
export * from "./use-proforma-auto-recalc";

View File

@ -1,77 +0,0 @@
import { MoneyDTO } from "@erp/core";
import { useMoney, usePercentage, useQuantity } from "@erp/core/hooks";
import { useMemo } from "react";
import { InvoiceItemFormData } from "../../schemas";
/**
* Calcula totales derivados de un ítem de factura
*/
export type InvoiceItemTotals = Readonly<{
// valores base ya normalizados a number
quantity: number;
unitAmount: number;
discountPercent: number;
// desgloses numéricos
subtotal: number; // qty * unit
discountAmount: number; // subtotal * (discountPercent/100)
taxableBase: number; // subtotal - discountAmount
taxes: number; // por ahora 0 (o calcula según tax_codes si lo necesitas)
total: number; // taxableBase + taxes
// equivalentes en MoneyDTO (misma divisa/escala que useMoney)
subtotalDTO: MoneyDTO;
discountAmountDTO: MoneyDTO;
taxableBaseDTO: MoneyDTO;
taxesDTO: MoneyDTO;
totalDTO: MoneyDTO;
}>;
/**
* Calcula totales derivados de una línea de factura usando tus hooks de Money/Quantity/Percentage.
*/
export function useCalcInvoiceItemTotals(item?: InvoiceItemFormData): InvoiceItemTotals {
const moneyHelper = useMoney();
const qtyHelper = useQuantity();
const pctHelper = usePercentage();
return useMemo<InvoiceItemTotals>(() => {
// valores base
const quantity = item ? qtyHelper.toNumber(item.quantity) : 0;
const unitAmount = item ? moneyHelper.toNumber(item.unit_amount) : 0;
const discountPercent = item ? pctHelper.toNumber(item.discount_percentage) : 0;
// cálculos
const subtotal = quantity * unitAmount;
const discountAmount = subtotal * (discountPercent / 100);
const taxableBase = subtotal - discountAmount;
// impuestos (ajústalo si quieres aplicar tax_codes)
const taxes = 0;
const total = taxableBase + taxes;
// DTOs
const subtotalDTO = moneyHelper.fromNumber(subtotal);
const discountAmountDTO = moneyHelper.fromNumber(discountAmount);
const taxableBaseDTO = moneyHelper.fromNumber(taxableBase);
const taxesDTO = moneyHelper.fromNumber(taxes);
const totalDTO = moneyHelper.fromNumber(total);
return {
quantity,
unitAmount,
discountPercent,
subtotal,
discountAmount,
taxableBase,
taxes,
total,
subtotalDTO,
discountAmountDTO,
taxableBaseDTO,
taxesDTO,
totalDTO,
};
}, [item, moneyHelper, qtyHelper, pctHelper]);
}

View File

@ -1,87 +0,0 @@
import { MoneyDTO } from "@erp/core";
import { useMoney, usePercentage, useQuantity } from "@erp/core/hooks";
import { useMemo } from "react";
import { InvoiceItemFormData } from "../../schemas";
export type InvoiceTotals = Readonly<{
subtotal: number;
discountTotal: number;
taxableBase: number;
taxes: number;
total: number;
subtotalDTO: MoneyDTO;
discountTotalDTO: MoneyDTO;
taxableBaseDTO: MoneyDTO;
taxesDTO: MoneyDTO;
totalDTO: MoneyDTO;
// número de líneas válidas consideradas
itemCount: number;
}>;
/**
* Calcula los totales generales de la factura a partir de sus líneas.
*/
export function useCalcInvoiceTotals(items: InvoiceItemFormData[] | undefined): InvoiceTotals {
const money = useMoney();
const qty = useQuantity();
const pct = usePercentage();
return useMemo<InvoiceTotals>(() => {
if (!items?.length) {
const zero = money.fromNumber(0);
return {
subtotal: 0,
discountTotal: 0,
taxableBase: 0,
taxes: 0,
total: 0,
subtotalDTO: zero,
discountTotalDTO: zero,
taxableBaseDTO: zero,
taxesDTO: zero,
totalDTO: zero,
itemCount: 0,
};
}
let subtotal = 0;
let discountTotal = 0;
let taxableBase = 0;
let taxes = 0;
let total = 0;
for (const item of items) {
const quantity = qty.toNumber(item.quantity);
const unit = money.toNumber(item.unit_amount);
const discountPct = pct.toNumber(item.discount_percentage);
const lineSubtotal = quantity * unit;
const lineDiscount = lineSubtotal * (discountPct / 100);
const lineTaxable = lineSubtotal - lineDiscount;
const lineTaxes = 0; // ← ajusta si aplicas IVA o impuestos reales
const lineTotal = lineTaxable + lineTaxes;
subtotal += lineSubtotal;
discountTotal += lineDiscount;
taxableBase += lineTaxable;
taxes += lineTaxes;
total += lineTotal;
}
return {
subtotal,
discountTotal,
taxableBase,
taxes,
total,
subtotalDTO: money.fromNumber(subtotal),
discountTotalDTO: money.fromNumber(discountTotal),
taxableBaseDTO: money.fromNumber(taxableBase),
taxesDTO: money.fromNumber(taxes),
totalDTO: money.fromNumber(total),
itemCount: items.length,
};
}, [items, money, qty, pct]);
}

View File

@ -1,102 +0,0 @@
import { MoneyDTO, PercentageDTO, QuantityDTO, SpainTaxCatalogProvider } from "@erp/core";
import { useMoney, usePercentage, useQuantity } from "@erp/core/hooks";
import { useMemo } from "react";
/**
* Calcula subtotal, descuento, base imponible, impuestos y total de una línea de factura.
* Trabaja con DTOs escalados (value+scale) como los del backend.
*/
type ItemShape = {
quantity: QuantityDTO | null | undefined;
unit_amount: MoneyDTO | null | undefined;
discount_percentage?: PercentageDTO | null;
tax_codes?: string[] | null;
};
// ⚠️ Devuelve todo en MoneyDTO manteniendo moneda/escala del unit_amount
export function useInvoiceItemSummary(item: ItemShape) {
const {
add,
sub,
multiply,
percentage: moneyPct,
fromNumber,
isEmptyMoneyDTO,
fallbackCurrency,
} = useMoney();
const { toNumber: qtyToNumber } = useQuantity();
const { toNumber: pctToNumber } = usePercentage();
// Cero monetario con la misma divisa/escala del unit_amount (fallback EUR/2)
const zero = useMemo<MoneyDTO>(() => {
const cur = item.unit_amount?.currency_code ?? fallbackCurrency;
const sc = Number(item.unit_amount?.scale ?? 2);
return fromNumber(0, cur as any, sc);
}, [item.unit_amount?.currency_code, item.unit_amount?.scale, fromNumber, fallbackCurrency]);
const taxCatalog = useMemo(() => SpainTaxCatalogProvider(), []);
return useMemo(() => {
// 1) Cantidad
const qty = qtyToNumber(item.quantity); // 0 si null/DTO vacío
// 2) Subtotal = quantity × unit_amount
const unit = item.unit_amount && !isEmptyMoneyDTO(item.unit_amount) ? item.unit_amount : zero;
const subtotal = multiply(unit, qty); // usa dinero.js, respeta escala
// 3) Descuento
const pctDTO = item.discount_percentage ?? ({ value: "", scale: "" } as PercentageDTO);
const pct = pctToNumber(pctDTO); // 0 si vacío
const discountAmount = pct !== 0 ? moneyPct(subtotal, pct) : zero;
// 4) Base imponible = subtotal - descuento
const baseAmount = sub(subtotal, discountAmount);
// 5) Impuestos (cada código es un % sobre base; soporta negativos)
const taxesBreakdown =
item.tax_codes?.map((code) => {
const maybe = taxCatalog.findByCode(code);
if (maybe.isNone()) return { label: code, percentage: 0, amount: zero };
const tax = maybe.unwrap()!; // { name, value, scale }
const p = pctToNumber({ value: tax.value, scale: tax.scale }); // ej. 21 → 21%
return {
label: tax.name,
percentage: p,
amount: moneyPct(baseAmount, p),
};
}) ?? [];
// 6) Total impuestos = suma amounts
const taxesTotal = taxesBreakdown.reduce((acc, t) => add(acc, t.amount), zero);
// 7) Total línea = base + impuestos
const total = add(baseAmount, taxesTotal);
return {
qty,
subtotal,
discountAmount,
baseAmount,
taxesBreakdown, // [{label, percentage, amount}]
taxesTotal,
total,
};
}, [
item.quantity,
item.unit_amount,
item.discount_percentage,
item.tax_codes,
qtyToNumber,
pctToNumber,
add,
sub,
multiply,
moneyPct,
isEmptyMoneyDTO,
taxCatalog,
zero,
]);
}

View File

@ -1,191 +0,0 @@
import type { TaxCatalogProvider } from "@erp/core";
import React from "react";
import { type UseFormReturn, useWatch } from "react-hook-form";
import {
type InvoiceItemCalcResult,
calculateInvoiceHeaderAmounts,
calculateInvoiceItemAmounts,
} from "../../domain";
import type { ProformaFormData } from "../../proformas/types";
import type { InvoiceFormData, InvoiceItemFormData } from "../../schemas";
export type UseProformaAutoRecalcParams = {
currency_code: string;
taxCatalog: TaxCatalogProvider;
debug?: boolean;
};
/**
* Hook que recalcula automáticamente los totales de cada línea
* y los totales generales de la factura cuando cambian los valores relevantes.
* Adaptado a formulario con números planos (no DTOs).
* Evita renders innecesarios (debounce + useDeferredValue).
*/
export function useProformaAutoRecalc(
form: UseFormReturn<ProformaFormData>,
{ currency_code, taxCatalog, debug = true }: UseProformaAutoRecalcParams
) {
const { trigger, control } = form;
// Observa los ítems y el descuento global
const watchedItems = useWatch({ control, name: "items" }) ?? [];
const watchedDiscount = useWatch({ control, name: "discount_percentage", defaultValue: 0 }); // <- descuento global
// Diferir valores pesados para reducir renders (React 19)
const deferredItems = React.useDeferredValue(watchedItems);
const deferredDiscount = React.useDeferredValue(watchedDiscount);
// Cache para evitar recálculos redundantes
const [prevDiscount, setPrevDiscount] = React.useState(watchedDiscount);
const itemCache = React.useRef<Map<number, InvoiceItemCalcResult>>(new Map());
// Debounce para agrupar recalculados rápidos
const debounceTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
// Cálculo de una línea individual
const calculateItemTotals = React.useCallback(
(item: InvoiceItemFormData) => {
const sanitize = (v?: number | string) => (v && !Number.isNaN(Number(v)) ? String(v) : "0");
return calculateInvoiceItemAmounts(
{
quantity: sanitize(item.quantity),
unit_amount: sanitize(item.unit_amount),
discount_percentage: sanitize(item.discount_percentage),
tax_codes: item.tax_codes,
},
currency_code,
taxCatalog
);
},
[taxCatalog, currency_code]
);
// Cálculo global de factura
const calculateInvoiceTotals = React.useCallback(
(items: InvoiceItemFormData[], header_discount_percentage: number) => {
const lines = items
//.filter((i) => i.is_valued)
.map((i) => {
const totals = calculateItemTotals(i);
return {
subtotal_amount: totals.subtotal_amount,
discount_amount: totals.discount_amount,
taxable_amount: totals.taxable_amount,
taxes_amount: totals.taxes_amount,
total_amount: totals.total_amount,
taxes_summary: totals.taxes_summary,
};
});
return calculateInvoiceHeaderAmounts(lines, header_discount_percentage, currency_code);
},
[calculateItemTotals, currency_code]
);
// Observamos el formulario esperando cualquier cambio
React.useEffect(() => {
if (!deferredItems.length) return;
if (debounceTimer.current) clearTimeout(debounceTimer.current);
debounceTimer.current = setTimeout(() => {
let shouldUpdateHeader = false;
if (prevDiscount !== deferredDiscount) {
shouldUpdateHeader = true;
setPrevDiscount(deferredDiscount);
}
deferredItems.forEach((item, idx) => {
const prev = itemCache.current.get(idx);
const next = calculateItemTotals(item);
const itemHasChanges =
!prev ||
prev.subtotal_amount !== next.subtotal_amount ||
prev.total_amount !== next.total_amount ||
prev.taxes_amount !== next.taxes_amount;
//if (!prev || JSON.stringify(prev) !== JSON.stringify(next)) { <-- Costoso y poco preciso
if (itemHasChanges) {
shouldUpdateHeader = true;
itemCache.current.set(idx, next);
setInvoiceItemTotals(form, idx, next);
if (debug) console.log(`💡 Recalc line ${idx + 1}`, next.total_amount);
}
});
if (shouldUpdateHeader) {
const totals = calculateInvoiceTotals(deferredItems, deferredDiscount);
setInvoiceTotals(form, totals);
if (debug) console.log("📊 Recalc invoice totals", totals.subtotal_amount);
trigger([
"subtotal_amount",
"discount_amount",
"taxable_amount",
"taxes_amount",
"total_amount",
]);
}
}, 100); // <-- debounce de 100ms, ajustable
return () => {
if (debounceTimer.current) clearTimeout(debounceTimer.current);
};
}, [
deferredItems,
deferredDiscount,
calculateItemTotals,
calculateInvoiceTotals,
form,
trigger,
debug,
prevDiscount,
]);
}
// Ayudante para rellenar los importes de una línea
function setInvoiceItemTotals(
form: UseFormReturn<InvoiceFormData>,
index: number,
totals: InvoiceItemCalcResult
) {
const { setValue } = form;
const opts = { shouldDirty: true, shouldValidate: false } as const;
setValue(`items.${index}.subtotal_amount`, totals.subtotal_amount, opts);
setValue(`items.${index}.discount_amount`, totals.discount_amount, opts);
setValue(`items.${index}.taxable_amount`, totals.taxable_amount, opts);
setValue(`items.${index}.taxes_amount`, totals.taxes_amount, opts);
setValue(`items.${index}.total_amount`, totals.total_amount, opts);
}
// Ayudante para actualizar los importes de la cabecera
function setInvoiceTotals(
form: UseFormReturn<InvoiceFormData>,
totals: ReturnType<typeof calculateInvoiceHeaderAmounts>
) {
const { setValue } = form;
const opts = { shouldDirty: true, shouldValidate: false } as const;
setValue("subtotal_amount", totals.subtotal_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("taxes_amount", totals.taxes_amount, opts);
setValue("total_amount", totals.total_amount, opts);
setValue(
"taxes",
totals.taxes_summary.map((t) => ({
tax_code: t.code,
tax_label: t.name,
taxable_amount: t.taxable_amount,
taxes_amount: t.taxes_amount,
})),
opts
);
}

View File

@ -1,4 +1,5 @@
import { MoneyDTOHelper, formatCurrency } from "@erp/core";
import { MoneyDTOHelper } from "@erp/core";
import { MoneyHelper } from "@repo/rdx-utils";
import type { ListIssuedInvoicesResponseDTO } from "../../../../common";
import type { IssuedInvoiceList, IssuedInvoiceListRow, IssuedInvoiceStatus } from "../entities";
@ -50,7 +51,7 @@ const IssuedInvoiceListRowAdapter = {
},
subtotalAmount: MoneyDTOHelper.toNumber(dto.subtotal_amount),
subtotalAmountFmt: formatCurrency(
subtotalAmountFmt: MoneyHelper.formatCurrency(
MoneyDTOHelper.toNumber(dto.subtotal_amount),
Number(dto.total_amount.scale || 2),
dto.currency_code,
@ -58,7 +59,7 @@ const IssuedInvoiceListRowAdapter = {
),
totalDiscountAmount: MoneyDTOHelper.toNumber(dto.total_discount_amount),
totalDiscountAmountFmt: formatCurrency(
totalDiscountAmountFmt: MoneyHelper.formatCurrency(
MoneyDTOHelper.toNumber(dto.total_discount_amount),
Number(dto.total_amount.scale || 2),
dto.currency_code,
@ -66,7 +67,7 @@ const IssuedInvoiceListRowAdapter = {
),
taxableAmount: MoneyDTOHelper.toNumber(dto.taxable_amount),
taxableAmountFmt: formatCurrency(
taxableAmountFmt: MoneyHelper.formatCurrency(
MoneyDTOHelper.toNumber(dto.taxable_amount),
Number(dto.total_amount.scale || 2),
dto.currency_code,
@ -74,7 +75,7 @@ const IssuedInvoiceListRowAdapter = {
),
taxesAmount: MoneyDTOHelper.toNumber(dto.taxes_amount),
taxesAmountFmt: formatCurrency(
taxesAmountFmt: MoneyHelper.formatCurrency(
MoneyDTOHelper.toNumber(dto.taxes_amount),
Number(dto.total_amount.scale || 2),
dto.currency_code,
@ -82,7 +83,7 @@ const IssuedInvoiceListRowAdapter = {
),
totalAmount: MoneyDTOHelper.toNumber(dto.total_amount),
totalAmountFmt: formatCurrency(
totalAmountFmt: MoneyHelper.formatCurrency(
MoneyDTOHelper.toNumber(dto.total_amount),
Number(dto.total_amount.scale || 2),
dto.currency_code,

View File

@ -1,874 +0,0 @@
import { CustomerModalSelector } from "@erp/customers/components";
import { DevTool } from "@hookform/devtools";
import { zodResolver } from "@hookform/resolvers/zod";
import { TextAreaField, TextField } from "@repo/rdx-ui/components";
import {
Button,
Calendar,
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Label,
Popover,
PopoverContent,
PopoverTrigger,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Separator,
Textarea,
} from "@repo/shadcn-ui/components";
import { format } from "date-fns";
import { es } from "date-fns/locale";
import { CalendarIcon, PlusIcon, Save, Trash2Icon, X } from "lucide-react";
import { useFieldArray, useForm } from "react-hook-form";
import * as z from "zod";
import { useTranslation } from "../../i18n";
import { CustomerInvoicePricesCard } from "../../proformas/shared/ui/components";
import { CustomerInvoiceItemsCardEditor } from "../../proformas/shared/ui/components/items";
import type { CustomerInvoiceData } from "./customer-invoice.schema";
import { formatCurrency } from "./utils";
const invoiceFormSchema = z.object({
id: z.string(),
invoice_status: z.string(),
invoice_number: z.string().min(1, "Número de factura requerido"),
invoice_series: z.string().min(1, "Serie requerida"),
invoice_date: z.string(),
operation_date: z.string(),
language_code: z.string(),
currency: z.string(),
customer_id: z.string().min(1, "ID de cliente requerido"),
items: z
.array(
z.object({
description: z.string().optional(),
quantity: z
.object({
amount: z.number().nullable(),
scale: z.number(),
})
.optional(),
unit_price: z
.object({
amount: z.number().nullable(),
scale: z.number(),
currency_code: z.string(),
})
.optional(),
subtotal_price: z
.object({
amount: z.number().nullable(),
scale: z.number(),
currency_code: z.string(),
})
.optional(),
discount: z
.object({
amount: z.number().min(0).max(100).nullable(),
scale: z.number(),
})
.optional(),
discount_price: z
.object({
amount: z.number().nullable(),
scale: z.number(),
currency_code: z.string(),
})
.optional(),
total_price: z
.object({
amount: z.number().nullable(),
scale: z.number(),
currency_code: z.string(),
})
.optional(),
})
)
.min(1, "Al menos un item es requerido"),
subtotal_price: z.object({
amount: z.number().nullable(),
scale: z.number(),
currency_code: z.string(),
}),
discount: z.object({
amount: z.number().nullable(),
scale: z.number(),
}),
discount_price: z.object({
amount: z.number().nullable(),
scale: z.number(),
currency_code: z.string(),
}),
before_tax_price: z.object({
amount: z.number().nullable(),
scale: z.number(),
currency_code: z.string(),
}),
tax: z.object({
amount: z.number().nullable(),
scale: z.number(),
}),
tax_price: z.object({
amount: z.number().nullable(),
scale: z.number(),
currency_code: z.string(),
}),
total_price: z.object({
amount: z.number().nullable(),
scale: z.number(),
currency_code: z.string(),
}),
metadata: z.object({
entity: z.string(),
}),
});
const defaultInvoiceData = {
id: "34ae34af-1ffc-4de5-b0a8-c2cf203ef011",
invoice_status: "draft",
invoice_number: "1",
invoice_series: "A",
invoice_date: "2025-04-30T00:00:00.000Z",
operation_date: "2025-04-30T00:00:00.000Z",
description: "",
language_code: "ES",
currency: "EUR",
customer_id: "5e4dc5b3-96b9-4968-9490-14bd032fec5f",
items: [
{
description: "",
quantity: {
amount: 100,
scale: 2,
},
unit_price: {
amount: 100,
scale: 2,
currency_code: "EUR",
},
subtotal_price: {
amount: 100,
scale: 2,
currency_code: "EUR",
},
discount: {
amount: 0,
scale: 2,
},
discount_price: {
amount: 0,
scale: 2,
currency_code: "EUR",
},
total_price: {
amount: 100,
scale: 2,
currency_code: "EUR",
},
},
],
subtotal_price: {
amount: 0,
scale: 2,
currency_code: "EUR",
},
discount: {
amount: 0,
scale: 0,
},
discount_price: {
amount: 0,
scale: 0,
currency_code: "EUR",
},
before_tax_price: {
amount: 0,
scale: 2,
currency_code: "EUR",
},
tax: {
amount: 2100,
scale: 2,
},
tax_price: {
amount: 0,
scale: 2,
currency_code: "EUR",
},
total_price: {
amount: 0,
scale: 2,
currency_code: "EUR",
},
};
interface InvoiceFormProps {
initialData?: CustomerInvoiceData;
isPending?: boolean;
/**
* Callback function to handle form submission.
* @param data - The invoice data submitted by the form.
*/
onSubmit?: (data: CustomerInvoiceData) => void;
}
export const CreateCustomerInvoiceEditForm = ({
initialData = defaultInvoiceData,
onSubmit,
isPending,
}: InvoiceFormProps) => {
const { t } = useTranslation();
const form = useForm<CustomerInvoiceData>({
resolver: zodResolver(invoiceFormSchema),
defaultValues: initialData,
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "items",
});
const watchedItems = form.watch("items");
const watchedTaxRate = form.watch("tax.amount");
const addItem = () => {
append({
id_article: "",
description: "",
quantity: { amount: 100, scale: 2 },
unit_price: { amount: 0, scale: 2, currency_code: form.getValues("currency") },
subtotal_price: { amount: 0, scale: 2, currency_code: form.getValues("currency") },
discount: { amount: 0, scale: 2 },
discount_price: { amount: 0, scale: 2, currency_code: form.getValues("currency") },
total_price: { amount: 0, scale: 2, currency_code: form.getValues("currency") },
});
};
const handleSubmit = (data: CustomerInvoiceData) => {
console.log("Datos del formulario:", data);
onSubmit?.(data);
};
const handleError = (errors: any) => {
console.error("Errores en el formulario:", errors);
// Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
};
const handleCancel = () => {
form.reset(initialData);
};
return (
<Form {...form}>
<form
className="grid grid-cols-1 md:gap-6 md:grid-cols-2"
onSubmit={form.handleSubmit(handleSubmit, handleError)}
>
<Card className="border-0 shadow-none md:grid-span-2">
<CardHeader>
<CardTitle>Cliente</CardTitle>
<CardDescription>Description</CardDescription>
<CardAction>
<Button variant="link">Sign Up</Button>
<Button variant="link">Sign Up</Button>
<Button variant="link">Sign Up</Button>
<Button variant="link">Sign Up</Button>
</CardAction>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 space-y-6">
<div>
<div className="space-y-1">
<h4 className="text-sm leading-none font-medium">Radix Primitives</h4>
<p className="text-muted-foreground text-sm">
An open-source UI component library.
</p>
</div>
<Separator className="my-4" />
<div className="flex h-5 items-center space-x-4 text-sm">
<div>Blog</div>
<Separator orientation="vertical" />
<div>Docs</div>
<Separator orientation="vertical" />
<div>Source</div>
</div>
</div>
</CardContent>
<CardFooter className="flex-col gap-2">
<Button className="w-full" type="submit">
Login
</Button>
<Button className="w-full" variant="outline">
Login with Google
</Button>
</CardFooter>{" "}
</Card>
{/* Información básica */}
<Card className="border-0 shadow-none ">
<CardHeader>
<CardTitle>Información Básica</CardTitle>
<CardDescription>Detalles generales de la factura</CardDescription>
</CardHeader>
<CardContent className="space-y-8">
<div className="grid gap-y-6 gap-x-8 md:grid-cols-4">
<TextField
control={form.control}
description={t("form_fields.invoice_number.description")}
disabled
label={t("form_fields.invoice_number.label")}
name="invoice_number"
placeholder={t("form_fields.invoice_number.placeholder")}
readOnly
required
/>
<DatePickerInputField
control={form.control}
description={t("form_fields.invoice_date.description")}
label={t("form_fields.invoice_date.label")}
name="invoice_date"
placeholder={t("form_fields.invoice_date.placeholder")}
required
/>
<TextField
control={form.control}
description={t("form_fields.invoice_series.description")}
label={t("form_fields.invoice_series.label")}
name="invoice_series"
placeholder={t("form_fields.invoice_series.placeholder")}
required
/>
</div>
<div className="grid gap-y-6 gap-x-8 grid-cols-1">
<TextField
control={form.control}
description={t("form_fields.description.description")}
label={t("form_fields.description.label")}
name="description"
placeholder={t("form_fields.description.placeholder")}
required
/>
</div>
<div className="grid gap-y-6 gap-x-8 grid-cols-1">
<TextAreaField
control={form.control}
description={t("form_fields.notes.description")}
label={t("form_fields.notes.label")}
name="notes"
placeholder={t("form_fields.notes.placeholder")}
required
/>
</div>
</CardContent>
</Card>
{/* Cliente */}
<Card className="col-span-full">
<CardHeader>
<CardTitle>Cliente</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 space-y-6">
<CustomerModalSelector />
<TextField
control={form.control}
description={t("form_fields.customer_id.description")}
label={t("form_fields.customer_id.label")}
name="customer_id"
placeholder={t("form_fields.customer_id.placeholder")}
required
/>
</CardContent>
</Card>
{/*Items */}
<CustomerInvoiceItemsCardEditor
className="col-span-full"
defaultValues={defaultInvoiceData}
/>
{/* Items */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Artículos</CardTitle>
<CardDescription>Lista de productos o servicios facturados</CardDescription>
</div>
<Button onClick={addItem} size="sm" type="button">
<PlusIcon className="h-4 w-4 mr-2" />
Agregar Item
</Button>
</CardHeader>
<CardContent className="space-y-4">
{fields.map((field, index) => (
<Card className="p-4" key={field.id}>
<div className="flex justify-between items-start mb-4">
<div className="flex items-center gap-2">
<h4 className="font-medium">Item {index + 1}</h4>
</div>
{fields.length > 1 && (
<Button onClick={() => remove(index)} size="sm" type="button" variant="outline">
<Trash2Icon className="h-4 w-4" />
</Button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
<FormField
control={form.control}
name={`items.${index}.id_article`}
render={({ field }) => (
<FormItem>
<FormLabel>Código Artículo</FormLabel>
<FormControl>
<Input placeholder="Código" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`items.${index}.description`}
render={({ field }) => (
<FormItem className="md:col-span-2">
<FormLabel>Descripción</FormLabel>
<FormControl>
<Textarea placeholder="Descripción del producto/servicio" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<TextAreaField
control={form.control}
description={t("form_fields.items.description.description")}
label={t("form_fields.items.description.label")}
name={`items.${index}.description`}
placeholder={t("form_fields.items.description.placeholder")}
/>
<FormField
control={form.control}
name={`items.${index}.quantity.amount`}
render={({ field }) => (
<FormItem>
<FormLabel>Cantidad</FormLabel>
<FormControl>
<Input
min="0"
step="0.01"
type="number"
{...field}
onChange={(e) => field.onChange(Number(e.target.value) * 100)}
value={field.value / 100}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`items.${index}.unit_price.amount`}
render={({ field }) => (
<FormItem>
<FormLabel>Precio Unitario</FormLabel>
<FormControl>
<Input
min="0"
step="0.01"
type="number"
{...field}
onChange={(e) => field.onChange(Number(e.target.value) * 100)}
value={field.value / 100}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`items.${index}.discount.amount`}
render={({ field }) => (
<FormItem>
<FormLabel>Descuento (%)</FormLabel>
<FormControl>
<Input
max="100"
min="0"
step="0.01"
type="number"
{...field}
onChange={(e) => field.onChange(Number(e.target.value) * 100)}
value={field.value / 100}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="mt-4 p-3 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground">
Total del item:{" "}
{formatCurrency(
watchedItems[index]?.total_price?.amount || 0,
2,
form.getValues("currency")
)}
</div>
</div>
</Card>
))}
</CardContent>
</Card>
<CustomerInvoicePricesCard />
{/* Configuración de Impuestos */}
<Card>
<CardHeader>
<CardTitle>Impuestos y Totales</CardTitle>
<CardDescription>Configuración de impuestos y resumen de totales</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="tax.amount"
render={({ field }) => (
<FormItem>
<FormLabel>Tasa de Impuesto (%)</FormLabel>
<FormControl>
<Input
max="100"
min="0"
step="0.01"
type="number"
{...field}
onChange={(e) => field.onChange(Number(e.target.value) * 100)}
value={field.value / 100}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Separator />
{/* Resumen de totales */}
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span>Subtotal:</span>
<span>
{formatCurrency(form.watch("subtotal_price.amount"), 2, form.watch("currency"))}
</span>
</div>
<div className="flex justify-between text-sm">
<span>Descuento:</span>
<span>
-{formatCurrency(form.watch("discount_price.amount"), 2, form.watch("currency"))}
</span>
</div>
<div className="flex justify-between text-sm">
<span>Base imponible:</span>
<span>
{formatCurrency(form.watch("before_tax_price.amount"), 2, form.watch("currency"))}
</span>
</div>
<div className="flex justify-between text-sm">
<span>Impuestos ({(form.watch("tax.amount") / 100).toFixed(2)}%):</span>
<span>
{formatCurrency(form.watch("tax_price.amount"), 2, form.watch("currency"))}
</span>
</div>
<Separator />
<div className="flex justify-between text-lg font-semibold">
<span>Total:</span>
<span>
{formatCurrency(form.watch("total_price.amount"), 2, form.watch("currency"))}
</span>
</div>
</div>
</CardContent>
</Card>
<div className="flex justify-end space-x-4">
<Button disabled={isPending} onClick={handleCancel} type="button" variant="outline">
Cancelar
</Button>
<Button disabled={isPending} type="submit">
Guardar Factura
</Button>
</div>
</form>
<DevTool control={form.control} />
</Form>
);
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 bg-muted/50">
<form className="space-y-6" onSubmit={handleSubmit}>
{/* Información básica */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="id">ID de Factura</Label>
<Input className="bg-muted" disabled id="id" value={formData.id} />
</div>
<div className="space-y-2">
<Label htmlFor="invoice_status">Estado</Label>
<Select
onValueChange={(value) => handleInputChange("invoice_status", value)}
value={formData.invoice_status}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">Borrador</SelectItem>
<SelectItem value="sent">Enviada</SelectItem>
<SelectItem value="paid">Pagada</SelectItem>
<SelectItem value="cancelled">Cancelada</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="language_code">Idioma</Label>
<Select
onValueChange={(value) => handleInputChange("language_code", value)}
value={formData.language_code}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ES">Español</SelectItem>
<SelectItem value="EN">English</SelectItem>
<SelectItem value="FR">Français</SelectItem>
<SelectItem value="DE">Deutsch</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Numeración */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="invoice_series">Serie</Label>
<Input
id="invoice_series"
onChange={(e) => handleInputChange("invoice_series", e.target.value)}
placeholder="A"
value={formData.invoice_series}
/>
</div>
<div className="space-y-2">
<Label htmlFor="invoice_number">Número</Label>
<Input
id="invoice_number"
onChange={(e) => handleInputChange("invoice_number", e.target.value)}
placeholder="1"
value={formData.invoice_number}
/>
</div>
<div className="space-y-2 hidden">
<Label htmlFor="currency">Moneda</Label>
<Select
onValueChange={(value) => handleInputChange("currency", value)}
value={formData.currency}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="EUR">EUR ()</SelectItem>
<SelectItem value="USD">USD ($)</SelectItem>
<SelectItem value="GBP">GBP (£)</SelectItem>
<SelectItem value="JPY">JPY (¥)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Fechas */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Fecha de Emisión</Label>
<Popover>
<PopoverTrigger>
<Button className="w-full justify-start text-left font-normal" variant="outline">
<CalendarIcon className="mr-2 h-4 w-4" />
{format(invoiceDate, "PPP", { locale: es })}
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-auto p-0">
<Calendar
initialFocus
mode="single"
onSelect={(date) => handleDateChange("invoice_date", date)}
selected={invoiceDate}
/>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label>Fecha de Operación</Label>
<Popover>
<PopoverTrigger>
<Button className="w-full justify-start text-left font-normal" variant="outline">
<CalendarIcon className="mr-2 h-4 w-4" />
{format(operationDate, "PPP", { locale: es })}
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-auto p-0">
<Calendar
initialFocus
mode="single"
onSelect={(date) => handleDateChange("operation_date", date)}
selected={operationDate}
/>
</PopoverContent>
</Popover>
</div>
</div>
{/* Importes */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Importes</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Subtotal</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-2">
<Label htmlFor="subtotal_amount">Importe</Label>
<Input
id="subtotal_amount"
onChange={(e) =>
handleNestedChange(
"subtotal",
"amount",
Number.parseFloat(e.target.value) * 10 ** formData.subtotal.scale
)
}
step="0.01"
type="number"
value={formData.subtotal.amount / 10 ** formData.subtotal.scale}
/>
</div>
<div className="space-y-2">
<Label htmlFor="subtotal_currency">Moneda</Label>
<Select
onValueChange={(value) =>
handleNestedChange("subtotal", "currency_code", value)
}
value={formData.subtotal.currency_code}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="EUR">EUR</SelectItem>
<SelectItem value="USD">USD</SelectItem>
<SelectItem value="GBP">GBP</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Total</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-2">
<Label htmlFor="total_amount">Importe</Label>
<Input
id="total_amount"
onChange={(e) =>
handleNestedChange(
"total",
"amount",
Number.parseFloat(e.target.value) * 10 ** formData.total.scale
)
}
step="0.01"
type="number"
value={formData.total.amount / 10 ** formData.total.scale}
/>
</div>
<div className="space-y-2">
<Label htmlFor="total_currency">Moneda</Label>
<Select
onValueChange={(value) => handleNestedChange("total", "currency_code", value)}
value={formData.total.currency_code}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="EUR">EUR</SelectItem>
<SelectItem value="USD">USD</SelectItem>
<SelectItem value="GBP">GBP</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
</div>
</div>
{/* Botones de acción */}
<div className="flex justify-end gap-3 pt-6 border-t">
<Button
className="flex items-center gap-2"
onClick={handleCancel}
type="button"
variant="outline"
>
<X className="h-4 w-4" />
Cancelar
</Button>
<Button className="flex items-center gap-2" type="submit">
<Save className="h-4 w-4" />
Guardar Cambios
</Button>
</div>
</form>
</div>
);
};

View File

@ -1,131 +0,0 @@
import { AppContent } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { useNavigate } from "react-router-dom";
import { useCreateProforma } from "../../hooks";
import { useTranslation } from "../../i18n";
import { CreateCustomerInvoiceEditForm } from "./create-customer-invoice-edit-form";
export const CustomerInvoiceCreate = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { mutate, isPending, isError, error } = useCreateProforma();
const handleSubmit = (data: any) => {
// Handle form submission logic here
console.log("Form submitted with data:", data);
mutate(data);
// Navigate to the list page after submission
navigate("/customer-invoices/list");
};
if (isError) {
console.error("Error creating customer invoice:", error);
// Optionally, you can show an error message to the user
}
// Render the component
// You can also handle loading state if needed
// For example, you can disable the submit button while the mutation is in progress
// const isLoading = useCreateCustomerInvoiceMutation().isLoading;
// Return the JSX for the component
// You can customize the form and its fields as needed
// For example, you can use a form library like react-hook-form or Formik to handle form state and validation
// Here, we are using a simple form with a submit button
// Note: Make sure to replace the form fields with your actual invoice fields
// and handle validation as needed.
// This is just a basic example to demonstrate the structure of the component.
// If you are using a form library, you can pass the handleSubmit function to the form's onSubmit prop
// and use the form library's methods to handle form state and validation.
// Example of a simple form submission handler
// You can replace this with your actual form handling logic
// const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
// event.preventDefault();
// const formData = new FormData(event.currentTarget);
return (
<>
<AppContent>
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">{t("pages.create.title")}</h2>
<p className="text-muted-foreground">{t("pages.create.description")}</p>
</div>
<div className="flex items-center justify-end mb-4">
<Button className="cursor-pointer" onClick={() => navigate("/customer-invoices/list")}>
{t("pages.create.back_to_list")}
</Button>
</div>
</div>
<div className="flex flex-1 flex-col gap-4 p-4">
<CreateCustomerInvoiceEditForm isPending={isPending} onSubmit={handleSubmit} />
</div>
</AppContent>
</>
);
};
/*
return (
<>
<div className='flex items-center justify-between space-y-2'>
<div>
<h2 className='text-2xl font-bold tracking-tight'>
{t('customerInvoices.list.title' />
</h2>
<p className='text-muted-foreground'>
{t('CustomerInvoices.list.subtitle' />
</p>
</div>
<div className='flex items-center space-x-2'>
<Button onClick={() => navigate("/CustomerInvoices/add")}>
<PlusIcon className='w-4 h-4 mr-2' />
{t("customerInvoices.create.title")}
</Button>
</div>
</div>
<Tabs value={status} onValueChange={setStatus}>
<div className='flex flex-col items-start justify-between mb-4 sm:flex-row sm:items-center'>
<div className='w-full mb-4 sm:w-auto sm:mb-0'>
<TabsList className='hidden sm:flex'>
{CustomerInvoiceStatuses.map((s) => (
<TabsTrigger key={s.value} value={s.value}>
{s.label}
</TabsTrigger>
))}
</TabsList>
<div className='flex items-center w-full space-x-2 sm:hidden'>
<Label>{t("customerInvoices.list.tabs_title")}</Label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger>
<SelectValue placeholder='Seleccionar estado' />
</SelectTrigger>
<SelectContent>
{CustomerInvoiceStatuses.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{CustomerInvoiceStatuses.map((s) => (
<TabsContent key={s.value} value={s.value}>
<CustomerInvoicesGrid />
</TabsContent>
))}
</Tabs>
</>
);
};
*/

View File

@ -1,41 +0,0 @@
import { z } from "zod/v4";
import { CreateProformaRequestSchema } from "../../../common/dto";
export const CustomerInvoiceItemDataFormSchema = CreateProformaRequestSchema.extend({
subtotal_price: z.object({
amount: z.number().nullable(),
scale: z.number(),
currency_code: CurrencyCodeSchema,
}),
discount: z.object({
amount: z.number().nullable(),
scale: z.number(),
}),
discount_price: z.object({
amount: z.number().nullable(),
scale: z.number(),
currency_code: CurrencyCodeSchema,
}),
before_tax_price: z.object({
amount: z.number().nullable(),
scale: z.number(),
currency_code: CurrencyCodeSchema,
}),
tax: z.object({
amount: z.number().nullable(),
scale: z.number(),
}),
tax_price: z.object({
amount: z.number().nullable(),
scale: z.number(),
currency_code: CurrencyCodeSchema,
}),
total_price: z.object({
amount: z.number().nullable(),
scale: z.number(),
currency_code: CurrencyCodeSchema,
}),
});
export type CustomerInvoiceData = {};

View File

@ -1 +0,0 @@
export * from "./create-customer-invoice-page";

View File

@ -1,41 +0,0 @@
import type { InvoiceItem } from "@/types/invoice";
export function calculateItemTotal(quantity: number, unitPrice: number, discount = 0): number {
const subtotal = quantity * unitPrice;
const discountAmount = (subtotal * discount) / 100;
return subtotal - discountAmount;
}
export function calculateInvoiceTotals(items: InvoiceItem[], taxRate = 21) {
const subtotal = items.reduce((sum, item) => {
return (
sum + (item.quantity.amount * item.unit_price.amount) / Math.pow(10, item.unit_price.scale)
);
}, 0);
const totalDiscount = items.reduce((sum, item) => {
const itemSubtotal =
(item.quantity.amount * item.unit_price.amount) / Math.pow(10, item.unit_price.scale);
return sum + (itemSubtotal * item.discount.amount) / Math.pow(10, item.discount.scale) / 100;
}, 0);
const beforeTax = subtotal - totalDiscount;
const taxAmount = (beforeTax * taxRate) / 100;
const total = beforeTax + taxAmount;
return {
subtotal: Math.round(subtotal * 100),
totalDiscount: Math.round(totalDiscount * 100),
beforeTax: Math.round(beforeTax * 100),
taxAmount: Math.round(taxAmount * 100),
total: Math.round(total * 100),
};
}
export function formatCurrency(amount: number, scale = 2, currency = "EUR"): string {
const value = amount / Math.pow(10, scale);
return new Intl.NumberFormat("es-ES", {
style: "currency",
currency: currency,
}).format(value);
}

View File

@ -1,4 +0,0 @@
export * from "../proformas";
export * from "./create";
export * from "./list";

View File

@ -1 +0,0 @@
export * from "./invoices-list-page";

View File

@ -1,204 +0,0 @@
import { Badge, Button, Separator } from "@repo/shadcn-ui/components";
import {
Calendar,
Copy,
CreditCard,
Download,
Edit,
FileText,
Hash,
Mail,
MapPin,
Pin,
Trash2,
User,
X
} from "lucide-react";
import { InvoiceSummaryFormData } from '../../schemas';
export type InvoicePreviewPanelProps = {
invoice: InvoiceSummaryFormData;
isPinned: boolean;
onClose: () => void;
onTogglePin: () => void;
};
export function InvoicePreviewPanel({
invoice,
isPinned,
onClose,
onTogglePin,
}: InvoicePreviewPanelProps) {
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="bg-gradient-to-r from-blue-600 to-violet-600 p-6 text-white">
<div className="flex items-start justify-between mb-4">
<div>
<h2 className="text-2xl font-bold mb-1">
{invoice.is_proforma ? "Proforma" : "Factura"} {invoice.invoice_number}
</h2>
<p className="text-blue-100 text-sm">
Serie: {invoice.series} Ref: {invoice.reference}
</p>
</div>
<div className="flex gap-2">
<Button
size="icon"
variant="ghost"
onClick={onTogglePin}
className={`text-white hover:bg-white/20 ${isPinned ? "bg-white/30" : ""}`}
title={isPinned ? "Desanclar" : "Anclar"}
aria-label={isPinned ? "Desanclar panel" : "Anclar panel"}
>
<Pin className={`h-4 w-4 ${isPinned ? "fill-current" : ""}`} />
</Button>
{!isPinned && (
<Button
size="icon"
variant="ghost"
onClick={onClose}
className="text-white hover:bg-white/20"
aria-label="Cerrar vista previa"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
<Badge variant="outline" className="bg-white/20 text-white border-white/30">
{invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)}
</Badge>
</div>
{/* Body */}
<div className="p-6 overflow-y-auto flex-1">
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<User className="h-5 w-5 text-blue-600" />
<h3 className="font-semibold text-gray-900">Cliente</h3>
</div>
<div className="bg-gradient-to-r from-blue-50 to-violet-50 p-4 rounded-lg space-y-2">
<p className="text-gray-700 font-semibold text-lg">{invoice.recipient.name}</p>
<div className="flex items-start gap-2 text-sm">
<Hash className="h-4 w-4 text-gray-500 mt-0.5" />
<div>
<span className="text-gray-500">TIN:</span>
<span className="ml-2 text-gray-700 font-medium">{invoice.recipient.tin}</span>
</div>
</div>
<div className="flex items-start gap-2 text-sm">
<MapPin className="h-4 w-4 text-gray-500 mt-0.5" />
<div className="text-gray-600">
<div>{invoice.recipient.street}</div>
{invoice.recipient.street2 && <div>{invoice.recipient.street2}</div>}
<div>
{invoice.recipient.postal_code} {invoice.recipient.city}, {invoice.recipient.province}
</div>
<div>{invoice.recipient.country}</div>
</div>
</div>
</div>
</div>
<Separator className="my-6" />
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<Calendar className="h-5 w-5 text-blue-600" />
<h3 className="font-semibold text-gray-900">Fechas</h3>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="bg-blue-50 p-3 rounded-lg">
<span className="text-xs font-medium text-gray-600 block mb-1">Fecha factura</span>
<p className="text-gray-900 font-semibold">{invoice.invoice_date}</p>
</div>
<div className="bg-violet-50 p-3 rounded-lg">
<span className="text-xs font-medium text-gray-600 block mb-1">Fecha operación</span>
<p className="text-gray-900 font-semibold">{invoice.operation_date}</p>
</div>
</div>
</div>
<Separator className="my-6" />
{!!invoice.description && (
<>
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<FileText className="h-5 w-5 text-blue-600" />
<h3 className="font-semibold text-gray-900">Descripción</h3>
</div>
<p className="text-gray-600 text-sm bg-gray-50 p-3 rounded-lg">{invoice.description}</p>
</div>
<Separator className="my-6" />
</>
)}
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<CreditCard className="h-5 w-5 text-blue-600" />
<h3 className="font-semibold text-gray-900">Resumen Financiero</h3>
</div>
<div className="bg-gradient-to-br from-blue-50 to-violet-50 p-4 rounded-lg space-y-3">
<div className="flex justify-between text-sm">
<span className="text-gray-600">Subtotal</span>
<span className="text-gray-900 font-medium">{invoice.subtotal_amount_fmt}</span>
</div>
{invoice.discount_amount > 0 && (
<>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Descuento ({invoice.discount_percentage}%)</span>
<span className="text-red-600 font-medium">-{invoice.discount_amount_fmt}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Base imponible</span>
<span className="text-gray-900 font-medium">{invoice.taxable_amount_fmt}</span>
</div>
</>
)}
<Separator />
<div className="flex justify-between text-sm">
<span className="text-gray-600">Total impuestos</span>
<span className="text-gray-900 font-medium">{invoice.taxes_amoun_fmt}</span>
</div>
<Separator />
<div className="flex justify-between items-center pt-2">
<span className="font-bold text-gray-900 text-lg">Total</span>
<span className="text-3xl font-bold bg-gradient-to-r from-blue-600 to-violet-600 bg-clip-text text-transparent">
{invoice.total_amount_fmt}
</span>
</div>
</div>
</div>
</div>
{/* Footer acciones */}
<div className="p-6 bg-gradient-to-r from-blue-50 to-violet-50 border-t border-gray-200">
<div className="grid grid-cols-2 gap-3">
<Button variant="outline" className="border-blue-200 text-blue-600 hover:bg-blue-50 bg-transparent">
<Edit className="mr-2 h-4 w-4" /> Editar
</Button>
<Button variant="outline" className="border-violet-200 text-violet-600 hover:bg-violet-50 bg-transparent">
<Copy className="mr-2 h-4 w-4" /> Duplicar
</Button>
<Button variant="outline" className="border-blue-200 text-blue-600 hover:bg-blue-50 bg-transparent">
<Download className="mr-2 h-4 w-4" /> Descargar
</Button>
<Button variant="outline" className="border-violet-200 text-violet-600 hover:bg-violet-50 bg-transparent">
<Mail className="mr-2 h-4 w-4" /> Enviar
</Button>
</div>
<Button variant="outline" className="w-full mt-3 border-red-200 text-red-600 hover:bg-red-50 bg-transparent">
<Trash2 className="mr-2 h-4 w-4" /> Eliminar factura
</Button>
</div>
</div>
);
}

View File

@ -1,102 +0,0 @@
import { PageHeader } from "@erp/core/components";
import { ErrorAlert } from "@erp/customers/components";
import { AppContent, AppHeader, BackHistoryButton, useDebounce } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { PlusIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useInvoicesQuery } from "../../hooks";
import { useTranslation } from "../../i18n";
import { issuedInvoiceResumeDtoToFormAdapter } from "../../issued-invoices/adapters/issued-invoice-resume-dto.adapter";
export const InvoiceListPage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [search, setSearch] = useState("");
const debouncedQ = useDebounce(search, 300);
const criteria = useMemo(
() => ({
q: debouncedQ || "",
pageSize,
pageNumber: pageIndex,
}),
[pageSize, pageIndex, debouncedQ]
);
const { data, isLoading, isError, error } = useInvoicesQuery({
criteria,
});
const invoicesPageData = useMemo(() => {
if (!data) return undefined;
return {
...data,
items: issuedInvoiceResumeDtoToFormAdapter.fromDto(data.items),
};
}, [data]);
const handlePageChange = (newPageIndex: number) => {
setPageIndex(newPageIndex);
};
const handlePageSizeChange = (newSize: number) => {
setPageSize(newSize);
setPageIndex(0);
};
const handleSearchChange = (value: string) => {
// Normalización ligera: recorta y colapsa espacios internos
const cleaned = value.trim().replace(/\s+/g, " ");
setSearch(cleaned);
setPageIndex(0);
};
if (isError || !invoicesPageData) {
return (
<AppContent>
<ErrorAlert
message={(error as Error)?.message || "Error al cargar el listado"}
title={t("pages.list.loadErrorTitle")}
/>
<BackHistoryButton />
</AppContent>
);
}
return (
<>
<AppHeader>
<PageHeader
description={t("pages.list.description")}
rightSlot={
<div className="flex items-center space-x-2">
<Button
aria-label={t("pages.create.title")}
className="cursor-pointer"
onClick={() => navigate("/customer-invoices/create")}
variant={"default"}
>
<PlusIcon aria-hidden className="mr-2 h-4 w-4" />
{t("pages.create.title")}
</Button>
</div>
}
title={t("pages.list.title")}
/>
</AppHeader>
<AppContent>
<div className="flex flex-col w-full h-full py-3">
<div className={"flex-1"}>
<>hola</>
</div>
</div>
</AppContent>
</>
);
};

View File

@ -32,17 +32,17 @@ export const getProformaStatusButtonVariant = (
export const getProformaStatusColor = (status: ProformaStatus): string => {
switch (status) {
case "draft":
return "bg-gray-100 text-gray-700 hover:bg-gray-100";
return "border-gray-200 bg-gray-100 text-gray-700 hover:bg-gray-100";
case "sent":
return "bg-yellow-100 text-yellow-700 hover:bg-yellow-100";
return "border-yellow-200 bg-yellow-100 text-yellow-700 hover:bg-yellow-100";
case "approved":
return "bg-green-100 text-green-700 hover:bg-green-100";
return "border-green-200 bg-green-100 text-green-700 hover:bg-green-100";
case "rejected":
return "bg-red-100 text-red-700 hover:bg-red-100";
return "border-red-200 bg-red-100 text-red-700 hover:bg-red-100";
case "issued":
return "bg-blue-100 text-blue-700 hover:bg-blue-100";
return "border-blue-200 bg-blue-100 text-blue-700 hover:bg-blue-100";
default:
return "bg-gray-100 text-gray-700 hover:bg-gray-100";
return "border-gray-200 bg-gray-100 text-gray-700 hover:bg-gray-100";
}
};

View File

@ -45,18 +45,29 @@ export const ProformasGrid = ({
}
return (
<DataTable
columns={columns}
data={items}
enablePagination
enableRowSelection
manualPagination
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
//onRowClick={(row) => onRowClick?.(row.id)}
pageIndex={pageIndex}
pageSize={pageSize}
totalItems={totalItems}
/>
<div className="mx-auto space-y-4">
{/*
* CAPA DE FADE
* Div absolutamente posicionado sobre el área scrollable.
* - pointer-events: none no bloquea interacciones.
* - linear-gradient de transparente a bg-card (blanco/oscuro).
* - z-10 queda por DEBAJO de la columna sticky (z-20).
* - Solo visible cuando aún hay contenido a la derecha.
*
*/}
<DataTable
columns={columns}
data={items}
enablePagination
enableRowSelection
manualPagination
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
//onRowClick={(row) => onRowClick?.(row.id)}
pageIndex={pageIndex}
pageSize={pageSize}
totalItems={totalItems}
/>
</div>
);
};

View File

@ -70,7 +70,7 @@ export function useProformasGridColumns(
},*/
{
accessorKey: "invoiceNumber",
header: ({ column }) => {
/*header: ({ column }) => {
return (
<Button
className="-ml-4 h-8 font-semibold"
@ -81,7 +81,8 @@ export function useProformasGridColumns(
<ArrowUpDownIcon className="ml-2 size-4" />
</Button>
);
},
},*/
header: "#",
cell: ({ row }) => <div className="font-medium">{row.getValue("invoiceNumber")}</div>,
},
@ -163,6 +164,7 @@ export function useProformasGridColumns(
{
accessorKey: "reference",
header: "Referencia",
cellClassName: "text-ellipsis",
},
{
accessorKey: "invoiceDate",
@ -184,8 +186,6 @@ export function useProformasGridColumns(
</div>
),
enableSorting: false,
size: 140,
minSize: 120,
meta: {
title: t("pages.issued_invoices.list.grid_columns.invoice_date"),
},
@ -250,6 +250,9 @@ export function useProformasGridColumns(
},
{
id: "actions",
meta: {
isActionsColumn: true,
},
header: "Acciones",
enableSorting: false,
cell: ({ row }) => {

View File

@ -24,10 +24,10 @@ export const ProformaStatusBadge = ({ status, className }: ProformaStatusBadgePr
<TooltipTrigger
render={
<Badge
className={cn(getProformaStatusColor(normalizedStatus), "font-semibold", className)}
className={cn(getProformaStatusColor(normalizedStatus), "gap-1", className)}
variant={getProformaStatusButtonVariant(normalizedStatus)}
>
<Icon />
<Icon className="size-3" />
{t(`catalog.proformas.status.${normalizedStatus}.label`, { defaultValue: status })}
</Badge>
}

View File

@ -25,6 +25,8 @@ import type { ProformaListRow } from "../../../shared";
import { useListProformasPageController } from "../../controllers";
import { ProformaSummaryPanel, ProformasGrid, useProformasGridColumns } from "../blocks";
import { OrdersTable } from "./orders-table";
export const ListProformasPage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
@ -71,7 +73,7 @@ export const ListProformasPage = () => {
</SelectContent>
</Select>
</div>
<div className="min-h-0 flex-1 overflow-auto">
<div>
<ProformasGrid
columns={columns}
data={listCtrl.data}
@ -83,6 +85,8 @@ export const ListProformasPage = () => {
pageIndex={listCtrl.pageIndex}
pageSize={listCtrl.pageSize}
/>
<OrdersTable />
</div>
</div>
);
@ -118,11 +122,11 @@ export const ListProformasPage = () => {
/>
</AppHeader>
<AppContent>
<AppContent className="min-h-screen">
{isPanelOpen ? (
<ResizablePanelGroup
autoSave="list-proformas-page"
className="h-full"
className="h-full mx-auto w-full space-y-4"
orientation="horizontal"
>
<ResizablePanel defaultSize="70%" maxSize="75%" minSize="70%">
@ -153,7 +157,7 @@ export const ListProformasPage = () => {
</ResizablePanel>
</ResizablePanelGroup>
) : (
<div className="flex min-h-0 flex-1 overflow-hidden">{listContent}</div>
<div className="mx-auto w-full space-y-4">{listContent}</div>
)}
<>
{/* Issue */}

View File

@ -0,0 +1,362 @@
"use client";
import {
Badge,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { CheckCircle2, Clock, MoreHorizontal, XCircle } from "lucide-react";
import { useEffect, useRef, useState } from "react";
const orders = [
{
id: "Order-12567",
total: "$8092.897",
status: "Paid",
fulfillment: "Unfulfilled",
customer: "Bagus Fikri",
tag: "CST-124",
date: "Mon 14 Apr, 2023",
channel: "Fikri Store",
},
{
id: "Order-12566",
total: "$2056.99",
status: "Paid",
fulfillment: "Unfulfilled",
customer: "Raihan Fikri",
tag: "CST-412",
date: "Mon 14 Apr, 2023",
channel: "Fikri Store",
},
{
id: "Order-12565",
total: "$0.00",
status: "Cancelled",
fulfillment: "Cancelled",
customer: "Panji Dwi",
tag: "CST-434",
date: "Mon 14 Apr, 2023",
channel: "Fikri Store",
},
{
id: "Order-12564",
total: "$567.98",
status: "Paid",
fulfillment: "Fulfilled",
customer: "Bani Zuhilmi",
tag: "CST-124",
date: "Fri 12 Apr, 2023",
channel: "Fikri Store",
},
{
id: "Order-12563",
total: "$989.00",
status: "Paid",
fulfillment: "Unfulfilled",
customer: "Hendar Misdar",
tag: "CST-434",
date: "Fri 12 Apr, 2023",
channel: "Fikri Store",
},
{
id: "Order-12562",
total: "$9900.00",
status: "Paid",
fulfillment: "Unfulfilled",
customer: "Prananda",
tag: "CST-456",
date: "Fri 12 Apr, 2023",
channel: "Fikri Store",
},
{
id: "Order-12561",
total: "$478.04",
status: "Paid",
fulfillment: "Unfulfilled",
customer: "Adit Irawan",
tag: "CST-345",
date: "Thu 11 Apr, 2023",
channel: "Fikri Store",
},
{
id: "Order-12560",
total: "$1099.00",
status: "Paid",
fulfillment: "Unfulfilled",
customer: "Iqbal Bahroin",
tag: "CST-455",
date: "Thu 11 Apr, 2023",
channel: "Fikri Store",
},
{
id: "Order-12559",
total: "$678.56",
status: "Paid",
fulfillment: "Unfulfilled",
customer: "Tri Mardani",
tag: "CST-676",
date: "Thu 11 Apr, 2023",
channel: "Fikri Store",
},
{
id: "Order-12558",
total: "$327.99",
status: "Paid",
fulfillment: "Fulfilled",
customer: "Panji Dwi",
tag: "CST-477",
date: "Thu 11 Apr, 2023",
channel: "Fikri Store",
},
{
id: "Order-12557",
total: "$89.00",
status: "Paid",
fulfillment: "Fulfilled",
customer: "Bryan Setiawan",
tag: "CST-456",
date: "Wed 10 Apr, 2023",
channel: "Fikri Store",
},
{
id: "Order-12556",
total: "$0.00",
status: "Cancelled",
fulfillment: "Cancelled",
customer: "Raihan Fikri",
tag: "CST-356",
date: "Wed 10 Apr, 2023",
channel: "Fikri Store",
},
];
function StatusBadge({ status }: { status: string }) {
if (status === "Paid") {
return (
<Badge className="gap-1 border-emerald-200 bg-emerald-50 text-emerald-700" variant="outline">
<CheckCircle2 className="h-3 w-3" />
Paid
</Badge>
);
}
if (status === "Cancelled") {
return (
<Badge className="gap-1 border-red-200 bg-red-50 text-red-600" variant="outline">
<XCircle className="h-3 w-3" />
Cancelled
</Badge>
);
}
return (
<Badge className="gap-1 border-amber-200 bg-amber-50 text-amber-700" variant="outline">
<Clock className="h-3 w-3" />
{status}
</Badge>
);
}
function FulfillmentBadge({ fulfillment }: { fulfillment: string }) {
if (fulfillment === "Fulfilled") {
return (
<Badge className="gap-1 border-blue-200 bg-blue-50 text-blue-700" variant="outline">
<CheckCircle2 className="h-3 w-3" />
Fulfilled
</Badge>
);
}
if (fulfillment === "Cancelled") {
return (
<Badge className="gap-1 border-red-200 bg-red-50 text-red-600" variant="outline">
<XCircle className="h-3 w-3" />
Cancelled
</Badge>
);
}
return (
<Badge className="gap-1 border-border bg-muted text-muted-foreground" variant="outline">
<Clock className="h-3 w-3" />
Unfulfilled
</Badge>
);
}
export function OrdersTable() {
const scrollRef = useRef<HTMLDivElement>(null);
const [isScrolled, setIsScrolled] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(true);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const check = () => {
setIsScrolled(el.scrollLeft > 0);
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
};
check();
el.addEventListener("scroll", check);
window.addEventListener("resize", check);
return () => {
el.removeEventListener("scroll", check);
window.removeEventListener("resize", check);
};
}, []);
return (
<div className="min-h-screen bg-muted/40">
<div className="mx-auto w-full space-y-4">
{/* Contenedor con fade + tabla */}
<div className="relative rounded-lg sm:rounded-xl border border-border bg-card shadow-sm overflow-hidden">
{/*
* CAPA DE FADE
* Div absolutamente posicionado sobre el área scrollable.
* - pointer-events: none no bloquea interacciones.
* - linear-gradient de transparente a bg-card (blanco/oscuro).
* - z-10 queda por DEBAJO de la columna sticky (z-20).
* - Solo visible cuando aún hay contenido a la derecha.
*
*/}
{canScrollRight && (
<div
aria-hidden
className="pointer-events-none absolute right-10 top-0 h-full w-32 z-10"
style={{
background: "linear-gradient(to right, transparent, hsl(var(--card, 0 0% 100%)))",
}}
/>
)}
{/* Scroll container */}
<div className="overflow-x-auto max-w-full -mx-px" ref={scrollRef}>
<Table className="min-w-full sm:min-w-[820px] w-full">
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50 text-xs sm:text-sm">
<TableHead className="whitespace-nowrap">Order ID</TableHead>
<TableHead className="whitespace-nowrap">Total</TableHead>
<TableHead className="whitespace-nowrap">Status</TableHead>
<TableHead className="hidden sm:table-cell whitespace-nowrap">
Fulfillment
</TableHead>
<TableHead className="whitespace-nowrap">Customer</TableHead>
<TableHead className="hidden md:table-cell whitespace-nowrap">
Order Date
</TableHead>
<TableHead className="hidden lg:table-cell whitespace-nowrap">Channel</TableHead>
{/*
* COLUMNA STICKY
* - sticky right-0 se ancla al borde derecho.
* - z-20 queda SOBRE la capa de fade (z-10).
* - bg-muted/50 coincide con el fondo del header.
* - La sombra izquierda aparece dinámicamente al hacer scroll.
*
*/}
<TableHead
className={cn(
"sticky right-0 z-20 w-10 bg-muted/50 transition-shadow",
isScrolled && "shadow-[-8px_0_12px_-4px_rgba(0,0,0,0.08)]"
)}
/>
</TableRow>
</TableHeader>
<TableBody>
{orders.map((order) => (
<TableRow className="text-xs sm:text-sm" key={order.id}>
<TableCell className="whitespace-nowrap font-medium">{order.id}</TableCell>
<TableCell className="whitespace-nowrap tabular-nums">{order.total}</TableCell>
<TableCell className="whitespace-nowrap">
<StatusBadge status={order.status} />
</TableCell>
<TableCell className="hidden sm:table-cell whitespace-nowrap">
<FulfillmentBadge fulfillment={order.fulfillment} />
</TableCell>
<TableCell className="whitespace-nowrap">
<div className="flex flex-col sm:flex-row sm:items-center sm:gap-2">
<span>{order.customer}</span>
<span className="text-xs text-muted-foreground">{order.tag}</span>
</div>
</TableCell>
<TableCell className="hidden md:table-cell whitespace-nowrap text-muted-foreground">
{order.date}
</TableCell>
<TableCell className="hidden lg:table-cell whitespace-nowrap text-muted-foreground">
{order.channel}
</TableCell>
{/* Celda de acciones — sticky */}
<TableCell
className={cn(
"sticky right-0 z-20 w-10 bg-card transition-shadow",
isScrolled && "shadow-[-8px_0_12px_-4px_rgba(0,0,0,0.08)]"
)}
>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
aria-label="Más opciones"
className="h-7 w-7 text-muted-foreground"
size="icon"
variant="ghost"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem>Ver detalle</DropdownMenuItem>
<DropdownMenuItem>Editar orden</DropdownMenuItem>
<DropdownMenuItem className="text-destructive">
Cancelar orden
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{/* Explicación técnica */}
<div className="rounded-lg sm:rounded-xl border border-border bg-card p-3 sm:p-4 text-xs sm:text-sm text-muted-foreground space-y-2">
<p className="font-semibold text-foreground">Implementación del efecto</p>
<ul className="list-disc list-inside space-y-1">
<li>
<strong className="text-foreground">Fade:</strong> Un{" "}
<code className="rounded bg-muted px-1 text-xs">div</code> absoluto con{" "}
<code className="rounded bg-muted px-1 text-xs">pointer-events: none</code> y{" "}
<code className="rounded bg-muted px-1 text-xs">linear-gradient</code> se superpone
encima del scroll.
</li>
<li>
<strong className="text-foreground">Columna sticky:</strong> La celda de acciones usa{" "}
<code className="rounded bg-muted px-1 text-xs">sticky right-0 z-20</code>.
</li>
<li>
<strong className="text-foreground">Responsive:</strong> Columnas ocultas en móviles (
<code className="rounded bg-muted px-1 text-xs">hidden sm:</code>,{" "}
<code className="rounded bg-muted px-1 text-xs">hidden md:</code>,{" "}
<code className="rounded bg-muted px-1 text-xs">hidden lg:</code>).
</li>
</ul>
</div>
</div>
</div>
);
}

View File

@ -1,4 +1,3 @@
import { formatCurrency } from "@erp/core";
import {
FieldDescription,
FieldGroup,
@ -12,8 +11,8 @@ import type { ComponentProps } from "react";
import { useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "../../../../../i18n";
import type { ProformaFormData } from "../../../../types";
import { useProformaContext } from "../../context";
import { PercentageInputField } from "../components";
export const ProformaTotals = (props: ComponentProps<"fieldset">) => {
const { t } = useTranslation();

View File

@ -1,4 +1,5 @@
import { MoneyDTOHelper, formatCurrency } from "@erp/core";
import { MoneyDTOHelper } from "@erp/core";
import { MoneyHelper } from "@repo/rdx-utils";
import type { ListProformasResponseDTO } from "../../../../common";
import type { ListProformasResult } from "../api";
@ -46,7 +47,6 @@ const ProformaListRowAdapter = {
return {
id: dto.id,
companyId: dto.company_id,
isProforma: dto.is_proforma,
invoiceNumber: dto.invoice_number,
status: dto.status as ProformaStatus,
@ -74,7 +74,7 @@ const ProformaListRowAdapter = {
},
subtotalAmount: MoneyDTOHelper.toNumber(dto.subtotal_amount),
subtotalAmountFmt: formatCurrency(
subtotalAmountFmt: MoneyHelper.formatCurrency(
MoneyDTOHelper.toNumber(dto.subtotal_amount),
Number(dto.total_amount.scale || 2),
dto.currency_code,
@ -82,7 +82,7 @@ const ProformaListRowAdapter = {
),
totalDiscountAmount: MoneyDTOHelper.toNumber(dto.total_discount_amount),
totalDiscountAmountFmt: formatCurrency(
totalDiscountAmountFmt: MoneyHelper.formatCurrency(
MoneyDTOHelper.toNumber(dto.total_discount_amount),
Number(dto.total_amount.scale || 2),
dto.currency_code,
@ -90,7 +90,7 @@ const ProformaListRowAdapter = {
),
taxableAmount: MoneyDTOHelper.toNumber(dto.taxable_amount),
taxableAmountFmt: formatCurrency(
taxableAmountFmt: MoneyHelper.formatCurrency(
MoneyDTOHelper.toNumber(dto.taxable_amount),
Number(dto.total_amount.scale || 2),
dto.currency_code,
@ -98,7 +98,7 @@ const ProformaListRowAdapter = {
),
taxesAmount: MoneyDTOHelper.toNumber(dto.taxes_amount),
taxesAmountFmt: formatCurrency(
taxesAmountFmt: MoneyHelper.formatCurrency(
MoneyDTOHelper.toNumber(dto.taxes_amount),
Number(dto.total_amount.scale || 2),
dto.currency_code,
@ -106,7 +106,7 @@ const ProformaListRowAdapter = {
),
totalAmount: MoneyDTOHelper.toNumber(dto.total_amount),
totalAmountFmt: formatCurrency(
totalAmountFmt: MoneyHelper.formatCurrency(
MoneyDTOHelper.toNumber(dto.total_amount),
Number(dto.total_amount.scale || 2),
dto.currency_code,

View File

@ -11,7 +11,6 @@ import type { ProformaStatus } from "./proforma-status.entity";
export interface ProformaListRow {
id: string;
companyId: string;
isProforma: boolean;
invoiceNumber: string;
status: ProformaStatus;

View File

@ -1,15 +1,16 @@
import type { ValidationErrorDetail } from "@repo/rdx-ddd";
import type { ZodError } from "zod";
/**
* Convierte un error de validación de Zod en una colección de errores de validación personalizada.
*
* @param error
* @returns array de objetos con el campo y el mensaje de error correspondiente.
* @param error - El error de validación de Zod que contiene los problemas de validación.
* @returns Un array de objetos, cada uno con el código de error, el campo, el mensaje y el valor de entrada.
*/
export function toValidationErrors(error: ZodError<unknown>) {
return error.issues.map((err) => ({
field: err.path.join("."),
export const toValidationErrors = (error: ZodError<unknown>): ValidationErrorDetail[] =>
error.issues.map((err) => ({
path: err.path.join("."),
message: err.message,
value: err.input,
}));
}

View File

@ -1 +0,0 @@
export * from "./proforma-tax-summary";

View File

@ -1,78 +0,0 @@
import { formatCurrency } from "@erp/core";
import {
Badge,
FieldDescription,
FieldGroup,
FieldLegend,
FieldSet,
} from "@repo/shadcn-ui/components";
import { ReceiptIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "../../../i18n";
import { useProformaContext } from "../../pages/update/context";
import type { ProformaFormData } from "../../types";
export const ProformaTaxSummary = (props: ComponentProps<"fieldset">) => {
const { t } = useTranslation();
const { control } = useFormContext<ProformaFormData>();
const { currency_code, language_code } = useProformaContext();
const taxes = useWatch({
control,
name: "taxes",
defaultValue: [],
});
const displayTaxes = taxes || [];
return (
<FieldGroup>
<FieldSet {...props}>
<FieldLegend className="flex items-center gap-2 text-foreground">
<ReceiptIcon className="size-5" /> {t("form_groups.tax_resume.title")}
</FieldLegend>
<FieldDescription>{t("form_groups.tax_resume.description")}</FieldDescription>
<FieldGroup className="grid grid-cols-1">
<div className="space-y-3">
{displayTaxes.map((tax, index) => (
<div
className="border rounded-lg p-3 space-y-2 text-base "
key={`${tax.tax_code}-${index}`}
>
<div className="flex items-center justify-between mb-2 ">
<Badge className="text-sm font-semibold" variant="secondary">
{tax.tax_label}
</Badge>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-current">Base para el impuesto:</span>
<span className="text-base text-current tabular-nums">
{formatCurrency(tax.taxable_amount, 2, currency_code, language_code)}
</span>
</div>
<div className="flex justify-between">
<span className="text-current font-semibold">Importe de impuesto:</span>
<span className="text-base text-current font-semibold tabular-nums">
{formatCurrency(tax.taxes_amount, 2, currency_code, language_code)}
</span>
</div>
</div>
</div>
))}
{displayTaxes.length === 0 && (
<div className="text-center py-6 text-muted-foreground">
<ReceiptIcon className="size-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No hay impuestos aplicados</p>
</div>
)}
</div>
</FieldGroup>
</FieldSet>
</FieldGroup>
);
};

View File

@ -1,56 +0,0 @@
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@repo/shadcn-ui/components";
import type { Control, FieldPath, FieldValues } from "react-hook-form";
import { AmountInput, type AmountInputProps } from "./amount-input";
type AmountInputFieldProps<T extends FieldValues> = {
inputId?: string;
control: Control<T>;
name: FieldPath<T>;
label?: string;
description?: string;
required?: boolean;
} & Omit<AmountInputProps, "value" | "onChange">;
export function AmountInputField<T extends FieldValues>({
inputId,
control,
name,
label,
description,
required = false,
...inputProps
}: AmountInputFieldProps<T>) {
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem>
{label ? (
<FormLabel htmlFor={inputId}>
{label} {required ? <span aria-hidden="true">*</span> : null}
</FormLabel>
) : null}
<FormControl>
<AmountInput
id={inputId}
onChange={field.onChange}
value={field.value ?? ""}
{...inputProps}
/>
</FormControl>
{description ? <FormDescription>{description}</FormDescription> : null}
<FormMessage />
</FormItem>
)}
/>
);
}

View File

@ -1,233 +0,0 @@
import { formatCurrency } from "@erp/core";
import { useMoney } from "@erp/core/hooks";
import { Input } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import * as React from "react";
import {
type InputEmptyMode,
type InputReadOnlyMode,
findFocusableInCell,
focusAndSelect,
} from "./input-utils";
export type AmountInputProps = {
value: number | string; // "" → no mostrar nada; string puede venir con separadores
onChange: (next: number | string) => void;
readOnly?: boolean;
readOnlyMode?: InputReadOnlyMode; // default "textlike-input"
id?: string;
"aria-label"?: string;
step?: number; // ↑/↓; default 0.01
emptyMode?: InputEmptyMode; // cómo presentar vacío
emptyText?: string; // texto en vacío para value/placeholder
scale?: number; // decimales; default 2 (ej. 4 para unit_amount)
languageCode?: string; // p.ej. "es-ES"
currencyCode?: string; // p.ej. "EUR"
className?: string;
};
export function AmountInput({
value,
onChange,
readOnly = false,
readOnlyMode = "textlike-input",
id,
"aria-label": ariaLabel = "Amount",
emptyMode = "blank",
emptyText = "",
scale = 2,
languageCode = "es",
currencyCode = "EUR",
className,
...inputProps
}: AmountInputProps) {
// Hook de dinero para parseo/redondeo consistente con el resto de la app
const { parse, roundToScale } = useMoney({
locale: languageCode,
fallbackCurrency: currencyCode as any,
});
const [raw, setRaw] = React.useState<string>("");
const [focused, setFocused] = React.useState(false);
const formatCurrencyNumber = React.useCallback(
(n: number) => formatCurrency(n, scale, currencyCode, languageCode),
[languageCode, currencyCode, scale]
);
// Derivar texto visual desde prop `value`
const visualText = React.useMemo(() => {
if (value === "" || value == null) {
return emptyMode === "value" ? emptyText : "";
}
const numeric =
typeof value === "number"
? value
: (parse(String(value)) ??
Number(
String(value)
.replace(/[^\d.,-]/g, "")
.replace(/\./g, "")
.replace(",", ".")
));
if (!Number.isFinite(numeric)) return emptyMode === "value" ? emptyText : "";
const n = roundToScale(numeric, scale);
return formatCurrencyNumber(n);
}, [value, emptyMode, emptyText, parse, roundToScale, scale, formatCurrencyNumber]);
const isShowingEmptyValue = emptyMode === "value" && raw === emptyText;
// Sin foco → mantener visual
React.useEffect(() => {
if (!focused) setRaw(visualText);
}, [visualText, focused]);
const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setRaw(e.currentTarget.value);
}, []);
const handleFocus = React.useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
setFocused(true);
// pasar de visual con símbolo → crudo
if (emptyMode === "value" && e.currentTarget.value === emptyText) {
setRaw("");
return;
}
const current =
parse(e.currentTarget.value) ??
(value === "" || value == null
? null
: typeof value === "number"
? value
: parse(String(value)));
setRaw(current !== null && current !== undefined ? String(current) : "");
},
[emptyMode, emptyText, parse, value]
);
const handleBlur = React.useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
setFocused(false);
const txt = e.currentTarget.value.trim();
if (txt === "" || isShowingEmptyValue) {
onChange("");
setRaw(emptyMode === "value" ? emptyText : "");
return;
}
const n = parse(txt);
if (n === null) {
onChange("");
setRaw(emptyMode === "value" ? emptyText : "");
return;
}
const rounded = roundToScale(n, scale);
onChange(rounded);
setRaw(formatCurrencyNumber(rounded)); // vuelve a visual con símbolo
},
[
isShowingEmptyValue,
onChange,
emptyMode,
emptyText,
parse,
roundToScale,
scale,
formatCurrencyNumber,
]
);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLElement>) => {
if (readOnly) return;
const keys = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"];
if (!keys.includes(e.key)) return;
e.preventDefault();
const current = e.currentTarget as HTMLElement;
const rowIndex = Number(current.dataset.rowIndex);
const colIndex = Number(current.dataset.colIndex);
let nextRow = rowIndex;
let nextCol = colIndex;
switch (e.key) {
case "ArrowUp":
nextRow--;
break;
case "ArrowDown":
nextRow++;
break;
case "ArrowLeft":
nextCol--;
break;
case "ArrowRight":
nextCol++;
break;
}
const nextElement = findFocusableInCell(nextRow, nextCol);
console.log(nextElement);
if (nextElement) {
focusAndSelect(nextElement);
}
},
[readOnly]
);
const handleBlock = React.useCallback((e: React.SyntheticEvent<HTMLInputElement>) => {
e.preventDefault();
(e.target as HTMLInputElement).blur();
}, []);
if (readOnly && readOnlyMode === "textlike-input") {
return (
<Input
aria-label={ariaLabel}
className={cn(
"w-full bg-transparent p-0 text-right tabular-nums border-0 shadow-none",
"focus:outline-none focus:ring-0 [caret-color:transparent] cursor-default",
className
)}
id={id}
onFocus={handleBlock}
onKeyDown={(e) => e.preventDefault()}
onMouseDown={handleBlock}
readOnly
tabIndex={-1}
value={visualText}
{...inputProps}
/>
);
}
return (
<Input
aria-label={ariaLabel}
className={cn(
"w-full bg-transparent p-0 text-right tabular-nums h-8 px-1 shadow-none",
"border-none",
"focus:bg-background",
"focus-visible:border-ring focus-visible:ring-ring/20 focus-visible:ring-[2px]",
"hover:border hover:ring-ring/20 hover:ring-[2px]",
className
)}
id={id}
inputMode="decimal"
onBlur={handleBlur}
onChange={handleChange}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
pattern="[0-9]*[.,]?[0-9]*"
placeholder={
emptyMode === "placeholder" && (value === "" || value == null) ? emptyText : undefined
}
readOnly={readOnly}
value={raw}
{...inputProps}
/>
);
}

View File

@ -1,3 +0,0 @@
export * from "./amount-input-field";
export * from "./percentage-input-field";
export * from "./quantity-input-field";

View File

@ -1,73 +0,0 @@
export type InputEmptyMode = "blank" | "placeholder" | "value";
export type InputReadOnlyMode = "textlike-input" | "normal";
export type InputSuffixMap = { one: string; other: string; zero?: string };
// Selectores típicos de elementos que son editables o permite foco
const FOCUSABLE_SELECTOR = [
"[data-cell-focus]", // permite marcar manualmente el target dentro de la celda
"input:not([disabled])",
"textarea:not([disabled])",
"select:not([disabled])",
'[contenteditable="true"]',
"button:not([disabled])",
"a[href]",
'[tabindex]:not([tabindex="-1"])',
].join(",");
// Busca el elemento focuseable dentro de la "celda" destino.
// Puedes poner data-row-index / data-col-index en la propia celda <td> o en el control.
// Este helper cubre ambos casos.
export function findFocusableInCell(row: number, col: number): HTMLElement | null {
// 1) ¿Hay un control que ya tenga los data-* directamente?
let el = document.querySelector<HTMLElement>(
`[data-row-index="${row}"][data-col-index="${col}"]${FOCUSABLE_SELECTOR.startsWith("[") ? "" : ""}`
);
// Si lo anterior no funcionó o seleccionó un contenedor, intenta:
if (!el) {
// 2) ¿Existe una celda contenedora (td/div) con esos data-*?
const cell = document.querySelector<HTMLElement>(
`[data-row-index="${row}"][data-col-index="${col}"]`
);
if (!cell) return null;
// 3) Dentro de la celda, busca el primer foco válido
el = cell.matches(FOCUSABLE_SELECTOR)
? cell
: cell.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);
}
return el || null;
}
// Da foco y selecciona contenido si procede.
export function focusAndSelect(el: HTMLElement) {
el.focus?.();
// Seleccionar tras el foco para evitar que el navegador cancele la selección
requestAnimationFrame(() => {
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
// Para inputs/textarea
try {
// select() funciona en la mayoría; si es type="number", cae en setSelectionRange
el.select?.();
// Asegura selección completa si select() no aplica (p.ej. type="number")
if (typeof (el as any).setSelectionRange === "function") {
const val = (el as any).value ?? "";
(el as any).setSelectionRange(0, String(val).length);
}
} catch {
/* no-op */
}
} else if ((el as HTMLElement).isContentEditable) {
// Para contenteditable
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}
// Para select/button/otros focuseables no hacemos selección de texto.
});
}

View File

@ -1,56 +0,0 @@
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@repo/shadcn-ui/components";
import type { Control, FieldPath, FieldValues } from "react-hook-form";
import { PercentageInput, type PercentageInputProps } from "./percentage-input";
type PercentageInputFieldProps<T extends FieldValues> = {
inputId?: string;
control: Control<T>;
name: FieldPath<T>;
label?: string;
description?: string;
required?: boolean;
} & Omit<PercentageInputProps, "value" | "onChange">;
export function PercentageInputField<T extends FieldValues>({
inputId,
control,
name,
label,
description,
required = false,
...inputProps
}: PercentageInputFieldProps<T>) {
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem>
{label ? (
<FormLabel htmlFor={inputId}>
{label} {required ? <span aria-hidden="true">*</span> : null}
</FormLabel>
) : null}
<FormControl>
<PercentageInput
id={inputId}
onChange={field.onChange}
value={field.value}
{...inputProps}
/>
</FormControl>
{description ? <FormDescription>{description}</FormDescription> : null}
<FormMessage />
</FormItem>
)}
/>
);
}

View File

@ -1,254 +0,0 @@
import { Input } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import * as React from "react";
import {
type InputEmptyMode,
type InputReadOnlyMode,
findFocusableInCell,
focusAndSelect,
} from "./input-utils";
export type PercentageInputProps = {
value: number | "" | string; // "" → no mostrar nada; string puede venir con separadores
onChange: (next: number | "") => void;
readOnly?: boolean;
readOnlyMode?: InputReadOnlyMode; // default "textlike-input"
id?: string;
"aria-label"?: string;
step?: number; // ↑/↓; default 0.1
emptyMode?: InputEmptyMode; // cómo presentar vacío
emptyText?: string; // texto en vacío para value/placeholder
scale?: number; // decimales; default 2
min?: number; // default 0 (p. ej. descuentos)
max?: number; // default 100
showSuffix?: boolean; // “%” en visual; default true
locale?: string; // para formateo numérico
className?: string;
};
export function PercentageInput({
value,
onChange,
readOnly = false,
readOnlyMode = "textlike-input",
id,
"aria-label": ariaLabel = "Percentage",
step = 0.1,
emptyMode = "blank",
emptyText = "",
scale = 2,
min = 0,
max = 100,
showSuffix = true,
locale,
className,
...inputProps
}: PercentageInputProps) {
const stripNumberish = (s: string) => s.replace(/[^\d.,-]/g, "").trim();
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
const parseLocaleNumber = React.useCallback((raw: string): number | null => {
if (!raw) return null;
const s = stripNumberish(raw);
if (!s) return null;
const lastComma = s.lastIndexOf(",");
const lastDot = s.lastIndexOf(".");
let normalized = s;
if (lastComma > -1 && lastDot > -1) {
normalized =
lastComma > lastDot ? s.replace(/\./g, "").replace(",", ".") : s.replace(/,/g, "");
} else if (lastComma > -1) {
normalized = s.replace(",", ".");
}
const n = Number(normalized);
return Number.isFinite(n) ? n : null;
}, []);
const roundToScale = React.useCallback((n: number, sc: number) => {
const f = 10 ** sc;
return Math.round(n * f) / f;
}, []);
const clamp = React.useCallback((n: number) => Math.min(Math.max(n, min), max), [min, max]);
const [raw, setRaw] = React.useState<string>("");
const [focused, setFocused] = React.useState(false);
const formatVisual = React.useCallback(
(n: number) => {
const txt = new Intl.NumberFormat(locale ?? undefined, {
maximumFractionDigits: scale,
minimumFractionDigits: Number.isInteger(n) ? 0 : 0,
useGrouping: false,
}).format(n);
return showSuffix ? `${txt}%` : txt;
},
[locale, scale, showSuffix]
);
const visualText = React.useMemo(() => {
if (value === "" || value == null) {
return emptyMode === "value" ? emptyText : "";
}
const numeric = typeof value === "number" ? value : parseLocaleNumber(String(value));
if (!Number.isFinite(numeric as number)) return emptyMode === "value" ? emptyText : "";
const n = roundToScale(clamp(numeric as number), scale);
return formatVisual(n);
}, [value, emptyMode, emptyText, parseLocaleNumber, roundToScale, clamp, scale, formatVisual]);
const isShowingEmptyValue = emptyMode === "value" && raw === emptyText;
React.useEffect(() => {
if (!focused) setRaw(visualText);
}, [visualText, focused]);
const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setRaw(e.currentTarget.value);
}, []);
const handleFocus = React.useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
setFocused(true);
if (emptyMode === "value" && e.currentTarget.value === emptyText) {
setRaw("");
return;
}
const n =
parseLocaleNumber(e.currentTarget.value) ??
(value === "" || value == null
? null
: typeof value === "number"
? value
: parseLocaleNumber(String(value)));
setRaw(n !== null && n !== undefined ? String(n) : "");
},
[emptyMode, emptyText, parseLocaleNumber, value]
);
const handleBlur = React.useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
setFocused(false);
const txt = e.currentTarget.value.trim().replace("%", "");
if (txt === "" || isShowingEmptyValue) {
onChange("");
setRaw(emptyMode === "value" ? emptyText : "");
return;
}
const parsed = parseLocaleNumber(txt);
if (parsed === null) {
onChange("");
setRaw(emptyMode === "value" ? emptyText : "");
return;
}
const rounded = roundToScale(clamp(parsed), scale);
onChange(rounded);
setRaw(formatVisual(rounded)); // vuelve a visual con %
},
[
isShowingEmptyValue,
onChange,
emptyMode,
emptyText,
parseLocaleNumber,
roundToScale,
clamp,
scale,
formatVisual,
]
);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLElement>) => {
if (readOnly) return;
const keys = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"];
if (!keys.includes(e.key)) return;
e.preventDefault();
const current = e.currentTarget as HTMLElement;
const rowIndex = Number(current.dataset.rowIndex);
const colIndex = Number(current.dataset.colIndex);
let nextRow = rowIndex;
let nextCol = colIndex;
switch (e.key) {
case "ArrowUp":
nextRow--;
break;
case "ArrowDown":
nextRow++;
break;
case "ArrowLeft":
nextCol--;
break;
case "ArrowRight":
nextCol++;
break;
}
const nextElement = findFocusableInCell(nextRow, nextCol);
console.log(nextElement);
if (nextElement) {
focusAndSelect(nextElement);
}
},
[readOnly]
);
// Bloquear foco/edición en modo texto
const handleBlock = React.useCallback((e: React.SyntheticEvent<HTMLInputElement>) => {
e.preventDefault();
(e.target as HTMLInputElement).blur();
}, []);
if (readOnly && readOnlyMode === "textlike-input") {
return (
<Input
aria-label={ariaLabel}
className={cn(
"w-full bg-transparent p-0 text-right tabular-nums border-0 shadow-none",
"focus:outline-none focus:ring-0 [caret-color:transparent] cursor-default",
className
)}
id={id}
onFocus={handleBlock}
onKeyDown={(e) => e.preventDefault()}
onMouseDown={handleBlock}
readOnly
tabIndex={-1}
value={visualText}
{...inputProps}
/>
);
}
return (
<Input
aria-label={ariaLabel}
className={cn(
"w-full bg-transparent p-0 text-right tabular-nums h-8 px-1 shadow-none",
"border-none",
"focus:bg-background",
"focus-visible:border-ring focus-visible:ring-ring/20 focus-visible:ring-[2px]",
"hover:border hover:ring-ring/20 hover:ring-[2px]",
className
)}
id={id}
inputMode="decimal"
onBlur={handleBlur}
onChange={handleChange}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
pattern="[0-9]*[.,]?[0-9]*%?"
placeholder={
emptyMode === "placeholder" && (value === "" || value == null) ? emptyText : undefined
}
readOnly={readOnly}
value={raw}
{...inputProps}
/>
);
}

View File

@ -1,56 +0,0 @@
import type { CommonInputProps } from "@repo/rdx-ui/components";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@repo/shadcn-ui/components";
import type { Control, FieldPath, FieldValues } from "react-hook-form";
import { QuantityInput, type QuantityInputProps } from "./quantity-input";
type QuantityInputFieldProps<TFormValues extends FieldValues> = CommonInputProps & {
inputId?: string;
control: Control<TFormValues>;
name: FieldPath<TFormValues>;
label?: string;
description?: string;
required?: boolean;
} & Omit<QuantityInputProps, "value" | "onChange">;
export function QuantityInputField<TFormValues extends FieldValues>({
inputId,
control,
name,
label,
description,
required = false,
...inputProps
}: QuantityInputFieldProps<TFormValues>) {
return (
<FormField
control={control}
name={name}
render={({ field }) => {
const { value, onChange } = field;
return (
<FormItem>
{label ? (
<FormLabel htmlFor={inputId}>
{label} {required ? <span aria-hidden="true">*</span> : null}
</FormLabel>
) : null}
<FormControl>
<QuantityInput id={inputId} onChange={onChange} value={value} {...inputProps} />
</FormControl>
{description ? <FormDescription>{description}</FormDescription> : null}
<FormMessage />
</FormItem>
);
}}
/>
);
}

View File

@ -1,269 +0,0 @@
// QuantityNumberInput.tsx — valor primitivo (number | "" | string numérica)
// Comentarios en español. TS estricto.
import { useQuantity } from "@erp/core/hooks";
import { Input } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import * as React from "react";
import {
type InputEmptyMode,
type InputReadOnlyMode,
type InputSuffixMap,
findFocusableInCell,
focusAndSelect,
} from "./input-utils";
export type QuantityInputProps = {
value: number | "" | string; // "" → no mostrar nada; string puede venir con separadores
onChange: (next: number | "") => void;
readOnly?: boolean;
readOnlyMode?: InputReadOnlyMode;
id?: string;
"aria-label"?: string;
emptyMode?: InputEmptyMode; // cómo presentar vacío
emptyText?: string; // texto de vacío para value-mode/placeholder
scale?: number; // default 2
locale?: string; // para plural/sufijo y formateo
className?: string;
// Sufijo solo en visual, p.ej. {one:"caja", other:"cajas"}
displaySuffix?: InputSuffixMap | ((n: number) => string);
nbspBeforeSuffix?: boolean; // separador no rompible
};
export function QuantityInput({
value,
onChange,
readOnly = false,
readOnlyMode = "textlike-input",
id,
"aria-label": ariaLabel = "Quantity",
emptyMode = "blank",
emptyText = "",
scale = 2,
locale,
className,
displaySuffix,
nbspBeforeSuffix = true,
...inputProps
}: QuantityInputProps) {
const { parse, roundToScale } = useQuantity({ defaultScale: scale });
const [raw, setRaw] = React.useState<string>("");
const [focused, setFocused] = React.useState(false);
const plural = React.useMemo(() => new Intl.PluralRules(locale ?? undefined), [locale]);
const suffixFor = React.useCallback(
(n: number): string => {
if (!displaySuffix) return "";
if (typeof displaySuffix === "function") return displaySuffix(n);
const cat = plural.select(Math.abs(n));
if (n === 0 && displaySuffix.zero) return displaySuffix.zero;
return displaySuffix[cat as "one" | "other"] ?? displaySuffix.other;
},
[displaySuffix, plural]
);
const formatNumber = React.useCallback(
(n: number) => {
return new Intl.NumberFormat(locale ?? undefined, {
maximumFractionDigits: scale,
minimumFractionDigits: Number.isInteger(n) ? 0 : 0,
useGrouping: false,
}).format(n);
},
[locale, scale]
);
// Derivar texto visual desde prop `value`
const visualText = React.useMemo(() => {
if (value === "" || value === null || value === undefined) {
return emptyMode === "value" ? emptyText : "";
}
const numeric =
typeof value === "number"
? value
: (parse(String(value)) ?? Number(String(value).replaceAll(",", ""))); // tolera string numérico
if (!Number.isFinite(numeric)) return emptyMode === "value" ? emptyText : "";
const n = roundToScale(numeric, scale);
const numTxt = formatNumber(n);
const suf = suffixFor(n);
return suf ? `${numTxt}${nbspBeforeSuffix ? "\u00A0" : " "}${suf}` : numTxt;
}, [
value,
emptyMode,
emptyText,
parse,
roundToScale,
scale,
formatNumber,
suffixFor,
nbspBeforeSuffix,
]);
const isShowingEmptyValue = emptyMode === "value" && raw === emptyText;
// Sin foco → mantener visual
React.useEffect(() => {
if (!focused) setRaw(visualText);
}, [visualText, focused]);
const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setRaw(e.currentTarget.value);
}, []);
const handleFocus = React.useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
setFocused(true);
if (emptyMode === "value" && e.currentTarget.value === emptyText) {
setRaw("");
return;
}
const n =
parse(e.currentTarget.value) ??
(value === "" || value == null
? null
: typeof value === "number"
? value
: parse(String(value)));
setRaw(n !== null && n !== undefined ? String(n) : "");
},
[emptyMode, emptyText, parse, value]
);
const handleBlur = React.useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
setFocused(false);
const txt = e.currentTarget.value.trim();
if (txt === "" || isShowingEmptyValue) {
onChange("");
setRaw(emptyMode === "value" ? emptyText : "");
return;
}
const n = parse(txt);
if (n === null) {
onChange("");
setRaw(emptyMode === "value" ? emptyText : "");
return;
}
const rounded = roundToScale(n, scale);
onChange(rounded);
const numTxt = formatNumber(rounded);
const suf = suffixFor(rounded);
setRaw(suf ? `${numTxt}${nbspBeforeSuffix ? "\u00A0" : " "}${suf}` : numTxt);
},
[
isShowingEmptyValue,
onChange,
emptyMode,
emptyText,
parse,
roundToScale,
scale,
formatNumber,
suffixFor,
nbspBeforeSuffix,
]
);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLElement>) => {
if (readOnly) return;
const keys = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"];
if (!keys.includes(e.key)) return;
e.preventDefault();
const current = e.currentTarget as HTMLElement;
const rowIndex = Number(current.dataset.rowIndex);
const colIndex = Number(current.dataset.colIndex);
let nextRow = rowIndex;
let nextCol = colIndex;
switch (e.key) {
case "ArrowUp":
nextRow--;
break;
case "ArrowDown":
nextRow++;
break;
case "ArrowLeft":
nextCol--;
break;
case "ArrowRight":
nextCol++;
break;
}
const nextElement = findFocusableInCell(nextRow, nextCol);
console.log(nextElement);
if (nextElement) {
focusAndSelect(nextElement);
}
},
[readOnly]
);
// ── READ-ONLY como input que parece texto ───────────────────────────────
if (readOnly && readOnlyMode === "textlike-input") {
const handleBlockFocus = React.useCallback((e: React.SyntheticEvent<HTMLInputElement>) => {
e.preventDefault();
(e.target as HTMLInputElement).blur();
}, []);
const handleBlockKey = React.useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
e.preventDefault();
}, []);
return (
<Input
aria-label={ariaLabel}
className={cn(
"w-full bg-transparent p-0 text-right tabular-nums border-0 shadow-none",
"focus:outline-none focus:ring-0 [caret-color:transparent] cursor-default",
className
)}
id={id}
onFocus={handleBlockFocus}
onKeyDown={handleBlockKey}
onMouseDown={handleBlockFocus}
readOnly
tabIndex={-1}
value={visualText}
{...inputProps}
/>
);
}
// ── Editable / readOnly normal ──────────────────────────────────────────
return (
<Input
aria-label={ariaLabel}
className={cn(
"w-full bg-transparent p-0 text-right tabular-nums h-8 px-1 shadow-none",
"border-none",
"focus:bg-background",
"focus-visible:border-ring focus-visible:ring-ring/20 focus-visible:ring-[2px]",
"hover:border hover:ring-ring/20 hover:ring-[2px]",
className
)}
id={id}
inputMode="decimal"
onBlur={handleBlur}
onChange={handleChange}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
pattern="[0-9]*[.,]?[0-9]*"
placeholder={
emptyMode === "placeholder" && (value === "" || value == null) ? emptyText : undefined
}
readOnly={readOnly}
value={raw}
{...inputProps}
/>
);
}

View File

@ -1,2 +0,0 @@
export * from "./blocks";
export * from "./components";

View File

@ -3,7 +3,12 @@ import { formHasAnyDirty } from "@erp/core/client";
import { useHookForm } from "@erp/core/hooks";
import type { CustomerSelectionOption } from "@erp/customers";
import { type ValidationErrorCollection, isValidationErrorCollection } from "@repo/rdx-ddd";
import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers";
import {
focusFirstInputFormError,
showErrorToast,
showSuccessToast,
showWarningToast,
} from "@repo/rdx-ui/helpers";
import { useEffect, useId, useMemo, useState } from "react";
import type { FieldErrors } from "react-hook-form";
@ -17,7 +22,6 @@ import {
buildProformaUpdateDefault,
buildProformaUpdatePatch,
buildUpdateProformaByIdParams,
focusFirstProformaUpdateFormError,
} from "../utils";
import { useUpdateProformaItemsController } from "./use-update-proforma-items-controller";
@ -32,11 +36,11 @@ export interface UseUpdateProformaControllerOptions {
const normalizeSubmitError = (error: unknown): Error | ValidationErrorCollection => {
if (isValidationErrorCollection(error)) {
return error;
return error satisfies ValidationErrorCollection;
}
if (error instanceof Error) {
return error;
return error satisfies Error;
}
return new Error("Unknown error");
@ -196,7 +200,7 @@ export const useUpdateProformaController = (
console.log("Errores de validación aplicados al form:", form.formState.errors);
focusFirstProformaUpdateFormError(form);
focusFirstInputFormError(form);
if (options?.errorToasts !== false) {
showWarningToast(
@ -219,7 +223,7 @@ export const useUpdateProformaController = (
},
(errors: FieldErrors<ProformaUpdateForm>) => {
console.log(errors);
focusFirstProformaUpdateFormError(form);
focusFirstInputFormError(form);
showWarningToast(
t("forms.validation.title", "Revisa los campos"),

View File

@ -1,7 +1,8 @@
import { NumberHelper } from "@erp/core";
import { NumberHelper } from "@repo/rdx-utils";
import * as React from "react";
import {
type FieldArrayWithId,
type FieldErrors,
type UseFormReturn,
useFieldArray,
useWatch,
@ -24,6 +25,8 @@ export interface ProformaItemsTotals {
export type ProformaItemField = FieldArrayWithId<ProformaUpdateForm, "items", "fieldId">;
export type ProformaItemError = FieldErrors<ProformaItemUpdateForm>;
export interface UseUpdateProformaItemsControllerResult {
fields: ProformaItemField[];
items: ProformaItemUpdateForm[];
@ -31,7 +34,14 @@ export interface UseUpdateProformaItemsControllerResult {
hasItems: boolean;
itemCount: number;
itemErrors: ProformaItemError[];
getItemError: (index: number) => ProformaItemError | undefined;
getItemErrorMessage: (index: number) => string | undefined;
addItemAtStart: () => void;
appendItem: () => void;
insertItemBefore: (index: number) => void;
insertItemAfter: (index: number) => void;
removeItem: (index: number) => void;
duplicateItem: (index: number) => void;
moveItemUp: (index: number) => void;
@ -104,7 +114,12 @@ const calculateItemsTotals = (items: ProformaItemUpdateForm[]): ProformaItemsTot
export const useUpdateProformaItemsController = ({
form,
}: UseUpdateProformaItemsControllerParams): UseUpdateProformaItemsControllerResult => {
const { control, getValues, setValue } = form;
const {
control,
getValues,
setValue,
formState: { errors },
} = form;
const { fields, append } = useFieldArray({
control,
@ -213,8 +228,69 @@ export const useUpdateProformaItemsController = ({
[items]
);
const insertItemAt = React.useCallback(
(index: number) => {
const currentItems = getValues("items") ?? [];
const safeIndex = Math.max(0, Math.min(index, currentItems.length));
const nextItems = [...currentItems];
nextItems.splice(safeIndex, 0, buildProformaItemUpdateDefault(safeIndex));
replaceItems(nextItems);
},
[getValues, replaceItems]
);
const addItemAtStart = React.useCallback(() => {
insertItemAt(0);
}, [insertItemAt]);
const insertItemBefore = React.useCallback(
(index: number) => {
insertItemAt(index);
},
[insertItemAt]
);
const insertItemAfter = React.useCallback(
(index: number) => {
insertItemAt(index + 1);
},
[insertItemAt]
);
const totals = React.useMemo(() => calculateItemsTotals(items), [items]);
const itemErrors = React.useMemo<ProformaItemError[]>(() => {
if (!Array.isArray(errors.items)) {
return [];
}
return errors.items.map((error) => error ?? {});
}, [errors.items]);
const getItemError = React.useCallback(
(index: number): ProformaItemError | undefined => {
return itemErrors[index];
},
[itemErrors]
);
const getItemErrorMessage = React.useCallback(
(index: number): string | undefined => {
const error = itemErrors[index];
return (
error?.isValued?.message ??
error?.quantity?.message ??
error?.unitAmount?.message ??
error?.description?.message ??
error?.itemDiscountPercentage?.message
);
},
[itemErrors]
);
return {
fields,
items,
@ -222,7 +298,14 @@ export const useUpdateProformaItemsController = ({
hasItems: items.length > 0,
itemCount: items.length,
itemErrors,
getItemError,
getItemErrorMessage,
addItemAtStart,
appendItem,
insertItemBefore,
insertItemAfter,
removeItem,
duplicateItem,
moveItemUp,

View File

@ -3,7 +3,7 @@ export interface ProformaItemUpdateForm {
position: number;
isValued: boolean;
description: string;
description: string | null;
quantity: number | null;
unitAmount: number | null;

View File

@ -14,17 +14,32 @@ import { z } from "zod/v4";
* - sin detalles impuestos por el widget
*/
export const ProformaItemUpdateFormSchema = z.object({
id: z.uuid(),
position: z.number().int().nonnegative(),
isValued: z.boolean(),
export const ProformaItemUpdateFormSchema = z
.object({
id: z.uuid(),
position: z.number().int().nonnegative(),
isValued: z.boolean(),
description: z.string(),
description: z.string().nullable(),
quantity: z.number().nullable(),
unitAmount: z.number().nonnegative().nullable(),
quantity: z.number().nullable(),
unitAmount: z.number().nonnegative().nullable(),
itemDiscountPercentage: z.number().min(0).max(100).nullable(),
});
itemDiscountPercentage: z.number().min(0).max(100).nullable(),
})
.refine(
(item) => {
if (!item.isValued) {
return item.quantity === null && item.unitAmount === null;
}
return item.quantity !== null && item.unitAmount !== null;
},
{
message:
"quantity and unitAmount must be null when isValued is false and non-null when isValued is true",
path: ["isValued"],
}
);
export type ProformaItemUpdateFormSchemaType = z.infer<typeof ProformaItemUpdateFormSchema>;

View File

@ -1,5 +1,5 @@
export * from "./items-editor";
export * from "./proforma-basic-info-fields";
export * from "./proforma-form-field-shell";
export * from "./proforma-header-fields-card";
export * from "./proforma-line-editor";
export * from "./selected-recipient";

View File

@ -0,0 +1,240 @@
// modules/customer-invoices/src/web/proformas/update/ui/blocks/line-editor.tsx
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import {
ChevronDown,
ChevronUp,
Copy,
MoreHorizontal,
Plus,
PlusCircle,
Trash2,
} from "lucide-react";
import * as React from "react";
export interface LineEditorColumn<TLine> {
id: string;
header: React.ReactNode;
className?: string;
headClassName?: string;
cell: (params: { line: TLine; index: number }) => React.ReactNode;
}
export interface LineEditorProps<TLine> {
title: React.ReactNode;
lines: TLine[];
getLineKey: (line: TLine, index: number) => React.Key;
columns: LineEditorColumn<TLine>[];
getLineErrorMessage?: (line: TLine, index: number) => string | undefined;
onAddAtStart: () => void;
onAddAtEnd: () => void;
onAddBefore: (index: number) => void;
onAddAfter: (index: number) => void;
onDuplicate: (index: number) => void;
onMoveUp: (index: number) => void;
onMoveDown: (index: number) => void;
onRemove: (index: number) => void;
renderFooter?: () => React.ReactNode;
addAtStartLabel: string;
addAtEndLabel: string;
actionsLabel: string;
insertBeforeLabel: string;
insertAfterLabel: string;
duplicateLabel: string;
moveUpLabel: string;
moveDownLabel: string;
removeLabel: string;
className?: string;
}
export const LineEditor = <TLine,>({
title,
lines,
getLineKey,
columns,
getLineErrorMessage,
onAddAtStart,
onAddAtEnd,
onAddBefore,
onAddAfter,
onDuplicate,
onMoveUp,
onMoveDown,
onRemove,
renderFooter,
addAtStartLabel,
addAtEndLabel,
actionsLabel,
insertBeforeLabel,
insertAfterLabel,
duplicateLabel,
moveUpLabel,
moveDownLabel,
removeLabel,
className,
}: LineEditorProps<TLine>) => {
return (
<div className={cn("w-full space-y-4", className)}>
<div className="flex items-center justify-between gap-4">
<h3 className="text-lg font-semibold">{title}</h3>
<div className="flex gap-2">
<Button onClick={onAddAtStart} size="sm" type="button" variant="outline">
<Plus className="mr-1 h-4 w-4" />
{addAtStartLabel}
</Button>
<Button onClick={onAddAtEnd} size="sm" type="button" variant="default">
<Plus className="mr-1 h-4 w-4" />
{addAtEndLabel}
</Button>
</div>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px]">#</TableHead>
{columns.map((column) => (
<TableHead className={column.headClassName} key={column.id}>
{column.header}
</TableHead>
))}
<TableHead className="w-[50px]" />
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line, index) => {
const lineKey = getLineKey(line, index);
const lineErrorMessage = getLineErrorMessage?.(line, index);
const hasLineError = Boolean(lineErrorMessage);
return (
<React.Fragment key={lineKey}>
<TableRow
aria-invalid={hasLineError ? "true" : undefined}
className={cn(
"group",
hasLineError && "bg-destructive/5 hover:bg-destructive/5"
)}
>
<TableCell className="text-muted-foreground text-sm font-medium">
{index + 1}
</TableCell>
{columns.map((column) => (
<TableCell className={column.className} key={column.id}>
{column.cell({ line, index })}
</TableCell>
))}
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
aria-label={actionsLabel}
className="h-8 w-8 opacity-0 group-hover:opacity-100 focus:opacity-100"
size="icon"
type="button"
variant="ghost"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onAddBefore(index)}>
<PlusCircle className="mr-2 h-4 w-4" />
{insertBeforeLabel}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onAddAfter(index)}>
<PlusCircle className="mr-2 h-4 w-4" />
{insertAfterLabel}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onDuplicate(index)}>
<Copy className="mr-2 h-4 w-4" />
{duplicateLabel}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={index === 0} onClick={() => onMoveUp(index)}>
<ChevronUp className="mr-2 h-4 w-4" />
{moveUpLabel}
</DropdownMenuItem>
<DropdownMenuItem
disabled={index === lines.length - 1}
onClick={() => onMoveDown(index)}
>
<ChevronDown className="mr-2 h-4 w-4" />
{moveDownLabel}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => onRemove(index)}
>
<Trash2 className="mr-2 h-4 w-4" />
{removeLabel}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
{lineErrorMessage ? (
<TableRow className="bg-destructive/5 hover:bg-destructive/5">
<TableCell className="px-2 py-1" colSpan={columns.length + 2}>
<p className="text-destructive text-xs leading-4">{lineErrorMessage}</p>
</TableCell>
</TableRow>
) : null}
</React.Fragment>
);
})}
</TableBody>
</Table>
</div>
{renderFooter?.()}
</div>
);
};

View File

@ -0,0 +1,145 @@
import { AmountField, PercentageField, QuantityField, TextField } from "@repo/rdx-ui/components";
import { MoneyHelper } from "@repo/rdx-utils";
import { useTranslation } from "../../../../i18n";
import type {
ProformaItemAmounts,
ProformaItemField,
ProformaItemsTotals,
} from "../../controllers";
import { LineEditor, type LineEditorColumn } from "./line-editor";
interface ProformaLineEditorProps {
fields: ProformaItemField[];
getItemAmounts: (index: number) => ProformaItemAmounts;
getItemErrorMessage: (index: number) => string | undefined;
totals: ProformaItemsTotals;
addItemAtStart: () => void;
appendItem: () => void;
insertItemBefore: (index: number) => void;
insertItemAfter: (index: number) => void;
duplicateItem: (index: number) => void;
moveItemUp: (index: number) => void;
moveItemDown: (index: number) => void;
removeItem: (index: number) => void;
currency?: string;
className?: string;
}
export const ProformaLineEditor = ({
fields,
getItemAmounts,
getItemErrorMessage,
totals,
addItemAtStart,
appendItem,
insertItemBefore,
insertItemAfter,
duplicateItem,
moveItemUp,
moveItemDown,
removeItem,
currency = "EUR",
className,
}: ProformaLineEditorProps) => {
const { t } = useTranslation();
const columns: LineEditorColumn<ProformaItemField>[] = [
{
id: "description",
header: t("form_fields.items.description.label", "Descripción"),
headClassName: "min-w-[200px]",
cell: ({ index }) => (
<TextField inputClassName="border-none" name={`items.${index}.description`} />
),
},
{
id: "quantity",
header: t("form_fields.items.quantity.label", "Cantidad"),
headClassName: "w-[100px] text-right",
cell: ({ index }) => (
<QuantityField inputClassName="border-none" name={`items.${index}.quantity`} />
),
},
{
id: "unitAmount",
header: t("form_fields.items.unit_amount.label", "Importe unitario"),
headClassName: "w-[120px] text-right",
cell: ({ index }) => (
<AmountField inputClassName="border-none" name={`items.${index}.unitAmount`} />
),
},
{
id: "itemDiscountPercentage",
header: t("form_fields.items.discount_percentage.label", "% Dto"),
headClassName: "w-[100px] text-right",
cell: ({ index }) => (
<PercentageField
inputClassName="border-none"
name={`items.${index}.itemDiscountPercentage`}
/>
),
},
{
id: "total",
header: t("form_fields.items.total.label", "Total"),
headClassName: "w-[120px] text-right",
className: "text-right font-medium tabular-nums",
cell: ({ index }) => MoneyHelper.formatCurrency(getItemAmounts(index).total, 2, currency),
},
];
return (
<LineEditor
actionsLabel={t("common.actions", "Acciones")}
addAtEndLabel={t("common.add_at_end", "Al final")}
addAtStartLabel={t("common.add_at_start", "Al inicio")}
className={className}
columns={columns}
duplicateLabel={t("common.duplicate", "Duplicar")}
getLineErrorMessage={(_, index) => {
const message = getItemErrorMessage(index);
return message ? t(message, message) : undefined;
}}
getLineKey={(field) => field.fieldId}
insertAfterLabel={t("common.insert_after", "Insertar después")}
insertBeforeLabel={t("common.insert_before", "Insertar antes")}
lines={fields}
moveDownLabel={t("common.move_down", "Mover abajo")}
moveUpLabel={t("common.move_up", "Mover arriba")}
onAddAfter={insertItemAfter}
onAddAtEnd={appendItem}
onAddAtStart={addItemAtStart}
onAddBefore={insertItemBefore}
onDuplicate={duplicateItem}
onMoveDown={moveItemDown}
onMoveUp={moveItemUp}
onRemove={removeItem}
removeLabel={t("common.remove", "Eliminar")}
renderFooter={() => (
<div className="flex justify-end">
<div className="rounded-md border bg-muted/50 px-6 py-3">
<div className="flex items-baseline gap-4">
<span className="text-muted-foreground text-sm">
{t("form_fields.items.total.label", "Total")}:
</span>
<span className=" font-bold tabular-nums">
{MoneyHelper.formatCurrency(totals.total, 2, currency)}
</span>
</div>
</div>
</div>
)}
title={t("form_fields.items.title", "Líneas de detalle")}
/>
);
};

View File

@ -1,13 +1,9 @@
import { FormSectionCard } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { PlusIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useTranslation } from "../../../../i18n";
import type { UseUpdateProformaItemsControllerResult } from "../../controllers/use-update-proforma-items-controller";
import { ProformaUpdateItemRowEditor } from "./proforma-update-item-row-editor";
import { ProformaUpdateItemsTotals } from "./proforma-update-items-totals";
import { ProformaLineEditor } from "../blocks";
interface ProformaUpdateItemsEditorProps extends ComponentProps<"fieldset"> {
itemsCtrl: UseUpdateProformaItemsControllerResult;
@ -25,38 +21,20 @@ export const ProformaUpdateItemsEditor = ({
description={t("form_groups.items.description")}
title={t("form_groups.items.title")}
>
<fieldset className="space-y-4" disabled={disabled} {...props}>
<div className="flex justify-end">
<Button onClick={itemsCtrl.appendItem} type="button" variant="outline">
<PlusIcon />
{t("common.add", "Añadir")}
</Button>
</div>
{itemsCtrl.hasItems ? (
<div className="space-y-4">
{itemsCtrl.fields.map((field, index) => (
<ProformaUpdateItemRowEditor
amounts={itemsCtrl.getItemAmounts(index)}
canMoveDown={index < itemsCtrl.itemCount - 1}
canMoveUp={index > 0}
index={index}
key={field.fieldId}
onDuplicate={() => itemsCtrl.duplicateItem(index)}
onMoveDown={() => itemsCtrl.moveItemDown(index)}
onMoveUp={() => itemsCtrl.moveItemUp(index)}
onRemove={() => itemsCtrl.removeItem(index)}
/>
))}
</div>
) : (
<div className="text-sm text-muted-foreground">
{t("form_groups.items.empty", "Todavía no hay líneas en la proforma.")}
</div>
)}
<ProformaUpdateItemsTotals totals={itemsCtrl.totals} />
</fieldset>
<ProformaLineEditor
addItemAtStart={itemsCtrl.addItemAtStart}
appendItem={itemsCtrl.appendItem}
duplicateItem={itemsCtrl.duplicateItem}
fields={itemsCtrl.fields}
getItemAmounts={itemsCtrl.getItemAmounts}
getItemErrorMessage={itemsCtrl.getItemErrorMessage}
insertItemAfter={itemsCtrl.insertItemAfter}
insertItemBefore={itemsCtrl.insertItemBefore}
moveItemDown={itemsCtrl.moveItemDown}
moveItemUp={itemsCtrl.moveItemUp}
removeItem={itemsCtrl.removeItem}
totals={itemsCtrl.totals}
/>
</FormSectionCard>
);
};

View File

@ -1,14 +0,0 @@
import type { UseFormReturn } from "react-hook-form";
import type { ProformaUpdateForm } from "../entities";
export const focusFirstProformaUpdateFormError = (form: UseFormReturn<ProformaUpdateForm>) => {
const errors = form.formState.errors;
const firstKey = Object.keys(errors)[0] as keyof ProformaUpdateForm | undefined;
if (firstKey) {
form.setFocus(firstKey);
}
return;
};

View File

@ -3,4 +3,3 @@ export * from "./build-proforma-update-default";
export * from "./build-proforma-update-default";
export * from "./build-proforma-update-patch";
export * from "./build-update-proforma-by-id-params";
export * from "./focus-first-proforma-update-form-error";

View File

@ -1,3 +1,4 @@
import { MoneyHelper } from "@repo/rdx-utils";
import { TrendingUpIcon } from "lucide-react";
export const CustomerStatsSection = ({
@ -5,9 +6,6 @@ export const CustomerStatsSection = ({
}: {
stats: { currency: string; totalPurchases: number; purchasesThisYear: number };
}) => {
const formatCurrency = (value: number) =>
new Intl.NumberFormat("es-ES", { style: "currency", currency: stats.currency }).format(value);
return (
<div className="p-4">
<h3 className="text-sm font-medium text-foreground mb-3 flex items-center gap-2">
@ -18,13 +16,13 @@ export const CustomerStatsSection = ({
<div className="rounded-lg border bg-muted/50 p-3">
<p className="text-xs text-muted-foreground">Total histórico</p>
<p className="text-lg font-semibold text-foreground">
{formatCurrency(stats.totalPurchases || 0)}
{MoneyHelper.formatCurrency(stats.totalPurchases || 0)}
</p>
</div>
<div className="rounded-lg border bg-muted/50 p-3">
<p className="text-xs text-muted-foreground">Este año</p>
<p className="text-lg font-semibold text-foreground">
{formatCurrency(stats.purchasesThisYear || 0)}
{MoneyHelper.formatCurrency(stats.purchasesThisYear || 0)}
</p>
</div>
</div>

View File

@ -2,7 +2,12 @@ import { applyValidationErrorCollection } from "@erp/core";
import { formHasAnyDirty } from "@erp/core/client";
import { useHookForm } from "@erp/core/hooks";
import { type ValidationErrorCollection, isValidationErrorCollection } from "@repo/rdx-ddd";
import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers";
import {
focusFirstInputFormError,
showErrorToast,
showSuccessToast,
showWarningToast,
} from "@repo/rdx-ui/helpers";
import { useEffect, useId, useMemo } from "react";
import type { FieldErrors } from "react-hook-form";
@ -19,7 +24,6 @@ import {
buildCustomerUpdateDefault,
buildCustomerUpdatePatch,
buildUpdateCustomerByIdParams,
focusFirstCustomerUpdateFormError,
} from "../utils";
export interface UseCustomerUpdateControllerOptions {
@ -164,7 +168,7 @@ export const useCustomerUpdateController = (
console.log("Errores de validación aplicados al form:", form.formState.errors);
focusFirstCustomerUpdateFormError(form);
focusFirstInputFormError(form);
if (options?.errorToasts !== false) {
showWarningToast(
@ -193,7 +197,7 @@ export const useCustomerUpdateController = (
},
(errors: FieldErrors<CustomerUpdateForm>) => {
console.log(errors);
focusFirstCustomerUpdateFormError(form);
focusFirstInputFormError(form);
showWarningToast(
t("forms.validation.title", "Revisa los campos"),

View File

@ -1,14 +0,0 @@
import type { UseFormReturn } from "react-hook-form";
import type { CustomerUpdateForm } from "../entities";
export const focusFirstCustomerUpdateFormError = (form: UseFormReturn<CustomerUpdateForm>) => {
const errors = form.formState.errors;
const firstKey = Object.keys(errors)[0] as keyof CustomerUpdateForm | undefined;
if (firstKey) {
form.setFocus(firstKey);
}
return;
};

View File

@ -1,4 +1,3 @@
export * from "./build-customer.update-patch";
export * from "./build-customer-update-default";
export * from "./build-update-customer-by-id-params";
export * from "./focus-first-customer-update-form-error";

View File

@ -13,7 +13,7 @@
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
"@types/dinero.js": "^2.0.0",
"@types/dinero.js": "1.9.1",
"@types/node": "^25.6.0",
"typescript": "^6.0.2"
},

View File

@ -1,8 +1,9 @@
{
"name": "@repo/rdx-ui",
"version": "0.0.0",
"type": "module",
"version": "0.6.2",
"private": true,
"type": "module",
"sideEffects": false,
"scripts": {
"ui:lint": "biome lint --fix"
},
@ -49,6 +50,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@repo/i18next": "workspace:*",
"@repo/shadcn-ui": "workspace:*",
"@repo/rdx-utils": "workspace:*",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"cmdk": "^1.1.1",

View File

@ -74,7 +74,12 @@ export function DataTableToolbar<TData>({
// Render principal
return (
<div className={cn("flex items-center justify-between gap-2 py-4 bg-transparent", className)}>
<div
className={cn(
"flex items-center justify-between gap-2 py-4 bg-transparent border-b",
className
)}
>
{/* IZQUIERDA: acciones + contador */}
<div className="flex flex-1 items-center gap-3 flex-wrap">
{/* Botón añadir */}

View File

@ -1,5 +1,3 @@
"use client";
import {
TableBody,
TableCell,
@ -9,6 +7,7 @@ import {
TableHeader,
TableRow,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import {
type ColumnDef,
type ColumnFiltersState,
@ -34,6 +33,8 @@ import { useTranslation } from "../../locales/i18n.ts";
import { DataTablePagination } from "./data-table-pagination.tsx";
import { DataTableToolbar } from "./data-table-toolbar.tsx";
import "./types.ts";
export type DataTableOps<TData> = {
onAdd?: (table: Table<TData>) => void;
};
@ -63,6 +64,8 @@ export type DataTableMeta<TData> = TableMeta<TData> & {
};
export interface DataTableProps<TData, TValue> {
className?: string;
columns: ColumnDef<TData, TValue>[];
data: TData[];
meta?: DataTableMeta<TData>;
@ -88,6 +91,8 @@ export interface DataTableProps<TData, TValue> {
}
export function DataTable<TData, TValue>({
className,
columns,
data,
meta,
@ -173,101 +178,171 @@ export function DataTable<TData, TValue>({
getFacetedUniqueValues: getFacetedUniqueValues(),
});
const scrollRef = React.useRef<HTMLDivElement>(null);
const [isScrolled, setIsScrolled] = React.useState(false);
const [canScrollRight, setCanScrollRight] = React.useState(true);
React.useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const check = () => {
setIsScrolled(el.scrollLeft > 0);
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
};
check();
el.addEventListener("scroll", check);
window.addEventListener("resize", check);
return () => {
el.removeEventListener("scroll", check);
window.removeEventListener("resize", check);
};
}, []);
// Render principal
return (
<div className="transition-[max-height] duration-300 ease-in-out">
<div
className={cn(
"transition-[max-height] duration-300 ease-in-out rounded-xl border border-border bg-card shadow-sm ",
className
)}
>
<div className="flex flex-col gap-0">
<DataTableToolbar showViewOptions={!readOnly} table={table} />
<div className="overflow-hidden rounded-md border">
<TableComp>
{/* CABECERA */}
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((h) => {
const w = h.getSize();
const minW = h.column.columnDef.minSize;
const maxW = h.column.columnDef.maxSize;
return (
<TableHead
colSpan={h.colSpan}
key={h.id}
style={{
width: w ? `${w}px` : undefined,
minWidth: typeof minW === "number" ? `${minW}px` : undefined,
maxWidth: typeof maxW === "number" ? `${maxW}px` : undefined,
}}
>
{h.isPlaceholder
? null
: flexRender(h.column.columnDef.header, h.getContext())}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
{/* Contenedor con fade + tabla */}
<div className="relative overflow-hidden">
{/*
* CAPA DE FADE
* Div absolutamente posicionado sobre el área scrollable.
* - pointer-events: none no bloquea interacciones.
* - linear-gradient de transparente a bg-card (blanco/oscuro).
* - z-10 queda por DEBAJO de la columna sticky (z-20).
* - Solo visible cuando aún hay contenido a la derecha.
*
*/}
{canScrollRight && (
<div
aria-hidden
className="pointer-events-none absolute right-10 top-0 h-full w-32 z-10"
style={{
background: "linear-gradient(to right, transparent, hsl(var(--card, 0 0% 100%)))",
}}
/>
)}
{/* CUERPO */}
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row, rowIndex) => (
<TableRow
className={"group bg-background"}
data-state={row.getIsSelected() && "selected"}
key={row.id}
onClick={(e) => onRowClick?.(row.original, rowIndex, e)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ")
onRowClick?.(row.original, rowIndex, e as any);
}}
role="button"
tabIndex={0}
>
{row.getVisibleCells().map((cell) => {
const w = cell.column.getSize();
const minW = cell.column.columnDef.minSize;
const maxW = cell.column.columnDef.maxSize;
{/* Scroll container */}
<div className="overflow-x-auto max-w-full -mx-px" ref={scrollRef}>
<TableComp>
{/* CABECERA */}
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow className="bg-muted/50 hover:bg-muted/50" key={hg.id}>
{hg.headers.map((h) => {
const w = h.getSize();
const minW = h.column.columnDef.minSize;
const maxW = h.column.columnDef.maxSize;
const isActionsColumn = h.column.columnDef.meta?.isActionsColumn;
const headerClassName = h.column.columnDef.meta?.headerClassName;
return (
<TableCell
className="align-top"
key={cell.id}
<TableHead
className={cn(
"whitespace-nowrap",
isActionsColumn &&
"sticky right-0 z-20 w-10 bg-muted/50 transition-shadow",
isActionsColumn &&
isScrolled &&
"shadow-[-8px_0_12px_-4px_rgba(0,0,0,0.08)]",
headerClassName
)}
colSpan={h.colSpan}
key={h.id}
style={{
width: w ? `${w}px` : undefined,
minWidth: typeof minW === "number" ? `${minW}px` : undefined,
maxWidth: typeof maxW === "number" ? `${maxW}px` : undefined,
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
{h.isPlaceholder
? null
: flexRender(h.column.columnDef.header, h.getContext())}
</TableHead>
);
})}
</TableRow>
))
) : (
<TableRow>
<TableCell
className="h-24 text-center text-muted-foreground"
colSpan={columns.length}
>
{t("components.datatable.empty")}
</TableCell>
</TableRow>
)}
</TableBody>
))}
</TableHeader>
{/* Paginación */}
{enablePagination && (
<TableFooter className="bg-background">
<TableRow>
<TableCell colSpan={100}>
<DataTablePagination table={table} />
</TableCell>
</TableRow>
</TableFooter>
)}
</TableComp>
{/* CUERPO */}
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row, rowIndex) => (
<TableRow
data-state={row.getIsSelected() && "selected"}
key={row.id}
onClick={(e) => onRowClick?.(row.original, rowIndex, e)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ")
onRowClick?.(row.original, rowIndex, e as any);
}}
role="button"
tabIndex={0}
>
{row.getVisibleCells().map((cell) => {
const w = cell.column.getSize();
const minW = cell.column.columnDef.minSize;
const maxW = cell.column.columnDef.maxSize;
const isActionsColumn = cell.column.columnDef.meta?.isActionsColumn;
const cellClassName = cell.column.columnDef.meta?.cellClassName;
return (
<TableCell
className={cn(
"align-top whitespace-nowrap font-medium truncate",
isActionsColumn &&
"sticky right-0 z-20 w-10 bg-card transition-shadow",
isActionsColumn &&
isScrolled &&
"shadow-[-8px_0_12px_-4px_rgba(0,0,0,0.08)]",
cellClassName
)}
key={cell.id}
style={{
width: w ? `${w}px` : undefined,
minWidth: typeof minW === "number" ? `${minW}px` : undefined,
maxWidth: typeof maxW === "number" ? `${maxW}px` : undefined,
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
);
})}
</TableRow>
))
) : (
<TableRow>
<TableCell
className="h-24 text-center text-muted-foreground"
colSpan={columns.length}
>
{t("components.datatable.empty")}
</TableCell>
</TableRow>
)}
</TableBody>
{/* Paginación */}
{enablePagination && (
<TableFooter className="bg-background">
<TableRow>
<TableCell colSpan={100}>
<DataTablePagination table={table} />
</TableCell>
</TableRow>
</TableFooter>
)}
</TableComp>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,9 @@
import type { RowData } from "@tanstack/react-table";
declare module "@tanstack/react-table" {
interface ColumnMeta<TData extends RowData, TValue> {
isActionsColumn?: boolean;
cellClassName?: string;
headerClassName?: string;
}
}

View File

@ -1,6 +1,6 @@
import { Button } from "@repo/shadcn-ui/components";
import { HomeIcon } from "lucide-react";
import { JSX } from "react";
import type { JSX } from "react";
import { useNavigate } from "react-router-dom";
import { BackHistoryButton } from "./buttons/back-history-button.tsx";

View File

@ -11,7 +11,7 @@ import {
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { Control, FieldPath, FieldValues } from "react-hook-form";
import type { Control, FieldPath, FieldValues } from "react-hook-form";
import { useTranslation } from "../../locales/i18n.ts";
type NumberFieldProps<TFormValues extends FieldValues> = {

View File

@ -16,6 +16,7 @@ type CheckboxFieldProps<TFormValues extends FieldValues> = {
label: string;
description?: string;
reserveDescriptionSpace?: boolean;
disabled?: boolean;
required?: boolean;
@ -32,6 +33,7 @@ export const CheckboxField = <TFormValues extends FieldValues>({
label,
description,
reserveDescriptionSpace = false,
disabled = false,
required = false,
@ -85,7 +87,9 @@ export const CheckboxField = <TFormValues extends FieldValues>({
</FormFieldLabel>
{description ? (
<FieldDescription id={descriptionId}>{description}</FieldDescription>
<FieldDescription>{description}</FieldDescription>
) : reserveDescriptionSpace ? (
<div aria-hidden="true" className="min-h-5" />
) : null}
<FieldError errors={[fieldState.error]} />

View File

@ -19,6 +19,7 @@ type DatePickerFieldProps<TFormValues extends FieldValues> = Omit<NativeInputPro
label?: string;
description?: string;
reserveDescriptionSpace?: boolean;
orientation?: "vertical" | "horizontal" | "responsive";
@ -30,6 +31,7 @@ export const DatePickerField = <TFormValues extends FieldValues>({
label,
description,
reserveDescriptionSpace = false,
required = false,
readOnly = false,
@ -91,9 +93,9 @@ export const DatePickerField = <TFormValues extends FieldValues>({
{description ? (
<FieldDescription>{description}</FieldDescription>
) : (
) : reserveDescriptionSpace ? (
<div aria-hidden="true" className="min-h-5" />
)}
) : null}
<FieldError errors={[fieldError]} />
</Field>

View File

@ -30,6 +30,7 @@ export const DecimalField = <TFormValues extends FieldValues>({
label,
description,
reserveDescriptionSpace = false,
required = false,
readOnly = false,
@ -131,7 +132,7 @@ export const DecimalField = <TFormValues extends FieldValues>({
<>
<InputGroup
className={cn(
"bg-muted/50 font-medium transition",
"bg-muted/50 tabular-nums transition",
"hover:border-ring hover:ring-ring/20 hover:ring-[3px]",
"focus-visible:border-ring focus-visible:ring-ring/60 focus-visible:ring-[3px]",
"placeholder:text-muted-foreground/50",
@ -148,7 +149,7 @@ export const DecimalField = <TFormValues extends FieldValues>({
{...inputRest}
aria-invalid={!!fieldError}
autoComplete="off"
className="placeholder:text-muted-foreground/50"
className="placeholder:text-muted-foreground/50 text-right tabular-nums"
disabled={disabled}
id={inputId}
inputMode="decimal"
@ -163,15 +164,17 @@ export const DecimalField = <TFormValues extends FieldValues>({
/>
{renderedRightAddon ? (
<InputGroupAddon aria-hidden="true">{renderedRightAddon}</InputGroupAddon>
<InputGroupAddon align="inline-end" aria-hidden="true">
{renderedRightAddon}
</InputGroupAddon>
) : null}
</InputGroup>
{description ? (
<FieldDescription>{description}</FieldDescription>
) : (
) : reserveDescriptionSpace ? (
<div aria-hidden="true" className="min-h-5" />
)}
) : null}
<FieldError errors={[fieldError]} />
</>

View File

@ -10,6 +10,7 @@ export type DecimalFieldBaseProps = Omit<
> & {
label?: string;
description?: string;
reserveDescriptionSpace?: boolean;
required?: boolean;
readOnly?: boolean;

View File

@ -1,3 +1,5 @@
import { NumberHelper } from "@repo/rdx-utils";
export const DECIMAL_INPUT_PATTERN = /^-?\d*([.,]\d*)?$/;
export const normalizeDecimalInput = (value: string): string => {
@ -49,9 +51,10 @@ export const clampNumber = (value: number, min?: number, max?: number): number =
};
export const formatDecimalValue = (value: number | null | undefined, scale: number): string => {
if (value === null || value === undefined || Number.isNaN(value)) {
return "";
}
return NumberHelper.formatNumber(value, scale);
/*if (value === null || value === undefined || Number.isNaN(value));
return "";
const asString = String(value);
@ -62,5 +65,5 @@ export const formatDecimalValue = (value: number | null | undefined, scale: numb
const [integerPart, decimalPart = ""] = asString.split(".");
const trimmedDecimalPart = decimalPart.slice(0, scale).replace(/0+$/, "");
return trimmedDecimalPart.length > 0 ? `${integerPart}.${trimmedDecimalPart}` : `${integerPart}`;
return trimmedDecimalPart.length > 0 ? `${integerPart}.${trimmedDecimalPart}` : `${integerPart}`;*/
};

View File

@ -1,6 +1,12 @@
// update/ui/blocks/proforma-section-card.tsx
import { FieldDescription, FieldLegend, FieldSet } from "@repo/shadcn-ui/components";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
FieldLegend,
FieldSet,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import type { ReactNode } from "react";
@ -9,6 +15,8 @@ interface FormSectionCardProps {
description?: string;
children: ReactNode;
className?: string;
headerClassName?: string;
contentClassName?: string;
}
export const FormSectionCard = ({
@ -16,23 +24,32 @@ export const FormSectionCard = ({
description,
children,
className,
headerClassName,
contentClassName,
}: FormSectionCardProps) => {
const hasHeader = Boolean(title || description);
return (
<section className={cn("rounded-xl border bg-background p-4 md:p-6", className)}>
<Card className={cn("", className)}>
<FieldSet>
{title || description ? (
<FieldLegend className="mb-4 space-y-1 md:mb-6">
{title ? <h2 className="text-base font-semibold tracking-tight">{title}</h2> : null}
{description ? (
<FieldDescription className="text-sm text-muted-foreground">
{description}
</FieldDescription>
) : null}
</FieldLegend>
{hasHeader ? (
<CardHeader className={cn("pb-4 md:pb-6", headerClassName)}>
<FieldLegend>
<div className="space-y-1">
{title ? (
<CardTitle className="text-base font-semibold tracking-tight">{title}</CardTitle>
) : null}
{description ? <CardDescription>{description}</CardDescription> : null}
</div>
</FieldLegend>
</CardHeader>
) : null}
{children}
<CardContent className={cn(hasHeader ? "pt-0" : undefined, contentClassName)}>
{children}
</CardContent>
</FieldSet>
</section>
</Card>
);
};

View File

@ -1,15 +1,13 @@
// update/ui/blocks/proforma-header-form-grid.tsx
import { FieldGroup } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import type { ReactNode } from "react";
interface ProformaHeaderFormGridProps {
interface FormSectionGridProps {
children: ReactNode;
className?: string;
}
export const FormSectionGrid = ({ children, className }: ProformaHeaderFormGridProps) => {
export const FormSectionGrid = ({ children, className }: FormSectionGridProps) => {
return (
<FieldGroup className={cn("grid grid-cols-1 gap-4 md:grid-cols-12", className)}>
{children}

View File

@ -57,6 +57,8 @@ type MultiSelectFieldProps<T extends FieldValues> = VariantProps<
label?: string;
description?: string;
reserveDescriptionSpace?: boolean;
required?: boolean;
readOnly?: boolean;
disabled?: boolean;
@ -76,8 +78,11 @@ type MultiSelectFieldProps<T extends FieldValues> = VariantProps<
export const MultiSelectField = <T extends FieldValues>({
name,
options,
label,
description,
reserveDescriptionSpace = false,
required = false,
readOnly = false,
disabled = false,
@ -326,9 +331,9 @@ export const MultiSelectField = <T extends FieldValues>({
{description ? (
<FieldDescription>{description}</FieldDescription>
) : (
) : reserveDescriptionSpace ? (
<div aria-hidden="true" className="min-h-5" />
)}
) : null}
<FieldError errors={[fieldState.error]} />
</Field>

View File

@ -25,6 +25,7 @@ type RadioGroupFieldProps<TFormValues extends FieldValues> = {
label?: string;
description?: string;
reserveDescriptionSpace?: boolean;
disabled?: boolean;
required?: boolean;
@ -40,11 +41,15 @@ type RadioGroupFieldProps<TFormValues extends FieldValues> = {
export const RadioGroupField = <TFormValues extends FieldValues>({
name,
items,
label,
description,
reserveDescriptionSpace = false,
disabled = false,
required = false,
readOnly = false,
orientation = "vertical",
className,
inputClassName,
@ -127,9 +132,9 @@ export const RadioGroupField = <TFormValues extends FieldValues>({
{description ? (
<FieldDescription>{description}</FieldDescription>
) : (
) : reserveDescriptionSpace ? (
<div aria-hidden="true" className="min-h-5" />
)}
) : null}
<FieldError errors={[fieldState.error]} />
</Field>

View File

@ -24,6 +24,7 @@ type SelectFieldProps<TFormValues extends FieldValues> = {
label?: string;
description?: string;
reserveDescriptionSpace?: boolean;
disabled?: boolean;
required?: boolean;
@ -43,6 +44,7 @@ export function SelectField<TFormValues extends FieldValues>({
label,
description,
reserveDescriptionSpace = false,
disabled = false,
required = false,
@ -117,9 +119,10 @@ export function SelectField<TFormValues extends FieldValues>({
{description ? (
<FieldDescription>{description}</FieldDescription>
) : (
) : reserveDescriptionSpace ? (
<div aria-hidden="true" className="min-h-5" />
)}
) : null}
<FieldError errors={[fieldState.error]} />
</Field>
);

View File

@ -11,6 +11,7 @@ type TextAreaFieldProps<TFormValues extends FieldValues> = Omit<NativeTextareaPr
label?: string;
description?: string;
reserveDescriptionSpace?: boolean;
orientation?: "vertical" | "horizontal" | "responsive";
@ -22,6 +23,7 @@ export function TextAreaField<TFormValues extends FieldValues>({
label,
description,
reserveDescriptionSpace = false,
required = false,
readOnly = false,
@ -80,9 +82,9 @@ export function TextAreaField<TFormValues extends FieldValues>({
{description ? (
<FieldDescription>{description}</FieldDescription>
) : (
) : reserveDescriptionSpace ? (
<div aria-hidden="true" className="min-h-5" />
)}
) : null}
<FieldError errors={[fieldError]} />
</Field>

View File

@ -29,6 +29,7 @@ type TextFieldProps<TFormValues extends FieldValues> = Omit<NativeInputProps, "n
label?: string;
description?: string;
reserveDescriptionSpace?: boolean;
orientation?: "vertical" | "horizontal" | "responsive";
@ -45,6 +46,7 @@ export const TextField = <TFormValues extends FieldValues>({
label,
description,
reserveDescriptionSpace = false,
required = false,
readOnly = false,
@ -111,9 +113,9 @@ export const TextField = <TFormValues extends FieldValues>({
{description ? (
<FieldDescription>{description}</FieldDescription>
) : (
) : reserveDescriptionSpace ? (
<div aria-hidden="true" className="min-h-5" />
)}
) : null}
<FieldError errors={[fieldError]} />
</Field>

View File

@ -1,5 +1,5 @@
import { cn } from "@repo/shadcn-ui/lib/utils";
import * as React from "react";
import type * as React from "react";
/** 112 spans por breakpoint */
export type Spans = Partial<{ base: number; sm: number; md: number; lg: number; xl: number }>;

View File

@ -1,6 +1,6 @@
import { cn } from "@repo/shadcn-ui/lib/utils";
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react";
import type * as React from "react";
/** 112 columnas por breakpoint */
export type Cols = Partial<{ base: number; sm: number; md: number; lg: number; xl: number }>;

View File

@ -1,4 +1,4 @@
import { JSX } from "react";
import type { JSX } from "react";
export const LoadingSpinIcon = ({
size = 5,

Some files were not shown because too many files have changed in this diff Show More