Facturas de cliente

This commit is contained in:
David Arranz 2025-07-16 17:47:45 +02:00
parent 645d4e8adb
commit 7059db0c5d
19 changed files with 1521 additions and 324 deletions

View 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"
}

View File

@ -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",

View File

@ -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"),

View File

@ -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."
}
}
}

View File

@ -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."
}
}
}

View File

@ -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>
);
};

View File

@ -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";

View File

@ -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];

View File

@ -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>
);
}

View File

@ -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>
);

View File

@ -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,

View File

@ -36,3 +36,5 @@ export const CustomerInvoiceItemDataFormSchema = CreateCustomerInvoiceCommandSch
currency_code: z.string(),
}),
});
export type CustomerInvoiceData = {};

View File

@ -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"

View File

@ -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";

View 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";

View 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";

View File

@ -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."
}
}
}

View File

@ -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."
}
}
}

View File

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