This commit is contained in:
David Arranz 2024-07-11 13:08:53 +02:00
parent 0b5f180b6f
commit 8e80bfe31e
10 changed files with 261 additions and 250 deletions

View File

@ -148,6 +148,11 @@ export function SortableDataTable({ columns, data, actions }: SortableDataTableP
getRowId: (originalRow: unknown) => originalRow?.id,
debugHeaders: false,
debugColumns: false,
defaultColumn: {
size: 8, //starting column size
minSize: 1, //enforced during column resizing
maxSize: 96, //enforced during column resizing
},
meta: {
insertItem: (rowIndex: number, data: object = {}) => {
actions.insert(rowIndex, data, { shouldFocus: true });
@ -326,13 +331,14 @@ export function SortableDataTable({ columns, data, actions }: SortableDataTableP
collisionDetection={closestCenter}
>
<SortableDataTableToolbar table={table} />
<Table className='table-auto'>
<Table className='table-fixed'>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className='hover:bg-transparent'>
{headerGroup.headers.map((header) => {
console.log(header.getSize());
return (
<TableHead key={header.id} className='px-1'>
<TableHead key={header.id} className={`px-1 w-${header.getSize()}`}>
{header.isPlaceholder ? null : (
<DataTableColumnHeader table={table} header={header} />
)}
@ -350,11 +356,7 @@ export function SortableDataTable({ columns, data, actions }: SortableDataTableP
{filterItems(table.getRowModel().rows).map((row) => (
<SortableTableRow key={row.id} id={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell
className='p-1 align-top'
key={cell.id}
style={{ width: cell.column.getSize() }}
>
<TableCell key={cell.id} className='px-2 py-1 align-top'>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}

View File

@ -58,7 +58,10 @@ export function SortableTableRow({ id, children }: PropsWithChildren<SortablePro
<TableRow
key={id}
id={String(id)}
className={cn(isDragging ? "opacity-40" : "opacity-100", "hover:bg-muted/30 m-0")}
className={cn(
isDragging ? "opacity-40" : "opacity-100",
"m-0 hover:bg-muted hover:focus-within:bg-accent focus-within:bg-accent"
)}
ref={setNodeRef}
style={style}
>

View File

@ -1,13 +1,9 @@
import {
FormCurrencyField,
FormQuantityField,
FormTextAreaField,
FormTextField,
} from "@/components";
import { FormQuantityField, FormTextField } from "@/components";
import { DataTableProvider } from "@/lib/hooks";
import { cn } from "@/lib/utils";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/ui";
import { CurrencyData, Quantity } from "@shared/contexts";
import { t } from "i18next";
import { useCallback, useState } from "react";
import { useFieldArray, useFormContext } from "react-hook-form";
import { useDetailColumns } from "../../hooks";
@ -38,32 +34,42 @@ export const QuoteDetailsCardEditor = ({ currency }: { currency: CurrencyData })
{
id: "quantity" as const,
accessorKey: "quantity",
header: "quantity",
header: () => (
<div className='text-right'>{t("quotes.form_fields.items.quantity.label")}</div>
),
size: 5,
cell: ({ row: { index } }) => {
return <FormQuantityField {...register(`items.${index}.quantity`)} />;
return (
<FormQuantityField
variant='outline'
precision={2}
{...register(`items.${index}.quantity`)}
/>
);
},
},
{
id: "description" as const,
accessorKey: "description",
header: "description",
header: t("quotes.form_fields.items.description.label"),
size: 24,
cell: ({ row: { index } }) => {
return <FormTextAreaField autoSize {...register(`items.${index}.description`)} />;
return <FormTextField autoSize {...register(`items.${index}.description`)} />;
},
},
{
id: "unit_price" as const,
accessorKey: "unit_price",
header: "unit_price",
size: 10,
header: () => (
<div className='text-right'>{t("quotes.form_fields.items.unit_price.label")}</div>
),
cell: ({ row: { index }, column: { id } }) => {
return (
<FormCurrencyField
<FormTextField
variant='outline'
currency={currency}
precision={4}
className='text-right'
{...register(`items.${index}.unit_price`)}
/>
);
@ -72,8 +78,9 @@ export const QuoteDetailsCardEditor = ({ currency }: { currency: CurrencyData })
{
id: "subtotal_price" as const,
accessorKey: "subtotal_price",
header: "subtotal_price",
size: 10,
header: () => (
<div className='text-right'>{t("quotes.form_fields.items.subtotal_price.label")}</div>
),
cell: ({ row: { index }, column: { id } }) => {
return <FormTextField {...register(`items.${index}.subtotal_price`)} />;
},
@ -81,17 +88,20 @@ export const QuoteDetailsCardEditor = ({ currency }: { currency: CurrencyData })
{
id: "discount" as const,
accessorKey: "discount",
header: "discount",
size: 5,
header: () => (
<div className='text-right'>{t("quotes.form_fields.items.discount.label")}</div>
),
cell: ({ row: { index }, column: { id } }) => {
return <FormTextField {...register(`items.${index}.discount`)} />;
return <FormTextField className='text-right' {...register(`items.${index}.discount`)} />;
},
},
{
id: "total_price" as const,
accessorKey: "total_price",
header: "total_price",
size: 10,
header: () => (
<div className='text-right'>{t("quotes.form_fields.items.total_price.label")}</div>
),
cell: ({ row: { index }, column: { id } }) => {
return <FormTextField {...register(`items.${index}.total_price`)} />;
},
@ -151,6 +161,8 @@ export const QuoteDetailsCardEditor = ({ currency }: { currency: CurrencyData })
const defaultLayout = [265, 440, 655];
const navCollapsedSize = 4;
return <SortableDataTable actions={fieldActions} columns={columns} data={fields} />;
return (
<ResizablePanelGroup
direction='horizontal'

View File

@ -5,27 +5,17 @@ import {
} from "@/components";
import { Checkbox } from "@/ui";
import { ColumnDef, Row, Table } from "@tanstack/react-table";
import { MoreHorizontalIcon } from "lucide-react";
import { useMemo } from "react";
/*function getSelectedRowRange<T>(
rows: Row<T>[],
currentID: number,
selectedID: number,
): Row<T>[] {
const rangeStart = selectedID > currentID ? currentID : selectedID;
const rangeEnd = rangeStart === currentID ? selectedID : currentID;
return rows.slice(rangeStart, rangeEnd + 1);
}*/
export function useDetailColumns<TData, TValue>(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function useDetailColumns<TData, TValue = unknown>(
columns: ColumnDef<TData>[],
options: {
enableDragHandleColumn?: boolean;
enableSelectionColumn?: boolean;
enableActionsColumn?: boolean;
rowActionFn?: DataTablaRowActionFunction<TData, TValue>;
rowActionFn?: DataTablaRowActionFunction<TData>;
} = {}
): ColumnDef<TData>[] {
const {
@ -67,7 +57,9 @@ export function useDetailColumns<TData, TValue>(
),
enableSorting: false,
enableHiding: false,
size: 30,
size: 2,
minSize: 2,
maxSize: 2,
});
}
@ -79,7 +71,10 @@ export function useDetailColumns<TData, TValue>(
),*/
header: () => null,
cell: (info) => <DataTableRowDragHandleCell rowId={info.row.id} />,
size: 16,
size: 2,
minSize: 2,
maxSize: 2,
enableSorting: false,
enableHiding: false,
});
@ -88,16 +83,23 @@ export function useDetailColumns<TData, TValue>(
if (enableActionsColumn) {
columns.push({
id: "row_actions",
header: () => (
/*header: () => (
<MoreHorizontalIcon
aria-label='Acciones'
className='items-center justify-center w-4 h-4'
/>
),
),*/
cell: (props) => {
return <DataTableRowActions {...props} actions={rowActionFn} />;
return (
<div className='w-full mx-auto text-center'>
<DataTableRowActions rowContext={props} actions={rowActionFn} />
</div>
);
},
size: 16,
size: 4,
minSize: 4,
maxSize: 4,
enableSorting: false,
enableHiding: false,
});

View File

@ -28,7 +28,7 @@ export function DataTableColumnHeader<TData, TValue>({
<>
<div
className={cn(
"data-[state=open]:bg-accent font-bold text-muted-foreground uppercase text-xs tracking-wide",
"data-[state=open]:bg-accent font-bold text-muted-foreground uppercase text-xs tracking-wide text-ellipsis",
className
)}
>

View File

@ -1,5 +1,6 @@
"use client";
import { cn } from "@/lib/utils";
import {
Button,
DropdownMenu,
@ -10,32 +11,31 @@ import {
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/ui";
import { CellContext, Row } from "@tanstack/react-table";
import { CellContext } from "@tanstack/react-table";
import { t } from "i18next";
import { MoreHorizontalIcon } from "lucide-react";
import { MoreVerticalIcon } from "lucide-react";
type DataTableRowActionContext<TData, TValue> = CellContext<TData, TValue> & {
row: Row<TData>;
};
export type DataTablaRowActionFunction<TData> = (
props: CellContext<TData, unknown>
) => DataTableRowActionDefinition<TData>[];
export type DataTablaRowActionFunction<TData, TValue> = (
props: DataTableRowActionContext<TData, TValue>
) => DataTableRowActionDefinition<TData, TValue>[];
export type DataTableRowActionDefinition<TData, TValue> = {
export type DataTableRowActionDefinition<TData> = {
label: string | "-";
shortcut?: string;
onClick?: (props: DataTableRowActionContext<TData, TValue>, e: React.BaseSyntheticEvent) => void;
onClick?: (props: CellContext<TData, unknown>, e: React.BaseSyntheticEvent) => void;
};
export type DataTableRowActionsProps<TData, TValue> = {
actions?: DataTablaRowActionFunction<TData, TValue>;
row?: DataTableRowActionContext<TData, TValue>;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export type DataTableRowActionsProps<TData, TValue = unknown> = {
className?: string;
actions?: DataTablaRowActionFunction<TData>;
rowContext: CellContext<TData, unknown>;
};
export function DataTableRowActions<TData = any, TValue = any>({
export function DataTableRowActions<TData = any, TValue = unknown>({
actions,
...props
rowContext,
className,
}: DataTableRowActionsProps<TData, TValue>) {
return (
<DropdownMenu>
@ -44,9 +44,9 @@ export function DataTableRowActions<TData = any, TValue = any>({
aria-haspopup='true'
size='icon'
variant='link'
className='w-4 h-4 translate-y-[2px]'
className={cn("w-4 h-4 mt-2 text-ring hover:text-muted-foreground", className)}
>
<MoreHorizontalIcon className='w-4 h-4' />
<MoreVerticalIcon className='w-4 h-4' />
<span className='sr-only'>{t("common.open_menu")}</span>
</Button>
</DropdownMenuTrigger>
@ -54,13 +54,13 @@ export function DataTableRowActions<TData = any, TValue = any>({
<DropdownMenuContent align='end'>
<DropdownMenuLabel>{t("common.actions")} </DropdownMenuLabel>
{actions &&
actions(props).map((action, index) =>
actions(rowContext).map((action, index) =>
action.label === "-" ? (
<DropdownMenuSeparator key={index} />
) : (
<DropdownMenuItem
key={index}
onClick={(event) => (action.onClick ? action.onClick(props, event) : null)}
onClick={(event) => (action.onClick ? action.onClick(rowContext, event) : null)}
>
{action.label}
<DropdownMenuShortcut>{action.shortcut}</DropdownMenuShortcut>

View File

@ -9,15 +9,14 @@ import { FormErrorMessage } from "./FormErrorMessage";
import { FormLabel, FormLabelProps } from "./FormLabel";
import { FormInputProps, FormInputWithIconProps } from "./FormProps";
export const formCurrencyFieldVariants = cva(
const formCurrencyFieldVariants = 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:
"ring-offset-background focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2 ",
outline: "focus-visible:border focus-visible:border-input",
},
},
defaultVariants: {
@ -31,6 +30,7 @@ export type FormCurrencyFieldProps<
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
button?: (props?: React.PropsWithChildren) => React.ReactNode;
defaultValue?: any;
} & InputProps &
FormInputProps &
Partial<FormLabelProps> &
@ -48,6 +48,7 @@ export const FormCurrencyField = React.forwardRef<HTMLInputElement, FormCurrency
label,
hint,
description,
placeholder,
className,
disabled,
defaultValue,
@ -97,8 +98,7 @@ export const FormCurrencyField = React.forwardRef<HTMLInputElement, FormCurrency
suffix={` ${currency?.symbol}`}
groupSeparator='.'
decimalSeparator=','
//placeholder={`0 ${fieldCurrenty.value?.symbol}`}
placeholder={placeholder}
//fixedDecimalLength={precision} <- no activar para que sea más cómodo escribir las cantidades
decimalsLimit={precision}
decimalScale={precision}

View File

@ -1,19 +1,30 @@
import { cn } from "@/lib/utils";
import { FormControl, FormDescription, FormItem, InputProps } from "@/ui";
import * as React from "react";
import { Quantity, QuantityObject } 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, Input, InputProps } from "@/ui";
import { Quantity } from "@shared/contexts";
import { cva, type VariantProps } from "class-variance-authority";
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",
{
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 FormQuantityFieldProps<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
@ -24,139 +35,82 @@ export type FormQuantityFieldProps<
FormInputProps &
Partial<FormLabelProps> &
FormInputWithIconProps &
UseControllerProps<TFieldValues, TName>;
export const FormQuantityField = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & FormQuantityFieldProps
>((props, ref) => {
const {
name,
label,
hint,
placeholder,
description,
required,
className,
leadIcon,
trailIcon,
button,
defaultValue,
} = props;
const { control } = useFormContext();
const [precision, setPrecision] = useState<number>(Quantity.DEFAULT_PRECISION);
const transform = {
input: (value: QuantityObject) => {
const quantityOrError = Quantity.create(value);
if (quantityOrError.isFailure) {
throw quantityOrError.error;
}
const quantityValue = quantityOrError.object;
setPrecision(quantityValue.getPrecision());
return quantityValue.toString();
},
output: (event: React.ChangeEvent<HTMLInputElement>): QuantityObject => {
const value = parseFloat(event.target.value);
const output = !isNaN(value) ? value : 0;
const quantityOrError = Quantity.create({
amount: output * Math.pow(10, precision),
precision,
});
if (quantityOrError.isFailure) {
throw quantityOrError.error;
}
return quantityOrError.object.toObject();
},
UseControllerProps<TFieldValues, TName> &
VariantProps<typeof formQuantityFieldVariants> & {
precision: number;
};
return (
<Controller
defaultValue={defaultValue}
control={control}
name={name}
rules={{
required,
}}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
render={({ field, fieldState, formState }) => {
return (
<input
type='number'
{...field}
className='text-right'
placeholder={placeholder}
onChange={(e) => field.onChange(transform.output(e))}
value={transform.input(field.value)}
/>
);
export const FormQuantityField = React.forwardRef<HTMLInputElement, FormQuantityFieldProps>(
(props, ref) => {
const {
name,
label,
hint,
description,
placeholder,
className,
disabled,
defaultValue,
rules,
precision,
variant,
} = props;
return (
<FormItem ref={ref} className={cn(className, "space-y-3")}>
{label && <FormLabel label={label} hint={hint} required={required} />}
<div className={cn(button ? "flex" : null)}>
<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'>
{createElement(
leadIcon,
{
className: "h-5 w-5 text-muted-foreground",
"aria-hidden": true,
},
null
)}
</div>
)}
const { control } = useFormContext();
<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>
const transformToInput = (value: any) => {
if (typeof value !== "object") {
return value;
}
{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>
const quantityOrError = Quantity.create(value);
if (quantityOrError.isFailure) {
throw quantityOrError.error;
}
{description && <FormDescription>{description}</FormDescription>}
<FormErrorMessage />
</FormItem>
);
}}
/>
);
});
return (
quantityOrError.object
.toNumber()
//.toPrecision(precision ?? value.precision)
.toString()
);
};
return (
<FormField
defaultValue={defaultValue}
control={control}
name={name}
disabled={disabled}
rules={rules}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
render={({ field }) => {
return (
<FormItem ref={ref} className={cn(className, "space-y-3")}>
{label && <FormLabel label={label} hint={hint} required={rules?.required ?? false} />}
<FormControl>
<Input
type='number'
name={field.name}
//ref={field.ref} <-- no activar que hace cosas raras
onBlur={field.onBlur}
disabled={field.disabled}
className={cn(formQuantityFieldVariants({ variant, className }))}
placeholder={placeholder}
value={transformToInput(field.value)}
onChange={(value) => {
// "value" ya viene con los "0" de la precisión
console.log(value);
field.onChange(value ?? "");
}}
/>
</FormControl>
{description && <FormDescription>{description}</FormDescription>}
<FormErrorMessage />
</FormItem>
);
}}
/>
);
}
);

View File

@ -190,6 +190,12 @@ export function useDataTable<TData, TValue>({
debugTable: false,
debugHeaders: false,
debugColumns: false,
defaultColumn: {
size: 5, //starting column size
minSize: 0, //enforced during column resizing
maxSize: 96, //enforced during column resizing
},
});
return { table };

View File

@ -86,9 +86,6 @@
"total_price": "Imp. total"
}
},
"status": {
"draft": "Borrador"
},
"create": {
"title": "Nueva cotización",
"buttons": {
@ -123,41 +120,76 @@
"desc": ""
}
},
"form_fields": {
"date": {
"label": "Fecha",
"desc": "Fecha de esta cotización",
"placeholder": ""
},
"reference": {
"label": "Referencia",
"desc": "Referencia para esta 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.",
"placeholder": "Nombre y apellidos\nCalle y número\nCódigo postal y ciudad..."
},
"payment_method": {
"label": "Forma de pago",
"placeholder": "placeholder",
"desc": "desc"
},
"notes": {
"label": "Notas",
"placeholder": "",
"desc": "desc"
},
"validity": {
"label": "Validez de la cotización",
"placeholder": "",
"desc": "desc"
}
"edit": {
"title": "Cotización"
}
},
"edit": {
"title": "Cotización"
"status": {
"draft": "Borrador"
},
"form_fields": {
"date": {
"label": "Fecha",
"desc": "Fecha de esta cotización",
"placeholder": ""
},
"reference": {
"label": "Referencia",
"desc": "Referencia para esta 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.",
"placeholder": "Nombre y apellidos\nCalle y número\nCódigo postal y ciudad..."
},
"payment_method": {
"label": "Forma de pago",
"placeholder": "placeholder",
"desc": "desc"
},
"notes": {
"label": "Notas",
"placeholder": "",
"desc": "desc"
},
"validity": {
"label": "Validez de la cotización",
"placeholder": "",
"desc": "desc"
},
"items": {
"quantity": {
"label": "Cantidad",
"placeholder": "",
"desc": ""
},
"description": {
"label": "Descripción",
"placeholder": "",
"desc": ""
},
"unit_price": {
"label": "Imp. unitario",
"placeholder": "",
"desc": "Importe unitario del artículo"
},
"subtotal_price": {
"label": "Subtotal",
"placeholder": "",
"desc": ""
},
"discount": {
"label": "Dto (%)",
"placeholder": "",
"desc": "Porcentaje de descuento"
},
"total_price": {
"label": "Imp. total",
"placeholder": "",
"desc": "Importe total con el descuento ya aplicado"
}
}
}
},
"settings": {