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 */
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 */

View File

@ -12,4 +12,8 @@ export type 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,
CustomerModel,
CustomerInvoiceTaxModel,
VerifactuRecordModel,
} = database.models;
CustomerInvoiceModel.belongsTo(CustomerModel, {

View File

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

View File

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

View File

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

View File

@ -82,7 +82,7 @@ export interface MultiSelectProps
* Optional function to validate an option before selection.
* 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. */
defaultValue?: string[];
@ -129,6 +129,15 @@ export interface MultiSelectProps
* Optional, defaults to false.
*/
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>(
@ -146,16 +155,33 @@ export const MultiSelect = React.forwardRef<HTMLButtonElement, MultiSelectProps>
asChild = false,
className,
selectAllVisible = false,
filterSelected,
autoFilter = false,
...props
},
ref
) => {
const [selectedValues, setSelectedValues] = React.useState<string[]>(defaultValue);
const [selectedValues, setSelectedValues] = React.useState<string[]>(defaultValue ?? []);
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
const [isAnimating, setIsAnimating] = React.useState(false);
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) => {
if (!acc[item.group || ""]) acc[item.group || ""] = [];
acc[item.group || ""].push(item);
@ -174,7 +200,7 @@ export const MultiSelect = React.forwardRef<HTMLButtonElement, MultiSelectProps>
};
const toggleOption = (option: string) => {
if (onValidateOption && !onValidateOption(option)) {
if (onValidateOption && !onValidateOption(option, selectedValues)) {
console.warn(`Option "${option}" is not valid.`);
return;
}