Facturas de cliente

This commit is contained in:
David Arranz 2025-10-08 20:04:25 +02:00
parent 4e757c86d0
commit b4fb4902dc
2 changed files with 250 additions and 184 deletions

View File

@ -0,0 +1,230 @@
import { Button, Checkbox, TableCell, TableRow, Tooltip, TooltipContent, TooltipTrigger } from "@repo/shadcn-ui/components";
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, Trash2Icon } from "lucide-react";
import { useEffect } from 'react';
import { Controller, useFormContext } from "react-hook-form";
import { useCalcInvoiceItemTotals } from '../../../hooks';
import { useTranslation } from '../../../i18n';
import { CustomerInvoiceItemFormData } from '../../../schemas';
import { CustomerInvoiceTaxesMultiSelect } from '../../customer-invoice-taxes-multi-select';
import { AmountDTOInputField } from './amount-dto-input-field';
import { HoverCardTotalsSummary } from './hover-card-total-summary';
import { PercentageDTOInputField } from './percentage-dto-input-field';
import { QuantityDTOInputField } from './quantity-dto-input-field';
export type ItemRowProps = {
item: CustomerInvoiceItemFormData;
rowIndex: number;
isSelected: boolean;
isFirst: boolean;
isLast: boolean;
readOnly: boolean;
onToggleSelect: () => void;
onDuplicate: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
onRemove: () => void;
}
export const ItemRow = ({ item,
rowIndex,
isSelected,
isFirst,
isLast,
readOnly,
onToggleSelect,
onDuplicate,
onMoveUp,
onMoveDown,
onRemove, }: ItemRowProps) => {
const { t } = useTranslation();
const { control, setValue } = useFormContext();
const totals = useCalcInvoiceItemTotals(item);
console.log(totals);
// sincroniza el total con el form
useEffect(() => {
if (totals?.totalDTO) {
setValue?.(`items.${rowIndex}.total_amount`, totals.totalDTO);
}
}, [totals.totalDTO, control, rowIndex]);
return (
<TableRow data-row-index={rowIndex}>
{/* selección */}
<TableCell className='align-top'>
<div className='h-5'>
<Checkbox
aria-label={`Seleccionar fila ${rowIndex + 1}`}
className="block h-5 w-5 leading-none align-middle"
checked={isSelected}
onCheckedChange={onToggleSelect}
disabled={readOnly}
/>
</div>
</TableCell>
{/* # */}
<TableCell className='text-left pt-[6px]'>
<span className='block translate-y-[-1px] text-muted-foreground tabular-nums text-xs'>
{rowIndex + 1}
</span>
</TableCell>
{/* description */}
<TableCell>
<Controller
control={control}
name={`items.${rowIndex}.description`}
render={({ field }) => (
<textarea
{...field}
aria-label={t("form_fields.item.description.label")}
className='w-full resize-none bg-transparent p-0 pt-1.5 leading-5 min-h-8 focus:outline-none focus:bg-background'
rows={1}
spellCheck
readOnly={readOnly}
onInput={(e) => {
const el = e.currentTarget;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}}
/>
)}
/>
</TableCell>
{/* qty */}
<TableCell className='text-right'>
<QuantityDTOInputField
control={control}
name={`items.${rowIndex}.quantity`}
readOnly={readOnly}
inputId={`quantity-${rowIndex}`}
emptyMode="blank"
/>
</TableCell>
{/* unit */}
<TableCell className='text-right'>
<AmountDTOInputField
control={control}
name={`items.${rowIndex}.unit_amount`}
readOnly={readOnly}
inputId={`unit-amount-${rowIndex}`}
scale={4}
locale={"es"}
/>
</TableCell>
{/* discount */}
<TableCell className='text-right'>
<PercentageDTOInputField
control={control}
name={`items.${rowIndex}.discount_percentage`}
readOnly={readOnly}
inputId={`discount-percentage-${rowIndex}`}
showSuffix
/>
</TableCell>
{/* taxes */}
<TableCell>
<Controller
control={control}
name={`items.${rowIndex}.tax_codes`}
render={({ field }) => (
<CustomerInvoiceTaxesMultiSelect
value={field.value}
onChange={field.onChange}
/>
)}
/>
</TableCell>
{/* total (solo lectura) */}
<TableCell className='text-right tabular-nums pt-[6px] leading-5'>
<HoverCardTotalsSummary totals={totals}>
<AmountDTOInputField
control={control}
name={`items.${rowIndex}.total_amount`}
readOnly
inputId={`total-amount-${rowIndex}`}
locale="es"
/>
</HoverCardTotalsSummary>
</TableCell>
{/* actions */}
<TableCell className='pt-[4px]'>
<div className='flex justify-end gap-0'>
{onDuplicate && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size='icon'
variant='ghost'
onMouseDown={(e) => e.preventDefault()}
onClick={onDuplicate}
disabled={readOnly}
aria-label='Duplicar fila'
className='h-8 w-8 self-start -translate-y-[1px]'
>
<CopyIcon className='size-4' />
</Button>
</TooltipTrigger>
<TooltipContent>Duplicar</TooltipContent>
</Tooltip>
)}
{onMoveUp && (
<Button
size='icon'
variant='ghost'
onMouseDown={(e) => e.preventDefault()}
onClick={onMoveUp}
disabled={readOnly || isFirst}
aria-label='Mover arriba'
className='h-8 w-8 self-start -translate-y-[1px]'
>
<ArrowUpIcon className='size-4' />
</Button>
)}
{onMoveDown && (
<Button
size='icon'
variant='ghost'
onMouseDown={(e) => e.preventDefault()}
onClick={onMoveDown}
disabled={readOnly || isLast}
aria-label='Mover abajo'
className='h-8 w-8 self-start -translate-y-[1px]'
>
<ArrowDownIcon className='size-4' />
</Button>
)}
{onRemove && (
<Button
size='icon'
variant='ghost'
onMouseDown={(e) => e.preventDefault()}
onClick={onRemove}
disabled={readOnly}
aria-label='Eliminar fila'
className='h-8 w-8 self-start -translate-y-[1px]'
>
<Trash2Icon className='size-4' />
</Button>
)}
</div>
</TableCell>
</TableRow>
);
}

View File

@ -1,18 +1,13 @@
import { useRowSelection } from '@repo/rdx-ui/hooks';
import { Button, Checkbox, Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, Tooltip, TooltipContent, TooltipTrigger } from "@repo/shadcn-ui/components";
import { ArrowDown, ArrowUp, CopyIcon, Trash2 } from "lucide-react";
import { Checkbox, Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@repo/shadcn-ui/components";
import * as React from "react";
import { Controller, useFormContext } from "react-hook-form";
import { useCalcInvoiceItemTotals, useItemsTableNavigation } from '../../../hooks';
import { useFormContext } from "react-hook-form";
import { useItemsTableNavigation } from '../../../hooks';
import { useTranslation } from '../../../i18n';
import { CustomerInvoiceItemFormData, defaultCustomerInvoiceItemFormData } from '../../../schemas';
import { CustomerInvoiceTaxesMultiSelect } from '../../customer-invoice-taxes-multi-select';
import { AmountDTOInputField } from './amount-dto-input-field';
import { HoverCardTotalsSummary } from './hover-card-total-summary';
import { ItemRow } from './item-row';
import { ItemsEditorToolbar } from './items-editor-toolbar';
import { LastCellTabHook } from './last-cell-tab-hook';
import { PercentageDTOInputField } from './percentage-dto-input-field';
import { QuantityDTOInputField } from './quantity-dto-input-field';
interface ItemsEditorProps {
value?: CustomerInvoiceItemFormData[];
@ -105,183 +100,24 @@ export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEdi
</TableRow>
</TableHeader>
<TableBody className='text-sm'>
{tableNav.fa.fields.map((f, rowIndex) => {
const isFirst = rowIndex === 0;
const isLast = rowIndex === tableNav.fa.fields.length - 1;
{tableNav.fa.fields.map((f, rowIndex) => (
<ItemRow
key={f.id}
item={form.watch(`items.${rowIndex}`)}
rowIndex={rowIndex}
isSelected={selectedRows.has(rowIndex)}
isFirst={rowIndex === 0}
isLast={rowIndex === tableNav.fa.fields.length - 1}
readOnly={readOnly}
onToggleSelect={() => toggleRow(rowIndex)}
onDuplicate={() => tableNav.duplicate(rowIndex)}
onMoveUp={() => tableNav.moveUp(rowIndex)}
onMoveDown={() => tableNav.moveDown(rowIndex)}
onRemove={() => tableNav.remove(rowIndex)}
/>
))}
const item = form.watch(`items.${rowIndex}`);
const totals = useCalcInvoiceItemTotals(item);
// sincronizar con react-hook-form
React.useEffect(() => {
form.setValue(`items.${rowIndex}.total_amount`, totals.totalDTO, { shouldDirty: true });
}, [totals.totalDTO, form, rowIndex]);
return (
<TableRow key={`row-${f.id}`} data-row-index={rowIndex}>
{/* selección */}
<TableCell className='align-top'>
<div className='h-5'>
<Checkbox
aria-label={t("common.select_row", { n: rowIndex + 1 })}
checked={selectedRows.has(rowIndex)}
disabled={readOnly}
onCheckedChange={() => toggleRow(rowIndex)}
/>
</div>
</TableCell>
{/* # */}
<TableCell className='text-left pt-[6px]'>
<span className='block translate-y-[-1px] text-muted-foreground tabular-nums text-xs'>
{rowIndex + 1}
</span>
</TableCell>
{/* description */}
<TableCell>
<Controller
control={control}
name={`items.${rowIndex}.description`}
render={({ field }) => (
<textarea
{...field}
aria-label={t("form_fields.item.description.label")}
className='w-full resize-none bg-transparent p-0 pt-1.5 leading-5 min-h-8 focus:outline-none focus:bg-background'
rows={1}
spellCheck
readOnly={readOnly}
onInput={(e) => {
const el = e.currentTarget;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}}
/>
)}
/>
</TableCell>
{/* qty */}
<TableCell className='text-right'>
<QuantityDTOInputField
control={control}
name={`items.${rowIndex}.quantity`}
readOnly={readOnly}
inputId={`quantity-${rowIndex}`}
emptyMode='blank'
/>
</TableCell>
{/* unit */}
<TableCell className='text-right'>
<AmountDTOInputField
control={control}
name={`items.${rowIndex}.unit_amount`}
readOnly={readOnly}
inputId={`unit-amount-${rowIndex}`}
scale={4}
locale={"es"}
/>
</TableCell>
{/* discount */}
<TableCell className='text-right'>
<PercentageDTOInputField
control={control}
name={`items.${rowIndex}.discount_percentage`}
readOnly={readOnly}
inputId={`discount-percentage-${rowIndex}`}
showSuffix
/>
</TableCell>
{/* taxes */}
<TableCell>
<Controller
control={control}
name={`items.${rowIndex}.tax_codes`}
render={({ field }) => (
<CustomerInvoiceTaxesMultiSelect
value={field.value}
onChange={field.onChange}
/>
)}
/>
</TableCell>
{/* total (solo lectura) */}
<TableCell className='text-right tabular-nums pt-[6px] leading-5'>
<HoverCardTotalsSummary totals={totals}>
<AmountDTOInputField
control={control}
name={`items.${rowIndex}.total_amount`}
readOnly
inputId={`total-amount-${rowIndex}`}
locale="es"
/>
</HoverCardTotalsSummary>
</TableCell>
{/* actions */}
<TableCell className='pt-[4px]'>
<div className='flex justify-end gap-0'>
<Tooltip>
<TooltipTrigger asChild>
<Button
size='icon'
variant='ghost'
onMouseDown={(e) => e.preventDefault()}
onClick={() => tableNav.duplicate(rowIndex)}
disabled={readOnly}
aria-label='Duplicar fila'
className='h-8 w-8 self-start translate-y-[-1px]'
>
<CopyIcon className='size-4' />
</Button>
</TooltipTrigger>
<TooltipContent>Duplicar</TooltipContent>
</Tooltip>
<Button
size='icon'
variant='ghost'
onMouseDown={(e) => e.preventDefault()}
onClick={() => tableNav.moveUp(rowIndex)}
disabled={readOnly || isFirst}
aria-label='Mover arriba'
className='h-8 w-8 self-start translate-y-[-1px]'
>
<ArrowUp className='size-4' />
</Button>
<Button
size='icon'
variant='ghost'
onMouseDown={(e) => e.preventDefault()}
onClick={() => tableNav.moveDown(rowIndex)}
disabled={readOnly || isLast}
aria-label='Mover abajo'
className='h-8 w-8 self-start translate-y-[-1px]'
>
<ArrowDown className='size-4' />
</Button>
<Button
size='icon'
variant='ghost'
onMouseDown={(e) => e.preventDefault()}
onClick={() => tableNav.remove(rowIndex)}
disabled={readOnly}
aria-label='Eliminar fila'
className='h-8 w-8 self-start translate-y-[-1px]'
>
<Trash2 className='size-4' />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
<TableFooter>
<TableRow>