Facturas de cliente

This commit is contained in:
David Arranz 2025-10-08 17:44:08 +02:00
parent 04edf3df68
commit 0f4619dd06
7 changed files with 75 additions and 43 deletions

View File

@ -37,7 +37,11 @@ export class JsonTaxCatalogProvider implements TaxCatalogProvider {
/** Devuelve un objeto indexado por código, compatible con TaxMultiSelectField */ /** Devuelve un objeto indexado por código, compatible con TaxMultiSelectField */
toOptionLookup(): TaxLookupItems { toOptionLookup(): TaxLookupItems {
return Array.from(this.catalog.values()); return this.getAll().map((item) => ({
label: item.name,
value: item.code,
group: item.group,
}));
} }
/** Devuelve la lista única de grupos disponibles */ /** Devuelve la lista única de grupos disponibles */

View File

@ -12,4 +12,8 @@ export type TaxItemType = {
export type TaxCatalogType = TaxItemType[]; export type TaxCatalogType = TaxItemType[];
export type TaxLookupItems = TaxItemType[]; export type TaxLookupItems = {
label: string;
value: string;
group: string;
}[];

View File

@ -100,6 +100,7 @@ export class CustomerInvoiceModel extends Model<
CustomerInvoiceItemModel, CustomerInvoiceItemModel,
CustomerModel, CustomerModel,
CustomerInvoiceTaxModel, CustomerInvoiceTaxModel,
VerifactuRecordModel,
} = database.models; } = database.models;
CustomerInvoiceModel.belongsTo(CustomerModel, { CustomerInvoiceModel.belongsTo(CustomerModel, {

View File

@ -1,5 +1,7 @@
import { SpainTaxCatalogProvider } from '@erp/core';
import { MultiSelect } from "@repo/rdx-ui/components"; import { MultiSelect } from "@repo/rdx-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils"; import { cn } from "@repo/shadcn-ui/lib/utils";
import { useCallback, useMemo } from 'react';
import { useTranslation } from "../i18n"; import { useTranslation } from "../i18n";
const taxesList = [ const taxesList = [
@ -33,6 +35,8 @@ const taxesList = [
{ label: "REC 0%", value: "rec_0", group: "Recargo de equivalencia" }, { label: "REC 0%", value: "rec_0", group: "Recargo de equivalencia" },
]; ];
interface CustomerInvoiceTaxesMultiSelect { interface CustomerInvoiceTaxesMultiSelect {
value: string[]; value: string[];
onChange: (selectedValues: string[]) => void; onChange: (selectedValues: string[]) => void;
@ -43,29 +47,37 @@ export const CustomerInvoiceTaxesMultiSelect = (props: CustomerInvoiceTaxesMulti
const { value, onChange, ...otherProps } = props; const { value, onChange, ...otherProps } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const handleOnChange = (selectedValues: string[]) => { const taxCatalog = useMemo(() => SpainTaxCatalogProvider(), []);
onChange(selectedValues); const catalogLookup = useMemo(() => SpainTaxCatalogProvider().toOptionLookup(), []);
};
const handleValidateOption = (candidateValue: string) => { /**
const exists = (value || []).some((item) => item.startsWith(candidateValue.substring(0, 3))); * Filtra para mantener solo un elemento por grupo.
if (exists) { * Si hay duplicados dentro del mismo grupo, se queda con el último.
alert(t("components.customer_invoice_taxes_multi_select.invalid_tax_selection")); */
} const filterSelectedByGroup = useCallback((selectedValues: string[]) => {
return exists === false; const groupMap = new Map<string | undefined, string>();
};
selectedValues.forEach((code) => {
const item = taxCatalog.findByCode(code).getOrUndefined();
const group = item?.group ?? "ungrouped";
groupMap.set(group, code); // Sobrescribe el anterior del mismo grupo
});
return Array.from(groupMap.values());
}, [taxCatalog]);
return ( return (
<div className={cn("w-full", "max-w-md")}> <div className={cn("w-full", "max-w-md")}>
<MultiSelect <MultiSelect
options={taxesList} options={catalogLookup}
onValueChange={handleOnChange} onValueChange={onChange}
onValidateOption={handleValidateOption}
defaultValue={value} defaultValue={value}
placeholder={t("components.customer_invoice_taxes_multi_select.placeholder")} placeholder={t("components.customer_invoice_taxes_multi_select.placeholder")}
variant='inverted' variant='inverted'
animation={0} animation={0}
maxCount={3} maxCount={3}
autoFilter={true}
filterSelected={filterSelectedByGroup}
{...otherProps} {...otherProps}
/> />
</div> </div>

View File

@ -7,12 +7,12 @@ import { Controller, useFormContext } from "react-hook-form";
import { useCalculateItemAmounts, useItemsTableNavigation } from '../../../hooks'; import { useCalculateItemAmounts, useItemsTableNavigation } from '../../../hooks';
import { useTranslation } from '../../../i18n'; import { useTranslation } from '../../../i18n';
import { CustomerInvoiceItemFormData, defaultCustomerInvoiceItemFormData } from '../../../schemas'; import { CustomerInvoiceItemFormData, defaultCustomerInvoiceItemFormData } from '../../../schemas';
import { CustomerInvoiceTaxesMultiSelect } from '../../customer-invoice-taxes-multi-select';
import { AmountDTOInputField } from './amount-dto-input-field'; import { AmountDTOInputField } from './amount-dto-input-field';
import { HoverCardTotalsSummary } from './hover-card-total-summary'; 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 { PercentageDTOInputField } from './percentage-dto-input-field';
import { QuantityDTOInputField } from './quantity-dto-input-field'; import { QuantityDTOInputField } from './quantity-dto-input-field';
import { TaxMultiSelectField } from './tax-multi-select-field';
import { TAXES } from './types.d'; import { TAXES } from './types.d';
interface ItemsEditorProps { interface ItemsEditorProps {
@ -197,25 +197,16 @@ export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEdi
{/* taxes */} {/* taxes */}
<TableCell> <TableCell>
<TaxMultiSelectField <Controller
name={`items.${rowIndex}.tax_codes`}
control={form.control}
lookupCatalog={taxCatalog.toOptionLookup()}
disabled={readOnly}
/>
{/*<Controller
control={control} control={control}
name={`items.${rowIndex}.tax_codes`} name={`items.${rowIndex}.tax_codes`}
render={({ field }) => ( render={({ field }) => (
<TaxMultiSelect <CustomerInvoiceTaxesMultiSelect
catalog={TAXES} value={field.value}
value={field.value ?? ["iva_21"]}
onChange={field.onChange} onChange={field.onChange}
disabled={readOnly}
buttonClassName='h-8 self-start translate-y-[-1px]'
/> />
)} )}
/>*/} />
</TableCell> </TableCell>
{/* taxes2 */} {/* taxes2 */}

View File

@ -16,17 +16,16 @@ export class VerifactuRecordModel extends Model<
declare estado: string; declare estado: string;
declare url: string; declare url: string;
declare qr1: JSON; declare qr: Blob;
declare qr2: Blob;
declare uuid: string; declare uuid: string;
declare operacion: string; declare operacion: string;
static associate(database: Sequelize) { static associate(database: Sequelize) {
const { VerifactuRecordModel } = database.models; const { CustomerInvoiceModel } = database.models;
VerifactuRecordModel.belongsTo(VerifactuRecordModel, { VerifactuRecordModel.belongsTo(CustomerInvoiceModel, {
as: "verifactu-record", as: "verifactu_records",
targetKey: "id", targetKey: "id",
foreignKey: "invoice_id", foreignKey: "invoice_id",
onDelete: "CASCADE", onDelete: "CASCADE",
@ -59,12 +58,7 @@ export default (database: Sequelize) => {
allowNull: false, allowNull: false,
}, },
qr1: { qr: {
type: new DataTypes.JSON(),
allowNull: false,
},
qr2: {
type: new DataTypes.BLOB(), type: new DataTypes.BLOB(),
allowNull: false, allowNull: false,
}, },

View File

@ -82,7 +82,7 @@ export interface MultiSelectProps
* Optional function to validate an option before selection. * Optional function to validate an option before selection.
* Receives the option value and should return true if valid, false otherwise. * Receives the option value and should return true if valid, false otherwise.
*/ */
onValidateOption?: (value: string) => boolean; onValidateOption?: (value: string, selectedValues: string[]) => boolean;
/** The default selected values when the component mounts. */ /** The default selected values when the component mounts. */
defaultValue?: string[]; defaultValue?: string[];
@ -129,6 +129,15 @@ export interface MultiSelectProps
* Optional, defaults to false. * Optional, defaults to false.
*/ */
selectAllVisible?: boolean; selectAllVisible?: boolean;
/**
* Filtra los items seleccionados
*/
filterSelected?: (selectedValues: string[]) => string[];
/** Si true, aplica el filtro automáticamente al cambiar los items seleccionados */
autoFilter?: boolean;
} }
export const MultiSelect = React.forwardRef<HTMLButtonElement, MultiSelectProps>( export const MultiSelect = React.forwardRef<HTMLButtonElement, MultiSelectProps>(
@ -146,16 +155,33 @@ export const MultiSelect = React.forwardRef<HTMLButtonElement, MultiSelectProps>
asChild = false, asChild = false,
className, className,
selectAllVisible = false, selectAllVisible = false,
filterSelected,
autoFilter = false,
...props ...props
}, },
ref ref
) => { ) => {
const [selectedValues, setSelectedValues] = React.useState<string[]>(defaultValue); const [selectedValues, setSelectedValues] = React.useState<string[]>(defaultValue ?? []);
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false); const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
const [isAnimating, setIsAnimating] = React.useState(false); const [isAnimating, setIsAnimating] = React.useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const applySelectedFilter = React.useCallback(() => {
if (!filterSelected) return;
const filtered = filterSelected(selectedValues);
if (filtered.length !== selectedValues.length) {
setSelectedValues(filtered);
onValueChange(filtered);
}
}, [filterSelected, selectedValues, onValueChange]);
// Filtro automático cuando cambia selectedValues
React.useEffect(() => {
if (autoFilter) applySelectedFilter();
}, [autoFilter, selectedValues, applySelectedFilter]);
const grouped = options.reduce<Record<string, MultiSelectOptionType[]>>((acc, item) => { const grouped = options.reduce<Record<string, MultiSelectOptionType[]>>((acc, item) => {
if (!acc[item.group || ""]) acc[item.group || ""] = []; if (!acc[item.group || ""]) acc[item.group || ""] = [];
acc[item.group || ""].push(item); acc[item.group || ""].push(item);
@ -174,7 +200,7 @@ export const MultiSelect = React.forwardRef<HTMLButtonElement, MultiSelectProps>
}; };
const toggleOption = (option: string) => { const toggleOption = (option: string) => {
if (onValidateOption && !onValidateOption(option)) { if (onValidateOption && !onValidateOption(option, selectedValues)) {
console.warn(`Option "${option}" is not valid.`); console.warn(`Option "${option}" is not valid.`);
return; return;
} }