Facturas de cliente
This commit is contained in:
parent
4e757c86d0
commit
b4fb4902dc
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,18 +1,13 @@
|
|||||||
import { useRowSelection } from '@repo/rdx-ui/hooks';
|
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 { Checkbox, Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@repo/shadcn-ui/components";
|
||||||
import { ArrowDown, ArrowUp, CopyIcon, Trash2 } from "lucide-react";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Controller, useFormContext } from "react-hook-form";
|
import { useFormContext } from "react-hook-form";
|
||||||
import { useCalcInvoiceItemTotals, useItemsTableNavigation } from '../../../hooks';
|
import { useItemsTableNavigation } from '../../../hooks';
|
||||||
import { useTranslation } from '../../../i18n';
|
import { useTranslation } from '../../../i18n';
|
||||||
import { CustomerInvoiceItemFormData, defaultCustomerInvoiceItemFormData } from '../../../schemas';
|
import { CustomerInvoiceItemFormData, defaultCustomerInvoiceItemFormData } from '../../../schemas';
|
||||||
import { CustomerInvoiceTaxesMultiSelect } from '../../customer-invoice-taxes-multi-select';
|
import { ItemRow } from './item-row';
|
||||||
import { AmountDTOInputField } from './amount-dto-input-field';
|
|
||||||
import { HoverCardTotalsSummary } from './hover-card-total-summary';
|
|
||||||
import { ItemsEditorToolbar } from './items-editor-toolbar';
|
import { ItemsEditorToolbar } from './items-editor-toolbar';
|
||||||
import { LastCellTabHook } from './last-cell-tab-hook';
|
import { LastCellTabHook } from './last-cell-tab-hook';
|
||||||
import { PercentageDTOInputField } from './percentage-dto-input-field';
|
|
||||||
import { QuantityDTOInputField } from './quantity-dto-input-field';
|
|
||||||
|
|
||||||
interface ItemsEditorProps {
|
interface ItemsEditorProps {
|
||||||
value?: CustomerInvoiceItemFormData[];
|
value?: CustomerInvoiceItemFormData[];
|
||||||
@ -105,183 +100,24 @@ export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEdi
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody className='text-sm'>
|
<TableBody className='text-sm'>
|
||||||
{tableNav.fa.fields.map((f, rowIndex) => {
|
|
||||||
|
|
||||||
const isFirst = rowIndex === 0;
|
{tableNav.fa.fields.map((f, rowIndex) => (
|
||||||
const isLast = rowIndex === tableNav.fa.fields.length - 1;
|
<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>
|
</TableBody>
|
||||||
<TableFooter>
|
<TableFooter>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user