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": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "1.9.4",
|
"@biomejs/biome": "1.9.4",
|
||||||
|
"@hookform/devtools": "^4.4.0",
|
||||||
"@types/dinero.js": "^1.9.4",
|
"@types/dinero.js": "^1.9.4",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/react": "^19.1.2",
|
"@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"),
|
invoice_series: z.string().min(1, "Customer invoice series is required"),
|
||||||
issue_date: z.string().datetime({ offset: true, message: "Invalid issue date format" }),
|
issue_date: z.string().datetime({ offset: true, message: "Invalid issue date format" }),
|
||||||
operation_date: z.string().datetime({ offset: true, message: "Invalid operation 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"),
|
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"),
|
currency_code: z.string().min(3, "Currency code must be at least 3 characters long"),
|
||||||
|
notes: z.string().optional(),
|
||||||
items: z.array(
|
items: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
description: z.string().min(1, "Item description is required"),
|
description: z.string().min(1, "Item description is required"),
|
||||||
|
|||||||
@ -67,6 +67,11 @@
|
|||||||
"placeholder": "Select a date",
|
"placeholder": "Select a date",
|
||||||
"description": "Invoice operation date"
|
"description": "Invoice operation date"
|
||||||
},
|
},
|
||||||
|
"description": {
|
||||||
|
"label": "Description",
|
||||||
|
"placeholder": "Description of the invoice",
|
||||||
|
"description": "General description of the invoice"
|
||||||
|
},
|
||||||
"subtotal_price": {
|
"subtotal_price": {
|
||||||
"label": "Subtotal",
|
"label": "Subtotal",
|
||||||
"placeholder": "",
|
"placeholder": "",
|
||||||
@ -82,22 +87,16 @@
|
|||||||
"placeholder": "",
|
"placeholder": "",
|
||||||
"desc": "Percentage discount price"
|
"desc": "Percentage discount price"
|
||||||
},
|
},
|
||||||
"tax": {
|
|
||||||
"label": "Tax (%)",
|
|
||||||
"placeholder": "",
|
|
||||||
"desc": "Percentage Tax"
|
|
||||||
},
|
|
||||||
"tax_price": {
|
|
||||||
"label": "Tax price",
|
|
||||||
"placeholder": "",
|
|
||||||
"desc": "Percentage tax price"
|
|
||||||
},
|
|
||||||
"total_price": {
|
"total_price": {
|
||||||
"label": "Total price",
|
"label": "Total price",
|
||||||
"placeholder": "",
|
"placeholder": "",
|
||||||
"desc": "Invoice total price"
|
"desc": "Invoice total price"
|
||||||
},
|
},
|
||||||
|
"notes": {
|
||||||
|
"label": "Notes",
|
||||||
|
"placeholder": "Additional notes about the invoice",
|
||||||
|
"description": "Additional notes that can be included in the invoice"
|
||||||
|
},
|
||||||
"items": {
|
"items": {
|
||||||
"quantity": {
|
"quantity": {
|
||||||
"label": "Quantity",
|
"label": "Quantity",
|
||||||
@ -124,11 +123,34 @@
|
|||||||
"placeholder": "",
|
"placeholder": "",
|
||||||
"description": "Percentage discount"
|
"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": {
|
"total_price": {
|
||||||
"label": "Total price",
|
"label": "Total price",
|
||||||
"placeholder": "",
|
"placeholder": "",
|
||||||
"description": "Total price with percentage discount"
|
"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",
|
"placeholder": "Seleccionar una fecha",
|
||||||
"description": "Fecha de intervención de los trabajos"
|
"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": {
|
"subtotal_price": {
|
||||||
"label": "Subtotal",
|
"label": "Subtotal",
|
||||||
"placeholder": "",
|
"placeholder": "",
|
||||||
@ -82,21 +87,16 @@
|
|||||||
"placeholder": "",
|
"placeholder": "",
|
||||||
"desc": "Importe del descuento"
|
"desc": "Importe del descuento"
|
||||||
},
|
},
|
||||||
"tax": {
|
|
||||||
"label": "IVA (%)",
|
|
||||||
"placeholder": "",
|
|
||||||
"desc": "Porcentaje de IVA"
|
|
||||||
},
|
|
||||||
"tax_price": {
|
|
||||||
"label": "Imp. IVA",
|
|
||||||
"placeholder": "",
|
|
||||||
"desc": "Importe del IVA"
|
|
||||||
},
|
|
||||||
"total_price": {
|
"total_price": {
|
||||||
"label": "Imp. total",
|
"label": "Imp. total",
|
||||||
"placeholder": "",
|
"placeholder": "",
|
||||||
"description": "Importe total con el descuento ya aplicado"
|
"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": {
|
"items": {
|
||||||
"quantity": {
|
"quantity": {
|
||||||
"label": "Cantidad",
|
"label": "Cantidad",
|
||||||
@ -123,11 +123,34 @@
|
|||||||
"placeholder": "",
|
"placeholder": "",
|
||||||
"description": "Porcentaje de descuento"
|
"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": {
|
"total_price": {
|
||||||
"label": "Imp. total",
|
"label": "Imp. total",
|
||||||
"placeholder": "",
|
"placeholder": "",
|
||||||
"description": "Importe total con el descuento ya aplicado"
|
"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-prices-card";
|
||||||
export * from "./customer-invoice-status-badge";
|
export * from "./customer-invoice-status-badge";
|
||||||
|
export * from "./customer-invoice-taxes-multi-select";
|
||||||
export * from "./customer-invoices-layout";
|
export * from "./customer-invoices-layout";
|
||||||
export * from "./customer-invoices-list-grid";
|
export * from "./customer-invoices-list-grid";
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { useFieldArray, useFormContext } from "react-hook-form";
|
|||||||
import { useDetailColumns } from "../../hooks";
|
import { useDetailColumns } from "../../hooks";
|
||||||
import { useTranslation } from "../../i18n";
|
import { useTranslation } from "../../i18n";
|
||||||
import { formatCurrency } from "../../pages/create/utils";
|
import { formatCurrency } from "../../pages/create/utils";
|
||||||
|
import { CustomerInvoiceTaxesMultiSelect } from "../customer-invoice-taxes-multi-select";
|
||||||
import {
|
import {
|
||||||
CustomerInvoiceItemsSortableDataTable,
|
CustomerInvoiceItemsSortableDataTable,
|
||||||
RowIdData,
|
RowIdData,
|
||||||
@ -185,6 +186,30 @@ export const CustomerInvoiceItemsCardEditor = ({
|
|||||||
),
|
),
|
||||||
size: 100,
|
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,
|
id: "total_price" as const,
|
||||||
accessorKey: "total_price",
|
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 [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
|
|
||||||
const defaultLayout = [265, 440, 655];
|
const defaultLayout = [265, 440, 655];
|
||||||
|
|||||||
@ -18,14 +18,7 @@ import {
|
|||||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { ButtonGroup, DataTableColumnHeader } from "@repo/rdx-ui/components";
|
import { ButtonGroup, DataTableColumnHeader } from "@repo/rdx-ui/components";
|
||||||
import {
|
import { Badge } from "@repo/shadcn-ui/components";
|
||||||
Badge,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@repo/shadcn-ui/components";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@ -306,69 +299,81 @@ export function CustomerInvoiceItemsSortableDataTable<
|
|||||||
onDragCancel={handleDragCancel}
|
onDragCancel={handleDragCancel}
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
>
|
>
|
||||||
<Card>
|
<>
|
||||||
<CardHeader className='sticky z-10 top-16 bg-card/90'>
|
<CustomerInvoiceItemsSortableDataTableToolbar table={table} />
|
||||||
<CardTitle>
|
<Table className='table-fixed'>
|
||||||
<CustomerInvoiceItemsSortableDataTableToolbar table={table} />
|
<TableHeader className='sticky top-0 z-10 bg-background'>
|
||||||
</CardTitle>
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
</CardHeader>
|
<TableRow key={headerGroup.id} className='hover:bg-transparent'>
|
||||||
<CardContent>
|
{headerGroup.headers.map((header) => {
|
||||||
<Table className='table-fixed'>
|
return (
|
||||||
<TableHeader className='sticky top-0 z-10 bg-background'>
|
<TableHead
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
key={header.id}
|
||||||
<TableRow key={headerGroup.id} className='hover:bg-transparent'>
|
className='px-2 py-1'
|
||||||
{headerGroup.headers.map((header) => {
|
style={{
|
||||||
return (
|
width:
|
||||||
<TableHead
|
header.getSize() === Number.MAX_SAFE_INTEGER ? "auto" : header.getSize(),
|
||||||
key={header.id}
|
}}
|
||||||
className='px-2 py-1'
|
>
|
||||||
style={{
|
{header.isPlaceholder ? null : (
|
||||||
width:
|
<DataTableColumnHeader table={table} header={header} />
|
||||||
header.getSize() === Number.MAX_SAFE_INTEGER
|
)}
|
||||||
? "auto"
|
</TableHead>
|
||||||
: header.getSize(),
|
);
|
||||||
}}
|
})}
|
||||||
>
|
</TableRow>
|
||||||
{header.isPlaceholder ? null : (
|
))}
|
||||||
<DataTableColumnHeader table={table} header={header} />
|
</TableHeader>
|
||||||
)}
|
<TableBody>
|
||||||
</TableHead>
|
<SortableContext
|
||||||
);
|
items={filterItems(sorteableRowIds)}
|
||||||
})}
|
strategy={verticalListSortingStrategy}
|
||||||
</TableRow>
|
>
|
||||||
|
{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>
|
</SortableContext>
|
||||||
<TableBody>
|
</TableBody>
|
||||||
<SortableContext
|
</Table>
|
||||||
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>
|
|
||||||
|
|
||||||
{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"}>
|
<DragOverlay dropAnimation={dropAnimationConfig} className={"z-40 opacity-100"}>
|
||||||
{activeId && (
|
{activeId && (
|
||||||
<div className='relative flex flex-wrap'>
|
<div className='relative flex flex-wrap'>
|
||||||
@ -380,26 +385,31 @@ export function CustomerInvoiceItemsSortableDataTable<
|
|||||||
{table.getSelectedRowModel().rows.length}
|
{table.getSelectedRowModel().rows.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
<div className='absolute z-40 bg-white border rounded shadow opacity-100 top left hover:bg-white border-muted-foreground/50'>
|
||||||
)}
|
<Table>
|
||||||
</DragOverlay>,
|
<TableBody>
|
||||||
document.body
|
{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 &&
|
{table.getSelectedRowModel().rows.length > 1 && (
|
||||||
createPortal(
|
<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'>
|
||||||
<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>
|
<Table>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{table.getRowModel().rows.map(
|
{table.getRowModel().rows.map(
|
||||||
@ -421,93 +431,66 @@ export function CustomerInvoiceItemsSortableDataTable<
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{table.getSelectedRowModel().rows.length > 1 && (
|
{table.getSelectedRowModel().rows.length > 2 && (
|
||||||
<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'>
|
<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>
|
<Table>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{table.getRowModel().rows.map(
|
{table.getRowModel().rows.map(
|
||||||
(row) =>
|
(row) =>
|
||||||
row.id === activeId && (
|
row.id === activeId && (
|
||||||
<TableRow key={row.id} id={row.id}>
|
<TableRow key={row.id} id={row.id}>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<TableCell
|
<TableCell
|
||||||
className='p-1 align-top'
|
className='p-1 align-top'
|
||||||
key={cell.id}
|
key={cell.id}
|
||||||
style={{ width: cell.column.getSize() }}
|
style={{ width: cell.column.getSize() }}
|
||||||
>
|
>
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{table.getSelectedRowModel().rows.length > 2 && (
|
{table.getSelectedRowModel().rows.length > 3 && (
|
||||||
<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'>
|
<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>
|
<Table>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{table.getRowModel().rows.map(
|
{table.getRowModel().rows.map(
|
||||||
(row) =>
|
(row) =>
|
||||||
row.id === activeId && (
|
row.id === activeId && (
|
||||||
<TableRow key={row.id} id={row.id}>
|
<TableRow key={row.id} id={row.id}>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<TableCell
|
<TableCell
|
||||||
className='p-1 align-top'
|
className='p-1 align-top'
|
||||||
key={cell.id}
|
key={cell.id}
|
||||||
style={{ width: cell.column.getSize() }}
|
style={{ width: cell.column.getSize() }}
|
||||||
>
|
>
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</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'>
|
</DragOverlay>,
|
||||||
<Table>
|
document.body
|
||||||
<TableBody>
|
)}
|
||||||
{table.getRowModel().rows.map(
|
<ButtonGroup>
|
||||||
(row) =>
|
<AppendEmptyRowButton onClick={() => table.options.meta?.appendItem()} />
|
||||||
row.id === activeId && (
|
</ButtonGroup>
|
||||||
<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>
|
|
||||||
</DndContext>
|
</DndContext>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import * as z from "zod";
|
|||||||
|
|
||||||
import { ClientSelector } from "@erp/customers/components";
|
import { ClientSelector } from "@erp/customers/components";
|
||||||
|
|
||||||
|
import { DevTool } from "@hookform/devtools";
|
||||||
import { DatePickerInputField, TextAreaField, TextField } from "@repo/rdx-ui/components";
|
import { DatePickerInputField, TextAreaField, TextField } from "@repo/rdx-ui/components";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@ -54,36 +55,47 @@ const invoiceSchema = z.object({
|
|||||||
items: z
|
items: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
id_article: z.string(),
|
description: z.string().optional(),
|
||||||
description: z.string(),
|
quantity: z
|
||||||
quantity: z.object({
|
.object({
|
||||||
amount: z.number().nullable(),
|
amount: z.number().nullable(),
|
||||||
scale: z.number(),
|
scale: z.number(),
|
||||||
}),
|
})
|
||||||
unit_price: z.object({
|
.optional(),
|
||||||
amount: z.number().nullable(),
|
unit_price: z
|
||||||
scale: z.number(),
|
.object({
|
||||||
currency_code: z.string(),
|
amount: z.number().nullable(),
|
||||||
}),
|
scale: z.number(),
|
||||||
subtotal_price: z.object({
|
currency_code: z.string(),
|
||||||
amount: z.number().nullable(),
|
})
|
||||||
scale: z.number(),
|
.optional(),
|
||||||
currency_code: z.string(),
|
subtotal_price: z
|
||||||
}),
|
.object({
|
||||||
discount: z.object({
|
amount: z.number().nullable(),
|
||||||
amount: z.number().min(0).max(100).nullable(),
|
scale: z.number(),
|
||||||
scale: z.number(),
|
currency_code: z.string(),
|
||||||
}),
|
})
|
||||||
discount_price: z.object({
|
.optional(),
|
||||||
amount: z.number().nullable(),
|
discount: z
|
||||||
scale: z.number(),
|
.object({
|
||||||
currency_code: z.string(),
|
amount: z.number().min(0).max(100).nullable(),
|
||||||
}),
|
scale: z.number(),
|
||||||
total_price: z.object({
|
})
|
||||||
amount: z.number().nullable(),
|
.optional(),
|
||||||
scale: z.number(),
|
discount_price: z
|
||||||
currency_code: z.string(),
|
.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"),
|
.min(1, "Al menos un item es requerido"),
|
||||||
@ -125,16 +137,17 @@ const invoiceSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const defaultInvoiceData: CustomerInvoiceData = {
|
const defaultInvoiceData = {
|
||||||
id: "",
|
id: "34ae34af-1ffc-4de5-b0a8-c2cf203ef011",
|
||||||
invoice_status: "draft",
|
invoice_status: "draft",
|
||||||
invoice_number: "1",
|
invoice_number: "1",
|
||||||
invoice_series: "A",
|
invoice_series: "A",
|
||||||
issue_date: "2025-04-30T00:00:00.000Z",
|
issue_date: "2025-04-30T00:00:00.000Z",
|
||||||
operation_date: "2025-04-30T00:00:00.000Z",
|
operation_date: "2025-04-30T00:00:00.000Z",
|
||||||
|
description: "",
|
||||||
language_code: "ES",
|
language_code: "ES",
|
||||||
currency: "EUR",
|
currency: "EUR",
|
||||||
customer_id: "",
|
customer_id: "5e4dc5b3-96b9-4968-9490-14bd032fec5f",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
description: "",
|
description: "",
|
||||||
@ -288,35 +301,57 @@ export const CustomerInvoiceEditForm = ({
|
|||||||
<CardTitle>Información Básica</CardTitle>
|
<CardTitle>Información Básica</CardTitle>
|
||||||
<CardDescription>Detalles generales de la factura</CardDescription>
|
<CardDescription>Detalles generales de la factura</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='grid gap-y-6 gap-x-8 md:grid-cols-4'>
|
<CardContent className='space-y-8'>
|
||||||
<TextField
|
<div className='grid gap-y-6 gap-x-8 md:grid-cols-4'>
|
||||||
control={form.control}
|
<TextField
|
||||||
name='invoice_number'
|
control={form.control}
|
||||||
required
|
name='invoice_number'
|
||||||
disabled
|
required
|
||||||
readOnly
|
disabled
|
||||||
label={t("form_fields.invoice_number.label")}
|
readOnly
|
||||||
placeholder={t("form_fields.invoice_number.placeholder")}
|
label={t("form_fields.invoice_number.label")}
|
||||||
description={t("form_fields.invoice_number.description")}
|
placeholder={t("form_fields.invoice_number.placeholder")}
|
||||||
/>
|
description={t("form_fields.invoice_number.description")}
|
||||||
|
/>
|
||||||
|
|
||||||
<DatePickerInputField
|
<DatePickerInputField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name='issue_date'
|
name='issue_date'
|
||||||
required
|
required
|
||||||
label={t("form_fields.issue_date.label")}
|
label={t("form_fields.issue_date.label")}
|
||||||
placeholder={t("form_fields.issue_date.placeholder")}
|
placeholder={t("form_fields.issue_date.placeholder")}
|
||||||
description={t("form_fields.issue_date.description")}
|
description={t("form_fields.issue_date.description")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name='invoice_series'
|
name='invoice_series'
|
||||||
required
|
required
|
||||||
label={t("form_fields.invoice_series.label")}
|
label={t("form_fields.invoice_series.label")}
|
||||||
placeholder={t("form_fields.invoice_series.placeholder")}
|
placeholder={t("form_fields.invoice_series.placeholder")}
|
||||||
description={t("form_fields.invoice_series.description")}
|
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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@ -546,6 +581,7 @@ export const CustomerInvoiceEditForm = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
<DevTool control={form.control} />
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -33,12 +33,6 @@ export const CustomerInvoiceItemsEditorGrid = ({ items: any[] }) => {
|
|||||||
resizable: true,
|
resizable: true,
|
||||||
},
|
},
|
||||||
sideBar: true,
|
sideBar: true,
|
||||||
statusBar: {
|
|
||||||
statusPanels: [
|
|
||||||
{ statusPanel: "agTotalAndFilteredRowCountComponent", align: "left" },
|
|
||||||
{ statusPanel: "agAggregationComponent" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
rowGroupPanelShow: "always",
|
rowGroupPanelShow: "always",
|
||||||
pagination: true,
|
pagination: true,
|
||||||
paginationPageSize: 10,
|
paginationPageSize: 10,
|
||||||
|
|||||||
@ -36,3 +36,5 @@ export const CustomerInvoiceItemDataFormSchema = CreateCustomerInvoiceCommandSch
|
|||||||
currency_code: z.string(),
|
currency_code: z.string(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type CustomerInvoiceData = {};
|
||||||
|
|||||||
@ -12,7 +12,10 @@
|
|||||||
"./components": "./src/components/index.tsx",
|
"./components": "./src/components/index.tsx",
|
||||||
"./components/*": "./src/components/*.tsx",
|
"./components/*": "./src/components/*.tsx",
|
||||||
"./locales/*": "./src/locales/*",
|
"./locales/*": "./src/locales/*",
|
||||||
"./hooks/*": ["./src/hooks/*.tsx", "./src/hooks/*.ts"]
|
"./hooks/*": [
|
||||||
|
"./src/hooks/*.tsx",
|
||||||
|
"./src/hooks/*.ts"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
@ -33,8 +36,8 @@
|
|||||||
"esbuild-plugin-react18": "^0.2.6",
|
"esbuild-plugin-react18": "^0.2.6",
|
||||||
"esbuild-plugin-react18-css": "^0.0.4",
|
"esbuild-plugin-react18-css": "^0.0.4",
|
||||||
"tailwindcss": "^4.1.5",
|
"tailwindcss": "^4.1.5",
|
||||||
"tw-animate-css": "^1.2.9",
|
|
||||||
"tsup": "^8.4.0",
|
"tsup": "^8.4.0",
|
||||||
|
"tw-animate-css": "^1.2.9",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"typescript-plugin-css-modules": "^5.1.0",
|
"typescript-plugin-css-modules": "^5.1.0",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
@ -46,13 +49,15 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@repo/shadcn-ui": "workspace:*",
|
"@repo/shadcn-ui": "workspace:*",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"esbuild-raw-plugin": "^0.2.0",
|
"esbuild-raw-plugin": "^0.2.0",
|
||||||
"lucide-react": "^0.503.0",
|
"lucide-react": "^0.503.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-i18next": "^15.5.1",
|
"react-i18next": "^15.5.1",
|
||||||
"react-router-dom": "^6.26.0",
|
|
||||||
"react-router": "^6.26.0",
|
"react-router": "^6.26.0",
|
||||||
|
"react-router-dom": "^6.26.0",
|
||||||
"recharts": "^2.15.3",
|
"recharts": "^2.15.3",
|
||||||
"sonner": "^2.0.3",
|
"sonner": "^2.0.3",
|
||||||
"zod": "^3.24.4"
|
"zod": "^3.24.4"
|
||||||
|
|||||||
@ -5,5 +5,7 @@ export * from "./error-overlay.tsx";
|
|||||||
export * from "./form/index.tsx";
|
export * from "./form/index.tsx";
|
||||||
export * from "./layout/index.tsx";
|
export * from "./layout/index.tsx";
|
||||||
export * from "./loading-overlay/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 "./scroll-to-top.tsx";
|
||||||
export * from "./tailwind-indicator.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": {
|
"common": {
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"invalid_date": "Invalid date",
|
"invalid_date": "Invalid date",
|
||||||
"required": "required"
|
"required": "required",
|
||||||
|
"search": "Search"
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
"loading_indicator": {
|
"loading_indicator": {
|
||||||
@ -11,6 +12,13 @@
|
|||||||
"loading_overlay": {
|
"loading_overlay": {
|
||||||
"title": "Loading...",
|
"title": "Loading...",
|
||||||
"subtitle": "This may take a few seconds. Please do not close this page."
|
"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": {
|
"common": {
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"invalid_date": "Fecha incorrecta o no válida",
|
"invalid_date": "Fecha incorrecta o no válida",
|
||||||
"required": "obligatorio"
|
"required": "obligatorio",
|
||||||
|
"search": "Buscar"
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
"LoadingIndicator": {
|
"LoadingIndicator": {
|
||||||
@ -11,6 +12,13 @@
|
|||||||
"loading_overlay": {
|
"loading_overlay": {
|
||||||
"title": "Cargando...",
|
"title": "Cargando...",
|
||||||
"subtitle": "Esto puede tardar unos segundos. Por favor, no cierre esta página."
|
"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))
|
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:
|
ts-jest:
|
||||||
specifier: ^29.2.5
|
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:
|
tsconfig-paths:
|
||||||
specifier: ^4.2.0
|
specifier: ^4.2.0
|
||||||
version: 4.2.0
|
version: 4.2.0
|
||||||
@ -260,7 +260,7 @@ importers:
|
|||||||
version: 4.1.11
|
version: 4.1.11
|
||||||
tw-animate-css:
|
tw-animate-css:
|
||||||
specifier: ^1.2.9
|
specifier: ^1.2.9
|
||||||
version: 1.3.4
|
version: 1.3.5
|
||||||
vite-plugin-html:
|
vite-plugin-html:
|
||||||
specifier: ^3.2.2
|
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))
|
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
|
version: 4.1.11
|
||||||
tw-animate-css:
|
tw-animate-css:
|
||||||
specifier: ^1.3.4
|
specifier: ^1.3.4
|
||||||
version: 1.3.4
|
version: 1.3.5
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.25.67
|
specifier: ^3.25.67
|
||||||
version: 3.25.67
|
version: 3.25.67
|
||||||
@ -532,6 +532,9 @@ importers:
|
|||||||
'@biomejs/biome':
|
'@biomejs/biome':
|
||||||
specifier: 1.9.4
|
specifier: 1.9.4
|
||||||
version: 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':
|
'@types/dinero.js':
|
||||||
specifier: ^1.9.4
|
specifier: ^1.9.4
|
||||||
version: 1.9.4
|
version: 1.9.4
|
||||||
@ -712,6 +715,12 @@ importers:
|
|||||||
'@tanstack/react-table':
|
'@tanstack/react-table':
|
||||||
specifier: ^8.21.3
|
specifier: ^8.21.3
|
||||||
version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
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:
|
date-fns:
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 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)
|
version: 8.4.0(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.4)(typescript@5.8.3)
|
||||||
tw-animate-css:
|
tw-animate-css:
|
||||||
specifier: ^1.2.9
|
specifier: ^1.2.9
|
||||||
version: 1.3.4
|
version: 1.3.5
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.8.3
|
specifier: ^5.8.3
|
||||||
version: 5.8.3
|
version: 5.8.3
|
||||||
@ -968,7 +977,7 @@ importers:
|
|||||||
version: 4.1.11
|
version: 4.1.11
|
||||||
tw-animate-css:
|
tw-animate-css:
|
||||||
specifier: ^1.2.9
|
specifier: ^1.2.9
|
||||||
version: 1.3.4
|
version: 1.3.5
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.8.3
|
specifier: ^5.8.3
|
||||||
version: 5.8.3
|
version: 5.8.3
|
||||||
@ -6166,9 +6175,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-kc8ZibdRcuWUG1pbYSBFWqmIjynlD8Lp7IB6U3vIzvOv9VG+6Sp8bzyeBWE3Oi8XV5KsQrznyRTBPvrf99E4mA==}
|
resolution: {integrity: sha512-kc8ZibdRcuWUG1pbYSBFWqmIjynlD8Lp7IB6U3vIzvOv9VG+6Sp8bzyeBWE3Oi8XV5KsQrznyRTBPvrf99E4mA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
tw-animate-css@1.3.4:
|
|
||||||
resolution: {integrity: sha512-dd1Ht6/YQHcNbq0znIT6dG8uhO7Ce+VIIhZUhjsryXsMPJQz3bZg7Q2eNzLwipb25bRZslGb2myio5mScd1TFg==}
|
|
||||||
|
|
||||||
tw-animate-css@1.3.5:
|
tw-animate-css@1.3.5:
|
||||||
resolution: {integrity: sha512-t3u+0YNoloIhj1mMXs779P6MO9q3p3mvGn4k1n3nJPqJw/glZcuijG2qTSN4z4mgNRfW5ZC3aXJFLwDtiipZXA==}
|
resolution: {integrity: sha512-t3u+0YNoloIhj1mMXs779P6MO9q3p3mvGn4k1n3nJPqJw/glZcuijG2qTSN4z4mgNRfW5ZC3aXJFLwDtiipZXA==}
|
||||||
|
|
||||||
@ -11834,7 +11840,7 @@ snapshots:
|
|||||||
|
|
||||||
ts-interface-checker@0.1.13: {}
|
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:
|
dependencies:
|
||||||
bs-logger: 0.2.6
|
bs-logger: 0.2.6
|
||||||
ejs: 3.1.10
|
ejs: 3.1.10
|
||||||
@ -11852,7 +11858,6 @@ snapshots:
|
|||||||
'@jest/transform': 29.7.0
|
'@jest/transform': 29.7.0
|
||||||
'@jest/types': 29.6.3
|
'@jest/types': 29.6.3
|
||||||
babel-jest: 29.7.0(@babel/core@7.27.4)
|
babel-jest: 29.7.0(@babel/core@7.27.4)
|
||||||
esbuild: 0.25.5
|
|
||||||
jest-util: 29.7.0
|
jest-util: 29.7.0
|
||||||
|
|
||||||
ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3):
|
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-64: 2.5.4
|
||||||
turbo-windows-arm64: 2.5.4
|
turbo-windows-arm64: 2.5.4
|
||||||
|
|
||||||
tw-animate-css@1.3.4: {}
|
|
||||||
|
|
||||||
tw-animate-css@1.3.5: {}
|
tw-animate-css@1.3.5: {}
|
||||||
|
|
||||||
type-detect@4.0.8: {}
|
type-detect@4.0.8: {}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user