.
This commit is contained in:
parent
79e90ec00f
commit
d216119a91
@ -9,34 +9,19 @@ import { z } from "zod/v4";
|
||||
|
||||
import { ItemPositionSchema, TaxCombinationCodeSchema } from "../../shared";
|
||||
|
||||
export const CreateProformaItemRequestSchema = z
|
||||
.object({
|
||||
position: ItemPositionSchema,
|
||||
export const CreateProformaItemRequestSchema = z.object({
|
||||
position: ItemPositionSchema,
|
||||
|
||||
is_valued: z.boolean(),
|
||||
description: z.string().nullable(),
|
||||
is_valued: z.boolean(),
|
||||
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 type CreateProformaItemRequestDTO = z.infer<typeof CreateProformaItemRequestSchema>;
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { DateHelper } from "@erp/core";
|
||||
import { DateHelper } from "@erp/rdx-utils";
|
||||
import { ReactQRCode } from "@lglab/react-qr-code";
|
||||
import { DataTableColumnHeader } from "@repo/rdx-ui/components";
|
||||
import {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { DateHelper } from "@erp/core";
|
||||
import { DateHelper } from "@repo/rdx-utils";
|
||||
import {
|
||||
Button,
|
||||
Tooltip,
|
||||
|
||||
@ -14,32 +14,17 @@ 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().nullable(),
|
||||
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(),
|
||||
})
|
||||
.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"],
|
||||
}
|
||||
);
|
||||
itemDiscountPercentage: z.number().min(0).max(100).nullable(),
|
||||
});
|
||||
|
||||
export type ProformaItemUpdateFormSchemaType = z.infer<typeof ProformaItemUpdateFormSchema>;
|
||||
|
||||
@ -18,7 +18,7 @@ import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
MoreHorizontal,
|
||||
MoreHorizontalIcon,
|
||||
Plus,
|
||||
PlusCircle,
|
||||
Trash2,
|
||||
@ -120,15 +120,20 @@ export const LineEditor = <TLine,>({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px]">#</TableHead>
|
||||
<TableHead className="w-[40px] font-semibold text-muted-foreground">#</TableHead>
|
||||
|
||||
{columns.map((column) => (
|
||||
<TableHead className={column.headClassName} key={column.id}>
|
||||
<TableHead
|
||||
className={cn("font-semibold text-muted-foreground", column.headClassName)}
|
||||
key={column.id}
|
||||
>
|
||||
{column.header}
|
||||
</TableHead>
|
||||
))}
|
||||
|
||||
<TableHead className="w-[50px]" />
|
||||
<TableHead className="w-[50px] font-semibold text-muted-foreground text-center">
|
||||
<MoreHorizontalIcon className="size-4 text-muted-foreground mx-auto" />
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
@ -147,28 +152,28 @@ export const LineEditor = <TLine,>({
|
||||
hasLineError && "bg-destructive/5 hover:bg-destructive/5"
|
||||
)}
|
||||
>
|
||||
<TableCell className="text-muted-foreground text-sm font-medium">
|
||||
<TableCell className="text-muted-foreground text-sm font-medium align-top pt-4">
|
||||
{index + 1}
|
||||
</TableCell>
|
||||
|
||||
{columns.map((column) => (
|
||||
<TableCell className={column.className} key={column.id}>
|
||||
<TableCell className={cn("align-top", column.className)} key={column.id}>
|
||||
{column.cell({ line, index })}
|
||||
</TableCell>
|
||||
))}
|
||||
|
||||
<TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm font-medium align-top">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={actionsLabel}
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||
className="size-8 opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import { AmountField, PercentageField, QuantityField, TextField } from "@repo/rdx-ui/components";
|
||||
import {
|
||||
AmountField,
|
||||
LineDescriptionField,
|
||||
PercentageField,
|
||||
QuantityField,
|
||||
} from "@repo/rdx-ui/components";
|
||||
import { MoneyHelper } from "@repo/rdx-utils";
|
||||
|
||||
import { useTranslation } from "../../../../i18n";
|
||||
@ -57,9 +62,14 @@ export const ProformaLineEditor = ({
|
||||
{
|
||||
id: "description",
|
||||
header: t("form_fields.items.description.label", "Descripción"),
|
||||
headClassName: "min-w-[200px]",
|
||||
headClassName: "min-w-[260px]",
|
||||
className: "min-w-[260px] align-top",
|
||||
cell: ({ index }) => (
|
||||
<TextField inputClassName="border-none" name={`items.${index}.description`} />
|
||||
<LineDescriptionField
|
||||
inputClassName="border-none"
|
||||
minRows={1}
|
||||
name={`items.${index}.description`}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
@ -67,7 +77,12 @@ export const ProformaLineEditor = ({
|
||||
header: t("form_fields.items.quantity.label", "Cantidad"),
|
||||
headClassName: "w-[100px] text-right",
|
||||
cell: ({ index }) => (
|
||||
<QuantityField inputClassName="border-none" name={`items.${index}.quantity`} />
|
||||
<QuantityField
|
||||
inputClassName="border-none"
|
||||
maxFractionDigits={4}
|
||||
minFractionDigits={0}
|
||||
name={`items.${index}.quantity`}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
@ -75,7 +90,12 @@ export const ProformaLineEditor = ({
|
||||
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`} />
|
||||
<AmountField
|
||||
inputClassName="border-none"
|
||||
maxFractionDigits={4}
|
||||
minFractionDigits={2}
|
||||
name={`items.${index}.unitAmount`}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
@ -85,6 +105,8 @@ export const ProformaLineEditor = ({
|
||||
cell: ({ index }) => (
|
||||
<PercentageField
|
||||
inputClassName="border-none"
|
||||
maxFractionDigits={2}
|
||||
minFractionDigits={0}
|
||||
name={`items.${index}.itemDiscountPercentage`}
|
||||
/>
|
||||
),
|
||||
@ -93,7 +115,7 @@ export const ProformaLineEditor = ({
|
||||
id: "total",
|
||||
header: t("form_fields.items.total.label", "Total"),
|
||||
headClassName: "w-[120px] text-right",
|
||||
className: "text-right font-medium tabular-nums",
|
||||
className: "text-right font-medium tabular-nums pt-4",
|
||||
cell: ({ index }) => MoneyHelper.formatCurrency(getItemAmounts(index).total, 2, currency),
|
||||
},
|
||||
];
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
// modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-update-editor.tsx
|
||||
|
||||
import type { CustomerSelectionOption } from "@erp/customers";
|
||||
import { preventEnterKeySubmitForm } from "@repo/rdx-ui/helpers";
|
||||
import { Button } from "@repo/shadcn-ui/components";
|
||||
|
||||
import { ProformaUpdateRecipientEditor } from ".";
|
||||
@ -37,7 +38,13 @@ export const ProformaUpdateEditorForm = ({
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<form className="space-y-6" id={formId} noValidate onSubmit={onSubmit}>
|
||||
<form
|
||||
className="space-y-6"
|
||||
id={formId}
|
||||
noValidate
|
||||
onKeyDown={preventEnterKeySubmitForm}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<ProformaUpdateHeaderEditor disabled={isSubmitting} />
|
||||
|
||||
<ProformaUpdateRecipientEditor
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { preventEnterKeySubmitForm } from "@repo/rdx-ui/helpers";
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
|
||||
import { CustomerAdditionalConfigFields } from "./customer-additional-config-fields";
|
||||
@ -17,7 +18,7 @@ export const CustomerCreateEditorForm = ({
|
||||
className,
|
||||
}: CustomerCreateEditorFormProps) => {
|
||||
return (
|
||||
<form id={formId} noValidate onSubmit={onSubmit}>
|
||||
<form id={formId} noValidate onKeyDown={preventEnterKeySubmitForm} onSubmit={onSubmit}>
|
||||
<section className={cn("space-y-12 p-6", className)}>
|
||||
<CustomerBasicInfoFields />
|
||||
<CustomerAddressFields />
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { preventEnterKeySubmitForm } from "@repo/rdx-ui/helpers";
|
||||
|
||||
import { CustomerAdditionalConfigEditor } from "./customer-additional-config-fields";
|
||||
import { CustomerAddressEditor } from "./customer-address-editor";
|
||||
import { CustomerBasicInfoEditor } from "./customer-basic-info-editor";
|
||||
@ -17,7 +19,13 @@ export const CustomerUpdateEditorForm = ({
|
||||
onReset,
|
||||
}: CustomerUpdateEditorFormProps) => {
|
||||
return (
|
||||
<form className="space-y-6" id={formId} noValidate onSubmit={onSubmit}>
|
||||
<form
|
||||
className="space-y-6"
|
||||
id={formId}
|
||||
noValidate
|
||||
onKeyDown={preventEnterKeySubmitForm}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<CustomerBasicInfoEditor disabled={isSubmitting} />
|
||||
<CustomerAddressEditor />
|
||||
<CustomerContactEditor />
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { translateZodValidationError } from "../helpers";
|
||||
|
||||
import { ValueObject } from "./value-object";
|
||||
|
||||
interface URLAddressProps {
|
||||
|
||||
@ -16,7 +16,9 @@ import type { DecimalFieldBaseProps } from "./decimal-field.types.ts";
|
||||
import {
|
||||
DECIMAL_INPUT_PATTERN,
|
||||
clampNumber,
|
||||
formatDecimalValue,
|
||||
formatDecimalDisplayValue,
|
||||
formatDecimalEditingValue,
|
||||
normalizeEditingDecimalInput,
|
||||
parseDecimalOrNull,
|
||||
trimToScale,
|
||||
} from "./decimal-field.utils.ts";
|
||||
@ -46,12 +48,25 @@ export const DecimalField = <TFormValues extends FieldValues>({
|
||||
rightAddon,
|
||||
|
||||
scale = 4,
|
||||
minFractionDigits,
|
||||
maxFractionDigits,
|
||||
useGrouping = true,
|
||||
min,
|
||||
max,
|
||||
|
||||
...inputRest
|
||||
}: DecimalFieldProps<TFormValues>) => {
|
||||
const { control, formState, getFieldState } = useFormContext<TFormValues>();
|
||||
const [isFocused, setIsFocused] = React.useState(false);
|
||||
const formatOptions = React.useMemo(
|
||||
() => ({
|
||||
scale,
|
||||
minFractionDigits,
|
||||
maxFractionDigits,
|
||||
useGrouping,
|
||||
}),
|
||||
[scale, minFractionDigits, maxFractionDigits, useGrouping]
|
||||
);
|
||||
|
||||
const { field } = useController({
|
||||
name,
|
||||
@ -59,23 +74,36 @@ export const DecimalField = <TFormValues extends FieldValues>({
|
||||
defaultValue: null as any,
|
||||
});
|
||||
|
||||
const getDisplayValue = React.useCallback(
|
||||
(value: number | null | undefined): string => {
|
||||
return formatDecimalDisplayValue(value, formatOptions);
|
||||
},
|
||||
[formatOptions]
|
||||
);
|
||||
|
||||
const getEditingValue = React.useCallback(
|
||||
(value: number | null | undefined): string => {
|
||||
return formatDecimalEditingValue(value, scale);
|
||||
},
|
||||
[scale]
|
||||
);
|
||||
|
||||
const inputId = React.useId();
|
||||
const disabled = formState.isSubmitting || inputRest.disabled;
|
||||
const fieldError = getFieldState(name, formState).error;
|
||||
const [inputValue, setInputValue] = React.useState<string>(() =>
|
||||
formatDecimalValue(field.value as number | null | undefined, scale)
|
||||
getDisplayValue(field.value as number | null | undefined)
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const nextFormattedValue = formatDecimalValue(field.value as number | null | undefined, scale);
|
||||
const value = field.value as number | null | undefined;
|
||||
const nextValue = isFocused ? getEditingValue(value) : getDisplayValue(value);
|
||||
|
||||
setInputValue((currentValue) =>
|
||||
currentValue === nextFormattedValue ? currentValue : nextFormattedValue
|
||||
);
|
||||
}, [field.value, scale]);
|
||||
setInputValue((currentValue) => (currentValue === nextValue ? currentValue : nextValue));
|
||||
}, [field.value, isFocused, getDisplayValue, getEditingValue]);
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const rawValue = event.target.value;
|
||||
const rawValue = normalizeEditingDecimalInput(event.target.value);
|
||||
|
||||
if (rawValue === "") {
|
||||
setInputValue("");
|
||||
@ -88,20 +116,22 @@ export const DecimalField = <TFormValues extends FieldValues>({
|
||||
}
|
||||
|
||||
const trimmedValue = trimToScale(rawValue, scale);
|
||||
|
||||
setInputValue(trimmedValue);
|
||||
|
||||
const parsedValue = parseDecimalOrNull(trimmedValue);
|
||||
|
||||
if (parsedValue === null) {
|
||||
field.onChange(null);
|
||||
return;
|
||||
}
|
||||
field.onChange(parsedValue === null ? null : clampNumber(parsedValue, min, max));
|
||||
};
|
||||
|
||||
field.onChange(clampNumber(parsedValue, min, max));
|
||||
const handleFocus = () => {
|
||||
setIsFocused(true);
|
||||
setInputValue(getEditingValue(field.value as number | null | undefined));
|
||||
};
|
||||
|
||||
const handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
|
||||
field.onBlur();
|
||||
setIsFocused(false);
|
||||
|
||||
const parsedValue = parseDecimalOrNull(event.target.value);
|
||||
|
||||
@ -112,10 +142,9 @@ export const DecimalField = <TFormValues extends FieldValues>({
|
||||
}
|
||||
|
||||
const clampedValue = clampNumber(parsedValue, min, max);
|
||||
const formattedValue = formatDecimalValue(clampedValue, scale);
|
||||
|
||||
setInputValue(formattedValue);
|
||||
field.onChange(clampedValue);
|
||||
setInputValue(getDisplayValue(clampedValue));
|
||||
};
|
||||
|
||||
const renderedLeftAddon = leftAddon ?? leftIcon;
|
||||
@ -156,6 +185,7 @@ export const DecimalField = <TFormValues extends FieldValues>({
|
||||
name={field.name}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
readOnly={readOnly}
|
||||
ref={field.ref}
|
||||
required={required}
|
||||
|
||||
@ -27,6 +27,10 @@ export type DecimalFieldBaseProps = Omit<
|
||||
rightAddon?: React.ReactNode;
|
||||
|
||||
scale?: number;
|
||||
minFractionDigits?: number;
|
||||
maxFractionDigits?: number;
|
||||
useGrouping?: boolean;
|
||||
|
||||
min?: number;
|
||||
max?: number;
|
||||
};
|
||||
|
||||
@ -1,25 +1,59 @@
|
||||
import { NumberHelper } from "@repo/rdx-utils";
|
||||
export interface FormatDecimalDisplayValueOptions {
|
||||
scale: number;
|
||||
minFractionDigits?: number;
|
||||
maxFractionDigits?: number;
|
||||
useGrouping?: boolean;
|
||||
}
|
||||
|
||||
export const DECIMAL_INPUT_PATTERN = /^-?\d*([.,]\d*)?$/;
|
||||
/**
|
||||
* Patrón permitido durante la edición de un decimal.
|
||||
*
|
||||
* Reglas:
|
||||
* - permite signo negativo inicial
|
||||
* - permite dígitos
|
||||
* - permite una única coma decimal
|
||||
* - no permite separadores de miles
|
||||
*/
|
||||
export const DECIMAL_INPUT_PATTERN = /^-?\d*(,\d*)?$/;
|
||||
|
||||
export const normalizeDecimalInput = (value: string): string => {
|
||||
return value.replace(",", ".");
|
||||
/**
|
||||
* Normaliza el valor introducido mientras el usuario edita.
|
||||
*
|
||||
* Convierte el punto del teclado numérico en coma decimal,
|
||||
* siguiendo el comportamiento habitual en España.
|
||||
*
|
||||
* @param value - Valor crudo del input.
|
||||
* @returns Valor normalizado para edición.
|
||||
*/
|
||||
export const normalizeEditingDecimalInput = (value: string): string => {
|
||||
return value.replace(".", ",");
|
||||
};
|
||||
|
||||
/**
|
||||
* Recorta la parte decimal al número máximo de decimales permitido.
|
||||
*
|
||||
* @param value - Valor en formato de edición, usando coma decimal.
|
||||
* @param scale - Número máximo de decimales permitidos.
|
||||
* @returns Valor recortado manteniendo formato de edición.
|
||||
*/
|
||||
export const trimToScale = (value: string, scale: number): string => {
|
||||
const normalized = normalizeDecimalInput(value);
|
||||
|
||||
if (!normalized.includes(".")) {
|
||||
return normalized;
|
||||
if (!value.includes(",")) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const [integerPart, decimalPart = ""] = normalized.split(".");
|
||||
const [integerPart, decimalPart = ""] = value.split(",");
|
||||
|
||||
return `${integerPart}.${decimalPart.slice(0, scale)}`;
|
||||
return `${integerPart},${decimalPart.slice(0, scale)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convierte un valor de edición a número o null.
|
||||
*
|
||||
* @param value - Valor en formato de edición, usando coma decimal.
|
||||
* @returns Número parseado o null si el valor no representa un número completo.
|
||||
*/
|
||||
export const parseDecimalOrNull = (value: string): number | null => {
|
||||
const normalized = normalizeDecimalInput(value).trim();
|
||||
const normalized = value.trim().replace(",", ".");
|
||||
|
||||
if (normalized === "") {
|
||||
return null;
|
||||
@ -31,13 +65,17 @@ export const parseDecimalOrNull = (value: string): number | null => {
|
||||
|
||||
const parsed = Number(normalized);
|
||||
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Limita un número dentro del rango indicado.
|
||||
*
|
||||
* @param value - Valor numérico.
|
||||
* @param min - Valor mínimo permitido.
|
||||
* @param max - Valor máximo permitido.
|
||||
* @returns Valor ajustado al rango.
|
||||
*/
|
||||
export const clampNumber = (value: number, min?: number, max?: number): number => {
|
||||
if (typeof min === "number" && value < min) {
|
||||
return min;
|
||||
@ -50,20 +88,59 @@ export const clampNumber = (value: number, min?: number, max?: number): number =
|
||||
return value;
|
||||
};
|
||||
|
||||
export const formatDecimalValue = (value: number | null | undefined, scale: number): string => {
|
||||
return NumberHelper.formatNumber(value, scale);
|
||||
|
||||
/*if (value === null || value === undefined || Number.isNaN(value));
|
||||
return "";
|
||||
|
||||
const asString = String(value);
|
||||
|
||||
if (!asString.includes(".")) {
|
||||
return asString;
|
||||
/**
|
||||
* Formatea un número para edición.
|
||||
*
|
||||
* No añade separadores de miles ni rellena decimales.
|
||||
* Usa coma decimal para mantener una experiencia natural en locale español.
|
||||
*
|
||||
* @param value - Valor numérico del formulario.
|
||||
* @param scale - Número máximo de decimales a mostrar.
|
||||
* @returns Valor textual editable.
|
||||
*/
|
||||
export const formatDecimalEditingValue = (
|
||||
value: number | null | undefined,
|
||||
scale: number
|
||||
): string => {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const [integerPart, decimalPart = ""] = asString.split(".");
|
||||
const trimmedDecimalPart = decimalPart.slice(0, scale).replace(/0+$/, "");
|
||||
const rawValue = String(value);
|
||||
|
||||
return trimmedDecimalPart.length > 0 ? `${integerPart}.${trimmedDecimalPart}` : `${integerPart}`;*/
|
||||
if (!rawValue.includes(".")) {
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
const [integerPart, decimalPart = ""] = rawValue.split(".");
|
||||
const trimmedDecimalPart = decimalPart.slice(0, scale);
|
||||
|
||||
return `${integerPart},${trimmedDecimalPart}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formatea un número para visualización en reposo.
|
||||
*
|
||||
* Puede añadir separadores de miles y controlar el número mínimo
|
||||
* y máximo de decimales visibles.
|
||||
*
|
||||
* @param value - Valor numérico del formulario.
|
||||
* @param options - Opciones de formato.
|
||||
* @returns Valor textual formateado para visualización.
|
||||
*/
|
||||
export const formatDecimalDisplayValue = (
|
||||
value: number | null | undefined,
|
||||
options: FormatDecimalDisplayValueOptions
|
||||
): string => {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const { scale, minFractionDigits = 0, maxFractionDigits = scale, useGrouping = true } = options;
|
||||
|
||||
return new Intl.NumberFormat("es-ES", {
|
||||
minimumFractionDigits: minFractionDigits,
|
||||
maximumFractionDigits: maxFractionDigits,
|
||||
useGrouping,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
@ -33,7 +33,7 @@ export const FormSectionCard = ({
|
||||
<Card className={cn("", className)}>
|
||||
<FieldSet>
|
||||
{hasHeader ? (
|
||||
<CardHeader className={cn("pb-4 md:pb-6", headerClassName)}>
|
||||
<CardHeader className={headerClassName}>
|
||||
<FieldLegend>
|
||||
<div className="space-y-1">
|
||||
{title ? (
|
||||
|
||||
@ -8,6 +8,6 @@ export * from "./form-section-grid.tsx";
|
||||
export * from "./multi-select-field.tsx";
|
||||
export * from "./radio-group-field.tsx";
|
||||
export * from "./select-field.tsx";
|
||||
export * from "./semantic-numeric-fields/index.ts";
|
||||
export * from "./semantic-fields/index.ts";
|
||||
export * from "./text-area-field.tsx";
|
||||
export * from "./text-field.tsx";
|
||||
|
||||
@ -7,8 +7,19 @@ import type { SemanticNumericFieldProps } from "./semantic-numeric-fields.types.
|
||||
|
||||
export const AmountField = <TFormValues extends FieldValues>({
|
||||
scale = 4,
|
||||
minFractionDigits = 2,
|
||||
maxFractionDigits = 4,
|
||||
rightAddon = "€",
|
||||
...props
|
||||
}: SemanticNumericFieldProps<TFormValues>) => {
|
||||
return <DecimalField {...props} min={0} rightAddon={rightAddon} scale={scale} />;
|
||||
return (
|
||||
<DecimalField
|
||||
{...props}
|
||||
maxFractionDigits={maxFractionDigits}
|
||||
min={0}
|
||||
minFractionDigits={minFractionDigits}
|
||||
rightAddon={rightAddon}
|
||||
scale={scale}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -1,3 +1,5 @@
|
||||
export * from "./amount-field.tsx";
|
||||
export * from "./line-description-field.tsx";
|
||||
export * from "./percentage-field.tsx";
|
||||
export * from "./quantity-field.tsx";
|
||||
export * from "./semantic-numeric-fields.types.ts";
|
||||
@ -0,0 +1,125 @@
|
||||
import { Field, FieldDescription, FieldError, Textarea } from "@repo/shadcn-ui/components";
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import * as React from "react";
|
||||
import { type FieldPath, type FieldValues, useFormContext } from "react-hook-form";
|
||||
|
||||
import { FormFieldLabel } from "../form-field-label.tsx";
|
||||
import type { NativeTextareaProps } from "../types.ts";
|
||||
|
||||
type LineDescriptionFieldProps<TFormValues extends FieldValues> = Omit<
|
||||
NativeTextareaProps,
|
||||
"name"
|
||||
> & {
|
||||
name: FieldPath<TFormValues>;
|
||||
|
||||
label?: string;
|
||||
description?: string;
|
||||
reserveDescriptionSpace?: boolean;
|
||||
|
||||
required?: boolean;
|
||||
readOnly?: boolean;
|
||||
|
||||
orientation?: "vertical" | "horizontal" | "responsive";
|
||||
|
||||
inputClassName?: string;
|
||||
|
||||
minRows?: number;
|
||||
maxRows?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_LINE_HEIGHT = 20;
|
||||
|
||||
export const LineDescriptionField = <TFormValues extends FieldValues>({
|
||||
name,
|
||||
|
||||
label,
|
||||
description,
|
||||
reserveDescriptionSpace = false,
|
||||
|
||||
required = false,
|
||||
readOnly = false,
|
||||
|
||||
orientation = "vertical",
|
||||
|
||||
className,
|
||||
inputClassName,
|
||||
|
||||
minRows = 1,
|
||||
maxRows = 99,
|
||||
|
||||
...textareaRest
|
||||
}: LineDescriptionFieldProps<TFormValues>) => {
|
||||
const { register, formState, getFieldState } = useFormContext<TFormValues>();
|
||||
|
||||
const textareaId = React.useId();
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const disabled = formState.isSubmitting || textareaRest.disabled;
|
||||
const fieldError = getFieldState(name, formState).error;
|
||||
|
||||
const registeredField = register(name);
|
||||
|
||||
const resizeTextarea = React.useCallback(() => {
|
||||
const textarea = textareaRef.current;
|
||||
|
||||
if (!textarea) return;
|
||||
|
||||
const minHeight = minRows * DEFAULT_LINE_HEIGHT;
|
||||
const maxHeight = maxRows * DEFAULT_LINE_HEIGHT;
|
||||
|
||||
textarea.style.height = "auto";
|
||||
|
||||
const nextHeight = Math.min(Math.max(textarea.scrollHeight, minHeight), maxHeight);
|
||||
|
||||
textarea.style.height = `${nextHeight}px`;
|
||||
textarea.style.overflowY = textarea.scrollHeight > maxHeight ? "auto" : "hidden";
|
||||
}, [maxRows, minRows]);
|
||||
|
||||
React.useEffect(() => {
|
||||
resizeTextarea();
|
||||
}, [resizeTextarea]);
|
||||
|
||||
return (
|
||||
<Field className={cn("gap-1", className)} data-invalid={!!fieldError} orientation={orientation}>
|
||||
{label ? (
|
||||
<FormFieldLabel htmlFor={textareaId} required={required}>
|
||||
{label}
|
||||
</FormFieldLabel>
|
||||
) : null}
|
||||
|
||||
<Textarea
|
||||
{...textareaRest}
|
||||
{...registeredField}
|
||||
aria-invalid={!!fieldError}
|
||||
className={cn(
|
||||
"min-h-8 resize-none bg-muted/50 py-1.5 font-normal leading-5",
|
||||
"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",
|
||||
inputClassName
|
||||
)}
|
||||
disabled={disabled}
|
||||
id={textareaId}
|
||||
onChange={(event) => {
|
||||
registeredField.onChange(event);
|
||||
resizeTextarea();
|
||||
}}
|
||||
readOnly={readOnly}
|
||||
ref={(element) => {
|
||||
registeredField.ref(element);
|
||||
textareaRef.current = element;
|
||||
}}
|
||||
required={required}
|
||||
rows={minRows}
|
||||
/>
|
||||
|
||||
{description ? (
|
||||
<FieldDescription>{description}</FieldDescription>
|
||||
) : reserveDescriptionSpace ? (
|
||||
<div aria-hidden="true" className="min-h-5" />
|
||||
) : null}
|
||||
|
||||
<FieldError errors={[fieldError]} />
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
@ -6,9 +6,21 @@ import { DecimalField } from "../decimal-field/index.ts";
|
||||
import type { SemanticNumericFieldProps } from "./semantic-numeric-fields.types.ts";
|
||||
|
||||
export const PercentageField = <TFormValues extends FieldValues>({
|
||||
scale = 4,
|
||||
scale = 2,
|
||||
minFractionDigits = 0,
|
||||
maxFractionDigits = 2,
|
||||
rightAddon = "%",
|
||||
...props
|
||||
}: SemanticNumericFieldProps<TFormValues>) => {
|
||||
return <DecimalField {...props} max={100} min={0} rightAddon={rightAddon} scale={scale} />;
|
||||
return (
|
||||
<DecimalField
|
||||
{...props}
|
||||
max={100}
|
||||
maxFractionDigits={maxFractionDigits}
|
||||
min={0}
|
||||
minFractionDigits={minFractionDigits}
|
||||
rightAddon={rightAddon}
|
||||
scale={scale}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -6,7 +6,16 @@ import type { SemanticNumericFieldProps } from "./semantic-numeric-fields.types.
|
||||
|
||||
export const QuantityField = <TFormValues extends FieldValues>({
|
||||
scale = 4,
|
||||
minFractionDigits = 0,
|
||||
maxFractionDigits = 4,
|
||||
...props
|
||||
}: SemanticNumericFieldProps<TFormValues>) => {
|
||||
return <DecimalField {...props} scale={scale} />;
|
||||
return (
|
||||
<DecimalField
|
||||
{...props}
|
||||
maxFractionDigits={maxFractionDigits}
|
||||
minFractionDigits={minFractionDigits}
|
||||
scale={scale}
|
||||
/>
|
||||
);
|
||||
};
|
||||
2
packages/rdx-ui/src/helpers/forms/index.ts
Normal file
2
packages/rdx-ui/src/helpers/forms/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./focus-first-input-form-error.ts";
|
||||
export * from "./prevent-enter-key-submit-form.ts";
|
||||
@ -0,0 +1,15 @@
|
||||
export const preventEnterKeySubmitForm = (event: React.KeyboardEvent<HTMLFormElement>) => {
|
||||
if (event.key !== "Enter") return;
|
||||
|
||||
const target = event.target;
|
||||
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
|
||||
const isTextArea = target.tagName === "TEXTAREA";
|
||||
const isSubmitButton =
|
||||
target.tagName === "BUTTON" && (target as HTMLButtonElement).type === "submit";
|
||||
|
||||
if (isTextArea || isSubmitButton) return;
|
||||
|
||||
event.preventDefault();
|
||||
};
|
||||
@ -1,2 +1,2 @@
|
||||
export * from "./focus-first-input-form-error.ts";
|
||||
export * from "./forms/index.ts";
|
||||
export * from "./toast-utils.ts";
|
||||
|
||||
Loading…
Reference in New Issue
Block a user