Edición de detalles de factura
This commit is contained in:
parent
4361df07a0
commit
79e90ec00f
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
@ -1,7 +0,0 @@
|
||||
const toSafeNumber = (value: string | number | null | undefined): number => {
|
||||
return Number(value ?? 0);
|
||||
};
|
||||
|
||||
export const NumberHelper = {
|
||||
toSafeNumber,
|
||||
};
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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;
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
export * from "./use-pagination";
|
||||
export * from "./use-pagination-sync-with-location";
|
||||
@ -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;
|
||||
};
|
||||
@ -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
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
};
|
||||
@ -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 }), []);
|
||||
}
|
||||
@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
import { keys } from "./key-builder";
|
||||
|
||||
export const useQueryKey = () => {
|
||||
return keys;
|
||||
};
|
||||
@ -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([]);
|
||||
@ -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];
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -1 +0,0 @@
|
||||
export * from "./use-proforma-auto-recalc";
|
||||
@ -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]);
|
||||
}
|
||||
@ -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]);
|
||||
}
|
||||
@ -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,
|
||||
]);
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
*/
|
||||
@ -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 = {};
|
||||
@ -1 +0,0 @@
|
||||
export * from "./create-customer-invoice-page";
|
||||
@ -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);
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
export * from "../proformas";
|
||||
|
||||
export * from "./create";
|
||||
export * from "./list";
|
||||
@ -1 +0,0 @@
|
||||
export * from "./invoices-list-page";
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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";
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -11,7 +11,6 @@ import type { ProformaStatus } from "./proforma-status.entity";
|
||||
export interface ProformaListRow {
|
||||
id: string;
|
||||
companyId: string;
|
||||
isProforma: boolean;
|
||||
|
||||
invoiceNumber: string;
|
||||
status: ProformaStatus;
|
||||
|
||||
@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
@ -1 +0,0 @@
|
||||
export * from "./proforma-tax-summary";
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
export * from "./amount-input-field";
|
||||
export * from "./percentage-input-field";
|
||||
export * from "./quantity-input-field";
|
||||
@ -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.
|
||||
});
|
||||
}
|
||||
@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
export * from "./blocks";
|
||||
export * from "./components";
|
||||
@ -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"),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -3,7 +3,7 @@ export interface ProformaItemUpdateForm {
|
||||
position: number;
|
||||
isValued: boolean;
|
||||
|
||||
description: string;
|
||||
description: string | null;
|
||||
|
||||
quantity: number | null;
|
||||
unitAmount: number | null;
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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;
|
||||
};
|
||||
@ -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";
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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;
|
||||
};
|
||||
@ -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";
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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>
|
||||
|
||||
9
packages/rdx-ui/src/components/datatable/types.ts
Normal file
9
packages/rdx-ui/src/components/datatable/types.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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> = {
|
||||
|
||||
@ -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]} />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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]} />
|
||||
</>
|
||||
|
||||
@ -10,6 +10,7 @@ export type DecimalFieldBaseProps = Omit<
|
||||
> & {
|
||||
label?: string;
|
||||
description?: string;
|
||||
reserveDescriptionSpace?: boolean;
|
||||
|
||||
required?: boolean;
|
||||
readOnly?: boolean;
|
||||
|
||||
@ -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}`;*/
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import * as React from "react";
|
||||
import type * as React from "react";
|
||||
|
||||
/** 1–12 spans por breakpoint */
|
||||
export type Spans = Partial<{ base: number; sm: number; md: number; lg: number; xl: number }>;
|
||||
|
||||
@ -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";
|
||||
|
||||
/** 1–12 columnas por breakpoint */
|
||||
export type Cols = Partial<{ base: number; sm: number; md: number; lg: number; xl: number }>;
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user