This commit is contained in:
David Arranz 2024-07-11 18:40:46 +02:00
parent 8e80bfe31e
commit 3e73eac05a
17 changed files with 394 additions and 310 deletions

4
.vscode/launch.json vendored
View File

@ -12,7 +12,7 @@
{ {
"name": "Launch Chrome localhost", "name": "Launch Chrome localhost",
"type": "pwa-chrome", "type": "chrome",
"request": "launch", "request": "launch",
"reAttach": true, "reAttach": true,
"url": "http://localhost:5173", "url": "http://localhost:5173",
@ -26,6 +26,7 @@
"url": "http://localhost:5173", "url": "http://localhost:5173",
"webRoot": "${workspaceFolder}/client" "webRoot": "${workspaceFolder}/client"
}, },
{ {
"type": "node", "type": "node",
"request": "attach", "request": "attach",
@ -34,6 +35,7 @@
"restart": true, "restart": true,
"cwd": "${workspaceRoot}" "cwd": "${workspaceRoot}"
}, },
{ {
"name": "Launch via YARN", "name": "Launch via YARN",
"request": "launch", "request": "launch",

View File

@ -271,7 +271,7 @@ export function SortableDataTable({ columns, data, actions }: SortableDataTableP
{ {
quantity: { amount: "123" }, quantity: { amount: "123" },
description: "aaaa", description: "aaaa",
retail_price: { unit_price: {
amount: "10000", amount: "10000",
precision: 4, precision: 4,
currency: "EUR", currency: "EUR",
@ -336,9 +336,8 @@ export function SortableDataTable({ columns, data, actions }: SortableDataTableP
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className='hover:bg-transparent'> <TableRow key={headerGroup.id} className='hover:bg-transparent'>
{headerGroup.headers.map((header) => { {headerGroup.headers.map((header) => {
console.log(header.getSize());
return ( return (
<TableHead key={header.id} className={`px-1 w-${header.getSize()}`}> <TableHead key={header.id} className={`px-2 py-1 w-${header.getSize()}`}>
{header.isPlaceholder ? null : ( {header.isPlaceholder ? null : (
<DataTableColumnHeader table={table} header={header} /> <DataTableColumnHeader table={table} header={header} />
)} )}

View File

@ -1,4 +1,9 @@
import { FormQuantityField, FormTextField } from "@/components"; import {
FormCurrencyField,
FormPercentageField,
FormQuantityField,
FormTextField,
} from "@/components";
import { DataTableProvider } from "@/lib/hooks"; import { DataTableProvider } from "@/lib/hooks";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/ui"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/ui";
@ -11,7 +16,7 @@ import { CatalogPickerDataTable } from "../CatalogPickerDataTable";
import { SortableDataTable } from "../SortableDataTable"; import { SortableDataTable } from "../SortableDataTable";
export const QuoteDetailsCardEditor = ({ currency }: { currency: CurrencyData }) => { export const QuoteDetailsCardEditor = ({ currency }: { currency: CurrencyData }) => {
const { control, register } = useFormContext(); const { control, register, getValues } = useFormContext();
const { fields, ...fieldActions } = useFieldArray({ const { fields, ...fieldActions } = useFieldArray({
control, control,
@ -42,7 +47,7 @@ export const QuoteDetailsCardEditor = ({ currency }: { currency: CurrencyData })
return ( return (
<FormQuantityField <FormQuantityField
variant='outline' variant='outline'
precision={2} precision={Quantity.DEFAULT_PRECISION}
{...register(`items.${index}.quantity`)} {...register(`items.${index}.quantity`)}
/> />
); );
@ -65,7 +70,7 @@ export const QuoteDetailsCardEditor = ({ currency }: { currency: CurrencyData })
), ),
cell: ({ row: { index }, column: { id } }) => { cell: ({ row: { index }, column: { id } }) => {
return ( return (
<FormTextField <FormCurrencyField
variant='outline' variant='outline'
currency={currency} currency={currency}
precision={4} precision={4}
@ -82,7 +87,16 @@ export const QuoteDetailsCardEditor = ({ currency }: { currency: CurrencyData })
<div className='text-right'>{t("quotes.form_fields.items.subtotal_price.label")}</div> <div className='text-right'>{t("quotes.form_fields.items.subtotal_price.label")}</div>
), ),
cell: ({ row: { index }, column: { id } }) => { cell: ({ row: { index }, column: { id } }) => {
return <FormTextField {...register(`items.${index}.subtotal_price`)} />; return (
<FormCurrencyField
variant='outline'
disabled
currency={currency}
precision={4}
className='text-right'
{...register(`items.${index}.subtotal_price`)}
/>
);
}, },
}, },
{ {
@ -93,7 +107,16 @@ export const QuoteDetailsCardEditor = ({ currency }: { currency: CurrencyData })
<div className='text-right'>{t("quotes.form_fields.items.discount.label")}</div> <div className='text-right'>{t("quotes.form_fields.items.discount.label")}</div>
), ),
cell: ({ row: { index }, column: { id } }) => { cell: ({ row: { index }, column: { id } }) => {
return <FormTextField className='text-right' {...register(`items.${index}.discount`)} />; return (
<>
<FormPercentageField
variant='outline'
precision={0}
className='text-right'
{...register(`items.${index}.discount`)}
/>
</>
);
}, },
}, },
{ {
@ -103,7 +126,16 @@ export const QuoteDetailsCardEditor = ({ currency }: { currency: CurrencyData })
<div className='text-right'>{t("quotes.form_fields.items.total_price.label")}</div> <div className='text-right'>{t("quotes.form_fields.items.total_price.label")}</div>
), ),
cell: ({ row: { index }, column: { id } }) => { cell: ({ row: { index }, column: { id } }) => {
return <FormTextField {...register(`items.${index}.total_price`)} />; return (
<FormCurrencyField
variant='outline'
disabled
currency={currency}
precision={4}
className='text-right'
{...register(`items.${index}.total_price`)}
/>
);
}, },
}, },
], ],
@ -147,7 +179,7 @@ export const QuoteDetailsCardEditor = ({ currency }: { currency: CurrencyData })
fieldActions.append({ fieldActions.append({
...newArticle, ...newArticle,
quantity: { quantity: {
amount: 1, amount: 12,
precision: Quantity.DEFAULT_PRECISION, precision: Quantity.DEFAULT_PRECISION,
}, },
unit_price: newArticle.retail_price, unit_price: newArticle.retail_price,
@ -161,8 +193,6 @@ export const QuoteDetailsCardEditor = ({ currency }: { currency: CurrencyData })
const defaultLayout = [265, 440, 655]; const defaultLayout = [265, 440, 655];
const navCollapsedSize = 4; const navCollapsedSize = 4;
return <SortableDataTable actions={fieldActions} columns={columns} data={fields} />;
return ( return (
<ResizablePanelGroup <ResizablePanelGroup
direction='horizontal' direction='horizontal'

View File

@ -17,30 +17,30 @@ export const QuoteGeneralCardEditor = () => {
<FormTextAreaField <FormTextAreaField
className='row-span-2' className='row-span-2'
required required
label={t("quotes.create.form_fields.customer_information.label")} label={t("quotes.form_fields.customer_information.label")}
description={t("quotes.create.form_fields.customer_information.desc")} description={t("quotes.form_fields.customer_information.desc")}
disabled={formState.disabled} disabled={formState.disabled}
placeholder={t("quotes.create.form_fields.customer_information.placeholder")} placeholder={t("quotes.form_fields.customer_information.placeholder")}
{...register("customer_information", { {...register("customer_information", {
required: true, required: true,
})} })}
errors={formState.errors} errors={formState.errors}
/> />
<FormTextField <FormTextField
label={t("quotes.create.form_fields.reference.label")} label={t("quotes.form_fields.reference.label")}
description={t("quotes.create.form_fields.reference.desc")} description={t("quotes.form_fields.reference.desc")}
disabled={formState.disabled} disabled={formState.disabled}
placeholder={t("quotes.create.form_fields.reference.placeholder")} placeholder={t("quotes.form_fields.reference.placeholder")}
{...register("reference", { {...register("reference", {
required: false, required: false,
})} })}
/> />
<FormDatePickerField <FormDatePickerField
required required
label={t("quotes.create.form_fields.date.label")} label={t("quotes.form_fields.date.label")}
description={t("quotes.create.form_fields.date.desc")} description={t("quotes.form_fields.date.desc")}
disabled={formState.disabled} disabled={formState.disabled}
placeholder={t("quotes.create.form_fields.date.placeholder")} placeholder={t("quotes.form_fields.date.placeholder")}
{...register("date", { {...register("date", {
required: true, required: true,
})} })}
@ -48,20 +48,20 @@ export const QuoteGeneralCardEditor = () => {
</div> </div>
<div className='grid grid-cols-2 grid-rows-2 gap-6'> <div className='grid grid-cols-2 grid-rows-2 gap-6'>
<FormTextField <FormTextField
label={t("quotes.create.form_fields.validity.label")} label={t("quotes.form_fields.validity.label")}
description={t("quotes.create.form_fields.validity.desc")} description={t("quotes.form_fields.validity.desc")}
disabled={formState.disabled} disabled={formState.disabled}
placeholder={t("quotes.create.form_fields.validity.placeholder")} placeholder={t("quotes.form_fields.validity.placeholder")}
{...register("validity", { {...register("validity", {
required: false, required: false,
})} })}
/> />
<FormTextAreaField <FormTextAreaField
label={t("quotes.create.form_fields.payment_method.label")} label={t("quotes.form_fields.payment_method.label")}
description={t("quotes.create.form_fields.payment_method.desc")} description={t("quotes.form_fields.payment_method.desc")}
disabled={formState.disabled} disabled={formState.disabled}
placeholder={t("quotes.create.form_fields.payment_method.placeholder")} placeholder={t("quotes.form_fields.payment_method.placeholder")}
{...register("payment_method", { {...register("payment_method", {
required: false, required: false,
})} })}
@ -69,10 +69,10 @@ export const QuoteGeneralCardEditor = () => {
<FormTextAreaField <FormTextAreaField
className='col-span-2' className='col-span-2'
label={t("quotes.create.form_fields.notes.label")} label={t("quotes.form_fields.notes.label")}
description={t("quotes.create.form_fields.notes.desc")} description={t("quotes.form_fields.notes.desc")}
disabled={formState.disabled} disabled={formState.disabled}
placeholder={t("quotes.create.form_fields.notes.placeholder")} placeholder={t("quotes.form_fields.notes.placeholder")}
{...register("notes", { {...register("notes", {
required: false, required: false,
})} })}
@ -100,20 +100,20 @@ export const QuoteGeneralCardEditor = () => {
</div> </div>
<FormTextField <FormTextField
required required
label={t("quotes.create.form_fields.lang_code.label")} label={t("quotes.form_fields.lang_code.label")}
description={t("quotes.create.form_fields.lang_code.desc")} description={t("quotes.form_fields.lang_code.desc")}
disabled={formState.disabled} disabled={formState.disabled}
placeholder={t("quotes.create.form_fields.lang_code.placeholder")} placeholder={t("quotes.form_fields.lang_code.placeholder")}
{...register("lang_code", { {...register("lang_code", {
required: true, required: true,
})} })}
/> />
<FormTextField <FormTextField
required required
label={t("quotes.create.form_fields.currency_code.label")} label={t("quotes.form_fields.currency_code.label")}
description={t("quotes.create.form_fields.currency_code.desc")} description={t("quotes.form_fields.currency_code.desc")}
disabled={formState.disabled} disabled={formState.disabled}
placeholder={t("quotes.create.form_fields.currency_code.placeholder")} placeholder={t("quotes.form_fields.currency_code.placeholder")}
{...register("currency_code", { {...register("currency_code", {
required: true, required: true,
})} })}

View File

@ -90,16 +90,16 @@ export const QuoteCreate = () => {
className='row-span-2' className='row-span-2'
name='reference' name='reference'
required required
label={t("quotes.create.form_fields.reference.label")} label={t("quotes.form_fields.reference.label")}
description={t("quotes.create.form_fields.reference.desc")} description={t("quotes.form_fields.reference.desc")}
placeholder={t("quotes.create.form_fields.reference.placeholder")} placeholder={t("quotes.form_fields.reference.placeholder")}
/> />
<FormDatePickerField <FormDatePickerField
required required
label={t("quotes.create.form_fields.date.label")} label={t("quotes.form_fields.date.label")}
description={t("quotes.create.form_fields.date.desc")} description={t("quotes.form_fields.date.desc")}
placeholder={t("quotes.create.form_fields.date.placeholder")} placeholder={t("quotes.form_fields.date.placeholder")}
name='date' name='date'
/> />
@ -108,9 +108,9 @@ export const QuoteCreate = () => {
className='row-span-2' className='row-span-2'
name='customer_information' name='customer_information'
required required
label={t("quotes.create.form_fields.customer_information.label")} label={t("quotes.form_fields.customer_information.label")}
description={t("quotes.create.form_fields.customer_information.desc")} description={t("quotes.form_fields.customer_information.desc")}
placeholder={t("quotes.create.form_fields.customer_information.placeholder")} placeholder={t("quotes.form_fields.customer_information.placeholder")}
/> />
<div className='flex items-center justify-around gap-2'> <div className='flex items-center justify-around gap-2'>

View File

@ -1,4 +1,4 @@
import { ErrorOverlay, FormTextField, LoadingOverlay, SubmitButton } from "@/components"; import { ErrorOverlay, FormCurrencyField, LoadingOverlay, SubmitButton } from "@/components";
import { calculateItemTotals } from "@/lib/calc"; import { calculateItemTotals } from "@/lib/calc";
import { useUrlId } from "@/lib/hooks/useUrlId"; import { useUrlId } from "@/lib/hooks/useUrlId";
import { Badge, Button, Form, Tabs, TabsContent, TabsList, TabsTrigger } from "@/ui"; import { Badge, Button, Form, Tabs, TabsContent, TabsList, TabsTrigger } from "@/ui";
@ -10,11 +10,6 @@ import { SubmitHandler, useForm } from "react-hook-form";
import { QuoteDetailsCardEditor, QuoteGeneralCardEditor } from "./components/editors"; import { QuoteDetailsCardEditor, QuoteGeneralCardEditor } from "./components/editors";
import { useQuotes } from "./hooks"; import { useQuotes } from "./hooks";
// simple typesafe helperfunction
type EndsWith<T, b extends string> = T extends `${infer f}${b}` ? T : never;
const endsWith = <T extends string, b extends string>(str: T, prefix: b): str is EndsWith<T, b> =>
str.endsWith(prefix);
interface QuoteDataForm extends IUpdateQuote_Request_DTO {} interface QuoteDataForm extends IUpdateQuote_Request_DTO {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
@ -51,11 +46,6 @@ export const QuoteEdit = () => {
payment_method: "", payment_method: "",
notes: "", notes: "",
validity: "", validity: "",
subtotal_price: {
amount: "",
precision: "",
currency_code: "",
},
items: [], items: [],
}, },
}); });
@ -67,21 +57,21 @@ export const QuoteEdit = () => {
// Transformación del form -> typo de request // Transformación del form -> typo de request
mutate(data, { mutate(data, {
onError: (error) => { onError: (error) => {
alert(error.message); console.debug(error);
//alert(error.message);
}, },
//onSettled: () => {}, //onSettled: () => {},
onSuccess: () => { onSuccess: () => {
alert("guardado"); //alert("guardado");
}, },
}); });
}; };
useEffect(() => { useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { unsubscribe } = watch((_, { name, type }) => { const { unsubscribe } = watch((_, { name, type }) => {
const value = getValues(); const value = getValues();
//console.debug({ name, type });
if (name) { if (name) {
if (name === "currency_code") { if (name === "currency_code") {
setQuoteCurrency( setQuoteCurrency(
@ -97,6 +87,11 @@ export const QuoteEdit = () => {
// Recálculo líneas // Recálculo líneas
items.map((item, index) => { items.map((item, index) => {
const itemTotals = calculateItemTotals(item); const itemTotals = calculateItemTotals(item);
if (itemTotals === null) {
return;
}
quoteSubtotal = quoteSubtotal.add(itemTotals.totalPrice); quoteSubtotal = quoteSubtotal.add(itemTotals.totalPrice);
setValue(`items.${index}.subtotal_price`, itemTotals.subtotalPrice.toObject()); setValue(`items.${index}.subtotal_price`, itemTotals.subtotalPrice.toObject());
@ -107,17 +102,16 @@ export const QuoteEdit = () => {
setValue("subtotal_price", quoteSubtotal.toObject()); setValue("subtotal_price", quoteSubtotal.toObject());
} }
if ( if (name.endsWith("quantity") || name.endsWith("unit_price") || name.endsWith("discount")) {
endsWith(name, "quantity") ||
endsWith(name, "unit_price") ||
endsWith(name, "discount")
) {
const { items } = value; const { items } = value;
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const [, indexString, fieldName] = String(name).split("."); const [, indexString, fieldName] = String(name).split(".");
const index = parseInt(indexString); const index = parseInt(indexString);
const itemTotals = calculateItemTotals(items[index]); const itemTotals = calculateItemTotals(items[index]);
if (itemTotals === null) {
return;
}
setValue(`items.${index}.subtotal_price`, itemTotals.subtotalPrice.toObject()); setValue(`items.${index}.subtotal_price`, itemTotals.subtotalPrice.toObject());
setValue(`items.${index}.total_price`, itemTotals.totalPrice.toObject()); setValue(`items.${index}.total_price`, itemTotals.totalPrice.toObject());
@ -141,8 +135,6 @@ export const QuoteEdit = () => {
return <LoadingOverlay />; return <LoadingOverlay />;
} }
console.log(quoteCurrency);
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}> <form onSubmit={form.handleSubmit(onSubmit)}>
@ -168,7 +160,10 @@ export const QuoteEdit = () => {
</div> </div>
</div> </div>
<FormTextField <FormCurrencyField
currency={quoteCurrency}
precision={4}
className='text-right'
label={"subtotal_price"} label={"subtotal_price"}
disabled={form.formState.disabled} disabled={form.formState.disabled}
{...form.register("subtotal_price")} {...form.register("subtotal_price")}

View File

@ -31,7 +31,7 @@ export function useDetailColumns<TData, TValue = unknown>(
if (enableSelectionColumn) { if (enableSelectionColumn) {
columns.unshift({ columns.unshift({
id: "select", id: "select",
/*header: ({ table }) => ( header: ({ table }) => (
<Checkbox <Checkbox
id='select-all' id='select-all'
checked={ checked={
@ -42,8 +42,8 @@ export function useDetailColumns<TData, TValue = unknown>(
aria-label='Seleccionar todo' aria-label='Seleccionar todo'
className='translate-y-[0px]' className='translate-y-[0px]'
/> />
),*/ ),
header: () => null,
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
cell: ({ row, table }: { row: Row<TData>; table: Table<TData> }) => ( cell: ({ row, table }: { row: Row<TData>; table: Table<TData> }) => (
<Checkbox <Checkbox

View File

@ -61,7 +61,9 @@ export const useQuotes = () => {
return dataSource.updateOne({ return dataSource.updateOne({
resource: "quotes", resource: "quotes",
id, id,
data, data: {
...data,
},
}); });
}, },
}), }),

View File

@ -53,6 +53,7 @@ export const FormCurrencyField = React.forwardRef<HTMLInputElement, FormCurrency
disabled, disabled,
defaultValue, defaultValue,
rules, rules,
readOnly,
precision, precision,
currency, currency,
variant, variant,
@ -60,20 +61,35 @@ export const FormCurrencyField = React.forwardRef<HTMLInputElement, FormCurrency
const { control } = useFormContext(); const { control } = useFormContext();
const transformToInput = (value: any) => { const transform = {
if (typeof value !== "object") { input: (value: any) => {
return value; if (typeof value !== "object") {
} return value;
}
const moneyOrError = MoneyValue.create(value); const moneyOrError = MoneyValue.create(value);
if (moneyOrError.isFailure) { if (moneyOrError.isFailure) {
throw moneyOrError.error; throw moneyOrError.error;
} }
return moneyOrError.object return moneyOrError.object
.convertPrecision(precision ?? value.precision) .convertPrecision(precision ?? value.precision)
.toUnit() .toUnit()
.toString(); .toString();
},
output: (value: string | undefined) => {
const moneyOrError = MoneyValue.create({
amount: value?.replace(",", "") ?? null,
precision,
currencyCode: currency.code,
});
if (moneyOrError.isFailure) {
throw moneyOrError.error;
}
return moneyOrError.object.toObject();
},
}; };
return ( return (
@ -87,13 +103,16 @@ export const FormCurrencyField = React.forwardRef<HTMLInputElement, FormCurrency
render={({ field, fieldState, formState }) => { render={({ field, fieldState, formState }) => {
return ( return (
<FormItem ref={ref} className={cn(className, "space-y-3")}> <FormItem ref={ref} className={cn(className, "space-y-3")}>
{label && <FormLabel label={label} hint={hint} required={rules?.required ?? false} />} {label && (
<FormLabel label={label} hint={hint} required={Boolean(rules?.required ?? false)} />
)}
<FormControl> <FormControl>
<CurrencyInput <CurrencyInput
name={field.name} name={field.name}
//ref={field.ref} <-- no activar que hace cosas raras //ref={field.ref} <-- no activar que hace cosas raras
onBlur={field.onBlur} onBlur={field.onBlur}
disabled={field.disabled} disabled={field.disabled}
readOnly={readOnly}
className={cn(formCurrencyFieldVariants({ variant, className }))} className={cn(formCurrencyFieldVariants({ variant, className }))}
suffix={` ${currency?.symbol}`} suffix={` ${currency?.symbol}`}
groupSeparator='.' groupSeparator='.'
@ -102,11 +121,8 @@ export const FormCurrencyField = React.forwardRef<HTMLInputElement, FormCurrency
//fixedDecimalLength={precision} <- no activar para que sea más cómodo escribir las cantidades //fixedDecimalLength={precision} <- no activar para que sea más cómodo escribir las cantidades
decimalsLimit={precision} decimalsLimit={precision}
decimalScale={precision} decimalScale={precision}
value={transformToInput(field.value)} value={transform.input(field.value)}
onValueChange={(value) => { onValueChange={(e) => field.onChange(transform.output(e))}
// "value" ya viene con los "0" de la precisión
field.onChange(value ?? "");
}}
/> />
</FormControl> </FormControl>
{description && <FormDescription>{description}</FormDescription>} {description && <FormDescription>{description}</FormDescription>}

View File

@ -1,19 +1,32 @@
import { cn } from "@/lib/utils"; import * as React from "react";
import { FormControl, FormDescription, FormItem, InputProps } from "@/ui";
import { Percentage, PercentageObject } from "@shared/contexts"; import { cn } from "@/lib/utils";
import { createElement, forwardRef, useState } from "react"; import { FormControl, FormDescription, FormField, FormItem, InputProps } from "@/ui";
import { import { Percentage } from "@shared/contexts";
Controller, import { cva, type VariantProps } from "class-variance-authority";
FieldPath, import CurrencyInput from "react-currency-input-field";
FieldValues, import { FieldPath, FieldValues, UseControllerProps, useFormContext } from "react-hook-form";
UseControllerProps,
useFormContext,
} from "react-hook-form";
import { FormErrorMessage } from "./FormErrorMessage"; import { FormErrorMessage } from "./FormErrorMessage";
import { FormLabel, FormLabelProps } from "./FormLabel"; import { FormLabel, FormLabelProps } from "./FormLabel";
import { FormInputProps, FormInputWithIconProps } from "./FormProps"; import { FormInputProps, FormInputWithIconProps } from "./FormProps";
const formPercentageFieldVariants = cva(
"flex h-10 w-full rounded-md bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
{
variants: {
variant: {
default:
"border border-input ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ",
outline: "focus-visible:border focus-visible:border-input",
},
},
defaultVariants: {
variant: "default",
},
}
);
export type FormPercentageFieldProps< export type FormPercentageFieldProps<
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
@ -24,9 +37,12 @@ export type FormPercentageFieldProps<
FormInputProps & FormInputProps &
Partial<FormLabelProps> & Partial<FormLabelProps> &
FormInputWithIconProps & FormInputWithIconProps &
UseControllerProps<TFieldValues, TName>; UseControllerProps<TFieldValues, TName> &
VariantProps<typeof formPercentageFieldVariants> & {
precision: number;
};
export const FormPercentageField = forwardRef< export const FormPercentageField = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & FormPercentageFieldProps React.HTMLAttributes<HTMLDivElement> & FormPercentageFieldProps
>((props, ref) => { >((props, ref) => {
@ -34,42 +50,42 @@ export const FormPercentageField = forwardRef<
name, name,
label, label,
hint, hint,
placeholder,
description, description,
placeholder,
required,
className, className,
leadIcon, disabled,
trailIcon,
button,
defaultValue, defaultValue,
rules,
readOnly,
precision,
variant,
} = props; } = props;
const { control } = useFormContext(); const { control } = useFormContext();
const [precision, setPrecision] = useState<number>(Percentage.DEFAULT_PRECISION);
const transform = { const transform = {
input: (value: PercentageObject) => { input: (value: any) => {
const percentageOrError = Percentage.create(value); if (typeof value !== "object") {
return value;
}
const percentageOrError = Percentage.create(value);
if (percentageOrError.isFailure) { if (percentageOrError.isFailure) {
throw percentageOrError.error; throw percentageOrError.error;
} }
const percentageValue = percentageOrError.object; return (
setPrecision(percentageValue.getPrecision()); percentageOrError.object
return percentageValue.toString(); .toNumber()
//.toPrecision(precision ?? value.precision)
.toString()
);
}, },
output: (event: React.ChangeEvent<HTMLInputElement>): PercentageObject => { output: (value: string | undefined) => {
const value = parseFloat(event.target.value);
const output = !isNaN(value) ? value : 0;
const percentageOrError = Percentage.create({ const percentageOrError = Percentage.create({
amount: output * Math.pow(10, precision), amount: value?.replace(",", "") ?? null,
precision, precision,
}); });
if (percentageOrError.isFailure) { if (percentageOrError.isFailure) {
throw percentageOrError.error; throw percentageOrError.error;
} }
@ -79,81 +95,39 @@ export const FormPercentageField = forwardRef<
}; };
return ( return (
<Controller <FormField
defaultValue={defaultValue} defaultValue={defaultValue}
control={control} control={control}
name={name} name={name}
disabled={disabled}
rules={{ rules={{
required, max: 100,
min: Percentage.MIN_VALUE, min: 0,
max: Percentage.MAX_VALUE, ...rules,
}} }}
// eslint-disable-next-line @typescript-eslint/no-unused-vars render={({ field }) => {
render={({ field, fieldState, formState }) => {
return (
<input
type='number'
{...field}
className='text-right'
placeholder='number'
onChange={(e) => field.onChange(transform.output(e))}
value={transform.input(field.value)}
/>
);
return ( return (
<FormItem ref={ref} className={cn(className, "space-y-3")}> <FormItem ref={ref} className={cn(className, "space-y-3")}>
{label && <FormLabel label={label} hint={hint} required={required} />} {label && (
<div className={cn(button ? "flex" : null)}> <FormLabel label={label} hint={hint} required={Boolean(rules?.required ?? false)} />
<div )}
className={cn( <FormControl>
leadIcon ? "relative flex items-stretch flex-grow focus-within:z-10" : "" <CurrencyInput
)} name={field.name}
> //ref={field.ref} <-- no activar que hace cosas raras
{leadIcon && ( onBlur={field.onBlur}
<div className='absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none'> disabled={field.disabled}
{createElement( readOnly={readOnly}
leadIcon, className={cn(formPercentageFieldVariants({ variant, className }))}
{ groupSeparator='.'
className: "h-5 w-5 text-muted-foreground", decimalSeparator=','
"aria-hidden": true, placeholder={placeholder}
}, decimalsLimit={precision}
null decimalScale={precision}
)} value={transform.input(field.value)}
</div> onValueChange={(e) => field.onChange(transform.output(e))}
)} />
</FormControl>
<FormControl
className={cn("block", leadIcon ? "pl-10" : "", trailIcon ? "pr-10" : "")}
>
<input
type='number'
placeholder={placeholder}
className={cn(
fieldState.error ? "border-destructive focus-visible:ring-destructive" : ""
)}
{...field}
onInput={(e) => field.onChange(transform.output(e))}
value={transform.input(field.value)}
/>
</FormControl>
{trailIcon && (
<div className='absolute inset-y-0 right-0 flex items-center pl-3 pointer-events-none'>
{createElement(
trailIcon,
{
className: "h-5 w-5 text-muted-foreground",
"aria-hidden": true,
},
null
)}
</div>
)}
</div>
{button && <>{createElement(button)}</>}
</div>
{description && <FormDescription>{description}</FormDescription>} {description && <FormDescription>{description}</FormDescription>}
<FormErrorMessage /> <FormErrorMessage />
</FormItem> </FormItem>

View File

@ -1,22 +1,23 @@
import * as React from "react"; import * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { FormControl, FormDescription, FormField, FormItem, Input, InputProps } from "@/ui"; import { FormControl, FormDescription, FormField, FormItem, InputProps } from "@/ui";
import { Quantity } from "@shared/contexts"; import { Quantity } from "@shared/contexts";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import CurrencyInput from "react-currency-input-field";
import { FieldPath, FieldValues, UseControllerProps, useFormContext } from "react-hook-form"; import { FieldPath, FieldValues, UseControllerProps, useFormContext } from "react-hook-form";
import { FormErrorMessage } from "./FormErrorMessage"; import { FormErrorMessage } from "./FormErrorMessage";
import { FormLabel, FormLabelProps } from "./FormLabel"; import { FormLabel, FormLabelProps } from "./FormLabel";
import { FormInputProps, FormInputWithIconProps } from "./FormProps"; import { FormInputProps, FormInputWithIconProps } from "./FormProps";
const formQuantityFieldVariants = cva( const formQuantityFieldVariants = cva(
"text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none", "flex h-10 w-full rounded-md bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
{ {
variants: { variants: {
variant: { variant: {
default: "", default:
outline: "border border-input ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ",
"border-0 focus-visible:border focus-visible:border-input focus-visible:ring-0 focus-visible:ring-offset-0 ", outline: "focus-visible:border focus-visible:border-input",
}, },
}, },
defaultVariants: { defaultVariants: {
@ -52,28 +53,42 @@ export const FormQuantityField = React.forwardRef<HTMLInputElement, FormQuantity
disabled, disabled,
defaultValue, defaultValue,
rules, rules,
readOnly,
precision, precision,
variant, variant,
} = props; } = props;
const { control } = useFormContext(); const { control } = useFormContext();
const transformToInput = (value: any) => { const transform = {
if (typeof value !== "object") { input: (value: any) => {
return value; if (typeof value !== "object") {
} return value;
}
const quantityOrError = Quantity.create(value); const quantityOrError = Quantity.create(value);
if (quantityOrError.isFailure) { if (quantityOrError.isFailure) {
throw quantityOrError.error; throw quantityOrError.error;
} }
return ( return (
quantityOrError.object quantityOrError.object
.toNumber() .toNumber()
//.toPrecision(precision ?? value.precision) //.toPrecision(precision ?? value.precision)
.toString() .toString()
); );
},
output: (value: string | undefined) => {
const quantityOrError = Quantity.create({
amount: value?.replace(",", "") ?? null,
precision,
});
if (quantityOrError.isFailure) {
throw quantityOrError.error;
}
return quantityOrError.object.toObject();
},
}; };
return ( return (
@ -83,26 +98,27 @@ export const FormQuantityField = React.forwardRef<HTMLInputElement, FormQuantity
name={name} name={name}
disabled={disabled} disabled={disabled}
rules={rules} rules={rules}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
render={({ field }) => { render={({ field }) => {
return ( return (
<FormItem ref={ref} className={cn(className, "space-y-3")}> <FormItem ref={ref} className={cn(className, "space-y-3")}>
{label && <FormLabel label={label} hint={hint} required={rules?.required ?? false} />} {label && (
<FormLabel label={label} hint={hint} required={Boolean(rules?.required ?? false)} />
)}
<FormControl> <FormControl>
<Input <CurrencyInput
type='number'
name={field.name} name={field.name}
//ref={field.ref} <-- no activar que hace cosas raras //ref={field.ref} <-- no activar que hace cosas raras
onBlur={field.onBlur} onBlur={field.onBlur}
disabled={field.disabled} disabled={field.disabled}
readOnly={readOnly}
className={cn(formQuantityFieldVariants({ variant, className }))} className={cn(formQuantityFieldVariants({ variant, className }))}
groupSeparator='.'
decimalSeparator=','
placeholder={placeholder} placeholder={placeholder}
value={transformToInput(field.value)} decimalsLimit={precision}
onChange={(value) => { decimalScale={precision}
// "value" ya viene con los "0" de la precisión value={transform.input(field.value)}
console.log(value); onValueChange={(e) => field.onChange(transform.output(e))}
field.onChange(value ?? "");
}}
/> />
</FormControl> </FormControl>
{description && <FormDescription>{description}</FormDescription>} {description && <FormDescription>{description}</FormDescription>}

View File

@ -1,6 +1,7 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { FormControl, FormDescription, FormField, FormItem, Input, InputProps } from "@/ui"; import { FormControl, FormDescription, FormField, FormItem, Input, InputProps } from "@/ui";
import { cva } from "class-variance-authority";
import * as React from "react"; import * as React from "react";
import { createElement } from "react"; import { createElement } from "react";
import { FieldPath, FieldValues, UseControllerProps, useFormContext } from "react-hook-form"; import { FieldPath, FieldValues, UseControllerProps, useFormContext } from "react-hook-form";
@ -8,6 +9,19 @@ import { FormErrorMessage } from "./FormErrorMessage";
import { FormLabel, FormLabelProps } from "./FormLabel"; import { FormLabel, FormLabelProps } from "./FormLabel";
import { FormInputProps, FormInputWithIconProps } from "./FormProps"; import { FormInputProps, FormInputWithIconProps } from "./FormProps";
const FormTextFieldVariants = cva("", {
variants: {
variant: {
default: "",
outline:
"border-0 focus-visible:border focus-visible:border-input focus-visible:ring-0 focus-visible:ring-offset-0 ",
},
},
defaultVariants: {
variant: "default",
},
});
export type FormTextFieldProps< export type FormTextFieldProps<
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
@ -19,94 +33,96 @@ export type FormTextFieldProps<
FormInputWithIconProps & FormInputWithIconProps &
UseControllerProps<TFieldValues, TName>; UseControllerProps<TFieldValues, TName>;
export const FormTextField = React.forwardRef< export const FormTextField = React.forwardRef<HTMLInputElement, FormTextFieldProps>(
HTMLDivElement, (props, ref) => {
React.HTMLAttributes<HTMLDivElement> & FormTextFieldProps const {
>((props, ref) => { name,
const { label,
name, hint,
label, description,
hint, placeholder,
placeholder, className,
description, disabled,
defaultValue,
rules,
type,
variant,
required, button,
className, leadIcon,
leadIcon, trailIcon,
trailIcon, } = props;
button,
defaultValue, const { control } = useFormContext();
type, return (
} = props; <FormField
defaultValue={defaultValue}
const { control } = useFormContext(); control={control}
name={name}
return ( disabled={disabled}
<FormField rules={rules}
defaultValue={defaultValue} render={({ field, fieldState }) => {
control={control} return (
name={name} <FormItem ref={ref} className={cn(className, "space-y-3")}>
rules={{ required }} {label && (
// eslint-disable-next-line @typescript-eslint/no-unused-vars <FormLabel label={label} hint={hint} required={Boolean(rules?.required ?? false)} />
render={({ field, fieldState, formState }) => { )}
return ( <div className={cn(button ? "flex" : null)}>
<FormItem ref={ref} className={cn(className, "space-y-3")}> <div
{label && <FormLabel label={label} hint={hint} required={required} />} className={cn(
<div className={cn(button ? "flex" : null)}> leadIcon ? "relative flex items-stretch flex-grow focus-within:z-10" : ""
<div )}
className={cn(
leadIcon ? "relative flex items-stretch flex-grow focus-within:z-10" : ""
)}
>
{leadIcon && (
<div className='absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none'>
{React.createElement(
leadIcon,
{
className: "h-5 w-5 text-muted-foreground",
"aria-hidden": true,
},
null
)}
</div>
)}
<FormControl
className={cn("block", leadIcon ? "pl-10" : "", trailIcon ? "pr-10" : "")}
> >
<Input {leadIcon && (
type={type} <div className='absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none'>
placeholder={placeholder} {React.createElement(
className={cn( leadIcon,
fieldState.error ? "border-destructive focus-visible:ring-destructive" : "" {
)} className: "h-5 w-5 text-muted-foreground",
{...field} "aria-hidden": true,
/> },
</FormControl> null
)}
</div>
)}
{trailIcon && ( <FormControl
<div className='absolute inset-y-0 right-0 flex items-center pl-3 pointer-events-none'> className={cn("block", leadIcon ? "pl-10" : "", trailIcon ? "pr-10" : "")}
{createElement( >
trailIcon, <Input
{ type={type}
className: "h-5 w-5 text-muted-foreground", placeholder={placeholder}
"aria-hidden": true, className={cn(
}, fieldState.error ? "border-destructive focus-visible:ring-destructive" : "",
null FormTextFieldVariants({ variant, className })
)} )}
</div> {...field}
)} />
</FormControl>
{trailIcon && (
<div className='absolute inset-y-0 right-0 flex items-center pl-3 pointer-events-none'>
{createElement(
trailIcon,
{
className: "h-5 w-5 text-muted-foreground",
"aria-hidden": true,
},
null
)}
</div>
)}
</div>
{button && <>{createElement(button)}</>}
</div> </div>
{button && <>{createElement(button)}</>}
</div>
{description && <FormDescription>{description}</FormDescription>} {description && <FormDescription>{description}</FormDescription>}
<FormErrorMessage /> <FormErrorMessage />
</FormItem> </FormItem>
); );
}} }}
/> />
); );
}); }
);

View File

@ -11,22 +11,22 @@ export const calculateItemTotals = (item: {
subtotalPrice: MoneyValue; subtotalPrice: MoneyValue;
discount: Percentage; discount: Percentage;
totalPrice: MoneyValue; totalPrice: MoneyValue;
} => { } | null => {
const { quantity: quantity_value, unit_price: unit_price_value, discount: discount_value } = item; const { quantity: quantity_dto, unit_price: unit_price_dto, discount: discount_dto } = item;
const quantityOrError = Quantity.create(quantity_value); const quantityOrError = Quantity.create(quantity_dto);
if (quantityOrError.isFailure) { if (quantityOrError.isFailure) {
throw quantityOrError.error; throw quantityOrError.error;
} }
const quantity = quantityOrError.object; const quantity = quantityOrError.object;
const unitPriceOrError = MoneyValue.create(unit_price_value); const unitPriceOrError = MoneyValue.create(unit_price_dto);
if (unitPriceOrError.isFailure) { if (unitPriceOrError.isFailure) {
throw unitPriceOrError.error; throw unitPriceOrError.error;
} }
const unitPrice = unitPriceOrError.object; const unitPrice = unitPriceOrError.object;
const discountOrError = Percentage.create(discount_value); const discountOrError = Percentage.create(discount_dto);
if (discountOrError.isFailure) { if (discountOrError.isFailure) {
throw discountOrError.error; throw discountOrError.error;
} }

View File

@ -16,6 +16,7 @@ export * from "./useCustomDialog";
export * from "./useDataSource"; export * from "./useDataSource";
export * from "./useDataTable"; export * from "./useDataTable";
export * from "./useLocalization"; export * from "./useLocalization";
export * from "./useMediaQuery";
export * from "./usePagination"; export * from "./usePagination";
export * from "./useTheme"; export * from "./useTheme";
export * from "./useUnsavedChangesNotifier"; export * from "./useUnsavedChangesNotifier";

View File

@ -0,0 +1 @@
export * from "./useMediaQuery";

View File

@ -0,0 +1,19 @@
import * as React from "react";
export function useMediaQuery(query: string) {
const [value, setValue] = React.useState(false);
React.useEffect(() => {
function onChange(event: MediaQueryListEvent) {
setValue(event.matches);
}
const result = matchMedia(query);
result.addEventListener("change", onChange);
setValue(result.matches);
return () => result.removeEventListener("change", onChange);
}, [query]);
return value;
}

View File

@ -124,6 +124,9 @@
"title": "Cotización" "title": "Cotización"
} }
}, },
"edit": {
"title": "Cotización"
},
"status": { "status": {
"draft": "Borrador" "draft": "Borrador"
}, },
@ -138,6 +141,16 @@
"desc": "Referencia para esta cotización", "desc": "Referencia para esta cotización",
"placeholder": "" "placeholder": ""
}, },
"lang_code": {
"label": "Idioma",
"desc": "Idioma de la cotización",
"placeholder": ""
},
"currency_code": {
"label": "Moneda",
"desc": "Moneda de la cotización",
"placeholder": ""
},
"customer_information": { "customer_information": {
"label": "Datos del cliente", "label": "Datos del cliente",
"desc": "Escriba el nombre del cliente en la primera línea, la direccion en la segunda y el código postal y ciudad en la tercera.", "desc": "Escriba el nombre del cliente en la primera línea, la direccion en la segunda y el código postal y ciudad en la tercera.",