diff --git a/.vscode/launch.json b/.vscode/launch.json index 5f4c45c..95034f8 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -12,7 +12,7 @@ { "name": "Launch Chrome localhost", - "type": "pwa-chrome", + "type": "chrome", "request": "launch", "reAttach": true, "url": "http://localhost:5173", @@ -26,6 +26,7 @@ "url": "http://localhost:5173", "webRoot": "${workspaceFolder}/client" }, + { "type": "node", "request": "attach", @@ -34,6 +35,7 @@ "restart": true, "cwd": "${workspaceRoot}" }, + { "name": "Launch via YARN", "request": "launch", diff --git a/client/src/app/quotes/components/SortableDataTable.tsx b/client/src/app/quotes/components/SortableDataTable.tsx index bc88621..0f33ed9 100644 --- a/client/src/app/quotes/components/SortableDataTable.tsx +++ b/client/src/app/quotes/components/SortableDataTable.tsx @@ -271,7 +271,7 @@ export function SortableDataTable({ columns, data, actions }: SortableDataTableP { quantity: { amount: "123" }, description: "aaaa", - retail_price: { + unit_price: { amount: "10000", precision: 4, currency: "EUR", @@ -336,9 +336,8 @@ export function SortableDataTable({ columns, data, actions }: SortableDataTableP {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { - console.log(header.getSize()); return ( - + {header.isPlaceholder ? null : ( )} diff --git a/client/src/app/quotes/components/editors/QuoteDetailsCardEditor.tsx b/client/src/app/quotes/components/editors/QuoteDetailsCardEditor.tsx index 590e4c1..2960560 100644 --- a/client/src/app/quotes/components/editors/QuoteDetailsCardEditor.tsx +++ b/client/src/app/quotes/components/editors/QuoteDetailsCardEditor.tsx @@ -1,4 +1,9 @@ -import { FormQuantityField, FormTextField } from "@/components"; +import { + FormCurrencyField, + FormPercentageField, + FormQuantityField, + FormTextField, +} from "@/components"; import { DataTableProvider } from "@/lib/hooks"; import { cn } from "@/lib/utils"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/ui"; @@ -11,7 +16,7 @@ import { CatalogPickerDataTable } from "../CatalogPickerDataTable"; import { SortableDataTable } from "../SortableDataTable"; export const QuoteDetailsCardEditor = ({ currency }: { currency: CurrencyData }) => { - const { control, register } = useFormContext(); + const { control, register, getValues } = useFormContext(); const { fields, ...fieldActions } = useFieldArray({ control, @@ -42,7 +47,7 @@ export const QuoteDetailsCardEditor = ({ currency }: { currency: CurrencyData }) return ( ); @@ -65,7 +70,7 @@ export const QuoteDetailsCardEditor = ({ currency }: { currency: CurrencyData }) ), cell: ({ row: { index }, column: { id } }) => { return ( - {t("quotes.form_fields.items.subtotal_price.label")} ), cell: ({ row: { index }, column: { id } }) => { - return ; + return ( + + ); }, }, { @@ -93,7 +107,16 @@ export const QuoteDetailsCardEditor = ({ currency }: { currency: CurrencyData })
{t("quotes.form_fields.items.discount.label")}
), cell: ({ row: { index }, column: { id } }) => { - return ; + return ( + <> + + + ); }, }, { @@ -103,7 +126,16 @@ export const QuoteDetailsCardEditor = ({ currency }: { currency: CurrencyData })
{t("quotes.form_fields.items.total_price.label")}
), cell: ({ row: { index }, column: { id } }) => { - return ; + return ( + + ); }, }, ], @@ -147,7 +179,7 @@ export const QuoteDetailsCardEditor = ({ currency }: { currency: CurrencyData }) fieldActions.append({ ...newArticle, quantity: { - amount: 1, + amount: 12, precision: Quantity.DEFAULT_PRECISION, }, unit_price: newArticle.retail_price, @@ -161,8 +193,6 @@ export const QuoteDetailsCardEditor = ({ currency }: { currency: CurrencyData }) const defaultLayout = [265, 440, 655]; const navCollapsedSize = 4; - return ; - return ( { {
{ {
{ className='row-span-2' name='reference' required - label={t("quotes.create.form_fields.reference.label")} - description={t("quotes.create.form_fields.reference.desc")} - placeholder={t("quotes.create.form_fields.reference.placeholder")} + label={t("quotes.form_fields.reference.label")} + description={t("quotes.form_fields.reference.desc")} + placeholder={t("quotes.form_fields.reference.placeholder")} /> @@ -108,9 +108,9 @@ export const QuoteCreate = () => { className='row-span-2' name='customer_information' required - label={t("quotes.create.form_fields.customer_information.label")} - description={t("quotes.create.form_fields.customer_information.desc")} - placeholder={t("quotes.create.form_fields.customer_information.placeholder")} + label={t("quotes.form_fields.customer_information.label")} + description={t("quotes.form_fields.customer_information.desc")} + placeholder={t("quotes.form_fields.customer_information.placeholder")} />
diff --git a/client/src/app/quotes/edit.tsx b/client/src/app/quotes/edit.tsx index d8fc74a..7dd583a 100644 --- a/client/src/app/quotes/edit.tsx +++ b/client/src/app/quotes/edit.tsx @@ -1,4 +1,4 @@ -import { ErrorOverlay, FormTextField, LoadingOverlay, SubmitButton } from "@/components"; +import { ErrorOverlay, FormCurrencyField, LoadingOverlay, SubmitButton } from "@/components"; import { calculateItemTotals } from "@/lib/calc"; import { useUrlId } from "@/lib/hooks/useUrlId"; 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 { useQuotes } from "./hooks"; -// simple typesafe helperfunction -type EndsWith = T extends `${infer f}${b}` ? T : never; -const endsWith = (str: T, prefix: b): str is EndsWith => - str.endsWith(prefix); - interface QuoteDataForm extends IUpdateQuote_Request_DTO {} // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -51,11 +46,6 @@ export const QuoteEdit = () => { payment_method: "", notes: "", validity: "", - subtotal_price: { - amount: "", - precision: "", - currency_code: "", - }, items: [], }, }); @@ -67,21 +57,21 @@ export const QuoteEdit = () => { // Transformación del form -> typo de request mutate(data, { onError: (error) => { - alert(error.message); + console.debug(error); + //alert(error.message); }, //onSettled: () => {}, onSuccess: () => { - alert("guardado"); + //alert("guardado"); }, }); }; useEffect(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars const { unsubscribe } = watch((_, { name, type }) => { const value = getValues(); - //console.debug({ name, type }); - if (name) { if (name === "currency_code") { setQuoteCurrency( @@ -97,6 +87,11 @@ export const QuoteEdit = () => { // Recálculo líneas items.map((item, index) => { const itemTotals = calculateItemTotals(item); + + if (itemTotals === null) { + return; + } + quoteSubtotal = quoteSubtotal.add(itemTotals.totalPrice); setValue(`items.${index}.subtotal_price`, itemTotals.subtotalPrice.toObject()); @@ -107,17 +102,16 @@ export const QuoteEdit = () => { setValue("subtotal_price", quoteSubtotal.toObject()); } - if ( - endsWith(name, "quantity") || - endsWith(name, "unit_price") || - endsWith(name, "discount") - ) { + if (name.endsWith("quantity") || name.endsWith("unit_price") || name.endsWith("discount")) { const { items } = value; // eslint-disable-next-line @typescript-eslint/no-unused-vars const [, indexString, fieldName] = String(name).split("."); const index = parseInt(indexString); const itemTotals = calculateItemTotals(items[index]); + if (itemTotals === null) { + return; + } setValue(`items.${index}.subtotal_price`, itemTotals.subtotalPrice.toObject()); setValue(`items.${index}.total_price`, itemTotals.totalPrice.toObject()); @@ -141,8 +135,6 @@ export const QuoteEdit = () => { return ; } - console.log(quoteCurrency); - return (
@@ -168,7 +160,10 @@ export const QuoteEdit = () => {
- ( if (enableSelectionColumn) { columns.unshift({ id: "select", - /*header: ({ table }) => ( + header: ({ table }) => ( ( aria-label='Seleccionar todo' className='translate-y-[0px]' /> - ),*/ - header: () => null, + ), + // eslint-disable-next-line @typescript-eslint/no-unused-vars cell: ({ row, table }: { row: Row; table: Table }) => ( { return dataSource.updateOne({ resource: "quotes", id, - data, + data: { + ...data, + }, }); }, }), diff --git a/client/src/components/Forms/FormCurrencyField.tsx b/client/src/components/Forms/FormCurrencyField.tsx index df0e96f..0908afc 100644 --- a/client/src/components/Forms/FormCurrencyField.tsx +++ b/client/src/components/Forms/FormCurrencyField.tsx @@ -53,6 +53,7 @@ export const FormCurrencyField = React.forwardRef { - if (typeof value !== "object") { - return value; - } + const transform = { + input: (value: any) => { + if (typeof value !== "object") { + return value; + } - const moneyOrError = MoneyValue.create(value); - if (moneyOrError.isFailure) { - throw moneyOrError.error; - } + const moneyOrError = MoneyValue.create(value); + if (moneyOrError.isFailure) { + throw moneyOrError.error; + } - return moneyOrError.object - .convertPrecision(precision ?? value.precision) - .toUnit() - .toString(); + return moneyOrError.object + .convertPrecision(precision ?? value.precision) + .toUnit() + .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 ( @@ -87,13 +103,16 @@ export const FormCurrencyField = React.forwardRef { return ( - {label && } + {label && ( + + )} { - // "value" ya viene con los "0" de la precisión - field.onChange(value ?? ""); - }} + value={transform.input(field.value)} + onValueChange={(e) => field.onChange(transform.output(e))} /> {description && {description}} diff --git a/client/src/components/Forms/FormPercentageField.tsx b/client/src/components/Forms/FormPercentageField.tsx index 2ea0157..d4ea2f6 100644 --- a/client/src/components/Forms/FormPercentageField.tsx +++ b/client/src/components/Forms/FormPercentageField.tsx @@ -1,19 +1,32 @@ -import { cn } from "@/lib/utils"; -import { FormControl, FormDescription, FormItem, InputProps } from "@/ui"; +import * as React from "react"; -import { Percentage, PercentageObject } from "@shared/contexts"; -import { createElement, forwardRef, useState } from "react"; -import { - Controller, - FieldPath, - FieldValues, - UseControllerProps, - useFormContext, -} from "react-hook-form"; +import { cn } from "@/lib/utils"; +import { FormControl, FormDescription, FormField, FormItem, InputProps } from "@/ui"; +import { Percentage } from "@shared/contexts"; +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 { FormErrorMessage } from "./FormErrorMessage"; import { FormLabel, FormLabelProps } from "./FormLabel"; 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< TFieldValues extends FieldValues = FieldValues, TName extends FieldPath = FieldPath @@ -24,9 +37,12 @@ export type FormPercentageFieldProps< FormInputProps & Partial & FormInputWithIconProps & - UseControllerProps; + UseControllerProps & + VariantProps & { + precision: number; + }; -export const FormPercentageField = forwardRef< +export const FormPercentageField = React.forwardRef< HTMLDivElement, React.HTMLAttributes & FormPercentageFieldProps >((props, ref) => { @@ -34,42 +50,42 @@ export const FormPercentageField = forwardRef< name, label, hint, - placeholder, description, - - required, + placeholder, className, - leadIcon, - trailIcon, - button, + disabled, defaultValue, + rules, + readOnly, + precision, + variant, } = props; const { control } = useFormContext(); - const [precision, setPrecision] = useState(Percentage.DEFAULT_PRECISION); - const transform = { - input: (value: PercentageObject) => { - const percentageOrError = Percentage.create(value); + input: (value: any) => { + if (typeof value !== "object") { + return value; + } + const percentageOrError = Percentage.create(value); if (percentageOrError.isFailure) { throw percentageOrError.error; } - const percentageValue = percentageOrError.object; - setPrecision(percentageValue.getPrecision()); - return percentageValue.toString(); + return ( + percentageOrError.object + .toNumber() + //.toPrecision(precision ?? value.precision) + .toString() + ); }, - output: (event: React.ChangeEvent): PercentageObject => { - const value = parseFloat(event.target.value); - const output = !isNaN(value) ? value : 0; - + output: (value: string | undefined) => { const percentageOrError = Percentage.create({ - amount: output * Math.pow(10, precision), + amount: value?.replace(",", "") ?? null, precision, }); - if (percentageOrError.isFailure) { throw percentageOrError.error; } @@ -79,81 +95,39 @@ export const FormPercentageField = forwardRef< }; return ( - { - return ( - field.onChange(transform.output(e))} - value={transform.input(field.value)} - /> - ); - + render={({ field }) => { return ( - {label && } -
-
- {leadIcon && ( -
- {createElement( - leadIcon, - { - className: "h-5 w-5 text-muted-foreground", - "aria-hidden": true, - }, - null - )} -
- )} - - - field.onChange(transform.output(e))} - value={transform.input(field.value)} - /> - - - {trailIcon && ( -
- {createElement( - trailIcon, - { - className: "h-5 w-5 text-muted-foreground", - "aria-hidden": true, - }, - null - )} -
- )} -
- {button && <>{createElement(button)}} -
- + {label && ( + + )} + + field.onChange(transform.output(e))} + /> + {description && {description}}
diff --git a/client/src/components/Forms/FormQuantityField.tsx b/client/src/components/Forms/FormQuantityField.tsx index 125fea3..f44e139 100644 --- a/client/src/components/Forms/FormQuantityField.tsx +++ b/client/src/components/Forms/FormQuantityField.tsx @@ -1,22 +1,23 @@ import * as React from "react"; 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 { cva, type VariantProps } from "class-variance-authority"; +import CurrencyInput from "react-currency-input-field"; import { FieldPath, FieldValues, UseControllerProps, useFormContext } from "react-hook-form"; import { FormErrorMessage } from "./FormErrorMessage"; import { FormLabel, FormLabelProps } from "./FormLabel"; import { FormInputProps, FormInputWithIconProps } from "./FormProps"; 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: { variant: { - default: "", - outline: - "border-0 focus-visible:border focus-visible:border-input focus-visible:ring-0 focus-visible:ring-offset-0 ", + 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: { @@ -52,28 +53,42 @@ export const FormQuantityField = React.forwardRef { - if (typeof value !== "object") { - return value; - } + const transform = { + input: (value: any) => { + if (typeof value !== "object") { + return value; + } - const quantityOrError = Quantity.create(value); - if (quantityOrError.isFailure) { - throw quantityOrError.error; - } + const quantityOrError = Quantity.create(value); + if (quantityOrError.isFailure) { + throw quantityOrError.error; + } - return ( - quantityOrError.object - .toNumber() - //.toPrecision(precision ?? value.precision) - .toString() - ); + return ( + quantityOrError.object + .toNumber() + //.toPrecision(precision ?? value.precision) + .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 ( @@ -83,26 +98,27 @@ export const FormQuantityField = React.forwardRef { return ( - {label && } + {label && ( + + )} - { - // "value" ya viene con los "0" de la precisión - console.log(value); - field.onChange(value ?? ""); - }} + decimalsLimit={precision} + decimalScale={precision} + value={transform.input(field.value)} + onValueChange={(e) => field.onChange(transform.output(e))} /> {description && {description}} diff --git a/client/src/components/Forms/FormTextField.tsx b/client/src/components/Forms/FormTextField.tsx index 4804279..7c4d5c4 100644 --- a/client/src/components/Forms/FormTextField.tsx +++ b/client/src/components/Forms/FormTextField.tsx @@ -1,6 +1,7 @@ import { cn } from "@/lib/utils"; import { FormControl, FormDescription, FormField, FormItem, Input, InputProps } from "@/ui"; +import { cva } from "class-variance-authority"; import * as React from "react"; import { createElement } from "react"; import { FieldPath, FieldValues, UseControllerProps, useFormContext } from "react-hook-form"; @@ -8,6 +9,19 @@ import { FormErrorMessage } from "./FormErrorMessage"; import { FormLabel, FormLabelProps } from "./FormLabel"; 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< TFieldValues extends FieldValues = FieldValues, TName extends FieldPath = FieldPath @@ -19,94 +33,96 @@ export type FormTextFieldProps< FormInputWithIconProps & UseControllerProps; -export const FormTextField = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes & FormTextFieldProps ->((props, ref) => { - const { - name, - label, - hint, - placeholder, - description, +export const FormTextField = React.forwardRef( + (props, ref) => { + const { + name, + label, + hint, + description, + placeholder, + className, + disabled, + defaultValue, + rules, + type, + variant, - required, - className, - leadIcon, - trailIcon, - button, + button, + leadIcon, + trailIcon, + } = props; - defaultValue, + const { control } = useFormContext(); - type, - } = props; - - const { control } = useFormContext(); - - return ( - { - return ( - - {label && } -
-
- {leadIcon && ( -
- {React.createElement( - leadIcon, - { - className: "h-5 w-5 text-muted-foreground", - "aria-hidden": true, - }, - null - )} -
- )} - - { + return ( + + {label && ( + + )} +
+
- - + {leadIcon && ( +
+ {React.createElement( + leadIcon, + { + className: "h-5 w-5 text-muted-foreground", + "aria-hidden": true, + }, + null + )} +
+ )} - {trailIcon && ( -
- {createElement( - trailIcon, - { - className: "h-5 w-5 text-muted-foreground", - "aria-hidden": true, - }, - null - )} -
- )} + + + + + {trailIcon && ( +
+ {createElement( + trailIcon, + { + className: "h-5 w-5 text-muted-foreground", + "aria-hidden": true, + }, + null + )} +
+ )} +
+ {button && <>{createElement(button)}}
- {button && <>{createElement(button)}} -
- {description && {description}} - - - ); - }} - /> - ); -}); + {description && {description}} + + + ); + }} + /> + ); + } +); diff --git a/client/src/lib/calc.ts b/client/src/lib/calc.ts index e68ebeb..494d004 100644 --- a/client/src/lib/calc.ts +++ b/client/src/lib/calc.ts @@ -11,22 +11,22 @@ export const calculateItemTotals = (item: { subtotalPrice: MoneyValue; discount: Percentage; totalPrice: MoneyValue; -} => { - const { quantity: quantity_value, unit_price: unit_price_value, discount: discount_value } = item; +} | null => { + 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) { throw quantityOrError.error; } const quantity = quantityOrError.object; - const unitPriceOrError = MoneyValue.create(unit_price_value); + const unitPriceOrError = MoneyValue.create(unit_price_dto); if (unitPriceOrError.isFailure) { throw unitPriceOrError.error; } const unitPrice = unitPriceOrError.object; - const discountOrError = Percentage.create(discount_value); + const discountOrError = Percentage.create(discount_dto); if (discountOrError.isFailure) { throw discountOrError.error; } diff --git a/client/src/lib/hooks/index.ts b/client/src/lib/hooks/index.ts index 564a872..16ebad0 100644 --- a/client/src/lib/hooks/index.ts +++ b/client/src/lib/hooks/index.ts @@ -16,6 +16,7 @@ export * from "./useCustomDialog"; export * from "./useDataSource"; export * from "./useDataTable"; export * from "./useLocalization"; +export * from "./useMediaQuery"; export * from "./usePagination"; export * from "./useTheme"; export * from "./useUnsavedChangesNotifier"; diff --git a/client/src/lib/hooks/useMediaQuery/index.ts b/client/src/lib/hooks/useMediaQuery/index.ts new file mode 100644 index 0000000..8a260d0 --- /dev/null +++ b/client/src/lib/hooks/useMediaQuery/index.ts @@ -0,0 +1 @@ +export * from "./useMediaQuery"; diff --git a/client/src/lib/hooks/useMediaQuery/useMediaQuery.tsx b/client/src/lib/hooks/useMediaQuery/useMediaQuery.tsx new file mode 100644 index 0000000..7ac3915 --- /dev/null +++ b/client/src/lib/hooks/useMediaQuery/useMediaQuery.tsx @@ -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; +} diff --git a/client/src/locales/es.json b/client/src/locales/es.json index f0e47d8..c6b5533 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/es.json @@ -124,6 +124,9 @@ "title": "Cotización" } }, + "edit": { + "title": "Cotización" + }, "status": { "draft": "Borrador" }, @@ -138,6 +141,16 @@ "desc": "Referencia para esta cotización", "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": { "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.",