This commit is contained in:
David Arranz 2026-04-29 21:48:56 +02:00
parent 79e90ec00f
commit d216119a91
25 changed files with 421 additions and 119 deletions

View File

@ -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>;

View File

@ -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 {

View File

@ -1,4 +1,4 @@
import { DateHelper } from "@erp/core";
import { DateHelper } from "@repo/rdx-utils";
import {
Button,
Tooltip,

View File

@ -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>;

View File

@ -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>
}
/>

View File

@ -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),
},
];

View File

@ -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

View File

@ -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 />

View File

@ -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 />

View File

@ -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 {

View File

@ -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}

View File

@ -27,6 +27,10 @@ export type DecimalFieldBaseProps = Omit<
rightAddon?: React.ReactNode;
scale?: number;
minFractionDigits?: number;
maxFractionDigits?: number;
useGrouping?: boolean;
min?: number;
max?: number;
};

View File

@ -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);
};

View File

@ -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 ? (

View File

@ -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";

View File

@ -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}
/>
);
};

View File

@ -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";

View File

@ -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>
);
};

View File

@ -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}
/>
);
};

View File

@ -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}
/>
);
};

View File

@ -0,0 +1,2 @@
export * from "./focus-first-input-form-error.ts";
export * from "./prevent-enter-key-submit-form.ts";

View File

@ -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();
};

View File

@ -1,2 +1,2 @@
export * from "./focus-first-input-form-error.ts";
export * from "./forms/index.ts";
export * from "./toast-utils.ts";