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: {}