Facturas de cliente
This commit is contained in:
parent
645d4e8adb
commit
7059db0c5d
56
docs/FACTURAS DE CLIENTE.md
Normal file
56
docs/FACTURAS DE CLIENTE.md
Normal file
@ -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"
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<div className={cn("w-full", "max-w-md")}>
|
||||
<MultiSelect
|
||||
options={taxesList}
|
||||
onValueChange={handleOnChange}
|
||||
onValidateOption={handleValidateOption}
|
||||
defaultValue={value}
|
||||
placeholder={t("components.customer_invoice_taxes_multi_select.placeholder")}
|
||||
variant='inverted'
|
||||
animation={0}
|
||||
maxCount={3}
|
||||
{...otherProps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -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";
|
||||
|
||||
@ -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: () => <div className='text-right'>{t("form_fields.items.taxes.label")}</div>,
|
||||
cell: ({ row: { index } }) => (
|
||||
<FormField
|
||||
control={control}
|
||||
name={`items.${index}.taxes.amount`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<CustomerInvoiceTaxesMultiSelect
|
||||
{...field}
|
||||
//onChange={(e) => field.onChange(Number(e.target.value) * 100)}
|
||||
//value={field.value / 100}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
),
|
||||
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];
|
||||
|
||||
@ -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}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className='sticky z-10 top-16 bg-card/90'>
|
||||
<CardTitle>
|
||||
<CustomerInvoiceItemsSortableDataTableToolbar table={table} />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table className='table-fixed'>
|
||||
<TableHeader className='sticky top-0 z-10 bg-background'>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className='hover:bg-transparent'>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className='px-2 py-1'
|
||||
style={{
|
||||
width:
|
||||
header.getSize() === Number.MAX_SAFE_INTEGER
|
||||
? "auto"
|
||||
: header.getSize(),
|
||||
}}
|
||||
>
|
||||
{header.isPlaceholder ? null : (
|
||||
<DataTableColumnHeader table={table} header={header} />
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
<>
|
||||
<CustomerInvoiceItemsSortableDataTableToolbar table={table} />
|
||||
<Table className='table-fixed'>
|
||||
<TableHeader className='sticky top-0 z-10 bg-background'>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className='hover:bg-transparent'>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className='px-2 py-1'
|
||||
style={{
|
||||
width:
|
||||
header.getSize() === Number.MAX_SAFE_INTEGER ? "auto" : header.getSize(),
|
||||
}}
|
||||
>
|
||||
{header.isPlaceholder ? null : (
|
||||
<DataTableColumnHeader table={table} header={header} />
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<SortableContext
|
||||
items={filterItems(sorteableRowIds)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{filterItems(table.getRowModel().rows).map((row) => (
|
||||
<CustomerInvoiceItemsSortableTableRow
|
||||
key={(row as Row<any>).id}
|
||||
id={(row as Row<any>).id}
|
||||
>
|
||||
{(row as Row<any>).getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className='px-2 py-2 align-top'
|
||||
style={{
|
||||
width:
|
||||
cell.column.getSize() === Number.MAX_SAFE_INTEGER
|
||||
? "auto"
|
||||
: cell.column.getSize(),
|
||||
}}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</CustomerInvoiceItemsSortableTableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<SortableContext
|
||||
items={filterItems(sorteableRowIds)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{filterItems(table.getRowModel().rows).map((row) => (
|
||||
<CustomerInvoiceItemsSortableTableRow
|
||||
key={(row as Row<any>).id}
|
||||
id={(row as Row<any>).id}
|
||||
>
|
||||
{(row as Row<any>).getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className='px-2 py-2 align-top'
|
||||
style={{
|
||||
width:
|
||||
cell.column.getSize() === Number.MAX_SAFE_INTEGER
|
||||
? "auto"
|
||||
: cell.column.getSize(),
|
||||
}}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</CustomerInvoiceItemsSortableTableRow>
|
||||
))}
|
||||
</SortableContext>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</SortableContext>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{createPortal(
|
||||
{createPortal(
|
||||
<DragOverlay dropAnimation={dropAnimationConfig} className={"z-40 opacity-100"}>
|
||||
{activeId && (
|
||||
<div className='relative flex flex-wrap'>
|
||||
{table.getSelectedRowModel().rows.length ? (
|
||||
<Badge
|
||||
variant='destructive'
|
||||
className='absolute z-50 flex items-center justify-center w-2 h-2 p-3 rounded-full top left -left-2 -top-2'
|
||||
>
|
||||
{table.getSelectedRowModel().rows.length}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</DragOverlay>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{false &&
|
||||
createPortal(
|
||||
<DragOverlay dropAnimation={dropAnimationConfig} className={"z-40 opacity-100"}>
|
||||
{activeId && (
|
||||
<div className='relative flex flex-wrap'>
|
||||
@ -380,26 +385,31 @@ export function CustomerInvoiceItemsSortableDataTable<
|
||||
{table.getSelectedRowModel().rows.length}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</DragOverlay>,
|
||||
document.body
|
||||
)}
|
||||
<div className='absolute z-40 bg-white border rounded shadow opacity-100 top left hover:bg-white border-muted-foreground/50'>
|
||||
<Table>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map(
|
||||
(row) =>
|
||||
row.id === activeId && (
|
||||
<TableRow key={row.id} id={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
className='p-1 align-top'
|
||||
key={cell.id}
|
||||
style={{ width: cell.column.getSize() }}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{false &&
|
||||
createPortal(
|
||||
<DragOverlay dropAnimation={dropAnimationConfig} className={"z-40 opacity-100"}>
|
||||
{activeId && (
|
||||
<div className='relative flex flex-wrap'>
|
||||
{table.getSelectedRowModel().rows.length ? (
|
||||
<Badge
|
||||
variant='destructive'
|
||||
className='absolute z-50 flex items-center justify-center w-2 h-2 p-3 rounded-full top left -left-2 -top-2'
|
||||
>
|
||||
{table.getSelectedRowModel().rows.length}
|
||||
</Badge>
|
||||
) : null}
|
||||
<div className='absolute z-40 bg-white border rounded shadow opacity-100 top left hover:bg-white border-muted-foreground/50'>
|
||||
{table.getSelectedRowModel().rows.length > 1 && (
|
||||
<div className='absolute z-30 transform -translate-x-1 translate-y-1 bg-white border rounded shadow opacity-100 hover:bg-white border-muted-foreground/50 top left rotate-1'>
|
||||
<Table>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map(
|
||||
@ -421,93 +431,66 @@ export function CustomerInvoiceItemsSortableDataTable<
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{table.getSelectedRowModel().rows.length > 1 && (
|
||||
<div className='absolute z-30 transform -translate-x-1 translate-y-1 bg-white border rounded shadow opacity-100 hover:bg-white border-muted-foreground/50 top left rotate-1'>
|
||||
<Table>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map(
|
||||
(row) =>
|
||||
row.id === activeId && (
|
||||
<TableRow key={row.id} id={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
className='p-1 align-top'
|
||||
key={cell.id}
|
||||
style={{ width: cell.column.getSize() }}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
{table.getSelectedRowModel().rows.length > 2 && (
|
||||
<div className='absolute z-20 transform translate-x-1 -translate-y-1 bg-white border rounded shadow opacity-100 hover:bg-white border-muted-foreground/50 top left -rotate-1'>
|
||||
<Table>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map(
|
||||
(row) =>
|
||||
row.id === activeId && (
|
||||
<TableRow key={row.id} id={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
className='p-1 align-top'
|
||||
key={cell.id}
|
||||
style={{ width: cell.column.getSize() }}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{table.getSelectedRowModel().rows.length > 2 && (
|
||||
<div className='absolute z-20 transform translate-x-1 -translate-y-1 bg-white border rounded shadow opacity-100 hover:bg-white border-muted-foreground/50 top left -rotate-1'>
|
||||
<Table>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map(
|
||||
(row) =>
|
||||
row.id === activeId && (
|
||||
<TableRow key={row.id} id={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
className='p-1 align-top'
|
||||
key={cell.id}
|
||||
style={{ width: cell.column.getSize() }}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{table.getSelectedRowModel().rows.length > 3 && (
|
||||
<div className='absolute z-10 transform translate-x-2 -translate-y-2 bg-white border rounded shadow opacity-100 hover:bg-white border-muted-foreground/50 top left rotate-2'>
|
||||
<Table>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map(
|
||||
(row) =>
|
||||
row.id === activeId && (
|
||||
<TableRow key={row.id} id={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
className='p-1 align-top'
|
||||
key={cell.id}
|
||||
style={{ width: cell.column.getSize() }}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DragOverlay>,
|
||||
document.body
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<ButtonGroup>
|
||||
<AppendEmptyRowButton onClick={() => table.options.meta?.appendItem()} />
|
||||
</ButtonGroup>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
{table.getSelectedRowModel().rows.length > 3 && (
|
||||
<div className='absolute z-10 transform translate-x-2 -translate-y-2 bg-white border rounded shadow opacity-100 hover:bg-white border-muted-foreground/50 top left rotate-2'>
|
||||
<Table>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map(
|
||||
(row) =>
|
||||
row.id === activeId && (
|
||||
<TableRow key={row.id} id={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
className='p-1 align-top'
|
||||
key={cell.id}
|
||||
style={{ width: cell.column.getSize() }}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DragOverlay>,
|
||||
document.body
|
||||
)}
|
||||
<ButtonGroup>
|
||||
<AppendEmptyRowButton onClick={() => table.options.meta?.appendItem()} />
|
||||
</ButtonGroup>
|
||||
</>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 = ({
|
||||
<CardTitle>Información Básica</CardTitle>
|
||||
<CardDescription>Detalles generales de la factura</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-y-6 gap-x-8 md:grid-cols-4'>
|
||||
<TextField
|
||||
control={form.control}
|
||||
name='invoice_number'
|
||||
required
|
||||
disabled
|
||||
readOnly
|
||||
label={t("form_fields.invoice_number.label")}
|
||||
placeholder={t("form_fields.invoice_number.placeholder")}
|
||||
description={t("form_fields.invoice_number.description")}
|
||||
/>
|
||||
<CardContent className='space-y-8'>
|
||||
<div className='grid gap-y-6 gap-x-8 md:grid-cols-4'>
|
||||
<TextField
|
||||
control={form.control}
|
||||
name='invoice_number'
|
||||
required
|
||||
disabled
|
||||
readOnly
|
||||
label={t("form_fields.invoice_number.label")}
|
||||
placeholder={t("form_fields.invoice_number.placeholder")}
|
||||
description={t("form_fields.invoice_number.description")}
|
||||
/>
|
||||
|
||||
<DatePickerInputField
|
||||
control={form.control}
|
||||
name='issue_date'
|
||||
required
|
||||
label={t("form_fields.issue_date.label")}
|
||||
placeholder={t("form_fields.issue_date.placeholder")}
|
||||
description={t("form_fields.issue_date.description")}
|
||||
/>
|
||||
<DatePickerInputField
|
||||
control={form.control}
|
||||
name='issue_date'
|
||||
required
|
||||
label={t("form_fields.issue_date.label")}
|
||||
placeholder={t("form_fields.issue_date.placeholder")}
|
||||
description={t("form_fields.issue_date.description")}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
control={form.control}
|
||||
name='invoice_series'
|
||||
required
|
||||
label={t("form_fields.invoice_series.label")}
|
||||
placeholder={t("form_fields.invoice_series.placeholder")}
|
||||
description={t("form_fields.invoice_series.description")}
|
||||
/>
|
||||
<TextField
|
||||
control={form.control}
|
||||
name='invoice_series'
|
||||
required
|
||||
label={t("form_fields.invoice_series.label")}
|
||||
placeholder={t("form_fields.invoice_series.placeholder")}
|
||||
description={t("form_fields.invoice_series.description")}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-y-6 gap-x-8 grid-cols-1'>
|
||||
<TextField
|
||||
control={form.control}
|
||||
name='description'
|
||||
required
|
||||
label={t("form_fields.description.label")}
|
||||
placeholder={t("form_fields.description.placeholder")}
|
||||
description={t("form_fields.description.description")}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-y-6 gap-x-8 grid-cols-1'>
|
||||
<TextAreaField
|
||||
control={form.control}
|
||||
name='notes'
|
||||
required
|
||||
label={t("form_fields.notes.label")}
|
||||
placeholder={t("form_fields.notes.placeholder")}
|
||||
description={t("form_fields.notes.description")}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -546,6 +581,7 @@ export const CustomerInvoiceEditForm = ({
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<DevTool control={form.control} />
|
||||
</Form>
|
||||
);
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -36,3 +36,5 @@ export const CustomerInvoiceItemDataFormSchema = CreateCustomerInvoiceCommandSch
|
||||
currency_code: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type CustomerInvoiceData = {};
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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";
|
||||
|
||||
384
packages/rdx-ui/src/components/multi-select.tsx
Normal file
384
packages/rdx-ui/src/components/multi-select.tsx
Normal file
@ -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<HTMLButtonElement>,
|
||||
VariantProps<typeof multiSelectVariants> {
|
||||
/**
|
||||
* 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<HTMLButtonElement, MultiSelectProps>(
|
||||
(
|
||||
{
|
||||
options,
|
||||
onValueChange,
|
||||
onValidateOption,
|
||||
variant,
|
||||
defaultValue = [],
|
||||
placeholder,
|
||||
animation = 0,
|
||||
maxCount = 3,
|
||||
modalPopover = false,
|
||||
asChild = false,
|
||||
className,
|
||||
selectAllVisible = false,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [selectedValues, setSelectedValues] = React.useState<string[]>(defaultValue);
|
||||
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
|
||||
const [isAnimating, setIsAnimating] = React.useState(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const grouped = options.reduce<Record<string, MultiSelectOptionType[]>>((acc, item) => {
|
||||
if (!acc[item.group || ""]) acc[item.group || ""] = [];
|
||||
acc[item.group || ""].push(item);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen} modal={modalPopover}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
ref={ref}
|
||||
{...props}
|
||||
onClick={handleTogglePopover}
|
||||
className={cn(
|
||||
"flex w-full p-1 rounded-md border min-h-10 h-auto items-center justify-between bg-inherit hover:bg-inherit [&_svg]:pointer-events-auto",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{selectedValues.length > 0 ? (
|
||||
<div className='flex justify-between items-center w-full'>
|
||||
<div className='flex flex-wrap items-center'>
|
||||
{selectedValues.slice(0, maxCount).map((value) => {
|
||||
const option = options.find((o) => o.value === value);
|
||||
const IconComponent = option?.icon;
|
||||
return (
|
||||
<Badge
|
||||
key={value}
|
||||
className={cn(
|
||||
isAnimating ? "animate-bounce" : "",
|
||||
multiSelectVariants({ variant })
|
||||
)}
|
||||
style={{ animationDuration: `${animation}s` }}
|
||||
>
|
||||
{IconComponent && <IconComponent className='h-4 w-4 mr-2' />}
|
||||
{option?.label}
|
||||
{/*<XCircle
|
||||
className='ml-2 h-4 w-4 cursor-pointer'
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleOption(value);
|
||||
}}
|
||||
/>*/}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
{selectedValues.length > maxCount && (
|
||||
<Badge
|
||||
className={cn(
|
||||
"bg-transparent text-foreground border-foreground/1 hover:bg-transparent",
|
||||
isAnimating ? "animate-bounce" : "",
|
||||
multiSelectVariants({ variant })
|
||||
)}
|
||||
style={{ animationDuration: `${animation}s` }}
|
||||
>
|
||||
{`+ ${selectedValues.length - maxCount} more`}
|
||||
<XCircle
|
||||
className='ml-2 h-4 w-4 cursor-pointer'
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
clearExtraOptions();
|
||||
}}
|
||||
/>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Separator orientation='vertical' className='flex min-h-6 h-full' />
|
||||
<ChevronDown className='h-4 mx-2 cursor-pointer text-muted-foreground' />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center justify-between w-full mx-auto'>
|
||||
<span className='text-sm text-muted-foreground mx-3'>
|
||||
{placeholder || t("components.multi_select.select_options")}
|
||||
</span>
|
||||
<ChevronDown className='h-4 cursor-pointer text-muted-foreground mx-2' />
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className='w-auto p-0'
|
||||
align='start'
|
||||
onEscapeKeyDown={() => setIsPopoverOpen(false)}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder={t("common.search")} onKeyDown={handleInputKeyDown} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("components.multi_select.no_results")}</CommandEmpty>
|
||||
|
||||
{selectAllVisible && (
|
||||
<CommandItem key='all' onSelect={toggleAll} className='cursor-pointer'>
|
||||
<div
|
||||
className={cn(
|
||||
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
|
||||
selectedValues.length === options.length
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50 [&_svg]:invisible"
|
||||
)}
|
||||
>
|
||||
<CheckIcon className='h-4 w-4' />
|
||||
</div>
|
||||
<span>(Select All)</span>
|
||||
</CommandItem>
|
||||
)}
|
||||
{Object.keys(grouped).map((group) => {
|
||||
return (
|
||||
<CommandGroup key={`group-${group || "ungrouped"}`} heading={group}>
|
||||
{grouped[group].map((option) => {
|
||||
const isSelected = selectedValues.includes(option.value);
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
onSelect={() => toggleOption(option.value)}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50 [&_svg]:invisible"
|
||||
)}
|
||||
>
|
||||
<CheckIcon className='h-4 w-4' />
|
||||
</div>
|
||||
{option.icon && (
|
||||
<option.icon className='mr-2 h-4 w-4 text-muted-foreground' />
|
||||
)}
|
||||
<span>{option.label}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
);
|
||||
})}
|
||||
|
||||
<CommandSeparator />
|
||||
<CommandGroup>
|
||||
<div className='flex items-center justify-between'>
|
||||
{selectedValues.length > 0 && (
|
||||
<>
|
||||
<CommandItem
|
||||
onSelect={handleClear}
|
||||
className='flex-1 justify-center cursor-pointer'
|
||||
>
|
||||
{t("components.multi_select.clear_selection")}
|
||||
</CommandItem>
|
||||
<Separator orientation='vertical' className='flex min-h-6 h-full' />
|
||||
</>
|
||||
)}
|
||||
<CommandItem
|
||||
onSelect={() => setIsPopoverOpen(false)}
|
||||
className='flex-1 justify-center cursor-pointer max-w-full'
|
||||
>
|
||||
{t("components.multi_select.close")}
|
||||
</CommandItem>
|
||||
</div>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
{animation > 0 && selectedValues.length > 0 && (
|
||||
<WandSparkles
|
||||
className={cn(
|
||||
"cursor-pointer my-2 text-foreground bg-background w-3 h-3",
|
||||
isAnimating ? "" : "text-muted-foreground"
|
||||
)}
|
||||
onClick={() => setIsAnimating(!isAnimating)}
|
||||
/>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
MultiSelect.displayName = "MultiSelect";
|
||||
616
packages/rdx-ui/src/components/multiple-selector.tsx
Normal file
616
packages/rdx-ui/src/components/multiple-selector.tsx
Normal file
@ -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<MultipleSelectorOption[]>;
|
||||
/**
|
||||
* 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<typeof Command>;
|
||||
/** Props of `CommandInput` */
|
||||
inputProps?: Omit<
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>,
|
||||
"value" | "placeholder" | "disabled"
|
||||
>;
|
||||
/** hide the clear all button. */
|
||||
hideClearAllButton?: boolean;
|
||||
}
|
||||
|
||||
export interface MultipleSelectorRef {
|
||||
selectedValue: MultipleSelectorOption[];
|
||||
input: HTMLInputElement;
|
||||
focus: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function useDebounce<T>(value: T, delay?: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = React.useState<T>(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<typeof CommandPrimitive.Empty>
|
||||
>(({ className, ...props }, forwardedRef) => {
|
||||
const render = useCommandState((state) => state.filtered.count === 0);
|
||||
|
||||
if (!render) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={forwardedRef}
|
||||
className={cn("py-6 text-center text-sm", className)}
|
||||
cmdk-empty=''
|
||||
role='presentation'
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
CommandEmpty.displayName = "CommandEmpty";
|
||||
|
||||
export const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorProps>(
|
||||
(
|
||||
{
|
||||
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<MultipleSelectorRef>
|
||||
) => {
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [onScrollbar, setOnScrollbar] = React.useState(false);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const dropdownRef = React.useRef<HTMLDivElement>(null); // Added this
|
||||
|
||||
const [selected, setSelected] = React.useState<MultipleSelectorOption[]>(value || []);
|
||||
const [options, setOptions] = React.useState<GroupOption>(
|
||||
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<HTMLDivElement>) => {
|
||||
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 <input /> 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 = (
|
||||
<CommandItem
|
||||
value={inputValue}
|
||||
className='cursor-pointer'
|
||||
onMouseDown={(e) => {
|
||||
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}"`}
|
||||
</CommandItem>
|
||||
);
|
||||
|
||||
// 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 (
|
||||
<CommandItem value='-' disabled>
|
||||
{emptyIndicator}
|
||||
</CommandItem>
|
||||
);
|
||||
}
|
||||
|
||||
return <CommandEmpty>{emptyIndicator}</CommandEmpty>;
|
||||
}, [creatable, emptyIndicator, onSearch, options]);
|
||||
|
||||
const selectables = React.useMemo<GroupOption>(
|
||||
() => 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 (
|
||||
<Command
|
||||
ref={dropdownRef}
|
||||
{...commandProps}
|
||||
onKeyDown={(e) => {
|
||||
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()}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start justify-between rounded-md border border-input px-3 py-2 text-base ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 md:text-sm",
|
||||
{
|
||||
"cursor-text": !disabled && selected.length !== 0,
|
||||
},
|
||||
className
|
||||
)}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
inputRef?.current?.focus();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === "Enter" || e.key === " ") && !disabled) {
|
||||
inputRef?.current?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className='relative flex flex-wrap gap-1'>
|
||||
{selected.map((option) => {
|
||||
return (
|
||||
<Badge
|
||||
key={option.value}
|
||||
className={cn(
|
||||
"data-[disabled]:bg-muted-foreground data-[disabled]:text-muted data-[disabled]:hover:bg-muted-foreground",
|
||||
"data-[fixed]:bg-muted-foreground data-[fixed]:text-muted data-[fixed]:hover:bg-muted-foreground",
|
||||
badgeClassName
|
||||
)}
|
||||
data-fixed={option.fixed}
|
||||
data-disabled={disabled || undefined}
|
||||
>
|
||||
{option.label}
|
||||
<button
|
||||
type='button'
|
||||
className={cn(
|
||||
"ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
(disabled || option.fixed) && "hidden"
|
||||
)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleUnselect(option);
|
||||
}
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={() => handleUnselect(option)}
|
||||
>
|
||||
<X className='h-3 w-3 text-muted-foreground hover:text-foreground' />
|
||||
</button>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
{/* Avoid having the "Search" Icon */}
|
||||
<CommandPrimitive.Input
|
||||
{...inputProps}
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
disabled={disabled}
|
||||
onValueChange={(value) => {
|
||||
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
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
setSelected(selected.filter((s) => s.fixed));
|
||||
onChange?.(selected.filter((s) => s.fixed));
|
||||
}}
|
||||
className={cn(
|
||||
"size-5",
|
||||
(hideClearAllButton ||
|
||||
disabled ||
|
||||
selected.length < 1 ||
|
||||
selected.filter((s) => s.fixed).length === selected.length) &&
|
||||
"hidden"
|
||||
)}
|
||||
>
|
||||
<X />
|
||||
</button>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-5 text-muted-foreground/50",
|
||||
(hideClearAllButton ||
|
||||
disabled ||
|
||||
selected.length >= 1 ||
|
||||
selected.filter((s) => s.fixed).length !== selected.length) &&
|
||||
"hidden"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='relative'>
|
||||
{open && (
|
||||
<CommandList
|
||||
className='absolute top-1 z-auto w-full rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in'
|
||||
onMouseLeave={() => {
|
||||
setOnScrollbar(false);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setOnScrollbar(true);
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
inputRef?.current?.focus();
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>{loadingIndicator}</>
|
||||
) : (
|
||||
<>
|
||||
{EmptyItem()}
|
||||
{CreatableItem()}
|
||||
{!selectFirstItem && <CommandItem value='-' className='hidden' />}
|
||||
{Object.entries(selectables).map(([key, dropdowns]) => (
|
||||
<CommandGroup key={key} heading={key} className='h-full overflow-auto'>
|
||||
{dropdowns.map((option) => {
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.label}
|
||||
disabled={option.disable}
|
||||
onMouseDown={(e) => {
|
||||
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}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
)}
|
||||
</div>
|
||||
</Command>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
MultipleSelector.displayName = "MultipleSelector";
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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: {}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user