Facturas de cliente

This commit is contained in:
David Arranz 2025-10-08 15:11:26 +02:00
parent 6559317e2f
commit 04edf3df68
24 changed files with 785 additions and 488 deletions

View File

@ -1,27 +1,23 @@
// --- Adaptador que carga el catálogo JSON en memoria e indexa por code --- // --- Adaptador que carga el catálogo JSON en memoria e indexa por code ---
import { Maybe } from "@repo/rdx-utils"; import { Maybe } from "@repo/rdx-utils";
import { TaxCatalogType, TaxItemType } from "./tax-catalog-types"; import { TaxCatalogType, TaxItemType, TaxLookupItems } from "./tax-catalog-types";
import { TaxCatalogProvider } from "./tax-catalog.provider"; import { TaxCatalogProvider } from "./tax-catalog.provider";
// Si quieres habilitar la carga desde fichero en Node:
// import * as fs from "node:fs";
// import * as path from "node:path";
export class JsonTaxCatalogProvider implements TaxCatalogProvider { export class JsonTaxCatalogProvider implements TaxCatalogProvider {
// Índice por código normalizado // Índice por código normalizado
private readonly index: Map<string, TaxItemType>; private readonly catalog: Map<string, TaxItemType>;
/** /**
* @param catalog Catálogo ya parseado (p.ej. import JSON o fetch) * @param catalog Catálogo ya parseado (p.ej. import JSON o fetch)
*/ */
constructor(catalog: TaxCatalogType) { constructor(catalog: TaxCatalogType) {
this.index = new Map<string, TaxItemType>(); this.catalog = new Map<string, TaxItemType>();
// Normalizamos códigos a minúsculas y sin espacios // Normalizamos códigos a minúsculas y sin espacios
for (const item of catalog) { for (const item of catalog) {
const normalized = JsonTaxCatalogProvider.normalizeCode(item.code); const normalized = JsonTaxCatalogProvider.normalizeCode(item.code);
// En caso de duplicados, el último gana (o lanza error si prefieres) // En caso de duplicados, el último gana (o lanza error si prefieres)
this.index.set(normalized, item); this.catalog.set(normalized, item);
} }
} }
@ -31,15 +27,21 @@ export class JsonTaxCatalogProvider implements TaxCatalogProvider {
findByCode(code: string): Maybe<TaxItemType> { findByCode(code: string): Maybe<TaxItemType> {
const normalized = JsonTaxCatalogProvider.normalizeCode(code); const normalized = JsonTaxCatalogProvider.normalizeCode(code);
const found = this.index.get(normalized); const found = this.catalog.get(normalized);
return found ? Maybe.some(found) : Maybe.none<TaxItemType>(); return found ? Maybe.some(found) : Maybe.none<TaxItemType>();
} }
// Opcional: carga desde fichero JSON en Node (descomenta si lo necesitas) getAll(): TaxItemType[] {
// static fromJsonFile(filePath: string): JsonTaxCatalogProvider { return Array.from(this.catalog.values());
// const full = path.resolve(filePath); }
// const raw = fs.readFileSync(full, "utf-8");
// const data = JSON.parse(raw) as TaxCatalogDto; /** Devuelve un objeto indexado por código, compatible con TaxMultiSelectField */
// return new JsonTaxCatalogProvider(data); toOptionLookup(): TaxLookupItems {
// } return Array.from(this.catalog.values());
}
/** Devuelve la lista única de grupos disponibles */
groups(): string[] {
return Array.from(new Set(Array.from(this.catalog.values()).map((i) => i.group)));
}
} }

View File

@ -1,4 +1,4 @@
import { JsonTaxCatalogProvider } from "./json-tax-catalog.provider"; import { JsonTaxCatalogProvider } from "./json-tax-catalog.provider";
import spainTaxCatalog from "./spain-tax-catalog.json"; import spainTaxCatalog from "./spain-tax-catalog.json";
export const spainTaxCatalogProvider = new JsonTaxCatalogProvider(spainTaxCatalog); export const SpainTaxCatalogProvider = () => new JsonTaxCatalogProvider(spainTaxCatalog);

View File

@ -11,3 +11,5 @@ export type TaxItemType = {
}; };
export type TaxCatalogType = TaxItemType[]; export type TaxCatalogType = TaxItemType[];
export type TaxLookupItems = TaxItemType[];

View File

@ -1,7 +1,7 @@
// --- Puerto (interfaz) para resolver tasas desde un catálogo --- // --- Puerto (interfaz) para resolver tasas desde un catálogo ---
import { Maybe } from "@repo/rdx-utils"; // Usa tu implementación real de Maybe import { Maybe } from "@repo/rdx-utils"; // Usa tu implementación real de Maybe
import { TaxItemType } from "./tax-catalog-types"; import { TaxCatalogType, TaxItemType, TaxLookupItems } from "./tax-catalog-types";
export interface TaxCatalogProvider { export interface TaxCatalogProvider {
/** /**
@ -9,4 +9,11 @@ export interface TaxCatalogProvider {
* Debe considerar normalización del 'code' según las reglas del catálogo. * Debe considerar normalización del 'code' según las reglas del catálogo.
*/ */
findByCode(code: string): Maybe<TaxItemType>; findByCode(code: string): Maybe<TaxItemType>;
// devuelve el catálogo completo como array
getAll(): TaxCatalogType;
toOptionLookup(): TaxLookupItems;
groups(): string[]; //Devuelve una lista con los grupos
} }

View File

@ -1,30 +1,68 @@
import { useState } from "react";
import { useFormContext } from "react-hook-form"; import { useFormContext } from "react-hook-form";
// Renderiza una propiedad recursivamente con expansión
function DebugField({ label, oldValue, newValue }: { label?: string; oldValue: any; newValue: any }) {
const [open, setOpen] = useState(false);
const isObject = typeof newValue === "object" && newValue !== null;
if (!isObject) {
return (
<li className="ml-4">
{label && <span className="font-medium">{label}: </span>}
<span className="text-gray-500 line-through">{String(oldValue)}</span>{" "}
<span className="text-green-600">{String(newValue)}</span>
</li>
);
}
return (
<li className="ml-4">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="text-left font-medium text-blue-600 hover:underline focus:outline-none"
>
{open ? "▼" : "▶"} {label}
</button>
{open && (
<ul className="ml-4 border-l pl-2 mt-1 space-y-1">
{Object.keys(newValue).map((key) => (
<DebugField
key={key}
label={key}
oldValue={oldValue?.[key]}
newValue={newValue[key]}
/>
))}
</ul>
)}
</li>
);
}
export const FormDebug = () => { export const FormDebug = () => {
const form = useFormContext(); // ✅ mantiene el tipo de T const { watch, formState } = useFormContext();
const { isDirty, dirtyFields, defaultValues } = formState;
const {
watch,
formState: { isDirty, dirtyFields, defaultValues },
} = form;
const currentValues = watch(); const currentValues = watch();
return ( return (
<div className='p-4 border rounded bg-red-50 mb-6'> <div className="p-4 border rounded bg-red-50 mb-6">
<p> <p>
<strong>¿Formulario modificado?</strong> {isDirty ? "Sí" : "No"} <strong>¿Formulario modificado?</strong> {isDirty ? "Sí" : "No"}
</p> </p>
<div className='mt-2'> <div className="mt-2">
<strong>Campos modificados:</strong> <strong>Campos modificados:</strong>
{Object.keys(dirtyFields).length > 0 ? ( {Object.keys(dirtyFields).length > 0 ? (
<ul className='list-disc list-inside mt-1'> <ul className="list-disc list-inside mt-1">
{Object.keys(dirtyFields).map((campo) => ( {Object.keys(dirtyFields).map((key) => (
<li key={campo}> <DebugField
<span className='font-medium'>{campo}:</span>{" "} key={key}
<span className='text-gray-500 line-through'>{String(defaultValues![campo])}</span>{" "} label={key}
<span className='text-green-600'>{String(currentValues[campo])}</span> oldValue={defaultValues?.[key]}
</li> newValue={currentValues[key]}
/>
))} ))}
</ul> </ul>
) : ( ) : (

View File

@ -12,9 +12,9 @@ import {
import { cn } from "@repo/shadcn-ui/lib/utils"; import { cn } from "@repo/shadcn-ui/lib/utils";
import { Control, FieldPath, FieldValues } from "react-hook-form"; import { Control, FieldPath, FieldValues } from "react-hook-form";
import { useTranslation } from "../../i18n.ts"; import { useTranslation } from "../../i18n.ts";
import { TaxesMultiSelect } from "../taxes-multi-select.tsx"; import { TaxesMultiSelect } from '../taxes-multi-select.tsx';
type TaxesMultiSelectFieldProps<TFormValues extends FieldValues> = { export type TaxesMultiSelectFieldProps<TFormValues extends FieldValues> = {
control: Control<TFormValues>; control: Control<TFormValues>;
name: FieldPath<TFormValues>; name: FieldPath<TFormValues>;
label?: string; label?: string;
@ -24,6 +24,7 @@ type TaxesMultiSelectFieldProps<TFormValues extends FieldValues> = {
required?: boolean; required?: boolean;
readOnly?: boolean; readOnly?: boolean;
className?: string; className?: string;
variant?: "default" | "secondary" | "destructive" | "inverted"
}; };
export function TaxesMultiSelectField<TFormValues extends FieldValues>({ export function TaxesMultiSelectField<TFormValues extends FieldValues>({
@ -36,6 +37,7 @@ export function TaxesMultiSelectField<TFormValues extends FieldValues>({
required = false, required = false,
readOnly = false, readOnly = false,
className, className,
variant = "inverted",
}: TaxesMultiSelectFieldProps<TFormValues>) { }: TaxesMultiSelectFieldProps<TFormValues>) {
const { t } = useTranslation(); const { t } = useTranslation();
const isDisabled = disabled || readOnly; const isDisabled = disabled || readOnly;
@ -59,7 +61,7 @@ export function TaxesMultiSelectField<TFormValues extends FieldValues>({
</div> </div>
)} )}
<FormControl> <FormControl>
<TaxesMultiSelect disabled={isDisabled} placeholder={placeholder} {...field} /> <TaxesMultiSelect disabled={isDisabled} placeholder={placeholder} variant={variant} {...field} />
</FormControl> </FormControl>
<FormDescription <FormDescription

View File

@ -1,8 +1,16 @@
import { MultiSelect } from "@repo/rdx-ui/components"; import { MultiSelect } from "@repo/rdx-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { useTranslation } from "../i18n"; import { useTranslation } from "../i18n";
export const TaxesList = [ // Tipos
type TaxGroup = "IVA" | "Retención" | "Recargo de equivalencia";
interface TaxOption {
label: string;
value: string;
group: TaxGroup;
}
export const TaxesList: ReadonlyArray<TaxOption> = [
{ label: "IVA 21%", value: "iva_21", group: "IVA" }, { label: "IVA 21%", value: "iva_21", group: "IVA" },
{ label: "IVA 10%", value: "iva_10", group: "IVA" }, { label: "IVA 10%", value: "iva_10", group: "IVA" },
{ label: "IVA 7,5%", value: "iva_7_5", group: "IVA" }, { label: "IVA 7,5%", value: "iva_7_5", group: "IVA" },
@ -33,38 +41,82 @@ export const TaxesList = [
{ label: "REC 0%", value: "rec_0", group: "Recargo de equivalencia" }, { label: "REC 0%", value: "rec_0", group: "Recargo de equivalencia" },
]; ];
interface TaxesMultiSelect { // Índices para lookup
name: string; const TAX_GROUP_BY_VALUE = new Map<string, TaxGroup>(
value: string[]; TaxesList.map((t) => [t.value, t.group])
onChange: (selectedValues: string[]) => void; );
[key: string]: any; // Allow other props to be passed
// Util: deduplicar por grupo manteniendo el *último índice* de `arr`
function dedupeByTaxGroup(arr: readonly string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (let i = arr.length - 1; i >= 0; i--) {
const v = arr[i];
const g = TAX_GROUP_BY_VALUE.get(v) ?? `__nogroup:${v}`;
if (!seen.has(g)) {
out.push(v);
seen.add(g);
}
}
out.reverse();
return out;
} }
export const TaxesMultiSelect = (props: TaxesMultiSelect) => { // Normaliza usando el *delta* para saber cuál fue el último clicado.
const { value, onChange, ...otherProps } = props; // Si se añadió uno, elimina los anteriores de su grupo.
// Si se quitaron, acepta `next` tal cual. Fallback: dedupe por índice.
function normalizeSelection(prev: readonly string[], next: readonly string[]): string[] {
const added = next.filter((v) => !prev.includes(v));
const removed = prev.filter((v) => !next.includes(v));
if (added.length === 1 && removed.length <= 1) {
const clicked = added[0];
const g = TAX_GROUP_BY_VALUE.get(clicked);
if (!g) return dedupeByTaxGroup(next);
return next.filter((v) => TAX_GROUP_BY_VALUE.get(v) !== g || v === clicked);
}
// Multi-selección por teclado/ratón o reordenamientos del componente
return dedupeByTaxGroup(next);
}
interface TaxesMultiSelectProps {
name: string;
value: string[];
variant?: "default" | "secondary" | "destructive" | "inverted"
onChange: (selectedValues: string[]) => void;
[key: string]: any;
}
export const TaxesMultiSelect = (props: TaxesMultiSelectProps) => {
const { variant, value, onChange, ...otherProps } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const handleOnChange = (selectedValues: string[]) => { const msVariant =
onChange(selectedValues); variant as "default" | "secondary" | "destructive" | "inverted" | undefined;
};
const handleValidateOption = (candidateValue: string) => { // IMPORTANTE: usar `value` (controlado). No usar `defaultValue`.
const exists = (value || []).some((item) => item.startsWith(candidateValue.substring(0, 3))); const handleOnValueChange = (nextValues: string[]) => {
if (exists) { const normalized = normalizeSelection(value, nextValues);
alert(t("components.taxes_multi_select.invalid_tax_selection")); // Evitar renders innecesarios si no cambia
if (
normalized.length === value.length &&
normalized.every((v, i) => v === value[i])
) {
return;
} }
return exists === false; onChange(normalized);
}; };
return ( return (
<div className={cn("w-full", "max-w-md")}> <div className="w-full max-w-md">
<MultiSelect <MultiSelect
options={TaxesList} options={[...TaxesList]}
onValueChange={handleOnChange} value={value}
onValidateOption={handleValidateOption} onValueChange={handleOnValueChange}
defaultValue={value}
placeholder={t("components.taxes_multi_select.placeholder")} placeholder={t("components.taxes_multi_select.placeholder")}
variant='inverted' variant={msVariant}
animation={0} animation={0}
maxCount={3} maxCount={3}
{...otherProps} {...otherProps}

View File

@ -48,6 +48,7 @@
"@repo/shadcn-ui": "workspace:*", "@repo/shadcn-ui": "workspace:*",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"ag-grid-react": "^33.3.0", "ag-grid-react": "^33.3.0",
"class-variance-authority": "^0.7.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"libphonenumber-js": "^1.12.7", "libphonenumber-js": "^1.12.7",
"lucide-react": "^0.503.0", "lucide-react": "^0.503.0",

View File

@ -23,7 +23,7 @@ import {
UpdateCustomerInvoiceUseCase, UpdateCustomerInvoiceUseCase,
} from "../application"; } from "../application";
import { JsonTaxCatalogProvider, spainTaxCatalogProvider } from "@erp/core"; import { JsonTaxCatalogProvider, SpainTaxCatalogProvider } from "@erp/core";
import { import {
CustomerInvoiceApplicationService, CustomerInvoiceApplicationService,
CustomerInvoiceItemsReportPersenter, CustomerInvoiceItemsReportPersenter,
@ -55,7 +55,7 @@ export type CustomerInvoiceDeps = {
export function buildCustomerInvoiceDependencies(params: ModuleParams): CustomerInvoiceDeps { export function buildCustomerInvoiceDependencies(params: ModuleParams): CustomerInvoiceDeps {
const { database, listServices, getService } = params; const { database, listServices, getService } = params;
const transactionManager = new SequelizeTransactionManager(database); const transactionManager = new SequelizeTransactionManager(database);
const catalogs = { taxes: spainTaxCatalogProvider }; const catalogs = { taxes: SpainTaxCatalogProvider() };
// Mapper Registry // Mapper Registry
const mapperRegistry = new InMemoryMapperRegistry(); const mapperRegistry = new InMemoryMapperRegistry();

View File

@ -11,7 +11,10 @@
"insert_row_above": "Insert row above", "insert_row_above": "Insert row above",
"insert_row_below": "Insert row below", "insert_row_below": "Insert row below",
"remove_row": "Remove", "remove_row": "Remove",
"actions": "Actions" "actions": "Actions",
"rows_selected": "{{count}} fila(s) seleccionadas.",
"rows_selected_of_total": "{{count}} de {{total}} fila(s) seleccionadas."
}, },
"catalog": { "catalog": {
"status": { "status": {

View File

@ -3,10 +3,18 @@
"append_empty_row": "Añadir fila", "append_empty_row": "Añadir fila",
"append_empty_row_tooltip": "Añadir una fila vacía", "append_empty_row_tooltip": "Añadir una fila vacía",
"duplicate_row": "Duplicar", "duplicate_row": "Duplicar",
"duplicate_selected_rows": "Duplicar",
"duplicate_selected_rows_tooltip": "Duplicar fila(s) seleccionada(s)",
"remove_selected_rows": "Eliminar",
"remove_selected_rows_tooltip": "Eliminar fila(s) seleccionada(s)",
"insert_row_above": "Insertar fila arriba", "insert_row_above": "Insertar fila arriba",
"insert_row_below": "Insertar fila abajo", "insert_row_below": "Insertar fila abajo",
"remove_row": "Eliminar", "remove_row": "Eliminar",
"actions": "Acciones" "actions": "Acciones",
"rows_selected": "{{count}} fila(s) seleccionadas.",
"rows_selected_of_total": "{{count}} de {{total}} fila(s) seleccionadas."
}, },
"catalog": { "catalog": {
"status": { "status": {

View File

@ -1,31 +1,12 @@
import { Button, Card, CardContent, CardHeader, CardTitle } from "@repo/shadcn-ui/components"; import { Card, CardContent, CardHeader, CardTitle } from "@repo/shadcn-ui/components";
import { Package } from "lucide-react";
import { Grid3X3Icon, Package, PlusIcon, TableIcon } from "lucide-react"; import { useTranslation } from '../../i18n';
import { useState } from "react";
import { useFormContext } from "react-hook-form";
import { useCalculateItemAmounts, useItemsTableNavigation } from '../../hooks';
import { useTranslation } from "../../i18n";
import { CustomerInvoiceItemFormData, defaultCustomerInvoiceItemFormData } from "../../schemas";
import { ItemsEditor } from "./items"; import { ItemsEditor } from "./items";
const createEmptyItem = () => defaultCustomerInvoiceItemFormData;
export const InvoiceItems = () => { export const InvoiceItems = () => {
const [viewMode, setViewMode] = useState<"blocks" | "table">("table");
const { t } = useTranslation(); const { t } = useTranslation();
const form = useFormContext();
const calculateItemAmounts = useCalculateItemAmounts();
const nav = useItemsTableNavigation(form, {
name: "items",
createEmpty: createEmptyItem,
firstEditableField: "description",
});
const { control, setValue, watch } = form;
const items = watch("items") as CustomerInvoiceItemFormData[];
return ( return (
<Card className='border-none shadow-none'> <Card className='border-none shadow-none'>
@ -33,54 +14,12 @@ export const InvoiceItems = () => {
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<CardTitle className='text-lg font-medium flex items-center gap-2'> <CardTitle className='text-lg font-medium flex items-center gap-2'>
<Package className='h-5 w-5' /> <Package className='h-5 w-5' />
Detalles de la Factura {t('form_groups.items.title')}
</CardTitle> </CardTitle>
<div className='flex items-center gap-2'>
<div className='flex items-center border rounded-lg p-1'>
<Button
variant={viewMode === "blocks" ? "secondary" : "ghost"}
size='sm'
onClick={() => setViewMode("blocks")}
className='h-8 px-3'
>
<Grid3X3Icon className='h-4 w-4 mr-1' />
Bloques
</Button>
<Button
variant={viewMode === "table" ? "secondary" : "ghost"}
size='sm'
onClick={() => setViewMode("table")}
className='h-8 px-3'
>
<TableIcon className='h-4 w-4 mr-1' />
Tabla
</Button>
</div>
<Button onClick={addNewItem} size='sm'>
<PlusIcon className='h-4 w-4 mr-2' />
Añadir Línea
</Button>
</div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className='overflow-auto'> <CardContent className='overflow-auto'>
<ItemsEditor /> <ItemsEditor />
{/*viewMode === "blocks" ? (
<BlocksView items={items} actions={
addNewItem,
updateItem,
duplicateItem,
removeItem
} />
) : (
<TableView items={items} actions={
addNewItem,
updateItem,
duplicateItem,
removeItem
} />
) */}
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@ -1,218 +0,0 @@
import { Button, Checkbox, TableCell, TableRow, Tooltip, TooltipContent, TooltipTrigger } from "@repo/shadcn-ui/components";
import { ArrowDown, ArrowUp, CopyIcon, Trash2 } from "lucide-react";
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from '../../../i18n';
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';
import { TaxMultiSelect } from './tax-multi-select';
import { TAXES } from './types.d';
export const ItemEditorRow = ({
itemRow,
rowIndex,
locale,
readOnly,
isFirst,
isLast,
checked,
onToggle,
onDuplicate,
onMoveUp,
onMoveDown,
onRemove,
}: {
itemRow: Record<"id", string>;
rowIndex: number;
locale: string;
readOnly: boolean;
isFirst: boolean;
isLast: boolean;
checked: boolean;
onToggle: () => void;
onDuplicate: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
onRemove: () => void;
}) => {
const { t } = useTranslation();
const form = useFormContext();
const { control } = form;
return (
<TableRow data-row-index={rowIndex}>
{/* selección */}
<TableCell className="align-top">
<div className="h-5">
<Checkbox
aria-label={t("common.select_row", { n: rowIndex + 1 })}
className="block h-5 w-5 leading-none align-middle"
checked={checked}
onCheckedChange={onToggle}
disabled={readOnly}
/>
</div>
</TableCell>
{/* # */}
<TableCell className="text-left pt-[6px]">
<span className="block translate-y-[-1px] text-muted-foreground">{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={locale}
/>
</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 }) => (
<TaxMultiSelect
catalog={TAXES}
value={field.value ?? ["iva_21"]}
onChange={field.onChange}
disabled={readOnly}
buttonClassName="h-8 self-start translate-y-[-1px]"
/>
)}
/>
</TableCell>
{/* total (solo lectura) */}
<TableCell className="text-right tabular-nums pt-[6px] leading-5">
<HoverCardTotalsSummary data={{ ...itemRow } as any}>
<AmountDTOInputField
control={control}
name={`items.${rowIndex}.total_amount`}
readOnly
inputId={`total-amount-${rowIndex}`}
// @ts-expect-error
readOnlyMode="textlike-input"
locale={locale}
/>
</HoverCardTotalsSummary>
</TableCell>
{/* acciones */}
<TableCell className="pt-[4px]">
<div className="flex justify-end gap-0">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
onMouseDown={(e) => e.preventDefault()}
onClick={onDuplicate}
disabled={readOnly}
aria-label={t("common.duplicate_row")}
className="h-8 w-8 self-start translate-y-[-1px]"
>
<CopyIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.duplicate_row")}</TooltipContent>
</Tooltip>
<Button
type="button"
size="icon"
variant="ghost"
onMouseDown={(e) => e.preventDefault()}
onClick={onMoveUp}
disabled={readOnly || isFirst}
aria-label={t("common.move_up")}
className="h-8 w-8 self-start translate-y-[-1px]"
>
<ArrowUp className="size-4" />
</Button>
<Button
type="button"
size="icon"
variant="ghost"
onMouseDown={(e) => e.preventDefault()}
onClick={onMoveDown}
disabled={readOnly || isLast}
aria-label={t("common.move_down")}
className="h-8 w-8 self-start translate-y-[-1px]"
>
<ArrowDown className="size-4" />
</Button>
<Button
type="button"
size="icon"
variant="ghost"
onMouseDown={(e) => e.preventDefault()}
onClick={onRemove}
disabled={readOnly}
aria-label={t("common.remove_row")}
className="h-8 w-8 self-start translate-y-[-1px]"
>
<Trash2 className="size-4" />
</Button>
</div>
</TableCell>
</TableRow >
);
}

View File

@ -13,40 +13,52 @@ export const ItemsEditorToolbar = ({
}: { }: {
readOnly: boolean; readOnly: boolean;
selectedIdx: number[]; selectedIdx: number[];
onAdd: () => void; onAdd?: () => void;
onDuplicate: () => void; onDuplicate?: () => void;
onMoveUp: () => void; onMoveUp?: () => void;
onMoveDown: () => void; onMoveDown?: () => void;
onRemove: () => void; onRemove?: () => void;
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const hasSel = selectedIdx.length > 0; const hasSel = selectedIdx.length > 0;
return ( return (
<nav className="flex items-center h-12 py-1 px-2 text-muted-foreground bg-muted border-b"> <nav className="flex items-center justify-between h-12 py-1 px-2 text-muted-foreground bg-muted border-b">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" onClick={onAdd} disabled={readOnly}>
<PlusIcon className="size-4 mr-1" />
{t("common.add_line")}
</Button>
<Tooltip> {onAdd && (
<TooltipTrigger asChild> <Tooltip>
<Button <TooltipTrigger asChild>
type="button" <Button type='button' variant='outline' size='sm' onClick={onAdd} disabled={readOnly}>
size="sm" <PlusIcon className='size-4 mr-1' />
variant="outline" {t("common.append_empty_row")}
onMouseDown={(e) => e.preventDefault()} </Button>
onClick={onDuplicate} </TooltipTrigger>
disabled={!hasSel || readOnly} <TooltipContent>{t("common.append_empty_row_tooltip")}</TooltipContent>
> </Tooltip>
<CopyPlusIcon className="size-4 sm:mr-2" /> )}
<span className="sr-only sm:not-sr-only">{t("common.duplicate_selected_rows")}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.duplicate_selected_rows_tooltip")}</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="mx-2" /> {onDuplicate && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type='button'
size='sm'
variant='outline'
onMouseDown={(e) => e.preventDefault()}
onClick={onDuplicate}
disabled={!hasSel || readOnly}
>
<CopyPlusIcon className='size-4 sm:mr-2' />
<span className='sr-only sm:not-sr-only'>
{t("common.duplicate_selected_rows")}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.duplicate_selected_rows_tooltip")}</TooltipContent>
</Tooltip>
)}
{/*<Separator orientation="vertical" className="mx-2" />
<Button <Button
type="button" type="button"
@ -71,24 +83,34 @@ export const ItemsEditorToolbar = ({
</Button> </Button>
<Separator orientation="vertical" className="mx-2" /> <Separator orientation="vertical" className="mx-2" />
*/}
<Tooltip> {onRemove && (<>
<TooltipTrigger asChild> <Separator orientation="vertical" className="mx-2" />
<Button <Tooltip>
type="button" <TooltipTrigger asChild>
size="sm" <Button
variant="outline" type='button'
onMouseDown={(e) => e.preventDefault()} size='sm'
onClick={onRemove} variant='outline'
disabled={!hasSel || readOnly} onMouseDown={(e) => e.preventDefault()}
aria-label={t("common.remove_selected_rows")} onClick={onRemove}
> disabled={!hasSel || readOnly}
<Trash2Icon className="size-4 sm:mr-2" /> aria-label={t("common.remove_selected_rows")}
<span className="sr-only sm:not-sr-only">{t("common.remove_selected_rows")}</span> >
</Button> <Trash2Icon className='size-4 sm:mr-2' />
</TooltipTrigger> <span className='sr-only sm:not-sr-only'>{t("common.remove_selected_rows")}</span>
<TooltipContent>{t("common.remove_selected_rows_tooltip")}</TooltipContent> </Button>
</Tooltip> </TooltipTrigger>
<TooltipContent>{t("common.remove_selected_rows_tooltip")}</TooltipContent>
</Tooltip>
</>
)}
</div>
<div className="flex items-center gap-2">
<p className='text-sm font-normal'>
{t("common.rows_selected", { count: selectedIdx.length })}
</p>
</div> </div>
</nav> </nav>
); );

View File

@ -1,10 +1,19 @@
import { Table, TableBody, TableFooter, TableHead, TableHeader, TableRow } from "@repo/shadcn-ui/components"; import { SpainTaxCatalogProvider } from '@erp/core';
import { TaxesMultiSelect } from '@erp/core/components';
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 * as React from "react"; import * as React from "react";
import { useFormContext } from "react-hook-form"; import { Controller, useFormContext } from "react-hook-form";
import { useCalculateItemAmounts, useItemsTableNavigation } from '../../../hooks';
import { useTranslation } from '../../../i18n'; import { useTranslation } from '../../../i18n';
import { CustomerInvoiceItemFormData } from '../../../schemas'; import { CustomerInvoiceItemFormData, defaultCustomerInvoiceItemFormData } from '../../../schemas';
import { ItemEditorRow } from './items-editor-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 { PercentageDTOInputField } from './percentage-dto-input-field';
import { QuantityDTOInputField } from './quantity-dto-input-field';
import { TaxMultiSelectField } from './tax-multi-select-field';
import { TAXES } from './types.d';
interface ItemsEditorProps { interface ItemsEditorProps {
value?: CustomerInvoiceItemFormData[]; value?: CustomerInvoiceItemFormData[];
@ -12,15 +21,30 @@ interface ItemsEditorProps {
readOnly?: boolean; readOnly?: boolean;
} }
const createEmptyItem = () => defaultCustomerInvoiceItemFormData;
export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEditorProps) => { export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEditorProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const form = useFormContext(); const form = useFormContext();
const taxCatalog = React.useMemo(() => SpainTaxCatalogProvider(), []);
const { watch } = form; const tableNav = useItemsTableNavigation(form, {
name: "items",
createEmpty: createEmptyItem,
firstEditableField: "description",
});
const { control, watch } = form;
const [selection, setSelection] = React.useState<Set<number>>(new Set()); const [selection, setSelection] = React.useState<Set<number>>(new Set());
const selectedIdx = React.useMemo(() => [...selection].sort((a, b) => a - b), [selection]); const selectedIdx = React.useMemo(() => [...selection].sort((a, b) => a - b), [selection]);
const resetSelection = () => setSelection(new Set());
const calculateItemAmounts = useCalculateItemAmounts({
currencyCode: 'EUR',
locale: 'es',
taxCatalog
});
// Emitir cambios a quien consuma el componente // Emitir cambios a quien consuma el componente
React.useEffect(() => { React.useEffect(() => {
@ -42,14 +66,17 @@ export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEdi
<ItemsEditorToolbar <ItemsEditorToolbar
readOnly={readOnly} readOnly={readOnly}
selectedIdx={selectedIdx} selectedIdx={selectedIdx}
onAdd={() => nav.addEmpty(true)} onAdd={() => tableNav.addEmpty(true)}
onDuplicate={() => selectedIdx.forEach((i) => nav.duplicate(i))} onDuplicate={() => selectedIdx.forEach((i) => tableNav.duplicate(i))}
onMoveUp={() => selectedIdx.forEach((i) => nav.moveUp(i))} onMoveUp={() => selectedIdx.forEach((i) => tableNav.moveUp(i))}
onMoveDown={() => [...selectedIdx].reverse().forEach((i) => nav.moveDown(i))} onMoveDown={() => [...selectedIdx].reverse().forEach((i) => tableNav.moveDown(i))}
onRemove={() => [...selectedIdx].reverse().forEach((i) => nav.remove(i))} onRemove={() => {
[...selectedIdx].reverse().forEach((i) => tableNav.remove(i));
resetSelection();
}}
/> />
<div className="container"> <div className="bg-background">
<Table className="w-full border-collapse text-sm"> <Table className="w-full border-collapse text-sm">
<colgroup> <colgroup>
<col className='w-[1%]' /> {/* sel */} <col className='w-[1%]' /> {/* sel */}
@ -59,12 +86,19 @@ export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEdi
<col className="w-[10%]" /> {/* unit */} <col className="w-[10%]" /> {/* unit */}
<col className="w-[4%]" /> {/* discount */} <col className="w-[4%]" /> {/* discount */}
<col className="w-[16%]" /> {/* taxes */} <col className="w-[16%]" /> {/* taxes */}
<col className="w-[8%]" /> {/* taxes2 */}
<col className="w-[12%]" /> {/* total */} <col className="w-[12%]" /> {/* total */}
<col className='w-[10%]' /> {/* actions */} <col className='w-[10%]' /> {/* actions */}
</colgroup> </colgroup>
<TableHeader className="text-sm bg-muted backdrop-blur supports-[backdrop-filter]:bg-muted/60 "> <TableHeader className="text-sm bg-muted backdrop-blur supports-[backdrop-filter]:bg-muted/60 ">
<TableRow> <TableRow>
<TableHead aria-hidden="true" /> <TableHead><div className='h-5'>
<Checkbox
aria-label={t("common.select_row")}
className='block h-5 w-5 leading-none align-middle'
disabled={readOnly}
/>
</div></TableHead>
<TableHead>#</TableHead> <TableHead>#</TableHead>
<TableHead>{t("form_fields.item.description.label")}</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.quantity.label")}</TableHead>
@ -76,33 +110,224 @@ export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEdi
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody className='text-sm'> <TableBody className='text-sm'>
{nav.fa.fields.map((f, i) => ( {tableNav.fa.fields.map((f, rowIndex) => {
<ItemEditorRow locale={"es"} //const comp = calculateItemAmounts(f);
//console.log(comp);
const isFirst = rowIndex === 0;
const isLast = rowIndex === tableNav.fa.fields.length - 1;
key={f.id} return (
itemRow={f} <TableRow key={`row-${f.id}`} data-row-index={rowIndex}>
rowIndex={i} {/* selección */}
readOnly={readOnly} <TableCell className='align-top'>
isFirst={i === 0} <div className='h-5'>
isLast={i === nav.fa.fields.length - 1} <Checkbox
checked={selection.has(i)} aria-label={t("common.select_row", { n: rowIndex + 1 })}
onToggle={() => toggleSel(i)} className='block h-5 w-5 leading-none align-middle'
onDuplicate={() => nav.duplicate(i)} checked={selection.has(rowIndex)}
onMoveUp={() => nav.moveUp(i)} onCheckedChange={() => toggleSel(rowIndex)}
onMoveDown={() => nav.moveDown(i)} disabled={readOnly}
onRemove={() => nav.remove(i)} />
/> </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>
<TaxMultiSelectField
name={`items.${rowIndex}.tax_codes`}
control={form.control}
lookupCatalog={taxCatalog.toOptionLookup()}
disabled={readOnly}
/>
{/*<Controller
control={control}
name={`items.${rowIndex}.tax_codes`}
render={({ field }) => (
<TaxMultiSelect
catalog={TAXES}
value={field.value ?? ["iva_21"]}
onChange={field.onChange}
disabled={readOnly}
buttonClassName='h-8 self-start translate-y-[-1px]'
/>
)}
/>*/}
</TableCell>
{/* taxes2 */}
<TableCell>
<Controller
control={control}
name={`items.${rowIndex}.tax_codes`}
render={({ field }) => (
<TaxesMultiSelect
catalog={TAXES}
value={field.value ?? ["iva_21"]}
onChange={field.onChange}
disabled={readOnly}
buttonClassName='h-8 self-start translate-y-[-1px]'
/>
)}
/>
</TableCell>
{/* total (solo lectura) */}
<TableCell className='text-right tabular-nums pt-[6px] leading-5'>
<HoverCardTotalsSummary data={{ ...f }}>
<AmountDTOInputField
control={control}
name={`items.${rowIndex}.total_amount`}
readOnly
inputId={`total-amount-${rowIndex}`}
// @ts-expect-error
readOnlyMode='textlike-input'
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>
<TableCell colSpan={9}>
<ItemsEditorToolbar
readOnly={readOnly}
selectedIdx={selectedIdx}
onAdd={() => tableNav.addEmpty(true)}
/>
</TableCell>
</TableRow>
</TableFooter> </TableFooter>
</Table> </Table>
</div> </div>
{/* Navegación por TAB: último campo de la fila */} {/* Navegación por TAB: último campo de la fila */}
<LastCellTabHook linesLen={nav.fa.fields.length} onTabFromLast={nav.onTabFromLastCell} /> <LastCellTabHook linesLen={tableNav.fa.fields.length} onTabFromLast={tableNav.onTabFromLastCell} />
</div> </div >
); );
} }

View File

@ -0,0 +1,262 @@
import { TaxItemType, TaxLookupItems } from '@erp/core';
import {
Badge,
Button,
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
Popover,
PopoverContent,
PopoverTrigger,
Separator
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { VariantProps, cva } from 'class-variance-authority';
import { CheckIcon, ChevronDownIcon, WandSparklesIcon, XCircleIcon } from "lucide-react";
import * as React from "react";
import { Control, useController } from "react-hook-form";
import { useTranslation } from 'react-i18next';
/**
* 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",
},
}
);
interface TaxMultiSelectFieldProps extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof multiSelectVariants> {
name: string;
control: Control<any>;
lookupCatalog: TaxLookupItems;
disabled?: boolean;
buttonClassName?: string;
onValidateOption?: (value: string) => boolean;
placeholder?: string;
defaultValue?: string[];
maxCount?: number; // Maximum number of items to display
animation?: number; // Animation duration in seconds for the visual effects
modalPopover?: boolean;
}
export const TaxMultiSelectField = ({
name,
control,
lookupCatalog,
disabled,
buttonClassName,
variant,
onValidateOption,
defaultValue = [],
placeholder,
maxCount = 3,
animation = 0,
modalPopover = false,
}: TaxMultiSelectFieldProps) => {
const { t } = useTranslation();
const [isAnimating, setIsAnimating] = React.useState(true);
const { field } = useController({ name, control });
const [open, setOpen] = React.useState(false);
const value: string[] = field.value ?? [];
const selected = value.map((id) => lookupCatalog.find((item) => item.code === id)).filter(Boolean);
// Agrupar catálogo por grupo
const grouped = React.useMemo(() => {
return Object.values(lookupCatalog).reduce<Record<string, TaxItemType[]>>((acc, item) => {
if (!acc[item.group]) acc[item.group] = [];
acc[item.group].push(item);
return acc;
}, {});
}, [lookupCatalog]);
// Mantener un solo elemento por grupo
const toggleTax = (id: string) => {
const t = lookupCatalog.find((item) => item.code === id);
if (!t) return;
const active = value.includes(id);
let next: string[];
if (active) {
next = value.filter((v) => v !== id);
} else {
next = [...value.filter((v) => lookupCatalog.find((item) => item.code === v)?.group !== t.group), id];
}
field.onChange(next);
};
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
setOpen(true);
} else if (event.key === "Backspace" && !event.currentTarget.value) {
const newSelectedValues = [...value];
newSelectedValues.pop();
field.onChange(newSelectedValues);
}
};
const handleTogglePopover = () => {
setOpen((prev) => !prev);
};
const clearExtraOptions = () => {
field.onChange(selected.slice(0, maxCount));
};
const handleClear = () => {
field.onChange([]);
};
return (
<Popover open={open} onOpenChange={setOpen} modal={modalPopover}>
<PopoverTrigger asChild>
<Button
onClick={handleTogglePopover}
className={cn(
"flex w-full -mt-0.5 px-1 py-0.5 rounded-md border min-h-8 h-auto items-center justify-between bg-background hover:bg-inherit [&_svg]:pointer-events-auto",
buttonClassName
)}
disabled={disabled}
aria-label="Select taxes"
>
<div className="flex gap-1 flex-wrap">
{selected.length > 0 ? (
<div className='flex justify-between items-center w-full'>
<div className='flex flex-wrap items-center'>
{selected.slice(0, maxCount).map((item) => {
const option = lookupCatalog.find((o) => o.code === item?.code);
return (
<Badge
key={item?.code}
className={cn(
isAnimating ? "animate-bounce" : "",
multiSelectVariants({ variant })
)}
style={{ animationDuration: `${animation}s` }}
>
{option?.name}
{/*<XCircle
className='ml-2 h-4 w-4 cursor-pointer'
onClick={(event) => {
event.stopPropagation();
toggleOption(value);
}}
/>*/}
</Badge>
);
})}
{selected.length > maxCount && (
<Badge
className={cn(
"bg-transparent text-foreground border-foreground/1 hover:bg-transparent",
isAnimating ? "animate-bounce" : "",
multiSelectVariants({ variant })
)}
style={{ animationDuration: `${animation}s` }}
>
{`+ ${selected.length - maxCount} more`}
<XCircleIcon
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' />
<ChevronDownIcon 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>
<ChevronDownIcon className='h-4 cursor-pointer text-muted-foreground mx-2' />
</div>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-64" align="start">
<Command>
<CommandInput placeholder={t("common.search")} onKeyDown={handleInputKeyDown} />
<CommandList>
{Object.entries(grouped).map(([group, items], idx, arr) => (
<React.Fragment key={group}>
<CommandGroup key={`group-${group || "ungrouped"}`} heading={group}>
{items.map((t) => {
const active = value.includes(t.code);
return (
<CommandItem
key={t.code}
onSelect={() => toggleTax(t.code)}
aria-selected={active}
className='cursor-pointer'
>
<div
className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
active
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}
>
<CheckIcon
className={cn("h-4 w-4", active ? "text-primary-foreground" : "")}
/>
</div>
<span>{t.name}</span>
</CommandItem>
);
})}
</CommandGroup>
{idx < arr.length - 1 && <Separator className="my-1" />}
</React.Fragment>
))}
</CommandList>
</Command>
</PopoverContent>
{animation > 0 && value.length > 0 && (
<WandSparklesIcon
className={cn(
"cursor-pointer my-2 text-foreground bg-background w-3 h-3",
isAnimating ? "" : "text-muted-foreground"
)}
onClick={() => setIsAnimating(!isAnimating)}
/>
)}
</Popover>
);
}

View File

@ -1,47 +0,0 @@
import { Badge, Button, Command, CommandInput, CommandItem, CommandList, Popover, PopoverContent, PopoverTrigger, ScrollArea } from "@repo/shadcn-ui/components";
import { cn } from '@repo/shadcn-ui/lib/utils';
import { Check } from "lucide-react";
import * as React from "react";
import { TaxCatalog } from "./types.d";
export function TaxMultiSelect({
catalog, value, onChange, disabled, buttonClassName,
}: { catalog: TaxCatalog; value: string[]; onChange: (ids: string[]) => void; disabled?: boolean; buttonClassName?: string }) {
const [open, setOpen] = React.useState(false);
const selected = value.map(id => catalog[id]).filter(Boolean);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button size="sm" variant="outline" className={cn("justify-start w-full overflow-hidden h-8 self-start", buttonClassName)} disabled={disabled} aria-label="Select taxes">
<div className="flex gap-1 flex-wrap">
{selected.length ? selected.map(t => <Badge key={t.id} variant="secondary">{t.label}</Badge>) : <span className="text-muted-foreground">IVA 21 % por defecto</span>}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-64" align="start">
<Command>
<CommandInput placeholder="Buscar impuesto…" />
<ScrollArea className="max-h-60">
<CommandList>
{Object.values(catalog).map(t => {
const active = value.includes(t.id);
return (
<CommandItem
key={t.id}
onSelect={() =>
onChange(active ? value.filter(v => v !== t.id) : [...value, t.id])
}
aria-selected={active} role="option"
>
<Check className={`mr-2 h-4 w-4 ${active ? "opacity-100" : "opacity-0"}`} />
{t.label}
</CommandItem>
);
})}
</CommandList>
</ScrollArea>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@ -7,15 +7,15 @@ import { CustomerInvoiceItem } from "../schemas";
* Recalcula todos los importes de una línea usando los hooks de escala. * Recalcula todos los importes de una línea usando los hooks de escala.
*/ */
type UseCalculateItemAmountsOptions = { type UseCalculateItemAmountsParams = {
locale: string; locale: string;
currencyCode: string; currencyCode: string;
keepNullWhenEmpty?: boolean; // Mantener todos los importes a null cuando la línea está “vacía” (qty+unit vacíos) keepNullWhenEmpty?: boolean; // Mantener todos los importes a null cuando la línea está “vacía” (qty+unit vacíos)
taxCatalog: TaxCatalogProvider; // Catálogo de impuestos (inyectable para test) taxCatalog: TaxCatalogProvider; // Catálogo de impuestos (inyectable para test)
}; };
export function useCalculateItemAmounts(opts: UseCalculateItemAmountsOptions) { export function useCalculateItemAmounts(params: UseCalculateItemAmountsParams) {
const { locale, currencyCode, taxCatalog, keepNullWhenEmpty } = opts; const { locale, currencyCode, taxCatalog, keepNullWhenEmpty } = params;
const { const {
add, add,

View File

@ -1,4 +1,4 @@
import { MoneyDTO, PercentageDTO, QuantityDTO, spainTaxCatalogProvider } from "@erp/core"; import { MoneyDTO, PercentageDTO, QuantityDTO, SpainTaxCatalogProvider } from "@erp/core";
import { useMoney, usePercentage, useQuantity } from "@erp/core/hooks"; import { useMoney, usePercentage, useQuantity } from "@erp/core/hooks";
import { useMemo } from "react"; import { useMemo } from "react";
@ -36,7 +36,7 @@ export function useInvoiceItemSummary(item: ItemShape) {
return fromNumber(0, cur as any, sc); return fromNumber(0, cur as any, sc);
}, [item.unit_amount?.currency_code, item.unit_amount?.scale, fromNumber, fallbackCurrency]); }, [item.unit_amount?.currency_code, item.unit_amount?.scale, fromNumber, fallbackCurrency]);
const taxCatalog = useMemo(() => spainTaxCatalogProvider, []); const taxCatalog = useMemo(() => SpainTaxCatalogProvider(), []);
return useMemo(() => { return useMemo(() => {
// 1) Cantidad // 1) Cantidad

View File

@ -19,10 +19,9 @@ import {
import { useCustomerInvoiceQuery, useUpdateCustomerInvoice } from "../../hooks"; import { useCustomerInvoiceQuery, useUpdateCustomerInvoice } from "../../hooks";
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
import { import {
CustomerInvoice,
CustomerInvoiceFormData, CustomerInvoiceFormData,
CustomerInvoiceFormSchema, CustomerInvoiceFormSchema,
defaultCustomerInvoiceFormData, defaultCustomerInvoiceFormData
} from "../../schemas"; } from "../../schemas";
export const CustomerInvoiceUpdatePage = () => { export const CustomerInvoiceUpdatePage = () => {
@ -48,9 +47,9 @@ export const CustomerInvoiceUpdatePage = () => {
// 3) Form hook // 3) Form hook
const form = useHookForm<CustomerInvoice>({ const form = useHookForm<CustomerInvoiceFormData>({
resolverSchema: CustomerInvoiceFormSchema, resolverSchema: CustomerInvoiceFormSchema,
initialValues: invoiceData ?? defaultCustomerInvoiceFormData, initialValues: (invoiceData as unknown as CustomerInvoiceFormData) ?? defaultCustomerInvoiceFormData,
disabled: isUpdating, disabled: isUpdating,
}); });
@ -72,7 +71,7 @@ export const CustomerInvoiceUpdatePage = () => {
showSuccessToast(t("pages.update.successTitle"), t("pages.update.successMsg")); showSuccessToast(t("pages.update.successTitle"), t("pages.update.successMsg"));
// 🔹 limpiar el form e isDirty pasa a false // 🔹 limpiar el form e isDirty pasa a false
form.reset(data); form.reset(data as unknown as CustomerInvoiceFormData);
}, },
onError(error) { onError(error) {
showErrorToast(t("pages.update.errorTitle"), error.message); showErrorToast(t("pages.update.errorTitle"), error.message);
@ -81,7 +80,8 @@ export const CustomerInvoiceUpdatePage = () => {
); );
}; };
const handleReset = () => form.reset(invoiceData ?? defaultCustomerInvoiceFormData); const handleReset = () =>
form.reset((invoiceData as unknown as CustomerInvoiceFormData) ?? defaultCustomerInvoiceFormData);
const handleBack = () => { const handleBack = () => {
navigate(-1); navigate(-1);

View File

@ -85,18 +85,18 @@ export const defaultCustomerInvoiceItemFormData: CustomerInvoiceItemFormData = {
description: "", description: "",
quantity: { quantity: {
value: "0", value: "",
scale: "2", scale: "2",
}, },
unit_amount: { unit_amount: {
currency_code: "EUR", currency_code: "EUR",
value: "0", value: "",
scale: "4", scale: "4",
}, },
discount_percentage: { discount_percentage: {
value: "0", value: "",
scale: "2", scale: "2",
}, },
@ -104,7 +104,7 @@ export const defaultCustomerInvoiceItemFormData: CustomerInvoiceItemFormData = {
total_amount: { total_amount: {
currency_code: "EUR", currency_code: "EUR",
value: "0", value: "",
scale: "4", scale: "4",
}, },
}; };

View File

@ -57,25 +57,21 @@ export default (database: Sequelize) => {
url: { url: {
type: new DataTypes.TEXT(), type: new DataTypes.TEXT(),
allowNull: false, allowNull: false,
defaultValue: null,
}, },
qr1: { qr1: {
type: new DataTypes.JSON(), type: new DataTypes.JSON(),
allowNull: false, allowNull: false,
defaultValue: null,
}, },
qr2: { qr2: {
type: new DataTypes.BLOB(), type: new DataTypes.BLOB(),
allowNull: false, allowNull: false,
defaultValue: null,
}, },
uuid: { uuid: {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: false, allowNull: false,
defaultValue: null,
}, },
operacion: { operacion: {

View File

@ -65,7 +65,7 @@ export type MultiSelectOptionType = {
*/ */
export interface MultiSelectProps export interface MultiSelectProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof multiSelectVariants> { VariantProps<typeof multiSelectVariants> {
/** /**
* An array of option objects to be displayed in the multi-select component. * An array of option objects to be displayed in the multi-select component.
* Each option object has a label, value, and an optional icon. * Each option object has a label, value, and an optional icon.

View File

@ -516,6 +516,9 @@ importers:
ag-grid-react: ag-grid-react:
specifier: ^33.3.0 specifier: ^33.3.0
version: 33.3.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) version: 33.3.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
date-fns: date-fns:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0 version: 4.1.0