Facturas de cliente
This commit is contained in:
parent
6559317e2f
commit
04edf3df68
@ -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)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -11,3 +11,5 @@ export type TaxItemType = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type TaxCatalogType = TaxItemType[];
|
export type TaxCatalogType = TaxItemType[];
|
||||||
|
|
||||||
|
export type TaxLookupItems = TaxItemType[];
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 >
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user