Facturas de cliente

This commit is contained in:
David Arranz 2025-10-16 13:18:55 +02:00
parent dc49094f00
commit d539e5b5f1
34 changed files with 1133 additions and 287 deletions

View File

@ -136,53 +136,53 @@
},
{
"name": "Retención 35%",
"name": "Retenc. 35%",
"code": "retencion_35",
"value": "3500",
"scale": "2",
"group": "Retención",
"description": "Retención profesional o fiscal tipo máximo.",
"description": "Retenc. profesional o fiscal tipo máximo.",
"aeat_code": null
},
{
"name": "Retención 19%",
"name": "Retenc. 19%",
"code": "retencion_19",
"value": "1900",
"scale": "2",
"group": "Retención",
"description": "Retención IRPF general.",
"description": "Retenc. IRPF general.",
"aeat_code": "R1"
},
{
"name": "Retención 15%",
"name": "Retenc. 15%",
"code": "retencion_15",
"value": "1500",
"scale": "2",
"group": "Retención",
"description": "Retención para autónomos y profesionales.",
"description": "Retenc. para autónomos y profesionales.",
"aeat_code": "R2"
},
{
"name": "Retención 7%",
"name": "Retenc. 7%",
"code": "retencion_7",
"value": "700",
"scale": "2",
"group": "Retención",
"description": "Retención para nuevos autónomos.",
"description": "Retenc. para nuevos autónomos.",
"aeat_code": null
},
{
"name": "Retención 2%",
"name": "Retenc. 2%",
"code": "retencion_2",
"value": "200",
"scale": "2",
"group": "Retención",
"description": "Retención sobre arrendamientos de inmuebles urbanos.",
"description": "Retenc. sobre arrendamientos de inmuebles urbanos.",
"aeat_code": "R3"
},
{
"name": "Rec. de equivalencia 5,2%",
"name": "Rec. 5,2%",
"code": "rec_5_2",
"value": "520",
"scale": "2",
@ -191,7 +191,7 @@
"aeat_code": "51"
},
{
"name": "Rec. de equivalencia 1,75%",
"name": "Rec. 1,75%",
"code": "rec_1_75",
"value": "175",
"scale": "2",
@ -200,7 +200,7 @@
"aeat_code": "52"
},
{
"name": "Rec. de equivalencia 1,4%",
"name": "Rec. 1,4%",
"code": "rec_1_4",
"value": "140",
"scale": "2",
@ -209,7 +209,7 @@
"aeat_code": null
},
{
"name": "Rec. de equivalencia 1%",
"name": "Rec. 1%",
"code": "rec_1",
"value": "100",
"scale": "2",
@ -218,7 +218,7 @@
"aeat_code": null
},
{
"name": "Rec. de equivalencia 0,62%",
"name": "Rec. 0,62%",
"code": "rec_0_62",
"value": "62",
"scale": "2",
@ -227,7 +227,7 @@
"aeat_code": null
},
{
"name": "Rec. de equivalencia 0,5%",
"name": "Rec. 0,5%",
"code": "rec_0_5",
"value": "50",
"scale": "2",
@ -236,7 +236,7 @@
"aeat_code": null
},
{
"name": "Rec. de equivalencia 0,26%",
"name": "Rec. 0,26%",
"code": "rec_0_26",
"value": "26",
"scale": "2",
@ -245,7 +245,7 @@
"aeat_code": null
},
{
"name": "Rec. de equivalencia 0%",
"name": "Rec. 0%",
"code": "rec_0",
"value": "0",
"scale": "2",

View File

@ -16,6 +16,7 @@
"rows_selected": "{{count}} fila(s) seleccionadas.",
"rows_selected_of_total": "{{count}} de {{total}} fila(s) seleccionadas."
},
"catalog": {
"status": {
"draft": "Draft",
@ -198,6 +199,9 @@
}
},
"components": {
"datatable": {
"actions": "Actions"
},
"customer_invoice_taxes_multi_select": {
"label": "Taxes",
"placeholder": "Select taxes",

View File

@ -191,6 +191,9 @@
}
},
"components": {
"datatable": {
"actions": "Acciones"
},
"customer_invoice_taxes_multi_select": {
"label": "Impuestos",
"placeholder": "Selecciona impuestos",

View File

@ -4,41 +4,9 @@ import { cn } from "@repo/shadcn-ui/lib/utils";
import { useCallback, useMemo } from 'react';
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[];
value?: string[];
onChange: (selectedValues: string[]) => void;
className?: string;
[key: string]: any; // Allow other props to be passed

View File

@ -1,5 +1,6 @@
import { formatCurrency } from '@erp/core';
import { useMoney } from '@erp/core/hooks';
import { Input } from '@repo/shadcn-ui/components';
import { cn } from '@repo/shadcn-ui/lib/utils';
import * as React from "react";
import { findFocusableInCell, focusAndSelect } from './input-utils';
@ -166,7 +167,7 @@ export function AmountInput({
if (readOnly && readOnlyMode === "textlike-input") {
return (
<input
<Input
id={id}
aria-label={ariaLabel}
readOnly
@ -186,13 +187,13 @@ export function AmountInput({
}
return (
<input
<Input
id={id}
aria-label={ariaLabel}
inputMode="decimal"
pattern="[0-9]*[.,]?[0-9]*"
className={cn(
"w-full bg-transparent p-0 text-right tabular-nums h-8 px-1",
"w-full bg-transparent p-0 text-right tabular-nums h-8 px-1 shadow-none",
"border-none",
"focus:bg-background",
"focus-visible:border-ring focus-visible:ring-ring/20 focus-visible:ring-[2px]",

View File

@ -0,0 +1,35 @@
import { Button, Input, Label, Textarea } from "@repo/shadcn-ui/components";
import { useFormContext } from "react-hook-form";
import { InvoiceFormData } from '../../../schemas';
export function ItemRowEditor({ index, close }: { index: number; close: () => void }) {
// Editor simple reutilizando el mismo RHF
const { register } = useFormContext<InvoiceFormData>();
return (
<div className="grid gap-3">
<h3 className="text-base font-semibold">Edit line #{index + 1}</h3>
<div>
<Label htmlFor={`desc-${index}`}>Description</Label>
<Textarea id={`desc-${index}`} rows={4} {...register(`items.${index}.description`)} />
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<Label htmlFor={`qty-${index}`}>Qty</Label>
<Input id={`qty-${index}`} type="number" step="1" {...register(`items.${index}.quantity`, { valueAsNumber: true })} />
</div>
<div>
<Label htmlFor={`unit-${index}`}>Unit</Label>
<Input id={`unit-${index}`} type="number" step="0.01" {...register(`items.${index}.unit_amount`, { valueAsNumber: true })} />
</div>
<div>
<Label htmlFor={`disc-${index}`}>Discount %</Label>
<Input id={`disc-${index}`} type="number" step="0.01" {...register(`items.${index}.discount_percentage`, { valueAsNumber: true })} />
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={close}>Close</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,85 @@
"use client"
import { Row, Table } from "@tanstack/react-table";
import {
Button,
Tooltip,
TooltipContent,
TooltipTrigger
} from '@repo/shadcn-ui/components';
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, PencilIcon, Trash2Icon } from 'lucide-react';
interface DataTableRowActionsProps<TData> {
row: Row<TData>,
table: Table<TData>
}
export function ItemDataTableRowActions<TData>({
row, table
}: DataTableRowActionsProps<TData>) {
const ops = (table.options.meta as any)?.rowOps as {
duplicate?: (i: number) => void;
remove?: (i: number) => void;
move?: (f: number, t: number) => void;
canMoveUp?: (i: number) => boolean;
canMoveDown?: (i: number, last: number) => boolean;
};
const openEditor = (table.options.meta as any)?.openEditor as (i: number) => void;
const lastRow = table.getRowModel().rows.length - 1;
const rowIndex = row.index;
return (
<div className="inline-flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Edit row" onClick={() => openEditor?.(rowIndex)}>
<PencilIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Duplicate row" onClick={() => ops?.duplicate?.(rowIndex)}>
<CopyIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Copy</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost" size="icon" aria-label="Move up"
disabled={ops?.canMoveUp ? !ops.canMoveUp(rowIndex) : rowIndex === 0}
onClick={() => ops?.move?.(rowIndex, rowIndex - 1)}
>
<ArrowUpIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Up</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost" size="icon" aria-label="Move down"
disabled={ops?.canMoveDown ? !ops.canMoveDown(rowIndex, lastRow) : rowIndex === lastRow}
onClick={() => ops?.move?.(rowIndex, rowIndex + 1)}
>
<ArrowDownIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Down</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Delete row" onClick={() => ops?.remove?.(rowIndex)}>
<Trash2Icon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</div>
);
}

View File

@ -1,13 +1,13 @@
import { useRowSelection } from '@repo/rdx-ui/hooks';
import { CheckedState, useRowSelection } from '@repo/rdx-ui/hooks';
import { Checkbox, Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@repo/shadcn-ui/components";
import { useCallback } from 'react';
import { useFormContext, useWatch } from "react-hook-form";
import { useItemsTableNavigation } from '../../../hooks';
import { useFormContext } from "react-hook-form";
import { useInvoiceContext } from '../../../context';
import { useInvoiceAutoRecalc, useItemsTableNavigation } from '../../../hooks';
import { useTranslation } from '../../../i18n';
import { InvoiceItemFormData, defaultCustomerInvoiceItemFormData } from '../../../schemas';
import { InvoiceFormData, InvoiceItemFormData, defaultCustomerInvoiceItemFormData } from '../../../schemas';
import { ItemRow } from './item-row';
import { ItemsEditorToolbar } from './items-editor-toolbar';
import { LastCellTabHook } from './last-cell-tab-hook';
interface ItemsEditorProps {
onChange?: (items: InvoiceItemFormData[]) => void;
@ -16,9 +16,11 @@ interface ItemsEditorProps {
const createEmptyItem = () => defaultCustomerInvoiceItemFormData;
export const ItemsEditor = ({ onChange, readOnly = false }: ItemsEditorProps) => {
export const ItemsEditor = ({ readOnly = false }: ItemsEditorProps) => {
const { t } = useTranslation();
const form = useFormContext();
const context = useInvoiceContext();
const form = useFormContext<InvoiceFormData>();
const { control } = form;
// Navegación y operaciones sobre las filas
const tableNav = useItemsTableNavigation(form, {
@ -27,6 +29,8 @@ export const ItemsEditor = ({ onChange, readOnly = false }: ItemsEditorProps) =>
firstEditableField: "description",
});
const { fieldArray: { fields } } = tableNav;
const {
selectedRows,
selectedIndexes,
@ -34,111 +38,83 @@ export const ItemsEditor = ({ onChange, readOnly = false }: ItemsEditorProps) =>
toggleRow,
setSelectAll,
clearSelection,
} = useRowSelection(tableNav.fa.fields.length);
} = useRowSelection(fields.length);
const { control } = form;
const items = useWatch({ control: control, name: "items" });
useInvoiceAutoRecalc(form, context);
// propagar cambios a componente padre
/*useEffect(() => {
onChange?.(items ?? []);
}, [items, onChange]);*/
const handleAdd = useCallback(() => {
const handleAddSelection = useCallback(() => {
if (readOnly) return;
tableNav.addEmpty(true);
}, [readOnly, tableNav]);
const handleDuplicate = useCallback(() => {
const handleDuplicateSelection = useCallback(() => {
if (readOnly || selectedIndexes.length === 0) return;
// duplicar en orden ascendente no rompe índices
selectedIndexes.forEach((i) => tableNav.duplicate(i));
}, [readOnly, selectedIndexes, tableNav]);
const handleMoveUp = useCallback(() => {
const handleMoveUpSelection = useCallback(() => {
if (readOnly || selectedIndexes.length === 0) return;
// mover de menor a mayor para mantener índices válidos
selectedIndexes.forEach((i) => tableNav.moveUp(i));
}, [readOnly, selectedIndexes, tableNav]);
const handleMoveDown = useCallback(() => {
const handleMoveDownSelection = useCallback(() => {
if (readOnly || selectedIndexes.length === 0) return;
// mover de mayor a menor evita desplazar objetivos
[...selectedIndexes].reverse().forEach((i) => tableNav.moveDown(i));
}, [readOnly, selectedIndexes, tableNav]);
const handleRemove = useCallback(() => {
const handleRemoveSelection = useCallback(() => {
if (readOnly || selectedIndexes.length === 0) return;
// borrar de mayor a menor para no invalidar índices siguientes
[...selectedIndexes].reverse().forEach((i) => tableNav.remove(i));
clearSelection();
}, [readOnly, selectedIndexes, tableNav, clearSelection]);
const hasSelection = selectedIndexes.length > 0;
return (
<div className="space-y-0">
{/* Toolbar selección múltiple */}
<ItemsEditorToolbar
readOnly={readOnly}
selectedIndexes={selectedIndexes}
onAdd={() => tableNav.addEmpty(true)}
onDuplicate={() => selectedIndexes.forEach((i) => tableNav.duplicate(i))}
onMoveUp={() => selectedIndexes.forEach((i) => tableNav.moveUp(i))}
onMoveDown={() => [...selectedIndexes].reverse().forEach((i) => tableNav.moveDown(i))}
onRemove={() => {
[...selectedIndexes].reverse().forEach((i) => tableNav.remove(i));
clearSelection();
}} />
onAdd={handleAddSelection}
onDuplicate={handleDuplicateSelection}
onMoveUp={handleMoveUpSelection}
onMoveDown={handleMoveDownSelection}
onRemove={handleRemoveSelection} />
<div className="bg-background">
<Table className="w-full border-collapse text-sm">
<colgroup>
<col className='w-[1%]' /> {/* sel */}
<col className='w-[1%]' /> {/* # */}
<col className='w-[42%]' /> {/* description */}
<col className="w-[4%]" /> {/* qty */}
<col className="w-[10%]" /> {/* unit */}
<col className="w-[4%]" /> {/* discount */}
<col className="w-[16%]" /> {/* taxes */}
<col className="w-[8%]" /> {/* taxes2 */}
<col className="w-[12%]" /> {/* total */}
<col className='w-[10%]' /> {/* actions */}
</colgroup>
<TableHeader className="text-sm bg-muted backdrop-blur supports-[backdrop-filter]:bg-muted/60 ">
<TableRow>
<TableHead>
<div className='h-5'>
<Checkbox
aria-label={t("common.select_all")}
checked={selectAllState}
disabled={readOnly}
onCheckedChange={(checked) => setSelectAll(checked)}
/>
</div>
<TableHead className='w-[1%] h-5'>
<Checkbox
aria-label={t("common.select_all")}
checked={selectAllState}
disabled={readOnly}
onCheckedChange={(checked: CheckedState) => setSelectAll(checked)}
/>
</TableHead>
<TableHead>#</TableHead>
<TableHead>{t("form_fields.item.description.label")}</TableHead>
<TableHead className="text-right">{t("form_fields.item.quantity.label")}</TableHead>
<TableHead className="text-right">{t("form_fields.item.unit_amount.label")}</TableHead>
<TableHead className="text-right">{t("form_fields.item.discount_percentage.label")}</TableHead>
<TableHead className="text-right">{t("form_fields.item.tax_codes.label")}</TableHead>
<TableHead className="text-right">{t("form_fields.item.total_amount.label")}</TableHead>
<TableHead aria-hidden="true" />
<TableHead className='w-[1%]' aria-hidden="true">#</TableHead>
<TableHead className='w-[40%]'>{t("form_fields.item.description.label")}</TableHead>
<TableHead className="w-[4%] text-right">{t("form_fields.item.quantity.label")}</TableHead>
<TableHead className="w-[10%] text-right">{t("form_fields.item.unit_amount.label")}</TableHead>
<TableHead className="w-[4%] text-right">{t("form_fields.item.discount_percentage.label")}</TableHead>
<TableHead className="w-[16%] text-right">{t("form_fields.item.tax_codes.label")}</TableHead>
<TableHead className="w-[8%] text-right">{t("form_fields.item.total_amount.label")}</TableHead>
<TableHead className='w-[1%]' aria-hidden="true" />
</TableRow>
</TableHeader>
<TableBody className='text-sm'>
{tableNav.fa.fields.map((f, rowIndex) => (
{fields.map((f, rowIndex: number) => (
<ItemRow
key={f.id}
control={control}
item={form.watch(`items.${rowIndex}`)}
rowIndex={rowIndex}
isSelected={selectedRows.has(rowIndex)}
isFirst={rowIndex === 0}
isLast={rowIndex === tableNav.fa.fields.length - 1}
isLast={rowIndex === fields.length - 1}
readOnly={readOnly}
onToggleSelect={() => toggleRow(rowIndex)}
onDuplicate={() => tableNav.duplicate(rowIndex)}
@ -151,22 +127,18 @@ export const ItemsEditor = ({ onChange, readOnly = false }: ItemsEditorProps) =>
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={9}>
<TableCell colSpan={9} className='p-0 m-0'>
<ItemsEditorToolbar
readOnly={readOnly}
selectedIndexes={selectedIndexes}
onAdd={() => tableNav.addEmpty(true)}
/>
</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
{/* Navegación por TAB: último campo de la fila */}
<LastCellTabHook itemsLength={tableNav.fa.fields.length} onTabFromLast={tableNav.onTabFromLastCell} />
</div >
);
}

View File

@ -1,157 +1,48 @@
import { CheckedState, useRowSelection } from '@repo/rdx-ui/hooks';
import { Checkbox, Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@repo/shadcn-ui/components";
import { useCallback } from 'react';
import { useFormContext } from "react-hook-form";
import { DataTable } from '@repo/rdx-ui/components';
import { useFieldArray, useFormContext } from "react-hook-form";
import { useInvoiceContext } from '../../../context';
import { useInvoiceAutoRecalc, useItemsTableNavigation } from '../../../hooks';
import { useInvoiceAutoRecalc } from '../../../hooks';
import { useTranslation } from '../../../i18n';
import { InvoiceFormData, InvoiceItemFormData, defaultCustomerInvoiceItemFormData } from '../../../schemas';
import { ItemRow } from './item-row';
import { ItemsEditorToolbar } from './items-editor-toolbar';
import { InvoiceFormData, defaultCustomerInvoiceItemFormData } from '../../../schemas';
import { ItemRowEditor } from './item-row-editor';
import { useItemsColumns } from './use-items-columns';
interface ItemsEditorProps {
onChange?: (items: InvoiceItemFormData[]) => void;
readOnly?: boolean;
}
const createEmptyItem = () => defaultCustomerInvoiceItemFormData;
export const ItemsEditor = ({ readOnly = false }: ItemsEditorProps) => {
export const ItemsEditor = () => {
const { t } = useTranslation();
const context = useInvoiceContext();
const form = useFormContext<InvoiceFormData>();
const { control } = form;
// Navegación y operaciones sobre las filas
const tableNav = useItemsTableNavigation(form, {
name: "items",
createEmpty: createEmptyItem,
firstEditableField: "description",
});
const { fieldArray: { fields } } = tableNav;
const {
selectedRows,
selectedIndexes,
selectAllState,
toggleRow,
setSelectAll,
clearSelection,
} = useRowSelection(fields.length);
const { control, getValues } = form;
useInvoiceAutoRecalc(form, context);
const handleAddSelection = useCallback(() => {
if (readOnly) return;
tableNav.addEmpty(true);
}, [readOnly, tableNav]);
const { fields, append, remove, move, insert } = useFieldArray({
control,
name: "items",
});
const handleDuplicateSelection = useCallback(() => {
if (readOnly || selectedIndexes.length === 0) return;
// duplicar en orden ascendente no rompe índices
selectedIndexes.forEach((i) => tableNav.duplicate(i));
}, [readOnly, selectedIndexes, tableNav]);
const handleMoveUpSelection = useCallback(() => {
if (readOnly || selectedIndexes.length === 0) return;
// mover de menor a mayor para mantener índices válidos
selectedIndexes.forEach((i) => tableNav.moveUp(i));
}, [readOnly, selectedIndexes, tableNav]);
const handleMoveDownSelection = useCallback(() => {
if (readOnly || selectedIndexes.length === 0) return;
// mover de mayor a menor evita desplazar objetivos
[...selectedIndexes].reverse().forEach((i) => tableNav.moveDown(i));
}, [readOnly, selectedIndexes, tableNav]);
const handleRemoveSelection = useCallback(() => {
if (readOnly || selectedIndexes.length === 0) return;
// borrar de mayor a menor para no invalidar índices siguientes
[...selectedIndexes].reverse().forEach((i) => tableNav.remove(i));
clearSelection();
}, [readOnly, selectedIndexes, tableNav, clearSelection]);
const columns = useItemsColumns();
return (
<div className="space-y-0">
{/* Toolbar selección múltiple */}
<ItemsEditorToolbar
readOnly={readOnly}
selectedIndexes={selectedIndexes}
onAdd={handleAddSelection}
onDuplicate={handleDuplicateSelection}
onMoveUp={handleMoveUpSelection}
onMoveDown={handleMoveDownSelection}
onRemove={handleRemoveSelection} />
<div className="bg-background">
<Table className="w-full border-collapse text-sm">
<colgroup>
<col className='w-[1%]' />
<col className='w-[1%]' />
<col className='w-[42%]' />
<col className="w-[4%]" />
<col className="w-[10%]" />
<col className="w-[4%]" />
<col className="w-[16%]" />
<col className="w-[8%]" />
<col className="w-[12%]" />
</colgroup>
<TableHeader className="text-sm bg-muted backdrop-blur supports-[backdrop-filter]:bg-muted/60 ">
<TableRow>
<TableHead>
<div className='h-5'>
<Checkbox
aria-label={t("common.select_all")}
checked={selectAllState}
disabled={readOnly}
onCheckedChange={(checked: CheckedState) => setSelectAll(checked)}
/>
</div>
</TableHead>
<TableHead>#</TableHead>
<TableHead>{t("form_fields.item.description.label")}</TableHead>
<TableHead className="text-right">{t("form_fields.item.quantity.label")}</TableHead>
<TableHead className="text-right">{t("form_fields.item.unit_amount.label")}</TableHead>
<TableHead className="text-right">{t("form_fields.item.discount_percentage.label")}</TableHead>
<TableHead className="text-right">{t("form_fields.item.tax_codes.label")}</TableHead>
<TableHead className="text-right">{t("form_fields.item.total_amount.label")}</TableHead>
<TableHead aria-hidden="true" />
</TableRow>
</TableHeader>
<TableBody className='text-sm'>
{fields.map((f, rowIndex: number) => (
<ItemRow
key={f.id}
control={control}
rowIndex={rowIndex}
isSelected={selectedRows.has(rowIndex)}
isFirst={rowIndex === 0}
isLast={rowIndex === fields.length - 1}
readOnly={readOnly}
onToggleSelect={() => toggleRow(rowIndex)}
onDuplicate={() => tableNav.duplicate(rowIndex)}
onMoveUp={() => tableNav.moveUp(rowIndex)}
onMoveDown={() => tableNav.moveDown(rowIndex)}
onRemove={() => tableNav.remove(rowIndex)}
/>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={9} className='p-0 m-0'>
<ItemsEditorToolbar
readOnly={readOnly}
selectedIndexes={selectedIndexes}
onAdd={() => tableNav.addEmpty(true)}
/>
</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
<DataTable columns={columns} data={fields}
pageSize={999}
getRowId={(r) => (r as any).id}
meta={{
rowOps: {
//duplicate: (indexRow: number) => insert(indexRow + 1, { ...getValues(`items.${indexRow}`) /*, id: crypto.randomUUID()*/ }),
remove: (indexRow: number) => remove(indexRow),
move: (fromIndex: number, toIndex: number) => {
if (toIndex < 0 || toIndex >= fields.length) return;
move(fromIndex, toIndex);
},
canMoveUp: (indexRow: number) => indexRow > 0,
canMoveDown: (indexRow: number, lastIndexRow: number) => indexRow < lastIndexRow,
},
}}
renderRowEditor={(index, close) => <ItemRowEditor index={index} close={close} />} />
</div >
);
}

View File

@ -1,3 +1,4 @@
import { Input } from '@repo/shadcn-ui/components';
import { cn } from '@repo/shadcn-ui/lib/utils';
import * as React from "react";
import { findFocusableInCell, focusAndSelect } from './input-utils';
@ -198,7 +199,7 @@ export function PercentageInput({
if (readOnly && readOnlyMode === "textlike-input") {
return (
<input
<Input
id={id}
aria-label={ariaLabel}
readOnly
@ -219,13 +220,13 @@ export function PercentageInput({
return (
<input
<Input
id={id}
aria-label={ariaLabel}
inputMode="decimal"
pattern="[0-9]*[.,]?[0-9]*%?"
className={cn(
"w-full bg-transparent p-0 text-right tabular-nums h-8 px-1",
"w-full bg-transparent p-0 text-right tabular-nums h-8 px-1 shadow-none",
"border-none",
"focus:bg-background",
"focus-visible:border-ring focus-visible:ring-ring/20 focus-visible:ring-[2px]",

View File

@ -2,6 +2,7 @@
// Comentarios en español. TS estricto.
import { useQuantity } from '@erp/core/hooks';
import { Input } from '@repo/shadcn-ui/components';
import { cn } from "@repo/shadcn-ui/lib/utils";
import * as React from "react";
import { findFocusableInCell, focusAndSelect } from './input-utils';
@ -196,7 +197,7 @@ export function QuantityInput({
}, []);
return (
<input
<Input
id={id}
aria-label={ariaLabel}
readOnly
@ -217,13 +218,13 @@ export function QuantityInput({
// ── Editable / readOnly normal ──────────────────────────────────────────
return (
<input
<Input
id={id}
aria-label={ariaLabel}
inputMode="decimal"
pattern="[0-9]*[.,]?[0-9]*"
className={cn(
"w-full bg-transparent p-0 text-right tabular-nums h-8 px-1",
"w-full bg-transparent p-0 text-right tabular-nums h-8 px-1 shadow-none",
"border-none",
"focus:bg-background",
"focus-visible:border-ring focus-visible:ring-ring/20 focus-visible:ring-[2px]",

View File

@ -0,0 +1,218 @@
import { DataTableColumnHeader } from '@repo/rdx-ui/components';
import { Checkbox, Textarea } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import type { ColumnDef } from "@tanstack/react-table";
import * as React from "react";
import { Controller, useFormContext } from "react-hook-form";
import { useInvoiceContext } from '../../../context';
import { CustomerInvoiceTaxesMultiSelect } from '../../customer-invoice-taxes-multi-select';
import { AmountInputField } from './amount-input-field';
import { HoverCardTotalsSummary } from './hover-card-total-summary';
import { ItemDataTableRowActions } from './items-data-table-row-actions';
import { PercentageInputField } from './percentage-input-field';
import { QuantityInputField } from './quantity-input-field';
export interface InvoiceItemFormData {
id: string; // ← mapea RHF field.id aquí
description: string;
quantity: number | "";
unit_amount: number | "";
discount_percentage: number | "";
tax_codes: string[];
total_amount: number | ""; // readonly calculado
}
export interface InvoiceFormData { items: InvoiceItemFormData[] }
export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
const { t, readOnly, currency_code, language_code } = useInvoiceContext();
const { control } = useFormContext<InvoiceFormData>();
// Atención: Memoizar siempre para evitar reconstrucciones y resets de estado de tabla
return React.useMemo<ColumnDef<InvoiceItemFormData>[]>(() => [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
aria-label="Select all"
className="translate-y-[2px]"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(v) => row.toggleSelected(!!v)}
aria-label="Select row"
className="translate-y-[2px]"
/>
),
enableSorting: false,
enableHiding: false,
size: 48, minSize: 40, maxSize: 64,
meta: { className: "w-[4ch]" }, // ancho aprox. por dígitos
},
{
id: 'position',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={"#"} className='text-center' />
),
cell: ({ row }) => row.index + 1,
enableSorting: false,
size: 32,
},
{
accessorKey: "description",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("form_fields.item.description.label")} className='text-left' />
),
cell: ({ row }) => (
<Controller
control={control}
name={`items.${row.index}.description`}
render={({ field }) => (
<Textarea
{...field}
id={`desc-${row.original.id}`} // ← estable
rows={1}
aria-label={t("form_fields.item.description.label")}
spellCheck
readOnly={readOnly}
// auto-grow simple
onInput={(e) => {
const el = e.currentTarget;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}}
className={cn(
"min-w-[12rem] max-w-[46rem] w-full resize-none bg-transparent border-dashed transition",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-background focus-visible:border-solid",
"focus:resize-y"
)}
data-cell-focus
/>
)}
/>
),
enableSorting: false,
size: 480, minSize: 240, maxSize: 768,
},
{
accessorKey: "quantity",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("form_fields.item.quantity.label")} className='text-right' />
),
cell: ({ row }) => (
<QuantityInputField
control={control}
name={`items.${row.index}.quantity`}
readOnly={readOnly}
inputId={`qty-${row.original.id}`}
emptyMode="blank"
data-row-index={row.index}
data-col-index={4}
data-cell-focus
className="font-base"
/>
),
enableSorting: false,
size: 52, minSize: 48, maxSize: 64,
},
{
accessorKey: "unit_amount",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("form_fields.item.unit_amount.label")} className='text-right' />
),
cell: ({ row }) => (
<AmountInputField
control={control}
name={`items.${row.index}.unit_amount`}
readOnly={readOnly}
inputId={`unit-${row.original.id}`}
scale={4}
currencyCode={currency_code}
languageCode={language_code}
data-row-index={row.index}
data-col-index={5}
data-cell-focus
className="font-base"
/>
),
enableSorting: false,
size: 120, minSize: 100, maxSize: 160,
},
{
accessorKey: "discount_percentage",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("form_fields.item.discount_percentage.label")} className='text-right' />
),
cell: ({ row }) => (
<PercentageInputField
control={control}
name={`items.${row.index}.discount_percentage`}
readOnly={readOnly}
inputId={`disc-${row.original.id}`}
scale={4}
data-row-index={row.index}
data-col-index={6}
data-cell-focus
className="font-base"
/>
),
enableSorting: false,
size: 40, minSize: 40
},
{
accessorKey: "tax_codes",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("form_fields.item.tax_codes.label")} />
),
cell: ({ row }) => (
<Controller
control={control}
name={`items.${row.index}.tax_codes`}
render={({ field }) => (
<CustomerInvoiceTaxesMultiSelect
{...field}
inputId={`tax-${row.original.id}`}
data-row-index={row.index}
data-col-index={7}
data-cell-focus
/>
)}
/>
),
enableSorting: false,
size: 240, minSize: 232, maxSize: 320,
},
{
accessorKey: "total_amount",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("form_fields.item.total_amount.label")} className='text-right' />
),
cell: ({ row }) => (
<HoverCardTotalsSummary rowIndex={row.index}>
<AmountInputField
control={control}
name={`items.${row.index}.total_amount`}
readOnly
inputId={`total-${row.original.id}`}
currencyCode={currency_code}
languageCode={language_code}
className="font-semibold"
/>
</HoverCardTotalsSummary>
),
enableSorting: false,
size: 120, minSize: 100, maxSize: 160,
},
{
id: "actions",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("components.datatable.actions")} />
),
cell: ({ row, table }) => <ItemDataTableRowActions row={row} table={table} />,
},
], [t, readOnly, control, currency_code, language_code,]);
}

View File

@ -19,7 +19,6 @@ import { ColumnDef } from "@tanstack/react-table";
import { ChevronDownIcon, ChevronUpIcon, CopyIcon, Trash2Icon } from "lucide-react";
import { useState } from "react";
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";
@ -215,8 +214,8 @@ export const CustomerInvoiceItemsCardEditor = ({
<FormControl>
<CustomerInvoiceTaxesMultiSelect
{...field}
//onChange={(e) => field.onChange(Number(e.target.value) * 100)}
//value={field.value / 100}
//onChange={(e) => field.onChange(Number(e.target.value) * 100)}
//value={field.value / 100}
/>
</FormControl>
<FormMessage />

View File

@ -1,5 +1,8 @@
import { TaxCatalogProvider } from '@erp/core';
import { TFunction } from 'i18next';
import { PropsWithChildren, createContext, useCallback, useContext, useMemo, useState } from "react";
import { useTranslation } from "../i18n";
import { MODULE_NAME } from '../manifest';
export type InvoiceContextValue = {
company_id: string;
@ -12,6 +15,8 @@ export type InvoiceContextValue = {
readOnly: boolean;
taxCatalog: TaxCatalogProvider;
t: TFunction<typeof MODULE_NAME>;
changeLanguage: (lang: string) => void;
changeCurrency: (currency: string) => void;
changeIsProforma: (value: boolean) => void;
@ -36,6 +41,8 @@ export const InvoiceProvider = ({ taxCatalog: initialTaxCatalog, invoice_id, com
currency_code: initialCurrency = "EUR", readOnly: initialReadOnly = false,
is_proforma: initialProforma = true, children }: PropsWithChildren<InvoiceProviderParams>) => {
const { t } = useTranslation();
// Estado interno local para campos dinámicos
const [language_code, setLanguage] = useState(initialLang);
const [currency_code, setCurrency] = useState(initialCurrency);
@ -53,6 +60,8 @@ export const InvoiceProvider = ({ taxCatalog: initialTaxCatalog, invoice_id, com
const value = useMemo<InvoiceContextValue>(() => {
return {
t,
invoice_id,
company_id,
status,
@ -68,7 +77,7 @@ export const InvoiceProvider = ({ taxCatalog: initialTaxCatalog, invoice_id, com
changeIsProforma: setIsProformaMemo,
setReadOnly: setReadOnlyMemo,
}
}, [readOnly, company_id, invoice_id, status, language_code, currency_code, is_proforma, taxCatalog, setLanguageMemo, setCurrencyMemo, setIsProformaMemo, setReadOnlyMemo]);
}, [t, readOnly, company_id, invoice_id, status, language_code, currency_code, is_proforma, taxCatalog, setLanguageMemo, setCurrencyMemo, setIsProformaMemo, setReadOnlyMemo]);
return <InvoiceContext.Provider value={value}>{children}</InvoiceContext.Provider>;
};

View File

@ -1,7 +1,6 @@
export * from "./calcs";
export * from "./use-create-customer-invoice-mutation";
export * from "./use-customer-invoices-query";
export * from "./use-detail-columns";
export * from "./use-invoice-query";
export * from "./use-items-table-navigation";
export * from "./use-update-customer-invoice-mutation";

View File

@ -1,8 +1,3 @@
import {
DataTablaRowActionFunction,
DataTableRowActions,
DataTableRowDragHandleCell,
} from "@repo/rdx-ui/components";
import { Checkbox } from "@repo/shadcn-ui/components";
import { ColumnDef } from "@tanstack/react-table";

View File

@ -1,5 +1,5 @@
import { i18n } from "i18next";
import { useTranslation as useI18NextTranslation } from "react-i18next";
import { KeyPrefix, Namespace, i18n } from "i18next";
import { UseTranslationResponse, useTranslation as useI18NextTranslation } from "react-i18next";
import enResources from "../common/locales/en.json";
import esResources from "../common/locales/es.json";
import { MODULE_NAME } from "./manifest";
@ -17,9 +17,13 @@ const addMissingBundles = (i18n: i18n) => {
}
};
export const useTranslation = () => {
export const useTranslation = <
Ns extends Namespace = typeof MODULE_NAME,
K extends KeyPrefix<Ns> = undefined,
>(
keyPrefix?: K
): UseTranslationResponse<Ns, K> => {
const { i18n } = useI18NextTranslation();
addMissingBundles(i18n);
return useI18NextTranslation(MODULE_NAME);
return useI18NextTranslation(MODULE_NAME, { keyPrefix });
};

View File

@ -0,0 +1,69 @@
import { Column } from "@tanstack/react-table";
import { ArrowDown, ArrowUp, ChevronsUpDown, EyeOff } from "lucide-react";
import { useTranslation } from "../../locales/i18n.ts";
import {
Button, DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@repo/shadcn-ui/components';
import { cn } from '@repo/shadcn-ui/lib/utils';
interface DataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>
title: string
}
export function DataTableColumnHeader<TData, TValue>({
column,
title,
className,
}: DataTableColumnHeaderProps<TData, TValue>) {
const { t } = useTranslation();
if (!column.getCanSort()) {
return <div className={cn("text-xs text-muted-foreground text-nowrap", className)}>{title}</div>
}
return (
<div className={cn("flex items-center gap-2", className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="data-[state=open]:bg-accent -ml-3 h-8 text-xs text-muted-foreground text-nowrap cursor-pointer"
>
<span>{title}</span>
{column.getIsSorted() === "desc" ? (
<ArrowDown />
) : column.getIsSorted() === "asc" ? (
<ArrowUp />
) : (
<ChevronsUpDown />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUp />
{t("components.datatabla.asc")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDown />
{t("components.datatabla.desc")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
<EyeOff />
{t("components.datatabla.hide")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}

View File

@ -0,0 +1,141 @@
import { Column } from "@tanstack/react-table"
import { Check, PlusCircle } 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'
interface DataTableFacetedFilterProps<TData, TValue> {
column?: Column<TData, TValue>
title?: string
options: {
label: string
value: string
icon?: React.ComponentType<{ className?: string }>
}[]
}
export function DataTableFacetedFilter<TData, TValue>({
column,
title,
options,
}: DataTableFacetedFilterProps<TData, TValue>) {
const facets = column?.getFacetedUniqueValues()
const selectedValues = new Set(column?.getFilterValue() as string[])
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8 border-dashed">
<PlusCircle />
{title}
{selectedValues?.size > 0 && (
<>
<Separator orientation="vertical" className="mx-2 h-4" />
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal lg:hidden"
>
{selectedValues.size}
</Badge>
<div className="hidden gap-1 lg:flex">
{selectedValues.size > 2 ? (
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal"
>
{selectedValues.size} selected
</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge
variant="secondary"
key={option.value}
className="rounded-sm px-1 font-normal"
>
{option.label}
</Badge>
))
)}
</div>
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder={title} />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const isSelected = selectedValues.has(option.value)
return (
<CommandItem
key={option.value}
onSelect={() => {
if (isSelected) {
selectedValues.delete(option.value)
} else {
selectedValues.add(option.value)
}
const filterValues = Array.from(selectedValues)
column?.setFilterValue(
filterValues.length ? filterValues : undefined
)
}}
>
<div
className={cn(
"flex size-4 items-center justify-center rounded-[4px] border",
isSelected
? "bg-primary border-primary text-primary-foreground"
: "border-input [&_svg]:invisible"
)}
>
<Check className="text-primary-foreground size-3.5" />
</div>
{option.icon && (
<option.icon className="text-muted-foreground size-4" />
)}
<span>{option.label}</span>
{facets?.get(option.value) && (
<span className="text-muted-foreground ml-auto flex size-4 items-center justify-center font-mono text-xs">
{facets.get(option.value)}
</span>
)}
</CommandItem>
)
})}
</CommandGroup>
{selectedValues.size > 0 && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => column?.setFilterValue(undefined)}
className="justify-center text-center"
>
Clear filters
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@ -0,0 +1,100 @@
import { Table } from "@tanstack/react-table"
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
} from "lucide-react"
import {
Button, Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@repo/shadcn-ui/components'
interface DataTablePaginationProps<TData> {
table: Table<TData>
}
export function DataTablePagination<TData>({
table,
}: DataTablePaginationProps<TData>) {
return (
<div className="flex items-center justify-between px-2">
<div className="text-muted-foreground flex-1 text-sm">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="flex items-center space-x-6 lg:space-x-8">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium">Rows per page</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 25, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="icon"
className="hidden size-8 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<ChevronsLeft />
</Button>
<Button
variant="outline"
size="icon"
className="size-8"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<ChevronLeft />
</Button>
<Button
variant="outline"
size="icon"
className="size-8"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<ChevronRight />
</Button>
<Button
variant="outline"
size="icon"
className="hidden size-8 lg:flex"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<ChevronsRight />
</Button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,28 @@
"use client"
import { Button } from '@repo/shadcn-ui/components'
import { Table } from "@tanstack/react-table"
import { DataTableViewOptions } from './data-table-view-options.tsx'
interface DataTableToolbarProps<TData> {
table: Table<TData>
}
export function DataTableToolbar<TData>({
table,
}: DataTableToolbarProps<TData>) {
const isFiltered = table.getState().columnFilters.length > 0
return (
<div className="flex items-center justify-between">
<div className="flex flex-1 items-center gap-2">
</div>
<div className="flex items-center gap-2">
<DataTableViewOptions table={table} />
<Button size="sm">Add Task</Button>
</div>
</div>
)
}

View File

@ -0,0 +1,56 @@
"use client"
import { Table } from "@tanstack/react-table"
import { Settings2 } from "lucide-react"
import {
Button, DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@repo/shadcn-ui/components'
export function DataTableViewOptions<TData>({
table,
}: {
table: Table<TData>
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="ml-auto hidden h-8 lg:flex"
>
<Settings2 />
View
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[150px]">
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
<DropdownMenuSeparator />
{table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== "undefined" && column.getCanHide()
)
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{column.id}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -0,0 +1,183 @@
"use client"
import {
ColumnDef,
ColumnFiltersState,
ColumnSizingState,
SortingState,
VisibilityState,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable
} from "@tanstack/react-table"
import * as React from "react"
import {
Dialog,
DialogContent,
Table, TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@repo/shadcn-ui/components'
import { DataTablePagination } from './data-table-pagination.tsx'
import { DataTableToolbar } from "./data-table-toolbar.tsx"
import { useTranslation } from "../../locales/i18n.ts"
type DataTableRowOps = {
duplicate?(index: number): void;
remove?(index: number): void;
move?(from: number, to: number): void;
canMoveUp?(index: number): boolean;
canMoveDown?(index: number, lastIndex: number): boolean;
};
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
data: TData[],
meta?: Record<string, any>,
getRowId?: (row: TData, index: number) => string;
pageSize?: number;
enableRowSelection?: boolean;
renderRowEditor?: (index: number, close: () => void) => React.ReactNode; // editor modal opcional. Se muestra dentro de un Dialog.
}
export function DataTable<TData, TValue>({
columns,
data,
meta,
getRowId,
pageSize = 25,
enableRowSelection = true,
renderRowEditor,
}: DataTableProps<TData, TValue>) {
const { t } = useTranslation();
const [rowSelection, setRowSelection] = React.useState({});
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({});
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
const [sorting, setSorting] = React.useState<SortingState>([]);
const [colSizes, setColSizes] = React.useState<ColumnSizingState>({});
const [editIndex, setEditIndex] = React.useState<number | null>(null);
const openEditor = React.useCallback((i: number) => setEditIndex(i), []);
const closeEditor = React.useCallback(() => setEditIndex(null), []);
const table = useReactTable({
data,
columns,
columnResizeMode: "onChange",
onColumnSizingChange: setColSizes,
getRowId: getRowId ?? ((row: any, idx) => (row?.id ? String(row.id) : String(idx))),
state: {
columnSizing: colSizes,
sorting,
columnVisibility,
rowSelection,
columnFilters,
},
initialState: { pagination: { pageSize } },
meta: { ...meta, openEditor },
enableRowSelection,
getCoreRowModel: getCoreRowModel(),
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
})
return (
<div className="flex flex-col gap-4">
<DataTableToolbar table={table} />
<div className="overflow-hidden rounded-md border">
<Table className="w-full text-sm">
<TableHeader className="sticky top-0 bg-muted hover:bg-muted z-10">
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((h) => {
const w = h.getSize(); // px
const minW = h.column.columnDef.minSize;
const maxW = h.column.columnDef.maxSize;
return (
<TableHead
key={h.id}
colSpan={h.colSpan}
style={{
width: w ? `${w}px` : undefined,
minWidth: typeof minW === "number" ? `${minW}px` : undefined,
maxWidth: typeof maxW === "number" ? `${maxW}px` : undefined,
}}
>
{h.isPlaceholder ? null : flexRender(h.column.columnDef.header, h.getContext())}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
{row.getVisibleCells().map((cell) => {
const w = cell.column.getSize();
const minW = cell.column.columnDef.minSize;
const maxW = cell.column.columnDef.maxSize;
return (
<TableCell
key={cell.id}
className="align-top"
style={{
width: w ? `${w}px` : undefined,
minWidth: typeof minW === "number" ? `${minW}px` : undefined,
maxWidth: typeof maxW === "number" ? `${maxW}px` : undefined,
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
);
})}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
{t("components.datatabla.empty")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<DataTablePagination table={table} />
{!!renderRowEditor && editIndex !== null && (
<Dialog open onOpenChange={(open) => (!open ? closeEditor() : null)}>
<DialogContent className="max-w-xl">
{renderRowEditor(editIndex, closeEditor)}
</DialogContent>
</Dialog>
)}
</div>
);
}

View File

@ -1,3 +1,3 @@
export * from "./datatable-column-header.tsx";
export * from "./datatable-row-actions.tsx";
export * from "./datatable-row-drag-handle-cell.tsx";
export * from "./data-table-column-header.tsx";
export * from "./data-table.tsx";

View File

@ -0,0 +1,62 @@
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/registry/new-york-v4/ui/avatar"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/registry/new-york-v4/ui/dropdown-menu"
export function UserNav() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
<Avatar className="h-9 w-9">
<AvatarImage src="/avatars/03.png" alt="@shadcn" />
<AvatarFallback>SC</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm leading-none font-medium">shadcn</p>
<p className="text-muted-foreground text-xs leading-none">
m@example.com
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
Profile
<DropdownMenuShortcut>P</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
Billing
<DropdownMenuShortcut>B</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
Settings
<DropdownMenuShortcut>S</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>New Team</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
Log out
<DropdownMenuShortcut>Q</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -0,0 +1,3 @@
export * from "./datatable-column-header.tsx";
export * from "./datatable-row-actions.tsx";
export * from "./datatable-row-drag-handle-cell.tsx";

View File

@ -13,3 +13,4 @@ export * from "./multi-select.tsx";
export * from "./multiple-selector.tsx";
export * from "./scroll-to-top.tsx";
export * from "./tailwind-indicator.tsx";

View File

@ -1,4 +1,5 @@
import { useTranslation } from "@repo/rdx-ui/locales/i18n.ts";
import { useTranslation } from "../../locales/i18n.ts";
import {
Button,
Dialog,

View File

@ -7,6 +7,13 @@
"read_only": "Read only"
},
"components": {
"datatable": {
"asc": "Asc",
"desc": "Desc",
"hide": "Hide",
"empty": "No results found",
"actions": "Actions"
},
"loading_indicator": {
"title": "Loading...",
"subtitle": "This may take a few seconds. Please do not close this page."

View File

@ -7,6 +7,13 @@
"search": "Buscar"
},
"components": {
"datatable": {
"asc": "Asc",
"desc": "Desc",
"hide": "Ocultar",
"empty": "No hay resultados",
"actions": "Acciones"
},
"loading_indicator": {
"title": "Cargando...",
"subtitle": "Esto puede tardar unos segundos. Por favor, no cierre esta página."

View File

@ -8,6 +8,9 @@
"plugins": [{ "name": "typescript-plugin-css-modules" }]
},
"include": ["src"],
"include": [
"src",
"../../modules/customer-invoices/src/web/components/editor/items/items-data-table-row-actions.tsx"
],
"exclude": ["node_modules", "dist"]
}