Update de proformas
This commit is contained in:
parent
88a7a36c4b
commit
ff6905b845
@ -1,3 +1,7 @@
|
||||
export const toSafeNumber = (value: number | null | undefined): number => {
|
||||
const toSafeNumber = (value: number | null | undefined): number => {
|
||||
return value ?? 0;
|
||||
};
|
||||
|
||||
export const NumberHelper = {
|
||||
toSafeNumber,
|
||||
};
|
||||
|
||||
@ -230,8 +230,6 @@ export function useProformasGridColumns(
|
||||
const availableTransitions =
|
||||
PROFORMA_STATUS_TRANSITIONS[proforma.status as ProformaStatus] ?? [];
|
||||
|
||||
console.log(availableTransitions, proforma.status);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{!isIssued && actionHandlers.onEditClick && (
|
||||
|
||||
@ -68,7 +68,7 @@ export const GetProformaByIdAdapter = {
|
||||
const mapItem = (dto: GetProformaByIdResponseDTO["items"][number]): ProformaItem => {
|
||||
return {
|
||||
id: dto.id,
|
||||
position: dto.position,
|
||||
position: Number(dto.position),
|
||||
description: dto.description,
|
||||
|
||||
quantity: QuantityDTOHelper.toNumber(dto.quantity),
|
||||
|
||||
@ -19,7 +19,6 @@ export const ProformaToListRowPatchAdapter = {
|
||||
fromProforma(proforma: Proforma): ProformaListRowPatch {
|
||||
return {
|
||||
id: proforma.id,
|
||||
customerId: proforma.customerId,
|
||||
invoiceNumber: proforma.invoiceNumber,
|
||||
status: proforma.status,
|
||||
series: proforma.series,
|
||||
@ -29,10 +28,19 @@ export const ProformaToListRowPatchAdapter = {
|
||||
currencyCode: proforma.currencyCode,
|
||||
reference: proforma.reference,
|
||||
description: proforma.description,
|
||||
recipientName: proforma.recipient.name,
|
||||
recipientTin: proforma.recipient.tin,
|
||||
recipient: {
|
||||
id: proforma.customerId,
|
||||
tin: proforma.recipient.tin,
|
||||
name: proforma.recipient.name,
|
||||
street: proforma.recipient.street,
|
||||
street2: proforma.recipient.street2,
|
||||
city: proforma.recipient.city,
|
||||
province: proforma.recipient.province,
|
||||
postalCode: proforma.recipient.postalCode,
|
||||
country: proforma.recipient.country,
|
||||
},
|
||||
subtotalAmount: proforma.subtotalAmount,
|
||||
discountPercentage: proforma.discountPercentage,
|
||||
discountPercentage: proforma.globalDiscountPercentage,
|
||||
discountAmount: proforma.discountAmount,
|
||||
taxableAmount: proforma.taxableAmount,
|
||||
taxesAmount: proforma.taxesAmount,
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
|
||||
export interface ProformaItem {
|
||||
id: string;
|
||||
position: string;
|
||||
position: number;
|
||||
description: string;
|
||||
|
||||
quantity: number;
|
||||
|
||||
@ -0,0 +1,22 @@
|
||||
import type { ProformaItem } from "../../shared";
|
||||
import type { ProformaItemUpdateForm } from "../entities";
|
||||
|
||||
/**
|
||||
* Mapea un cliente a un formulario de actualización de cliente.
|
||||
*
|
||||
* @param proforma
|
||||
* @returns
|
||||
*/
|
||||
|
||||
export const mapProformaItemsToProformaItemsUpdateForm = (
|
||||
item: ProformaItem
|
||||
): ProformaItemUpdateForm => {
|
||||
return {
|
||||
id: item.id,
|
||||
position: item.position,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unitAmount: item.unitAmount,
|
||||
itemDiscountPercentage: item.itemDiscountPercentage,
|
||||
};
|
||||
};
|
||||
@ -1,6 +1,8 @@
|
||||
import type { Proforma } from "../../shared";
|
||||
import type { ProformaUpdateForm } from "../entities";
|
||||
|
||||
import { mapProformaItemsToProformaItemsUpdateForm } from "./proforma-items-to-proforma-items-update-form.adapter";
|
||||
|
||||
/**
|
||||
* Mapea un cliente a un formulario de actualización de cliente.
|
||||
*
|
||||
@ -27,5 +29,7 @@ export const mapProformaToProformaUpdateForm = (proforma: Proforma): ProformaUpd
|
||||
globalDiscountPercentage: proforma.globalDiscountPercentage ?? 0,
|
||||
|
||||
paymentMethod: proforma.paymentMethod ?? "",
|
||||
|
||||
items: proforma.items.map(mapProformaItemsToProformaItemsUpdateForm),
|
||||
};
|
||||
};
|
||||
|
||||
@ -9,12 +9,9 @@ import type { UpdateProformaByIdParams } from "../../shared";
|
||||
import type { Proforma } from "../../shared/entities";
|
||||
import { useProformaGetQuery, useProformaUpdateMutation } from "../../shared/hooks";
|
||||
import { mapProformaToProformaUpdateForm, mapProformaToSelectedCustomer } from "../adapters";
|
||||
import { type ProformaUpdateForm, ProformaUpdateFormSchema } from "../entities";
|
||||
import {
|
||||
type ProformaUpdateForm,
|
||||
ProformaUpdateFormSchema,
|
||||
defaultProformaUpdateForm,
|
||||
} from "../entities";
|
||||
import {
|
||||
buildProformaUpdateDefault,
|
||||
buildProformaUpdatePatch,
|
||||
buildUpdateProformaByIdParams,
|
||||
focusFirstProformaUpdateError,
|
||||
@ -54,7 +51,7 @@ export const useUpdateProformaController = (
|
||||
} = useProformaUpdateMutation();
|
||||
|
||||
const initialValues = useMemo<ProformaUpdateForm>(() => {
|
||||
if (!proformaData) return defaultProformaUpdateForm;
|
||||
if (!proformaData) return buildProformaUpdateDefault();
|
||||
|
||||
return mapProformaToProformaUpdateForm(proformaData);
|
||||
}, [proformaData]);
|
||||
@ -84,7 +81,7 @@ export const useUpdateProformaController = (
|
||||
const resetForm = () => {
|
||||
const initialData = proformaData
|
||||
? mapProformaToProformaUpdateForm(proformaData)
|
||||
: defaultProformaUpdateForm;
|
||||
: buildProformaUpdateDefault();
|
||||
|
||||
form.reset(initialData, { keepDirty: false });
|
||||
setSelectedCustomer(mapProformaToSelectedCustomer(proformaData));
|
||||
@ -120,6 +117,9 @@ export const useUpdateProformaController = (
|
||||
const previousData = proformaData;
|
||||
|
||||
const patchData = buildProformaUpdatePatch(formData, form.formState.dirtyFields);
|
||||
|
||||
console.log(patchData);
|
||||
|
||||
const params = buildUpdateProformaByIdParams(proformaId, patchData);
|
||||
|
||||
try {
|
||||
@ -147,7 +147,9 @@ export const useUpdateProformaController = (
|
||||
error instanceof Error ? error : new Error(t("pages.update.error.unknown"));
|
||||
|
||||
form.reset(
|
||||
previousData ? mapProformaToProformaUpdateForm(previousData) : defaultProformaUpdateForm,
|
||||
previousData
|
||||
? mapProformaToProformaUpdateForm(previousData)
|
||||
: buildProformaUpdateDefault(),
|
||||
{ keepDirty: false }
|
||||
);
|
||||
|
||||
@ -159,6 +161,7 @@ export const useUpdateProformaController = (
|
||||
}
|
||||
},
|
||||
(errors: FieldErrors<ProformaUpdateForm>) => {
|
||||
console.log(errors);
|
||||
focusFirstProformaUpdateError(errors);
|
||||
|
||||
showWarningToast(
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { NumberHelper } from "@erp/core";
|
||||
import * as React from "react";
|
||||
import {
|
||||
type FieldArrayWithId,
|
||||
@ -7,11 +8,11 @@ import {
|
||||
} from "react-hook-form";
|
||||
|
||||
import type { ProformaItemUpdateForm, ProformaUpdateForm } from "../entities";
|
||||
import { buildProformaItemUpdateDefault } from "../utils/build-proforma-item-update-default";
|
||||
import { buildProformaItemUpdateDefault } from "../utils";
|
||||
|
||||
export interface ProformaItemAmounts {
|
||||
subtotal: number;
|
||||
discountAmount: number;
|
||||
itemDiscountAmount: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
@ -56,23 +57,27 @@ const normalizeItemPositions = (items: ProformaItemUpdateForm[]): ProformaItemUp
|
||||
};
|
||||
|
||||
const calculateItemAmounts = (
|
||||
item?: Pick<ProformaItemUpdateForm, "quantity" | "unitAmount" | "discountPercentage">
|
||||
item?: Pick<ProformaItemUpdateForm, "quantity" | "unitAmount" | "itemDiscountPercentage">
|
||||
): ProformaItemAmounts => {
|
||||
if (!item) {
|
||||
return {
|
||||
subtotal: 0,
|
||||
discountAmount: 0,
|
||||
itemDiscountAmount: 0,
|
||||
total: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const subtotal = roundCurrency(item.quantity * item.unitAmount);
|
||||
const discountAmount = roundCurrency(subtotal * (item.discountPercentage / 100));
|
||||
const total = roundCurrency(subtotal - discountAmount);
|
||||
const quantity = NumberHelper.toSafeNumber(item.quantity);
|
||||
const unitAmount = NumberHelper.toSafeNumber(item.unitAmount);
|
||||
const itemDiscountPercentage = NumberHelper.toSafeNumber(item.itemDiscountPercentage);
|
||||
|
||||
const subtotal = roundCurrency(quantity * unitAmount);
|
||||
const itemDiscountAmount = roundCurrency(subtotal * (itemDiscountPercentage / 100));
|
||||
const total = roundCurrency(subtotal - itemDiscountAmount);
|
||||
|
||||
return {
|
||||
subtotal,
|
||||
discountAmount,
|
||||
itemDiscountAmount,
|
||||
total,
|
||||
};
|
||||
};
|
||||
@ -84,7 +89,7 @@ const calculateItemsTotals = (items: ProformaItemUpdateForm[]): ProformaItemsTot
|
||||
|
||||
return {
|
||||
subtotal: roundCurrency(acc.subtotal + amounts.subtotal),
|
||||
discountAmount: roundCurrency(acc.discountAmount + amounts.discountAmount),
|
||||
discountAmount: roundCurrency(acc.discountAmount + amounts.itemDiscountAmount),
|
||||
total: roundCurrency(acc.total + amounts.total),
|
||||
};
|
||||
},
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
export * from "./proforma-item-update-form.entity";
|
||||
export * from "./proforma-item-update-form.schema";
|
||||
export * from "./proforma-item-update-patch.entity";
|
||||
export * from "./proforma-update-form.entity";
|
||||
export * from "./proforma-update-form.schema";
|
||||
export * from "./proforma-update-form-default.entity";
|
||||
export * from "./proforma-update-patch.entity";
|
||||
|
||||
@ -2,7 +2,7 @@ export interface ProformaItemUpdateForm {
|
||||
id: string;
|
||||
position: number;
|
||||
description: string;
|
||||
quantity: number;
|
||||
unitAmount: number;
|
||||
discountPercentage: number;
|
||||
quantity: number | null;
|
||||
unitAmount: number | null;
|
||||
itemDiscountPercentage: number | null;
|
||||
}
|
||||
|
||||
@ -17,10 +17,11 @@ import { z } from "zod/v4";
|
||||
export const ProformaItemUpdateFormSchema = z.object({
|
||||
id: z.uuid(),
|
||||
position: z.number().int().nonnegative(),
|
||||
|
||||
description: z.string().trim(),
|
||||
quantity: z.number().positive(),
|
||||
unitAmount: z.number().nonnegative(),
|
||||
discountPercentage: z.number().min(0).max(100),
|
||||
quantity: z.number().positive().nullable(),
|
||||
unitAmount: z.number().nonnegative().nullable(),
|
||||
itemDiscountPercentage: z.number().min(0).max(100).nullable(),
|
||||
});
|
||||
|
||||
export type ProformaItemUpdateForm = z.infer<typeof ProformaItemUpdateFormSchema>;
|
||||
export type ProformaItemUpdateFormSchemaType = z.infer<typeof ProformaItemUpdateFormSchema>;
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
export interface ProformaItemUpdatePatch {
|
||||
id: string;
|
||||
position: number;
|
||||
description: string;
|
||||
quantity: number | null;
|
||||
unitAmount: number | null;
|
||||
itemDiscountPercentage: number | null;
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
import type { ProformaUpdateForm } from ".";
|
||||
|
||||
export const defaultProformaUpdateForm: ProformaUpdateForm = {
|
||||
series: "",
|
||||
|
||||
invoiceDate: "",
|
||||
operationDate: "",
|
||||
|
||||
customerId: "",
|
||||
|
||||
description: "",
|
||||
reference: "",
|
||||
notes: "",
|
||||
|
||||
languageCode: "es",
|
||||
currencyCode: "EUR",
|
||||
|
||||
paymentMethod: "",
|
||||
|
||||
globalDiscountPercentage: 0,
|
||||
|
||||
items: [],
|
||||
};
|
||||
@ -13,7 +13,7 @@
|
||||
* - sin detalles impuestos por el widget
|
||||
*/
|
||||
|
||||
import type { ProformaItemUpdateForm } from "./proforma-item-update-form.schema";
|
||||
import type { ProformaItemUpdateForm } from "./proforma-item-update-form.entity";
|
||||
|
||||
export interface ProformaUpdateForm {
|
||||
series: string;
|
||||
|
||||
@ -42,7 +42,7 @@ export const ProformaUpdateEditorForm = ({
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<form className="space-y-6" id={formId} onSubmit={onSubmit}>
|
||||
<form className="space-y-6" id={formId} noValidate onSubmit={onSubmit}>
|
||||
<ProformaUpdateHeaderEditor disabled={isSubmitting} />
|
||||
|
||||
<ProformaUpdateRecipientEditor
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { TextField } from "@repo/rdx-ui/components";
|
||||
import { AmountField, PercentageField, QuantityField, TextField } from "@repo/rdx-ui/components";
|
||||
import { Button, Card, CardContent } from "@repo/shadcn-ui/components";
|
||||
import { CopyIcon, MoveDownIcon, MoveUpIcon, Trash2Icon } from "lucide-react";
|
||||
|
||||
@ -38,28 +38,24 @@ export const ProformaUpdateItemRowEditor = ({
|
||||
className="col-span-12"
|
||||
label={t("form_fields.items.description.label", "Descripción")}
|
||||
name={`items.${index}.description`}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextField
|
||||
<QuantityField
|
||||
className="col-span-12 md:col-span-3"
|
||||
label={t("form_fields.items.quantity.label", "Cantidad")}
|
||||
name={`items.${index}.quantity`}
|
||||
typePreset="number"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
<AmountField
|
||||
className="col-span-12 md:col-span-3"
|
||||
label={t("form_fields.items.unit_amount.label", "Importe unitario")}
|
||||
name={`items.${index}.unitAmount`}
|
||||
typePreset="number"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
<PercentageField
|
||||
className="col-span-12 md:col-span-3"
|
||||
label={t("form_fields.items.discount_percentage.label", "Descuento %")}
|
||||
name={`items.${index}.discountPercentage`}
|
||||
typePreset="number"
|
||||
/>
|
||||
|
||||
<div className="col-span-12 md:col-span-3 rounded-md border p-3 text-sm">
|
||||
@ -70,7 +66,7 @@ export const ProformaUpdateItemRowEditor = ({
|
||||
|
||||
<div className="flex justify-between gap-3">
|
||||
<span>{t("form_fields.items.discount_amount.label", "Descuento")}</span>
|
||||
<span>{amounts.discountAmount}</span>
|
||||
<span>{amounts.itemDiscountAmount}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-3 font-medium">
|
||||
|
||||
@ -7,8 +7,8 @@ export const buildProformaItemUpdateDefault = (position: number): ProformaItemUp
|
||||
id: UniqueID.generateNewID().toString(),
|
||||
position,
|
||||
description: "",
|
||||
quantity: 1,
|
||||
unitAmount: 0,
|
||||
discountPercentage: 0,
|
||||
quantity: null,
|
||||
unitAmount: null,
|
||||
itemDiscountPercentage: null,
|
||||
};
|
||||
};
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
import type { ProformaItemUpdateForm, ProformaItemUpdatePatch } from "../entities";
|
||||
|
||||
export const buildProformaItemsUpdatePatch = (
|
||||
items: ProformaItemUpdateForm[]
|
||||
): ProformaItemUpdatePatch[] => {
|
||||
return items.map((item, index) => ({
|
||||
id: item.id,
|
||||
position: index,
|
||||
description: item.description.trim(),
|
||||
quantity: item.quantity,
|
||||
unitAmount: item.unitAmount,
|
||||
itemDiscountPercentage: item.itemDiscountPercentage,
|
||||
}));
|
||||
};
|
||||
@ -0,0 +1,25 @@
|
||||
import type { ProformaUpdateForm } from "../entities";
|
||||
|
||||
export const buildProformaUpdateDefault = (): ProformaUpdateForm => {
|
||||
return {
|
||||
series: "",
|
||||
|
||||
invoiceDate: "",
|
||||
operationDate: "",
|
||||
|
||||
customerId: "",
|
||||
|
||||
description: "",
|
||||
reference: "",
|
||||
notes: "",
|
||||
|
||||
languageCode: "es",
|
||||
currencyCode: "EUR",
|
||||
|
||||
paymentMethod: "",
|
||||
|
||||
globalDiscountPercentage: 0,
|
||||
|
||||
items: [],
|
||||
};
|
||||
};
|
||||
@ -3,6 +3,8 @@ import type { FieldNamesMarkedBoolean } from "react-hook-form";
|
||||
|
||||
import type { ProformaUpdateForm, ProformaUpdatePatch } from "../entities";
|
||||
|
||||
import { buildProformaItemsUpdatePatch } from "./build-proforma-items-update-patch";
|
||||
|
||||
export const buildProformaUpdatePatch = (
|
||||
formData: ProformaUpdateForm,
|
||||
dirtyFields: FieldNamesMarkedBoolean<ProformaUpdateForm>
|
||||
@ -11,5 +13,10 @@ export const buildProformaUpdatePatch = (
|
||||
return {};
|
||||
}
|
||||
|
||||
return pickFormDirtyValues(formData, dirtyFields) as ProformaUpdatePatch;
|
||||
const itemsPatch = buildProformaItemsUpdatePatch(formData.items);
|
||||
|
||||
return {
|
||||
...pickFormDirtyValues(formData, dirtyFields),
|
||||
items: itemsPatch,
|
||||
} as ProformaUpdatePatch;
|
||||
};
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
export * from "./build-proforma-item-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-error";
|
||||
|
||||
@ -17,7 +17,7 @@ export const CustomerUpdateEditorForm = ({
|
||||
className,
|
||||
}: CustomerUpdateEditorFormProps) => {
|
||||
return (
|
||||
<form id={formId} noValidate onSubmit={onSubmit}>
|
||||
<form className="space-y-6" id={formId} noValidate onSubmit={onSubmit}>
|
||||
<section className={cn("space-y-12 p-6", className)}>
|
||||
<CustomerBasicInfoFields />
|
||||
<CustomerAddressFields />
|
||||
|
||||
@ -0,0 +1,180 @@
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import * as React from "react";
|
||||
import { type FieldPath, type FieldValues, useController, useFormContext } from "react-hook-form";
|
||||
|
||||
import { FormFieldLabel } from "../form-field-label.tsx";
|
||||
|
||||
import type { DecimalFieldBaseProps } from "./decimal-field.types.ts";
|
||||
import {
|
||||
DECIMAL_INPUT_PATTERN,
|
||||
clampNumber,
|
||||
formatDecimalValue,
|
||||
parseDecimalOrNull,
|
||||
trimToScale,
|
||||
} from "./decimal-field.utils.ts";
|
||||
|
||||
type DecimalFieldProps<TFormValues extends FieldValues> = DecimalFieldBaseProps & {
|
||||
name: FieldPath<TFormValues>;
|
||||
};
|
||||
|
||||
export const DecimalField = <TFormValues extends FieldValues>({
|
||||
name,
|
||||
|
||||
label,
|
||||
description,
|
||||
|
||||
required = false,
|
||||
readOnly = false,
|
||||
|
||||
orientation = "vertical",
|
||||
|
||||
className,
|
||||
inputClassName,
|
||||
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
leftAddon,
|
||||
rightAddon,
|
||||
|
||||
scale = 4,
|
||||
min,
|
||||
max,
|
||||
|
||||
...inputRest
|
||||
}: DecimalFieldProps<TFormValues>) => {
|
||||
const { control, formState, getFieldState } = useFormContext<TFormValues>();
|
||||
|
||||
const { field } = useController({
|
||||
name,
|
||||
control,
|
||||
defaultValue: null as any,
|
||||
});
|
||||
|
||||
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)
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const nextFormattedValue = formatDecimalValue(field.value as number | null | undefined, scale);
|
||||
|
||||
setInputValue((currentValue) =>
|
||||
currentValue === nextFormattedValue ? currentValue : nextFormattedValue
|
||||
);
|
||||
}, [field.value, scale]);
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const rawValue = event.target.value;
|
||||
|
||||
if (rawValue === "") {
|
||||
setInputValue("");
|
||||
field.onChange(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!DECIMAL_INPUT_PATTERN.test(rawValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedValue = trimToScale(rawValue, scale);
|
||||
setInputValue(trimmedValue);
|
||||
|
||||
const parsedValue = parseDecimalOrNull(trimmedValue);
|
||||
|
||||
if (parsedValue === null) {
|
||||
field.onChange(null);
|
||||
return;
|
||||
}
|
||||
|
||||
field.onChange(clampNumber(parsedValue, min, max));
|
||||
};
|
||||
|
||||
const handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
|
||||
field.onBlur();
|
||||
|
||||
const parsedValue = parseDecimalOrNull(event.target.value);
|
||||
|
||||
if (parsedValue === null) {
|
||||
setInputValue("");
|
||||
field.onChange(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const clampedValue = clampNumber(parsedValue, min, max);
|
||||
const formattedValue = formatDecimalValue(clampedValue, scale);
|
||||
|
||||
setInputValue(formattedValue);
|
||||
field.onChange(clampedValue);
|
||||
};
|
||||
|
||||
const renderedLeftAddon = leftAddon ?? leftIcon;
|
||||
const renderedRightAddon = rightAddon ?? rightIcon;
|
||||
|
||||
return (
|
||||
<Field className={cn("gap-1", className)} data-invalid={!!fieldError} orientation={orientation}>
|
||||
{label ? (
|
||||
<FormFieldLabel htmlFor={inputId} required={required}>
|
||||
{label}
|
||||
</FormFieldLabel>
|
||||
) : null}
|
||||
|
||||
<>
|
||||
<InputGroup
|
||||
className={cn(
|
||||
"bg-muted/50 font-medium 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",
|
||||
inputClassName
|
||||
)}
|
||||
>
|
||||
{renderedLeftAddon ? (
|
||||
<InputGroupAddon aria-hidden="true" className="bg-muted/50 font-medium">
|
||||
{renderedLeftAddon}
|
||||
</InputGroupAddon>
|
||||
) : null}
|
||||
|
||||
<InputGroupInput
|
||||
{...inputRest}
|
||||
aria-invalid={!!fieldError}
|
||||
autoComplete="off"
|
||||
className="placeholder:text-muted-foreground/50"
|
||||
disabled={disabled}
|
||||
id={inputId}
|
||||
inputMode="decimal"
|
||||
name={field.name}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
readOnly={readOnly}
|
||||
ref={field.ref}
|
||||
required={required}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
/>
|
||||
|
||||
{renderedRightAddon ? (
|
||||
<InputGroupAddon aria-hidden="true">{renderedRightAddon}</InputGroupAddon>
|
||||
) : null}
|
||||
</InputGroup>
|
||||
|
||||
{description ? (
|
||||
<FieldDescription>{description}</FieldDescription>
|
||||
) : (
|
||||
<div aria-hidden="true" className="min-h-5" />
|
||||
)}
|
||||
|
||||
<FieldError errors={[fieldError]} />
|
||||
</>
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,31 @@
|
||||
import type * as React from "react";
|
||||
|
||||
import type { NativeInputProps } from "../types.ts";
|
||||
|
||||
export type DecimalFieldOrientation = "vertical" | "horizontal" | "responsive";
|
||||
|
||||
export type DecimalFieldBaseProps = Omit<
|
||||
NativeInputProps,
|
||||
"name" | "type" | "value" | "defaultValue" | "onChange"
|
||||
> & {
|
||||
label?: string;
|
||||
description?: string;
|
||||
|
||||
required?: boolean;
|
||||
readOnly?: boolean;
|
||||
|
||||
orientation?: DecimalFieldOrientation;
|
||||
|
||||
className?: string;
|
||||
inputClassName?: string;
|
||||
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
|
||||
leftAddon?: React.ReactNode;
|
||||
rightAddon?: React.ReactNode;
|
||||
|
||||
scale?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
};
|
||||
@ -0,0 +1,66 @@
|
||||
export const DECIMAL_INPUT_PATTERN = /^-?\d*([.,]\d*)?$/;
|
||||
|
||||
export const normalizeDecimalInput = (value: string): string => {
|
||||
return value.replace(",", ".");
|
||||
};
|
||||
|
||||
export const trimToScale = (value: string, scale: number): string => {
|
||||
const normalized = normalizeDecimalInput(value);
|
||||
|
||||
if (!normalized.includes(".")) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const [integerPart, decimalPart = ""] = normalized.split(".");
|
||||
|
||||
return `${integerPart}.${decimalPart.slice(0, scale)}`;
|
||||
};
|
||||
|
||||
export const parseDecimalOrNull = (value: string): number | null => {
|
||||
const normalized = normalizeDecimalInput(value).trim();
|
||||
|
||||
if (normalized === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (normalized === "-" || normalized === "." || normalized === "-.") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number(normalized);
|
||||
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
};
|
||||
|
||||
export const clampNumber = (value: number, min?: number, max?: number): number => {
|
||||
if (typeof min === "number" && value < min) {
|
||||
return min;
|
||||
}
|
||||
|
||||
if (typeof max === "number" && value > max) {
|
||||
return max;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
export const formatDecimalValue = (value: number | null | undefined, scale: number): string => {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const asString = String(value);
|
||||
|
||||
if (!asString.includes(".")) {
|
||||
return asString;
|
||||
}
|
||||
|
||||
const [integerPart, decimalPart = ""] = asString.split(".");
|
||||
const trimmedDecimalPart = decimalPart.slice(0, scale).replace(/0+$/, "");
|
||||
|
||||
return trimmedDecimalPart.length > 0 ? `${integerPart}.${trimmedDecimalPart}` : `${integerPart}`;
|
||||
};
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./decimal-field.tsx";
|
||||
export * from "./decimal-field.types.ts";
|
||||
@ -1,8 +1,10 @@
|
||||
export * from "./date-picker-field.tsx";
|
||||
export * from "./date-picker-input-field/index.ts";
|
||||
export * from "./decimal-field/index.ts";
|
||||
export * from "./form-field-label.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 "./text-area-field.tsx";
|
||||
export * from "./text-field.tsx";
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
// packages/rdx-ui/src/components/form/amount-field.tsx
|
||||
import type { FieldValues } from "react-hook-form";
|
||||
|
||||
import { DecimalField } from "../decimal-field/index.ts";
|
||||
|
||||
import type { SemanticNumericFieldProps } from "./semantic-numeric-fields.types.ts";
|
||||
|
||||
export const AmountField = <TFormValues extends FieldValues>({
|
||||
scale = 4,
|
||||
rightAddon = "€",
|
||||
...props
|
||||
}: SemanticNumericFieldProps<TFormValues>) => {
|
||||
return <DecimalField {...props} min={0} rightAddon={rightAddon} scale={scale} />;
|
||||
};
|
||||
@ -0,0 +1,3 @@
|
||||
export * from "./amount-field.tsx";
|
||||
export * from "./percentage-field.tsx";
|
||||
export * from "./quantity-field.tsx";
|
||||
@ -0,0 +1,14 @@
|
||||
// packages/rdx-ui/src/components/form/percentage-field.tsx
|
||||
import type { FieldValues } from "react-hook-form";
|
||||
|
||||
import { DecimalField } from "../decimal-field/index.ts";
|
||||
|
||||
import type { SemanticNumericFieldProps } from "./semantic-numeric-fields.types.ts";
|
||||
|
||||
export const PercentageField = <TFormValues extends FieldValues>({
|
||||
scale = 4,
|
||||
rightAddon = "%",
|
||||
...props
|
||||
}: SemanticNumericFieldProps<TFormValues>) => {
|
||||
return <DecimalField {...props} max={100} min={0} rightAddon={rightAddon} scale={scale} />;
|
||||
};
|
||||
@ -0,0 +1,12 @@
|
||||
import type { FieldValues } from "react-hook-form";
|
||||
|
||||
import { DecimalField } from "../decimal-field/index.ts";
|
||||
|
||||
import type { SemanticNumericFieldProps } from "./semantic-numeric-fields.types.ts";
|
||||
|
||||
export const QuantityField = <TFormValues extends FieldValues>({
|
||||
scale = 4,
|
||||
...props
|
||||
}: SemanticNumericFieldProps<TFormValues>) => {
|
||||
return <DecimalField {...props} scale={scale} />;
|
||||
};
|
||||
@ -0,0 +1,11 @@
|
||||
// packages/rdx-ui/src/components/form/semantic-numeric-fields.types.ts
|
||||
import type { FieldPath, FieldValues } from "react-hook-form";
|
||||
|
||||
import type { DecimalFieldBaseProps } from "../decimal-field/index.ts";
|
||||
|
||||
export type SemanticNumericFieldProps<TFormValues extends FieldValues> = Omit<
|
||||
DecimalFieldBaseProps,
|
||||
"min" | "max"
|
||||
> & {
|
||||
name: FieldPath<TFormValues>;
|
||||
};
|
||||
@ -63,7 +63,7 @@ export const getInputPresetProps = (preset: TextFieldTypePreset = "text"): Resol
|
||||
|
||||
case "number":
|
||||
return {
|
||||
type: "text",
|
||||
type: "number",
|
||||
inputMode: "numeric",
|
||||
autoComplete: "off",
|
||||
spellCheck: false,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user