diff --git a/docs/FACTURAS DE CLIENTE.md b/docs/FACTURAS DE CLIENTE.md new file mode 100644 index 00000000..ccc37fa3 --- /dev/null +++ b/docs/FACTURAS DE CLIENTE.md @@ -0,0 +1,56 @@ + + +Funcionalidades +--------------- + +- Llamar a las facturas "borrador" como PROFORMA?? + +- Baja lógica de facturas +- Los datos del cliente están en la factura +- Descuento general (2 decimales) +- Resumen de impuestos +- Referencia de cliente -> ??? +- Idioma +- Divisa +- Observaciones +- Descripción de la operación -> obligatorio según Verifactu + + +Detalles: +- Descripción +- Cantidad -> 2 decimales +- Importe unidad -> 4 decimales +- IVA en los detalles +- Descuento -> 2 decimales + +Futuros: +- Unidad de medida -> texto + + + +Formas de pago: +- Sin plazo -> texto + establecer una fecha manualemente en el futuro +- Con plazo -> texto + fechas pre-establecidas no editables + + + + +{ + "serie": "A", + "numero": "2", + "fecha_expedicion": "15-07-2025", + "tipo_factura": "F1", + "descripcion": "Mensualidad de soporte", + "nif": "A15022510", + "nombre": "Empresa de prueba SL", + "lineas": [ + { + "base_imponible": "200", + "tipo_impositivo": "21", + "impuesto": "01", + "clave_regimen": "01", + "cuota_repercutida": "42" + } + ], + "importe_total": "242" +} \ No newline at end of file diff --git a/modules/customer-invoices/package.json b/modules/customer-invoices/package.json index eb8106fe..c6ea663c 100644 --- a/modules/customer-invoices/package.json +++ b/modules/customer-invoices/package.json @@ -14,6 +14,7 @@ }, "devDependencies": { "@biomejs/biome": "1.9.4", + "@hookform/devtools": "^4.4.0", "@types/dinero.js": "^1.9.4", "@types/express": "^4.17.21", "@types/react": "^19.1.2", diff --git a/modules/customer-invoices/src/common/dto/request/create-customer-invoice.command.dto.ts b/modules/customer-invoices/src/common/dto/request/create-customer-invoice.command.dto.ts index 45068650..8d0bbf6b 100644 --- a/modules/customer-invoices/src/common/dto/request/create-customer-invoice.command.dto.ts +++ b/modules/customer-invoices/src/common/dto/request/create-customer-invoice.command.dto.ts @@ -7,8 +7,10 @@ export const CreateCustomerInvoiceCommandSchema = z.object({ invoice_series: z.string().min(1, "Customer invoice series is required"), issue_date: z.string().datetime({ offset: true, message: "Invalid issue date format" }), operation_date: z.string().datetime({ offset: true, message: "Invalid operation date format" }), + description: z.string(), language_code: z.string().min(2, "Language code must be at least 2 characters long"), currency_code: z.string().min(3, "Currency code must be at least 3 characters long"), + notes: z.string().optional(), items: z.array( z.object({ description: z.string().min(1, "Item description is required"), diff --git a/modules/customer-invoices/src/common/locales/en.json b/modules/customer-invoices/src/common/locales/en.json index 412b46cf..ccf02fbe 100644 --- a/modules/customer-invoices/src/common/locales/en.json +++ b/modules/customer-invoices/src/common/locales/en.json @@ -67,6 +67,11 @@ "placeholder": "Select a date", "description": "Invoice operation date" }, + "description": { + "label": "Description", + "placeholder": "Description of the invoice", + "description": "General description of the invoice" + }, "subtotal_price": { "label": "Subtotal", "placeholder": "", @@ -82,22 +87,16 @@ "placeholder": "", "desc": "Percentage discount price" }, - "tax": { - "label": "Tax (%)", - "placeholder": "", - "desc": "Percentage Tax" - }, - "tax_price": { - "label": "Tax price", - "placeholder": "", - "desc": "Percentage tax price" - }, "total_price": { "label": "Total price", "placeholder": "", "desc": "Invoice total price" }, - + "notes": { + "label": "Notes", + "placeholder": "Additional notes about the invoice", + "description": "Additional notes that can be included in the invoice" + }, "items": { "quantity": { "label": "Quantity", @@ -124,11 +123,34 @@ "placeholder": "", "description": "Percentage discount" }, + "discount_price": { + "label": "Discount price", + "placeholder": "", + "desc": "Percentage discount price" + }, + "taxes": { + "label": "Taxes", + "placeholder": "", + "desc": "Taxes" + }, + "taxes_price": { + "label": "Taxes price", + "placeholder": "", + "desc": "Percentage taxes price" + }, "total_price": { "label": "Total price", "placeholder": "", "description": "Total price with percentage discount" } } + }, + "components": { + "customer_invoice_taxes_multi_select": { + "label": "Taxes", + "placeholder": "Select taxes", + "description": "Select the taxes to apply to the invoice items", + "invalid_tax_selection": "Invalid tax selection. Please select a valid tax." + } } } diff --git a/modules/customer-invoices/src/common/locales/es.json b/modules/customer-invoices/src/common/locales/es.json index d88abafd..00dc1344 100644 --- a/modules/customer-invoices/src/common/locales/es.json +++ b/modules/customer-invoices/src/common/locales/es.json @@ -67,6 +67,11 @@ "placeholder": "Seleccionar una fecha", "description": "Fecha de intervención de los trabajos" }, + "description": { + "label": "Descripción", + "placeholder": "Descripción de la factura", + "description": "Descripción general de la factura" + }, "subtotal_price": { "label": "Subtotal", "placeholder": "", @@ -82,21 +87,16 @@ "placeholder": "", "desc": "Importe del descuento" }, - "tax": { - "label": "IVA (%)", - "placeholder": "", - "desc": "Porcentaje de IVA" - }, - "tax_price": { - "label": "Imp. IVA", - "placeholder": "", - "desc": "Importe del IVA" - }, "total_price": { "label": "Imp. total", "placeholder": "", "description": "Importe total con el descuento ya aplicado" }, + "notes": { + "label": "Notas", + "placeholder": "Notas adicionales sobre la factura", + "description": "Notas adicionales que se pueden incluir en la factura" + }, "items": { "quantity": { "label": "Cantidad", @@ -123,11 +123,34 @@ "placeholder": "", "description": "Porcentaje de descuento" }, + "discount_price": { + "label": "Imp. descuento", + "placeholder": "", + "desc": "Importe del descuento" + }, + "taxes": { + "label": "Impuestos", + "placeholder": "", + "desc": "Lista de impuestos aplicables" + }, + "taxes_price": { + "label": "Imp. impuestos", + "placeholder": "", + "desc": "Importe de los impuestos" + }, "total_price": { "label": "Imp. total", "placeholder": "", "description": "Importe total con el descuento ya aplicado" } } + }, + "components": { + "customer_invoice_taxes_multi_select": { + "label": "Impuestos", + "placeholder": "Seleccionar impuestos", + "description": "Seleccionar los impuestos a aplicar a los artículos de la factura", + "invalid_tax_selection": "Selección de impuestos no válida. Por favor, seleccione un impuesto válido." + } } } diff --git a/modules/customer-invoices/src/web/components/customer-invoice-taxes-multi-select.tsx b/modules/customer-invoices/src/web/components/customer-invoice-taxes-multi-select.tsx new file mode 100644 index 00000000..b44c7908 --- /dev/null +++ b/modules/customer-invoices/src/web/components/customer-invoice-taxes-multi-select.tsx @@ -0,0 +1,73 @@ +import { MultiSelect } from "@repo/rdx-ui/components"; +import { cn } from "@repo/shadcn-ui/lib/utils"; +import { useTranslation } from "../i18n"; + +const taxesList = [ + { label: "IVA 21%", value: "iva_21", group: "IVA" }, + { label: "IVA 10%", value: "iva_10", group: "IVA" }, + { label: "IVA 7,5%", value: "iva_7_5", group: "IVA" }, + { label: "IVA 5%", value: "iva_5", group: "IVA" }, + { label: "IVA 4%", value: "iva_4", group: "IVA" }, + { label: "IVA 2%", value: "iva_2", group: "IVA" }, + { label: "IVA 0%", value: "iva_0", group: "IVA" }, + { label: "Exenta", value: "iva_exenta", group: "IVA" }, + { label: "No sujeto", value: "iva_no_sujeto", group: "IVA" }, + { label: "Iva Intracomunitario Bienes", value: "iva_intracomunitario_bienes", group: "IVA" }, + { label: "Iva Intracomunitario Servicio", value: "iva_intracomunitario_servicio", group: "IVA" }, + { label: "Exportación", value: "iva_exportacion", group: "IVA" }, + { label: "Inv. Suj. Pasivo", value: "iva_inversion_sujeto_pasivo", group: "IVA" }, + + { label: "Retención 35%", value: "retencion_35", group: "Retención" }, + { label: "Retención 19%", value: "retencion_19", group: "Retención" }, + { label: "Retención 15%", value: "retencion_15", group: "Retención" }, + { label: "Retención 7%", value: "retencion_7", group: "Retención" }, + { label: "Retención 2%", value: "retencion_2", group: "Retención" }, + + { label: "REC 5,2%", value: "rec_5_2", group: "Recargo de equivalencia" }, + { label: "REC 1,75%", value: "rec_1_75", group: "Recargo de equivalencia" }, + { label: "REC 1,4%", value: "rec_1_4", group: "Recargo de equivalencia" }, + { label: "REC 1%", value: "rec_1", group: "Recargo de equivalencia" }, + { label: "REC 0,62%", value: "rec_0_62", group: "Recargo de equivalencia" }, + { label: "REC 0,5%", value: "rec_0_5", group: "Recargo de equivalencia" }, + { label: "REC 0,26%", value: "rec_0_26", group: "Recargo de equivalencia" }, + { label: "REC 0%", value: "rec_0", group: "Recargo de equivalencia" }, +]; + +interface CustomerInvoiceTaxesMultiSelect { + value: string[]; + onChange: (selectedValues: string[]) => void; + [key: string]: any; // Allow other props to be passed +} + +export const CustomerInvoiceTaxesMultiSelect = (props: CustomerInvoiceTaxesMultiSelect) => { + const { value, onChange, ...otherProps } = props; + const { t } = useTranslation(); + + const handleOnChange = (selectedValues: string[]) => { + onChange(selectedValues); + }; + + const handleValidateOption = (candidateValue: string) => { + const exists = (value || []).some((item) => item.startsWith(candidateValue.substring(0, 3))); + if (exists) { + alert(t("components.customer_invoice_taxes_multi_select.invalid_tax_selection")); + } + return exists === false; + }; + + return ( +
+ +
+ ); +}; diff --git a/modules/customer-invoices/src/web/components/index.tsx b/modules/customer-invoices/src/web/components/index.tsx index 039b784e..c474870a 100644 --- a/modules/customer-invoices/src/web/components/index.tsx +++ b/modules/customer-invoices/src/web/components/index.tsx @@ -1,4 +1,5 @@ export * from "./customer-invoice-prices-card"; export * from "./customer-invoice-status-badge"; +export * from "./customer-invoice-taxes-multi-select"; export * from "./customer-invoices-layout"; export * from "./customer-invoices-list-grid"; diff --git a/modules/customer-invoices/src/web/components/items/customer-invoice-items-card-editor.tsx b/modules/customer-invoices/src/web/components/items/customer-invoice-items-card-editor.tsx index d671f1bd..d11435d1 100644 --- a/modules/customer-invoices/src/web/components/items/customer-invoice-items-card-editor.tsx +++ b/modules/customer-invoices/src/web/components/items/customer-invoice-items-card-editor.tsx @@ -8,6 +8,7 @@ import { useFieldArray, useFormContext } from "react-hook-form"; import { useDetailColumns } from "../../hooks"; import { useTranslation } from "../../i18n"; import { formatCurrency } from "../../pages/create/utils"; +import { CustomerInvoiceTaxesMultiSelect } from "../customer-invoice-taxes-multi-select"; import { CustomerInvoiceItemsSortableDataTable, RowIdData, @@ -185,6 +186,30 @@ export const CustomerInvoiceItemsCardEditor = ({ ), size: 100, }, + { + id: "taxes" as const, + accessorKey: "taxes", + header: () =>
{t("form_fields.items.taxes.label")}
, + cell: ({ row: { index } }) => ( + ( + + + field.onChange(Number(e.target.value) * 100)} + //value={field.value / 100} + /> + + + + )} + /> + ), + size: 150, + }, { id: "total_price" as const, accessorKey: "total_price", @@ -240,53 +265,6 @@ export const CustomerInvoiceItemsCardEditor = ({ } ); - /*const handleAppendCatalogArticle = useCallback( - (article: any, quantity = 1) => { - fieldActions.append({ - ...article, - quantity: { - amount: 100 * quantity, - scale: Quantity.DEFAULT_SCALE, - }, - unit_price: article.retail_price, - discount: { - amount: null, - scale: 2, - }, - }); - toast({ - title: t("quotes.catalog_picker_dialog.toast_article_added"), - description: article.description, - }); - }, - [fieldActions, toast] - ); - - const handleAppendBlock = useCallback( - (block: any) => { - fieldActions.append({ - description: `${block.title}\n${block.body}`, - quantity: { - amount: null, - scale: Quantity.DEFAULT_SCALE, - }, - unit_price: { - amount: null, - scale: UnitPrice.DEFAULT_SCALE, - }, - discount: { - amount: null, - scale: 2, - }, - }); - toast({ - title: t("quotes.blocks_picker_dialog.toast_article_added"), - description: block.title, - }); - }, - [fieldActions] - );*/ - const [isCollapsed, setIsCollapsed] = useState(false); const defaultLayout = [265, 440, 655]; diff --git a/modules/customer-invoices/src/web/components/items/customer-invoice-items-sortable-datatable.tsx b/modules/customer-invoices/src/web/components/items/customer-invoice-items-sortable-datatable.tsx index bf1b8919..2933d7c9 100644 --- a/modules/customer-invoices/src/web/components/items/customer-invoice-items-sortable-datatable.tsx +++ b/modules/customer-invoices/src/web/components/items/customer-invoice-items-sortable-datatable.tsx @@ -18,14 +18,7 @@ import { import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { ButtonGroup, DataTableColumnHeader } from "@repo/rdx-ui/components"; -import { - Badge, - Card, - CardContent, - CardFooter, - CardHeader, - CardTitle, -} from "@repo/shadcn-ui/components"; +import { Badge } from "@repo/shadcn-ui/components"; import { Table, TableBody, @@ -306,69 +299,81 @@ export function CustomerInvoiceItemsSortableDataTable< onDragCancel={handleDragCancel} collisionDetection={closestCenter} > - - - - - - - - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder ? null : ( - - )} - - ); - })} - + <> + +
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder ? null : ( + + )} + + ); + })} + + ))} + + + + {filterItems(table.getRowModel().rows).map((row) => ( + ).id} + id={(row as Row).id} + > + {(row as Row).getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + ))} - - - - {filterItems(table.getRowModel().rows).map((row) => ( - ).id} - id={(row as Row).id} - > - {(row as Row).getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - ))} - - -
+ + + - {createPortal( + {createPortal( + + {activeId && ( +
+ {table.getSelectedRowModel().rows.length ? ( + + {table.getSelectedRowModel().rows.length} + + ) : null} +
+ )} +
, + document.body + )} + + {false && + createPortal( {activeId && (
@@ -380,26 +385,31 @@ export function CustomerInvoiceItemsSortableDataTable< {table.getSelectedRowModel().rows.length} ) : null} -
- )} -
, - document.body - )} +
+ + + {table.getRowModel().rows.map( + (row) => + row.id === activeId && ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ) + )} + +
+
- {false && - createPortal( - - {activeId && ( -
- {table.getSelectedRowModel().rows.length ? ( - - {table.getSelectedRowModel().rows.length} - - ) : null} -
+ {table.getSelectedRowModel().rows.length > 1 && ( +
{table.getRowModel().rows.map( @@ -421,93 +431,66 @@ export function CustomerInvoiceItemsSortableDataTable<
+ )} - {table.getSelectedRowModel().rows.length > 1 && ( -
- - - {table.getRowModel().rows.map( - (row) => - row.id === activeId && ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - ) - )} - -
-
- )} + {table.getSelectedRowModel().rows.length > 2 && ( +
+ + + {table.getRowModel().rows.map( + (row) => + row.id === activeId && ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ) + )} + +
+
+ )} - {table.getSelectedRowModel().rows.length > 2 && ( -
- - - {table.getRowModel().rows.map( - (row) => - row.id === activeId && ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - ) - )} - -
-
- )} - - {table.getSelectedRowModel().rows.length > 3 && ( -
- - - {table.getRowModel().rows.map( - (row) => - row.id === activeId && ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - ) - )} - -
-
- )} -
- )} - , - document.body - )} - - - - table.options.meta?.appendItem()} /> - - - + {table.getSelectedRowModel().rows.length > 3 && ( +
+ + + {table.getRowModel().rows.map( + (row) => + row.id === activeId && ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ) + )} + +
+
+ )} +
+ )} +
, + document.body + )} + + table.options.meta?.appendItem()} /> + + ); } diff --git a/modules/customer-invoices/src/web/pages/create/customer-invoice-edit-form.tsx b/modules/customer-invoices/src/web/pages/create/customer-invoice-edit-form.tsx index 78b65bdf..6dcb3df8 100644 --- a/modules/customer-invoices/src/web/pages/create/customer-invoice-edit-form.tsx +++ b/modules/customer-invoices/src/web/pages/create/customer-invoice-edit-form.tsx @@ -4,6 +4,7 @@ import * as z from "zod"; import { ClientSelector } from "@erp/customers/components"; +import { DevTool } from "@hookform/devtools"; import { DatePickerInputField, TextAreaField, TextField } from "@repo/rdx-ui/components"; import { Button, @@ -54,36 +55,47 @@ const invoiceSchema = z.object({ items: z .array( z.object({ - id_article: z.string(), - description: z.string(), - quantity: z.object({ - amount: z.number().nullable(), - scale: z.number(), - }), - unit_price: z.object({ - amount: z.number().nullable(), - scale: z.number(), - currency_code: z.string(), - }), - subtotal_price: z.object({ - amount: z.number().nullable(), - scale: z.number(), - currency_code: z.string(), - }), - discount: z.object({ - amount: z.number().min(0).max(100).nullable(), - scale: z.number(), - }), - discount_price: z.object({ - amount: z.number().nullable(), - scale: z.number(), - currency_code: z.string(), - }), - total_price: z.object({ - amount: z.number().nullable(), - scale: z.number(), - currency_code: z.string(), - }), + description: z.string().optional(), + quantity: z + .object({ + amount: z.number().nullable(), + scale: z.number(), + }) + .optional(), + unit_price: z + .object({ + amount: z.number().nullable(), + scale: z.number(), + currency_code: z.string(), + }) + .optional(), + subtotal_price: z + .object({ + amount: z.number().nullable(), + scale: z.number(), + currency_code: z.string(), + }) + .optional(), + discount: z + .object({ + amount: z.number().min(0).max(100).nullable(), + scale: z.number(), + }) + .optional(), + discount_price: z + .object({ + amount: z.number().nullable(), + scale: z.number(), + currency_code: z.string(), + }) + .optional(), + total_price: z + .object({ + amount: z.number().nullable(), + scale: z.number(), + currency_code: z.string(), + }) + .optional(), }) ) .min(1, "Al menos un item es requerido"), @@ -125,16 +137,17 @@ const invoiceSchema = z.object({ }), }); -const defaultInvoiceData: CustomerInvoiceData = { - id: "", +const defaultInvoiceData = { + id: "34ae34af-1ffc-4de5-b0a8-c2cf203ef011", invoice_status: "draft", invoice_number: "1", invoice_series: "A", issue_date: "2025-04-30T00:00:00.000Z", operation_date: "2025-04-30T00:00:00.000Z", + description: "", language_code: "ES", currency: "EUR", - customer_id: "", + customer_id: "5e4dc5b3-96b9-4968-9490-14bd032fec5f", items: [ { description: "", @@ -288,35 +301,57 @@ export const CustomerInvoiceEditForm = ({ Información Básica Detalles generales de la factura - - + +
+ - + - + +
+
+ +
+
+ +
@@ -546,6 +581,7 @@ export const CustomerInvoiceEditForm = ({ + ); diff --git a/modules/customer-invoices/src/web/pages/create/customer-invoice-items-edit-grid.tsx b/modules/customer-invoices/src/web/pages/create/customer-invoice-items-edit-grid.tsx index 71881547..496aebf4 100644 --- a/modules/customer-invoices/src/web/pages/create/customer-invoice-items-edit-grid.tsx +++ b/modules/customer-invoices/src/web/pages/create/customer-invoice-items-edit-grid.tsx @@ -33,12 +33,6 @@ export const CustomerInvoiceItemsEditorGrid = ({ items: any[] }) => { resizable: true, }, sideBar: true, - statusBar: { - statusPanels: [ - { statusPanel: "agTotalAndFilteredRowCountComponent", align: "left" }, - { statusPanel: "agAggregationComponent" }, - ], - }, rowGroupPanelShow: "always", pagination: true, paginationPageSize: 10, diff --git a/modules/customer-invoices/src/web/pages/create/customer-invoice.schema.ts b/modules/customer-invoices/src/web/pages/create/customer-invoice.schema.ts index be01edf1..21305c62 100644 --- a/modules/customer-invoices/src/web/pages/create/customer-invoice.schema.ts +++ b/modules/customer-invoices/src/web/pages/create/customer-invoice.schema.ts @@ -36,3 +36,5 @@ export const CustomerInvoiceItemDataFormSchema = CreateCustomerInvoiceCommandSch currency_code: z.string(), }), }); + +export type CustomerInvoiceData = {}; diff --git a/packages/rdx-ui/package.json b/packages/rdx-ui/package.json index 68ef8b04..4226ff5e 100644 --- a/packages/rdx-ui/package.json +++ b/packages/rdx-ui/package.json @@ -12,7 +12,10 @@ "./components": "./src/components/index.tsx", "./components/*": "./src/components/*.tsx", "./locales/*": "./src/locales/*", - "./hooks/*": ["./src/hooks/*.tsx", "./src/hooks/*.ts"] + "./hooks/*": [ + "./src/hooks/*.tsx", + "./src/hooks/*.ts" + ] }, "peerDependencies": { "date-fns": "^4.1.0", @@ -33,8 +36,8 @@ "esbuild-plugin-react18": "^0.2.6", "esbuild-plugin-react18-css": "^0.0.4", "tailwindcss": "^4.1.5", - "tw-animate-css": "^1.2.9", "tsup": "^8.4.0", + "tw-animate-css": "^1.2.9", "typescript": "^5.8.3", "typescript-plugin-css-modules": "^5.1.0", "vite-tsconfig-paths": "^5.1.4" @@ -46,13 +49,15 @@ "@dnd-kit/utilities": "^3.2.2", "@repo/shadcn-ui": "workspace:*", "@tanstack/react-table": "^8.21.3", + "class-variance-authority": "^0.7.1", + "cmdk": "^1.1.1", "esbuild-raw-plugin": "^0.2.0", "lucide-react": "^0.503.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-i18next": "^15.5.1", - "react-router-dom": "^6.26.0", "react-router": "^6.26.0", + "react-router-dom": "^6.26.0", "recharts": "^2.15.3", "sonner": "^2.0.3", "zod": "^3.24.4" diff --git a/packages/rdx-ui/src/components/index.tsx b/packages/rdx-ui/src/components/index.tsx index c9ce73db..a6e29323 100644 --- a/packages/rdx-ui/src/components/index.tsx +++ b/packages/rdx-ui/src/components/index.tsx @@ -5,5 +5,7 @@ export * from "./error-overlay.tsx"; export * from "./form/index.tsx"; export * from "./layout/index.tsx"; export * from "./loading-overlay/index.tsx"; +export * from "./multi-select.tsx"; +export * from "./multiple-selector.tsx"; export * from "./scroll-to-top.tsx"; export * from "./tailwind-indicator.tsx"; diff --git a/packages/rdx-ui/src/components/multi-select.tsx b/packages/rdx-ui/src/components/multi-select.tsx new file mode 100644 index 00000000..484b559f --- /dev/null +++ b/packages/rdx-ui/src/components/multi-select.tsx @@ -0,0 +1,384 @@ +import { type VariantProps, cva } from "class-variance-authority"; +import { CheckIcon, ChevronDown, WandSparkles, XCircle } from "lucide-react"; +import * as React from "react"; + +import { + Badge, + Button, + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + Popover, + PopoverContent, + PopoverTrigger, + Separator, +} from "@repo/shadcn-ui/components"; +import { cn } from "@repo/shadcn-ui/lib/utils"; +import { useTranslation } from "../locales/i18n.ts"; + +/** + * Variants for the multi-select component to handle different styles. + * Uses class-variance-authority (cva) to define different styles based on "variant" prop. + */ +const multiSelectVariants = cva( + "m-1 transition ease-in-out delay-150 hover:-translate-y-1 hover:scale-110 duration-300", + { + variants: { + variant: { + default: "border-foreground/10 text-foreground bg-card hover:bg-card/80", + secondary: + "border-foreground/10 bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + inverted: "inverted", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +export type MultiSelectOptionType = { + /** The text to display for the option. */ + label: string; + /** The unique value associated with the option. */ + value: string; + + /** + * Optional group name to categorize the option. + * Useful for grouping options in the UI. + */ + group?: string; + /** Optional icon component to display alongside the option. */ + icon?: React.ComponentType<{ + className?: string; + }>; +}; + +/** + * Props for MultiSelect component + */ +export interface MultiSelectProps + extends React.ButtonHTMLAttributes, + VariantProps { + /** + * An array of option objects to be displayed in the multi-select component. + * Each option object has a label, value, and an optional icon. + */ + options: MultiSelectOptionType[]; + + /** + * Callback function triggered when the selected values change. + * Receives an array of the new selected values. + */ + onValueChange: (values: string[]) => void; + + /** + * Optional function to validate an option before selection. + * Receives the option value and should return true if valid, false otherwise. + */ + onValidateOption?: (value: string) => boolean; + + /** The default selected values when the component mounts. */ + defaultValue?: string[]; + + /** + * Placeholder text to be displayed when no values are selected. + * Optional, defaults to "Select options". + */ + placeholder?: string; + + /** + * Animation duration in seconds for the visual effects (e.g., bouncing badges). + * Optional, defaults to 0 (no animation). + */ + animation?: number; + + /** + * Maximum number of items to display. Extra selected items will be summarized. + * Optional, defaults to 3. + */ + maxCount?: number; + + /** + * The modality of the popover. When set to true, interaction with outside elements + * will be disabled and only popover content will be visible to screen readers. + * Optional, defaults to false. + */ + modalPopover?: boolean; + + /** + * If true, renders the multi-select component as a child of another component. + * Optional, defaults to false. + */ + asChild?: boolean; + + /** + * Additional class names to apply custom styles to the multi-select component. + * Optional, can be used to add custom styles. + */ + className?: string; + + /** + * If true, allows selecting all visible options at once. + * Optional, defaults to false. + */ + selectAllVisible?: boolean; +} + +export const MultiSelect = React.forwardRef( + ( + { + options, + onValueChange, + onValidateOption, + variant, + defaultValue = [], + placeholder, + animation = 0, + maxCount = 3, + modalPopover = false, + asChild = false, + className, + selectAllVisible = false, + ...props + }, + ref + ) => { + const [selectedValues, setSelectedValues] = React.useState(defaultValue); + const [isPopoverOpen, setIsPopoverOpen] = React.useState(false); + const [isAnimating, setIsAnimating] = React.useState(false); + + const { t } = useTranslation(); + + const grouped = options.reduce>((acc, item) => { + if (!acc[item.group || ""]) acc[item.group || ""] = []; + acc[item.group || ""].push(item); + return acc; + }, {}); + + const handleInputKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + setIsPopoverOpen(true); + } else if (event.key === "Backspace" && !event.currentTarget.value) { + const newSelectedValues = [...selectedValues]; + newSelectedValues.pop(); + setSelectedValues(newSelectedValues); + onValueChange(newSelectedValues); + } + }; + + const toggleOption = (option: string) => { + if (onValidateOption && !onValidateOption(option)) { + console.warn(`Option "${option}" is not valid.`); + return; + } + + const newSelectedValues = selectedValues.includes(option) + ? selectedValues.filter((value) => value !== option) + : [...selectedValues, option]; + setSelectedValues(newSelectedValues); + onValueChange(newSelectedValues); + }; + + const handleClear = () => { + setSelectedValues([]); + onValueChange([]); + }; + + const handleTogglePopover = () => { + setIsPopoverOpen((prev) => !prev); + }; + + const clearExtraOptions = () => { + const newSelectedValues = selectedValues.slice(0, maxCount); + setSelectedValues(newSelectedValues); + onValueChange(newSelectedValues); + }; + + const toggleAll = () => { + if (selectedValues.length === options.length) { + handleClear(); + } else { + const allValues = options.map((option) => option.value); + setSelectedValues(allValues); + onValueChange(allValues); + } + }; + + return ( + + + + + setIsPopoverOpen(false)} + > + + + + {t("components.multi_select.no_results")} + + {selectAllVisible && ( + +
+ +
+ (Select All) +
+ )} + {Object.keys(grouped).map((group) => { + return ( + + {grouped[group].map((option) => { + const isSelected = selectedValues.includes(option.value); + return ( + toggleOption(option.value)} + className='cursor-pointer' + > +
+ +
+ {option.icon && ( + + )} + {option.label} +
+ ); + })} +
+ ); + })} + + + +
+ {selectedValues.length > 0 && ( + <> + + {t("components.multi_select.clear_selection")} + + + + )} + setIsPopoverOpen(false)} + className='flex-1 justify-center cursor-pointer max-w-full' + > + {t("components.multi_select.close")} + +
+
+
+
+
+ {animation > 0 && selectedValues.length > 0 && ( + setIsAnimating(!isAnimating)} + /> + )} +
+ ); + } +); + +MultiSelect.displayName = "MultiSelect"; diff --git a/packages/rdx-ui/src/components/multiple-selector.tsx b/packages/rdx-ui/src/components/multiple-selector.tsx new file mode 100644 index 00000000..153f26fe --- /dev/null +++ b/packages/rdx-ui/src/components/multiple-selector.tsx @@ -0,0 +1,616 @@ +// https://shadcnui-expansions.typeart.cc/docs/multiple-selector + +import { Badge, Command, CommandGroup, CommandItem, CommandList } from "@repo/shadcn-ui/components"; +import { cn } from "@repo/shadcn-ui/lib/utils"; +import { Command as CommandPrimitive, useCommandState } from "cmdk"; +import { ChevronDownIcon, X } from "lucide-react"; +import * as React from "react"; +import { forwardRef, useEffect } from "react"; + +export interface MultipleSelectorOption { + value: string; + label: string; + disable?: boolean; + /** fixed option that can't be removed. */ + fixed?: boolean; + /** Group the options by providing key. */ + [key: string]: string | boolean | undefined; +} +interface GroupOption { + [key: string]: MultipleSelectorOption[]; +} + +interface MultipleSelectorProps { + value?: MultipleSelectorOption[]; + defaultOptions?: MultipleSelectorOption[]; + /** manually controlled options */ + options?: MultipleSelectorOption[]; + placeholder?: string; + /** Loading component. */ + loadingIndicator?: React.ReactNode; + /** Empty component. */ + emptyIndicator?: React.ReactNode; + /** Debounce time for async search. Only work with `onSearch`. */ + delay?: number; + /** + * Only work with `onSearch` prop. Trigger search when `onFocus`. + * For example, when user click on the input, it will trigger the search to get initial options. + **/ + triggerSearchOnFocus?: boolean; + /** async search */ + onSearch?: (value: string) => Promise; + /** + * sync search. This search will not showing loadingIndicator. + * The rest props are the same as async search. + * i.e.: creatable, groupBy, delay. + **/ + onSearchSync?: (value: string) => MultipleSelectorOption[]; + onChange?: (options: MultipleSelectorOption[]) => void; + /** Limit the maximum number of selected options. */ + maxSelected?: number; + /** When the number of selected options exceeds the limit, the onMaxSelected will be called. */ + onMaxSelected?: (maxLimit: number) => void; + /** Hide the placeholder when there are options selected. */ + hidePlaceholderWhenSelected?: boolean; + disabled?: boolean; + /** Group the options base on provided key. */ + groupBy?: string; + className?: string; + badgeClassName?: string; + /** + * First item selected is a default behavior by cmdk. That is why the default is true. + * This is a workaround solution by add a dummy item. + * + * @reference: https://github.com/pacocoursey/cmdk/issues/171 + */ + selectFirstItem?: boolean; + /** Allow user to create option when there is no option matched. */ + creatable?: boolean; + /** Props of `Command` */ + commandProps?: React.ComponentPropsWithoutRef; + /** Props of `CommandInput` */ + inputProps?: Omit< + React.ComponentPropsWithoutRef, + "value" | "placeholder" | "disabled" + >; + /** hide the clear all button. */ + hideClearAllButton?: boolean; +} + +export interface MultipleSelectorRef { + selectedValue: MultipleSelectorOption[]; + input: HTMLInputElement; + focus: () => void; + reset: () => void; +} + +export function useDebounce(value: T, delay?: number): T { + const [debouncedValue, setDebouncedValue] = React.useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay || 500); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} + +function transToGroupOption(options: MultipleSelectorOption[], groupBy?: string) { + if (options.length === 0) { + return {}; + } + if (!groupBy) { + return { + "": options, + }; + } + + const groupOption: GroupOption = {}; + options.forEach((option) => { + const key = (option[groupBy] as string) || ""; + if (!groupOption[key]) { + groupOption[key] = []; + } + groupOption[key].push(option); + }); + return groupOption; +} + +function removePickedOption(groupOption: GroupOption, picked: MultipleSelectorOption[]) { + const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption; + + for (const [key, value] of Object.entries(cloneOption)) { + cloneOption[key] = value.filter((val) => !picked.find((p) => p.value === val.value)); + } + return cloneOption; +} + +function isOptionsExist(groupOption: GroupOption, targetOption: MultipleSelectorOption[]) { + for (const [, value] of Object.entries(groupOption)) { + if (value.some((option) => targetOption.find((p) => p.value === option.value))) { + return true; + } + } + return false; +} + +/** + * The `CommandEmpty` of shadcn/ui will cause the cmdk empty not rendering correctly. + * So we create one and copy the `Empty` implementation from `cmdk`. + * + * @reference: https://github.com/hsuanyi-chou/shadcn-ui-expansions/issues/34#issuecomment-1949561607 + **/ +const CommandEmpty = forwardRef< + HTMLDivElement, + React.ComponentProps +>(({ className, ...props }, forwardedRef) => { + const render = useCommandState((state) => state.filtered.count === 0); + + if (!render) return null; + + return ( +
+ ); +}); + +CommandEmpty.displayName = "CommandEmpty"; + +export const MultipleSelector = React.forwardRef( + ( + { + value, + onChange, + placeholder, + defaultOptions: arrayDefaultOptions = [], + options: arrayOptions, + delay, + onSearch, + onSearchSync, + loadingIndicator, + emptyIndicator, + maxSelected = Number.MAX_SAFE_INTEGER, + onMaxSelected, + hidePlaceholderWhenSelected, + disabled, + groupBy, + className, + badgeClassName, + selectFirstItem = true, + creatable = false, + triggerSearchOnFocus = false, + commandProps, + inputProps, + hideClearAllButton = false, + }: MultipleSelectorProps, + ref: React.Ref + ) => { + const inputRef = React.useRef(null); + const [open, setOpen] = React.useState(false); + const [onScrollbar, setOnScrollbar] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(false); + const dropdownRef = React.useRef(null); // Added this + + const [selected, setSelected] = React.useState(value || []); + const [options, setOptions] = React.useState( + transToGroupOption(arrayDefaultOptions, groupBy) + ); + const [inputValue, setInputValue] = React.useState(""); + const debouncedSearchTerm = useDebounce(inputValue, delay || 500); + + React.useImperativeHandle( + ref, + () => ({ + selectedValue: [...selected], + input: inputRef.current as HTMLInputElement, + focus: () => inputRef?.current?.focus(), + reset: () => setSelected([]), + }), + [selected] + ); + + const handleClickOutside = (event: MouseEvent | TouchEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) && + inputRef.current && + !inputRef.current.contains(event.target as Node) + ) { + setOpen(false); + inputRef.current.blur(); + } + }; + + const handleUnselect = React.useCallback( + (option: MultipleSelectorOption) => { + const newOptions = selected.filter((s) => s.value !== option.value); + setSelected(newOptions); + onChange?.(newOptions); + }, + [onChange, selected] + ); + + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + const input = inputRef.current; + if (input) { + if (e.key === "Delete" || e.key === "Backspace") { + if (input.value === "" && selected.length > 0) { + const lastSelectOption = selected[selected.length - 1]; + // If there is a last item and it is not fixed, we can remove it. + if (lastSelectOption && !lastSelectOption.fixed) { + handleUnselect(lastSelectOption); + } + } + } + // This is not a default behavior of the field + if (e.key === "Escape") { + input.blur(); + } + } + }, + [handleUnselect, selected] + ); + + useEffect(() => { + if (open) { + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("touchend", handleClickOutside); + } else { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("touchend", handleClickOutside); + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("touchend", handleClickOutside); + }; + }, [open]); + + useEffect(() => { + if (value) { + setSelected(value); + } + }, [value]); + + useEffect(() => { + /** If `onSearch` is provided, do not trigger options updated. */ + if (!arrayOptions || onSearch) { + return; + } + const newOption = transToGroupOption(arrayOptions || [], groupBy); + if (JSON.stringify(newOption) !== JSON.stringify(options)) { + setOptions(newOption); + } + }, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options]); + + useEffect(() => { + /** sync search */ + + const doSearchSync = () => { + const res = onSearchSync?.(debouncedSearchTerm); + setOptions(transToGroupOption(res || [], groupBy)); + }; + + const exec = async () => { + if (!onSearchSync || !open) return; + + if (triggerSearchOnFocus) { + doSearchSync(); + } + + if (debouncedSearchTerm) { + doSearchSync(); + } + }; + + void exec(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]); + + useEffect(() => { + /** async search */ + + const doSearch = async () => { + setIsLoading(true); + const res = await onSearch?.(debouncedSearchTerm); + setOptions(transToGroupOption(res || [], groupBy)); + setIsLoading(false); + }; + + const exec = async () => { + if (!onSearch || !open) return; + + if (triggerSearchOnFocus) { + await doSearch(); + } + + if (debouncedSearchTerm) { + await doSearch(); + } + }; + + void exec(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]); + + const CreatableItem = () => { + if (!creatable) return undefined; + if ( + isOptionsExist(options, [{ value: inputValue, label: inputValue }]) || + selected.find((s) => s.value === inputValue) + ) { + return undefined; + } + + const Item = ( + { + e.preventDefault(); + e.stopPropagation(); + }} + onSelect={(value: string) => { + if (selected.length >= maxSelected) { + onMaxSelected?.(selected.length); + return; + } + setInputValue(""); + const newOptions = [...selected, { value, label: value }]; + setSelected(newOptions); + onChange?.(newOptions); + }} + > + {`Create "${inputValue}"`} + + ); + + // For normal creatable + if (!onSearch && inputValue.length > 0) { + return Item; + } + + // For async search creatable. avoid showing creatable item before loading at first. + if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) { + return Item; + } + + return undefined; + }; + + const EmptyItem = React.useCallback(() => { + if (!emptyIndicator) return undefined; + + // For async search that showing emptyIndicator + if (onSearch && !creatable && Object.keys(options).length === 0) { + return ( + + {emptyIndicator} + + ); + } + + return {emptyIndicator}; + }, [creatable, emptyIndicator, onSearch, options]); + + const selectables = React.useMemo( + () => removePickedOption(options, selected), + [options, selected] + ); + + /** Avoid Creatable Selector freezing or lagging when paste a long string. */ + const commandFilter = React.useCallback(() => { + if (commandProps?.filter) { + return commandProps.filter; + } + + if (creatable) { + return (value: string, search: string) => { + return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1; + }; + } + // Using default filter in `cmdk`. We don't have to provide it. + return undefined; + }, [creatable, commandProps?.filter]); + + return ( + { + handleKeyDown(e); + commandProps?.onKeyDown?.(e); + }} + className={cn("h-auto overflow-visible bg-transparent", commandProps?.className)} + shouldFilter={ + commandProps?.shouldFilter !== undefined ? commandProps.shouldFilter : !onSearch + } // When onSearch is provided, we don't want to filter the options. You can still override it. + filter={commandFilter()} + > +
{ + if (disabled) return; + inputRef?.current?.focus(); + }} + onKeyDown={(e) => { + if ((e.key === "Enter" || e.key === " ") && !disabled) { + inputRef?.current?.focus(); + } + }} + > +
+ {selected.map((option) => { + return ( + + {option.label} + + + ); + })} + {/* Avoid having the "Search" Icon */} + { + setInputValue(value); + inputProps?.onValueChange?.(value); + }} + onBlur={(event) => { + if (!onScrollbar) { + setOpen(false); + } + inputProps?.onBlur?.(event); + }} + onFocus={(event) => { + setOpen(true); + inputProps?.onFocus?.(event); + }} + placeholder={hidePlaceholderWhenSelected && selected.length !== 0 ? "" : placeholder} + className={cn( + "flex-1 self-baseline bg-transparent outline-none placeholder:text-muted-foreground", + { + "w-full": hidePlaceholderWhenSelected, + "ml-1": selected.length !== 0, + }, + inputProps?.className + )} + /> +
+ + = 1 || + selected.filter((s) => s.fixed).length !== selected.length) && + "hidden" + )} + /> +
+
+ {open && ( + { + setOnScrollbar(false); + }} + onMouseEnter={() => { + setOnScrollbar(true); + }} + onMouseUp={() => { + inputRef?.current?.focus(); + }} + > + {isLoading ? ( + <>{loadingIndicator} + ) : ( + <> + {EmptyItem()} + {CreatableItem()} + {!selectFirstItem && } + {Object.entries(selectables).map(([key, dropdowns]) => ( + + {dropdowns.map((option) => { + return ( + { + e.preventDefault(); + e.stopPropagation(); + }} + onSelect={() => { + if (selected.length >= maxSelected) { + onMaxSelected?.(selected.length); + return; + } + setInputValue(""); + const newOptions = [...selected, option]; + setSelected(newOptions); + onChange?.(newOptions); + }} + className={cn( + "cursor-pointer", + option.disable && "cursor-default text-muted-foreground" + )} + > + {option.label} + + ); + })} + + ))} + + )} + + )} +
+
+ ); + } +); + +MultipleSelector.displayName = "MultipleSelector"; diff --git a/packages/rdx-ui/src/locales/en.json b/packages/rdx-ui/src/locales/en.json index 0f981201..fd0bfa34 100644 --- a/packages/rdx-ui/src/locales/en.json +++ b/packages/rdx-ui/src/locales/en.json @@ -2,7 +2,8 @@ "common": { "actions": "Actions", "invalid_date": "Invalid date", - "required": "required" + "required": "required", + "search": "Search" }, "components": { "loading_indicator": { @@ -11,6 +12,13 @@ "loading_overlay": { "title": "Loading...", "subtitle": "This may take a few seconds. Please do not close this page." + }, + "multi_select": { + "clear_selection": "Clear", + "close": "Close", + "select_options": "Select options", + "select_all": "Select all", + "no_results": "No results found." } } } diff --git a/packages/rdx-ui/src/locales/es.json b/packages/rdx-ui/src/locales/es.json index 2b6f3ac6..4edd7a28 100644 --- a/packages/rdx-ui/src/locales/es.json +++ b/packages/rdx-ui/src/locales/es.json @@ -2,7 +2,8 @@ "common": { "actions": "Actions", "invalid_date": "Fecha incorrecta o no válida", - "required": "obligatorio" + "required": "obligatorio", + "search": "Buscar" }, "components": { "LoadingIndicator": { @@ -11,6 +12,13 @@ "loading_overlay": { "title": "Cargando...", "subtitle": "Esto puede tardar unos segundos. Por favor, no cierre esta página." + }, + "multi_select": { + "clear_selection": "Limpiar", + "close": "Cerrar", + "select_options": "Seleccionar opciones", + "select_all": "Seleccionar todo", + "no_results": "No se han encontrado resultados." } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f8691d5..660c935a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -173,7 +173,7 @@ importers: version: 29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)) ts-jest: specifier: ^29.2.5 - version: 29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(esbuild@0.25.5)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3) + version: 29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3) tsconfig-paths: specifier: ^4.2.0 version: 4.2.0 @@ -260,7 +260,7 @@ importers: version: 4.1.11 tw-animate-css: specifier: ^1.2.9 - version: 1.3.4 + version: 1.3.5 vite-plugin-html: specifier: ^3.2.2 version: 3.2.2(vite@6.3.5(@types/node@22.15.32)(jiti@2.4.2)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.0)(stylus@0.62.0)(terser@5.40.0)(tsx@4.19.4)) @@ -524,7 +524,7 @@ importers: version: 4.1.11 tw-animate-css: specifier: ^1.3.4 - version: 1.3.4 + version: 1.3.5 zod: specifier: ^3.25.67 version: 3.25.67 @@ -532,6 +532,9 @@ importers: '@biomejs/biome': specifier: 1.9.4 version: 1.9.4 + '@hookform/devtools': + specifier: ^4.4.0 + version: 4.4.0(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@types/dinero.js': specifier: ^1.9.4 version: 1.9.4 @@ -712,6 +715,12 @@ importers: '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + cmdk: + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) date-fns: specifier: ^4.1.0 version: 4.1.0 @@ -790,7 +799,7 @@ importers: version: 8.4.0(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.4)(typescript@5.8.3) tw-animate-css: specifier: ^1.2.9 - version: 1.3.4 + version: 1.3.5 typescript: specifier: ^5.8.3 version: 5.8.3 @@ -968,7 +977,7 @@ importers: version: 4.1.11 tw-animate-css: specifier: ^1.2.9 - version: 1.3.4 + version: 1.3.5 typescript: specifier: ^5.8.3 version: 5.8.3 @@ -6166,9 +6175,6 @@ packages: resolution: {integrity: sha512-kc8ZibdRcuWUG1pbYSBFWqmIjynlD8Lp7IB6U3vIzvOv9VG+6Sp8bzyeBWE3Oi8XV5KsQrznyRTBPvrf99E4mA==} hasBin: true - tw-animate-css@1.3.4: - resolution: {integrity: sha512-dd1Ht6/YQHcNbq0znIT6dG8uhO7Ce+VIIhZUhjsryXsMPJQz3bZg7Q2eNzLwipb25bRZslGb2myio5mScd1TFg==} - tw-animate-css@1.3.5: resolution: {integrity: sha512-t3u+0YNoloIhj1mMXs779P6MO9q3p3mvGn4k1n3nJPqJw/glZcuijG2qTSN4z4mgNRfW5ZC3aXJFLwDtiipZXA==} @@ -11834,7 +11840,7 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest@29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(esbuild@0.25.5)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3): + ts-jest@29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3): dependencies: bs-logger: 0.2.6 ejs: 3.1.10 @@ -11852,7 +11858,6 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 babel-jest: 29.7.0(@babel/core@7.27.4) - esbuild: 0.25.5 jest-util: 29.7.0 ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3): @@ -11992,8 +11997,6 @@ snapshots: turbo-windows-64: 2.5.4 turbo-windows-arm64: 2.5.4 - tw-animate-css@1.3.4: {} - tw-animate-css@1.3.5: {} type-detect@4.0.8: {}