This commit is contained in:
David Arranz 2026-05-04 12:12:06 +02:00
parent a8ea434ce1
commit 6a19698adc
40 changed files with 367 additions and 262 deletions

View File

@ -1,3 +1,5 @@
import { Maybe, Result } from "@repo/rdx-utils";
import {
EmailAddress,
PhoneNumber,
@ -5,10 +7,11 @@ import {
TINNumber,
UniqueID,
} from "@/core/common/domain";
import { Maybe, Result } from "@repo/rdx-utils";
import { Account, IAccountProps } from "../aggregates";
import { IAccountRepository } from "../repositories";
import { Account, type IAccountProps } from "../aggregates";
import type { IAccountRepository } from "../repositories";
import { AccountStatus } from "../value-objects";
import { AccountService } from "./account.service";
const mockAccountRepository: IAccountRepository = {
@ -113,8 +116,6 @@ describe("AccountService - Integración", () => {
const result = await accountService.activateAccount(existingAccount.id);
console.log(result);
expect(result.isSuccess).toBe(true);
expect(result.data.isActive).toBeTruthy();
expect(mockAccountRepository.update).toHaveBeenCalledWith(result.data);

View File

@ -40,7 +40,6 @@ function listarDirectorio(directorio: string): string[] {
// Retornar rutas absolutas (opcional)
const result = archivos.map((nombre) => path.join(directorio, nombre));
console.log(result);
return result;
} catch (error: any) {
throw new Error(`Error al listar el directorio: ${error.message}`);

View File

@ -218,8 +218,6 @@ function validateModuleDependencies() {
const declared = new Set(pkg.dependencies ?? []);
const used = usedDependenciesByModule.get(moduleName) ?? new Set<string>();
console.log(declared, used);
// ❌ usadas pero no declaradas
const undeclaredUsed = [...used].filter((d) => !declared.has(d));

View File

@ -5,14 +5,13 @@ export const LoginPage = () => {
const { login } = useAuth();
const handleOnSubmit = (data) => {
console.log(data);
const { email, password } = data;
login(email, password);
};
return (
<div className='flex min-h-svh w-full items-center justify-center p-6 md:p-10'>
<div className='w-full max-w-sm'>
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm">
<LoginForm onSubmit={handleOnSubmit} />
</div>
</div>

View File

@ -75,7 +75,7 @@ export const FormCommitButtonGroup = ({
const { t } = useTranslation();
const ctx = useFormContext();
const rhfIsSubmitting = !!ctx.formState.isSubmitting;
const rhfIsSubmitting = ctx.formState.isSubmitting;
const busy = isLoading ?? rhfIsSubmitting;
const showCancel = cancel?.show ?? true;

View File

@ -72,11 +72,6 @@ export const createAxiosDataSource = (client: AxiosInstance): IDataSource => {
): Promise<R> => {
const url = `${resource}/${id}`;
console.log("Axios updateOne => ", {
url,
data,
});
const res = await client.put<R, AxiosResponse<R>, TData>(
url,
data,

View File

@ -533,12 +533,16 @@ export default (database: Sequelize) => {
{ name: "uq_invoice_proforma_id", fields: ["proforma_id"], unique: true }, // <- para asegurar que una proforma solo tenga una factura vinculada
// Para búsquedas simples
{
// Para búsquedas simples => se hace con el "CriteriaToSequelizeConverter"
/*{
name: "ft_customer_invoice",
type: "FULLTEXT",
fields: ["invoice_number", "reference", "description"],
},
fields: [
"CustomerInvoiceModel.invoice_number",
"CustomerInvoiceModel.reference",
"CustomerInvoiceModel.description",
],
},*/
],
whereMergeStrategy: "and", // <- cómo tratar el merge de un scope

View File

@ -220,7 +220,9 @@ export class IssuedInvoiceRepository
const query = criteriaConverter.convert(criteria, {
searchableFields: ["invoice_number", "reference", "description"],
mappings: {
invoice_number: "CustomerInvoiceModel.invoice_number",
reference: "CustomerInvoiceModel.reference",
description: "CustomerInvoiceModel.description",
},
allowedFields: ["invoice_date", "id", "created_at"],
enableFullText: true,

View File

@ -437,7 +437,9 @@ export class ProformaRepository
const query = criteriaConverter.convert(criteria, {
searchableFields: ["invoice_number", "reference", "description"],
mappings: {
invoice_number: "CustomerInvoiceModel.invoice_number",
reference: "CustomerInvoiceModel.reference",
description: "CustomerInvoiceModel.description",
},
allowedFields: ["invoice_date", "id", "created_at"],
enableFullText: true,

View File

@ -10,6 +10,6 @@ export const mapProformaFormToCommercialDocumentLines = (
unitAmount: item.unitAmount,
itemDiscountPercentage: item.itemDiscountPercentage,
taxPercentage: item.taxPercentage,
equivalenceSurchargePercentage: item.equivalenceSurchargePercentage,
recPercentage: item.recPercentage,
}));
};

View File

@ -24,6 +24,6 @@ export const mapProformaItemsToProformaItemsUpdateForm = (
itemDiscountPercentage: item.itemDiscountPercentage ?? null,
taxPercentage: item.ivaPercentage ?? null,
equivalenceSurchargePercentage: item.recPercentage ?? null,
recPercentage: item.recPercentage ?? null,
};
};

View File

@ -1,5 +1,8 @@
import { PercentageHelper } from "@repo/rdx-utils";
import type { Proforma, ProformaItem } from "../../shared";
import type { ProformaTaxMode, ProformaUpdateForm } from "../entities";
import { buildProformaUpdateDefault } from "../utils";
import { mapProformaItemsToProformaItemsUpdateForm } from "./map-proforma-items-to-proforma-items-update-form.adapter";
@ -7,40 +10,46 @@ import { mapProformaItemsToProformaItemsUpdateForm } from "./map-proforma-items-
* Mapea una proforma a un formulario de actualización de proforma.
*/
export const mapProformaToProformaUpdateForm = (proforma: Proforma): ProformaUpdateForm => {
const proformaDefaults = buildProformaUpdateDefault();
const taxMode = inferProformaTaxMode(proforma.items);
const defaultTaxSummary = proforma.taxes[0];
const firstTaxableItem = getFirstTaxableItem(proforma.items);
const defaultTaxPercentage =
defaultTaxSummary?.ivaPercentage ?? firstTaxableItem?.ivaPercentage ?? null;
defaultTaxSummary?.ivaPercentage ??
firstTaxableItem?.ivaPercentage ??
proformaDefaults.defaultTaxPercentage;
const defaultEquivalenceSurchargePercentage =
const defaultRecPercentage =
defaultTaxSummary?.recPercentage ?? firstTaxableItem?.recPercentage ?? null;
const defaultRetentionPercentage =
defaultTaxSummary?.retentionPercentage ?? firstTaxableItem?.retentionPercentage ?? null;
return {
series: proforma.series ?? "",
series: proforma.series ?? proformaDefaults.series,
invoiceDate: proforma.invoiceDate ?? "",
operationDate: proforma.operationDate ?? "",
invoiceDate: proforma.invoiceDate ?? proformaDefaults.invoiceDate,
operationDate: proforma.operationDate ?? proformaDefaults.operationDate,
customerId: proforma.customerId ?? "",
customerId: proforma.customerId ?? proformaDefaults.customerId,
description: proforma.description ?? "",
reference: proforma.reference ?? "",
notes: proforma.notes ?? "",
description: proforma.description ?? proformaDefaults.description,
reference: proforma.reference ?? proformaDefaults.reference,
notes: proforma.notes ?? proformaDefaults.notes,
languageCode: proforma.languageCode ?? "es",
currencyCode: proforma.currencyCode ?? "EUR",
languageCode: proforma.languageCode ?? proformaDefaults.languageCode,
currencyCode: proforma.currencyCode ?? proformaDefaults.currencyCode,
globalDiscountPercentage: proforma.globalDiscountPercentage ?? 0,
globalDiscountPercentage:
proforma.globalDiscountPercentage ?? proformaDefaults.globalDiscountPercentage,
taxMode,
taxRegimeCode: proformaDefaults.taxRegimeCode,
defaultTaxPercentage,
hasEquivalenceSurcharge: hasPositivePercentage(defaultEquivalenceSurchargePercentage),
hasRecPercentage: hasPositivePercentage(defaultRecPercentage),
hasRetention: hasPositivePercentage(defaultRetentionPercentage),
retentionPercentage: defaultRetentionPercentage,
@ -84,5 +93,5 @@ const normalizePercentage = (value: number): number => {
};
const hasPositivePercentage = (value: number | null | undefined): boolean => {
return value !== null && value !== undefined && value > 0;
return PercentageHelper.hasPositivePercentage(value);
};

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect } from "react";
import { useCallback, useEffect, useRef } from "react";
import { type UseFormReturn, useWatch } from "react-hook-form";
import type { ProformaTaxMode, ProformaUpdateForm } from "../entities";
@ -11,7 +11,7 @@ export interface UseUpdateProformaTaxControllerResult {
taxMode: ProformaTaxMode;
defaultTaxPercentage: number | null;
hasEquivalenceSurcharge: boolean;
hasRecPercentage: boolean;
hasRetention: boolean;
retentionPercentage: number | null;
@ -22,9 +22,7 @@ export interface UseUpdateProformaTaxControllerResult {
disablePerLineTaxes: () => void;
}
const getEquivalenceSurchargePercentage = (
taxPercentage: number | null | undefined
): number | null => {
const getRecPercentage = (taxPercentage: number | null | undefined): number | null => {
if (taxPercentage === 21) return 5.2;
if (taxPercentage === 10) return 1.4;
if (taxPercentage === 4) return 0.5;
@ -38,33 +36,40 @@ export const useUpdateProformaTaxController = ({
const { control, getValues, setValue } = form;
const taxMode = useWatch({ control, name: "taxMode" });
const hasEquivalenceSurcharge = useWatch({ control, name: "hasEquivalenceSurcharge" }) ?? false;
const hasRecPercentage = useWatch({ control, name: "hasRecPercentage" }) ?? false;
const hasRetention = useWatch({ control, name: "hasRetention" }) ?? false;
const retentionPercentage = useWatch({ control, name: "retentionPercentage" }) ?? null;
const defaultTaxPercentage = useWatch({ control, name: "defaultTaxPercentage" }) ?? null;
const hasMountedRef = useRef(false);
useEffect(() => {
if (taxMode !== "single") return;
const currentItems = getValues("items") ?? [];
const equivalenceSurchargePercentage = hasEquivalenceSurcharge
? getEquivalenceSurchargePercentage(defaultTaxPercentage)
: null;
const recPercentage = hasRecPercentage ? getRecPercentage(defaultTaxPercentage) : null;
currentItems.forEach((_, index) => {
setValue(`items.${index}.taxPercentage`, defaultTaxPercentage ?? null, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
});
const shouldMarkDirty = hasMountedRef.current;
setValue(`items.${index}.equivalenceSurchargePercentage`, equivalenceSurchargePercentage, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
});
currentItems.forEach((item, index) => {
if (item.taxPercentage !== defaultTaxPercentage) {
setValue(`items.${index}.taxPercentage`, defaultTaxPercentage, {
shouldDirty: shouldMarkDirty,
shouldTouch: false,
shouldValidate: true,
});
}
if (item.recPercentage !== recPercentage) {
setValue(`items.${index}.recPercentage`, recPercentage, {
shouldDirty: shouldMarkDirty,
shouldTouch: false,
shouldValidate: true,
});
}
});
}, [defaultTaxPercentage, getValues, hasEquivalenceSurcharge, setValue, taxMode]);
hasMountedRef.current = true;
}, [defaultTaxPercentage, getValues, hasRecPercentage, setValue, taxMode]);
const enablePerLineTaxes = useCallback(() => {
setValue("taxMode", "perLine", {
@ -86,7 +91,7 @@ export const useUpdateProformaTaxController = ({
taxMode,
defaultTaxPercentage,
hasEquivalenceSurcharge,
hasRecPercentage,
hasRetention,
retentionPercentage,

View File

@ -3,6 +3,8 @@ export interface CommercialDocumentLineInput {
unitAmount: number | null;
itemDiscountPercentage: number | null;
taxPercentage: number | null;
recPercentage: number | null;
}
export interface CommercialDocumentLineAmounts {

View File

@ -11,5 +11,5 @@ export interface ProformaItemUpdateForm {
itemDiscountPercentage: number | null;
taxPercentage: number | null;
equivalenceSurchargePercentage: number | null;
recPercentage: number | null;
}

View File

@ -27,7 +27,7 @@ export const ProformaItemUpdateFormSchema = z.object({
itemDiscountPercentage: z.number().nullable(),
taxPercentage: z.number().nullable(),
equivalenceSurchargePercentage: z.number().nullable(),
recPercentage: z.number().nullable(),
});
export type ProformaItemUpdateFormSchemaType = z.infer<typeof ProformaItemUpdateFormSchema>;

View File

@ -35,9 +35,10 @@ export interface ProformaUpdateForm {
globalDiscountPercentage: number;
taxMode: ProformaTaxMode;
taxRegimeCode: string | null;
defaultTaxPercentage: number | null;
hasEquivalenceSurcharge: boolean;
hasRecPercentage: boolean;
hasRetention: boolean;
retentionPercentage: number | null;

View File

@ -34,9 +34,10 @@ export const ProformaUpdateFormSchema = z.object({
globalDiscountPercentage: z.number().min(0).max(100),
taxMode: z.enum(["single", "perLine"]),
taxRegimeCode: z.string(),
defaultTaxPercentage: z.number().nullable(),
hasEquivalenceSurcharge: z.boolean(),
hasRecPercentage: z.boolean(),
hasRetention: z.boolean(),
retentionPercentage: z.number().nullable(),

View File

@ -2,7 +2,7 @@ export interface ProformaTaxBreakdownLine {
taxPercentage: number;
taxableBase: number;
taxAmount: number;
equivalenceSurchargePercentage: number | null;
recPercentage: number | null;
equivalenceSurchargeAmount: number;
}

View File

@ -104,13 +104,13 @@ export const ProformaTotalsSummary = ({
value={formatMoney(tax.taxAmount)}
/>
{showEquivalenceSurcharge && tax.equivalenceSurchargePercentage !== null ? (
{showEquivalenceSurcharge && tax.recPercentage !== null ? (
<TotalsRow
description={formatMoney(tax.taxableBase)}
label={`${t(
"proformas.update.totals.equivalenceSurcharge",
"Recargo equivalencia"
)} ${formatPercent(tax.equivalenceSurchargePercentage)}`}
)} ${formatPercent(tax.recPercentage)}`}
value={formatMoney(tax.equivalenceSurchargeAmount)}
/>
) : null}

View File

@ -80,7 +80,7 @@ export const ProformaUpdateEditorForm = ({
className="md:col-span-6"
currency={currencyCode}
disabled={isSubmitting}
showEquivalenceSurcharge={taxCtrl.hasEquivalenceSurcharge}
showEquivalenceSurcharge={taxCtrl.hasRecPercentage}
showRetention={taxCtrl.hasRetention}
totals={totalsCtrl.totals}
/>

View File

@ -6,6 +6,8 @@ import {
SelectField,
} from "@repo/rdx-ui/components";
import {
Card,
CardContent,
Checkbox,
Field,
FieldDescription,
@ -14,6 +16,7 @@ import {
FieldSet,
Label,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { ReceiptTextIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n";
@ -47,116 +50,144 @@ export const ProformaUpdateTaxEditor = ({
icon={<ReceiptTextIcon className="size-5" />}
title={t("form_groups.proformas.taxes.title", "Impuestos")}
>
<FormSectionGrid>
<Field className="md:col-span-12 md:col-start-1" orientation="horizontal">
<SelectField
className="md:col-span-4 md:col-start-1"
disabled={disabled}
items={[
{ value: "1", label: "01: Operación de régimen general." },
{ value: "2", label: "02: Exportación." },
{
value: "3",
label:
"03: Operaciones a las que se aplique el régimen especial de bienes usados, objetos de arte, antigüedades y objetos de colección.",
},
{ value: "4", label: "04: Régimen especial del oro de inversión." },
{ value: "5", label: "05: Régimen especial de las agencias de viajes." },
{
value: "6",
label: "06: Régimen especial grupo de entidades en IVA o IGIC (Nivel Avanzado)",
},
{ value: "7", label: "07: Régimen especial del criterio de caja." },
{ value: "8", label: "08: Operaciones sujetas al IPSI/IVA o IGIC." },
{
value: "9",
label:
"09: Facturación de las prestaciones de servicios de agencias de viaje que actúan como mediadoras en nombre y por cuenta ajena (D.A.4ª RD1619/2012)",
},
{
value: "10",
label:
"10: Cobros por cuenta de terceros de honorarios profesionales o de derechos derivados de la propiedad industrial, de autor u otros por cuenta de sus socios, asociados o colegiados efectuados por sociedades, asociaciones, colegios profesionales u otras entidades que realicen estas funciones de cobro.",
},
{ value: "11", label: "11: Operaciones de arrendamiento de local de negocio." },
{
value: "14",
label:
"14: Factura con IVA o IGIC pendiente de devengo en certificaciones de obra cuyo destinatario sea una Administración Pública.",
},
{
value: "15",
label:
"15: Factura con IVA o IGIC pendiente de devengo en operaciones de tracto sucesivo.",
},
{
value: "17",
label:
"17: Operación acogida a alguno de los regímenes previstos en el Capítulo XI del Título IX (OSS e IOSS) o régimen especial de comerciante minorista",
},
{
value: "18",
label:
"18: Recargo de equivalencia o régimen especial del pequeño empresario o profesional.",
},
{
value: "19",
label:
"19: Operaciones de actividades incluidas en el Régimen Especial de Agricultura, Ganadería y Pesca (REAGYP) u operaciones interiores exentas por aplicación artículo 25 Ley 19/1994",
},
{ value: "20", label: "20: Régimen simplificado" },
]}
label={t("form_fields.proformas.tax_regime_code.label", "Régimen de IVA")}
name="taxRegimeCode"
placeholder={t(
"form_fields.proformas.tax_regime_code.placeholder",
"Selecciona el régimen de IVA de esta proforma"
)}
readOnly={readOnly || taxCtrl.usesPerLineTax}
/>
</Field>
</FormSectionGrid>
<FieldSeparator className="my-4" />
<FieldSet>
<FieldLegend>Billing Address</FieldLegend>
<FieldDescription>The billing address associated with your payment method</FieldDescription>
<FieldLegend className="text-primary">1. Régimen fiscal</FieldLegend>
<FieldDescription>
Selecciona el régimen fiscal que aplica a esta proforma.
</FieldDescription>
<FormSectionGrid>
<Field className="md:col-span-12 md:col-start-1" orientation="horizontal">
<Checkbox
checked={taxCtrl.usesSingleTax}
disabled={disabled || readOnly}
onCheckedChange={(checked) =>
checked ? taxCtrl.disablePerLineTaxes() : taxCtrl.enablePerLineTaxes()
}
/>
<Label htmlFor="terms-checkbox">
{t(
"proformas.update.taxes.disable_per_line",
"Usar el mismo impuesto en toda la proforma"
<SelectField
className="md:col-span-4 md:col-start-1"
disabled={disabled}
items={[
{ value: "01", label: "01: Operación de régimen general." },
{ value: "02", label: "02: Exportación." },
{
value: "03",
label:
"03: Operaciones a las que se aplique el régimen especial de bienes usados, objetos de arte, antigüedades y objetos de colección.",
},
{ value: "04", label: "04: Régimen especial del oro de inversión." },
{ value: "05", label: "05: Régimen especial de las agencias de viajes." },
{
value: "06",
label: "06: Régimen especial grupo de entidades en IVA o IGIC (Nivel Avanzado)",
},
{ value: "07", label: "07: Régimen especial del criterio de caja." },
{ value: "08", label: "08: Operaciones sujetas al IPSI/IVA o IGIC." },
{
value: "09",
label:
"09: Facturación de las prestaciones de servicios de agencias de viaje que actúan como mediadoras en nombre y por cuenta ajena (D.A.4ª RD1619/2012)",
},
{
value: "10",
label:
"10: Cobros por cuenta de terceros de honorarios profesionales o de derechos derivados de la propiedad industrial, de autor u otros por cuenta de sus socios, asociados o colegiados efectuados por sociedades, asociaciones, colegios profesionales u otras entidades que realicen estas funciones de cobro.",
},
{ value: "11", label: "11: Operaciones de arrendamiento de local de negocio." },
{
value: "14",
label:
"14: Factura con IVA o IGIC pendiente de devengo en certificaciones de obra cuyo destinatario sea una Administración Pública.",
},
{
value: "15",
label:
"15: Factura con IVA o IGIC pendiente de devengo en operaciones de tracto sucesivo.",
},
{
value: "17",
label:
"17: Operación acogida a alguno de los regímenes previstos en el Capítulo XI del Título IX (OSS e IOSS) o régimen especial de comerciante minorista",
},
{
value: "18",
label:
"18: Recargo de equivalencia o régimen especial del pequeño empresario o profesional.",
},
{
value: "19",
label:
"19: Operaciones de actividades incluidas en el Régimen Especial de Agricultura, Ganadería y Pesca (REAGYP) u operaciones interiores exentas por aplicación artículo 25 Ley 19/1994",
},
{ value: "20", label: "20: Régimen simplificado" },
]}
label={t("form_fields.proformas.tax_regime_code.label", "Régimen fiscal")}
name="taxRegimeCode"
placeholder={t(
"form_fields.proformas.tax_regime_code.placeholder",
"Selecciona el régimen fiscal para esta proforma"
)}
</Label>
readOnly={readOnly || taxCtrl.usesPerLineTax}
/>
</Field>
</FormSectionGrid>
</FieldSet>
<FieldSeparator className="my-4" />
<SelectField
className="md:col-span-4 md:col-start-1"
disabled={disabled}
items={[
{ value: "0", label: "0%" },
{ value: "4", label: "4%" },
{ value: "10", label: "10%" },
{ value: "21", label: "21%" },
]}
label={t("form_fields.proformas.default_tax_percentage.label", "IVA por defecto")}
name="defaultTaxPercentage"
placeholder={t(
"form_fields.proformas.default_tax_percentage.placeholder",
"Selecciona IVA"
)}
readOnly={readOnly || taxCtrl.usesPerLineTax}
/>
<Card
className={cn(disabled ? "bg-muted text-muted-foreground" : "bg-primary/10 text-primary")}
>
<CardContent>
<FieldSet>
<FieldLegend className="font-semibold">Configuración de IVA</FieldLegend>
<FieldDescription>
Puedes usar un tipo único para todos las líneas de detalle o permitir que cada línea
tenga su propio IVA.
</FieldDescription>
<FormSectionGrid>
<Field className="md:col-span-12 md:col-start-1" orientation="horizontal">
<Checkbox
checked={taxCtrl.usesSingleTax}
disabled={disabled || readOnly}
onCheckedChange={(checked) =>
checked ? taxCtrl.disablePerLineTaxes() : taxCtrl.enablePerLineTaxes()
}
/>
<Label htmlFor="terms-checkbox">
{t(
"proformas.update.taxes.disable_per_line",
"Aplicar el mismo IVA a todas las líneas de la proforma"
)}
</Label>
</Field>
<SelectField
className="md:col-span-4 md:col-start-1"
deserialize={(value) => (value === null || value === "" ? null : Number(value))}
disabled={disabled}
items={[
{ value: "0", label: "0%" },
{ value: "4", label: "4%" },
{ value: "10", label: "10%" },
{ value: "21", label: "21%" },
]}
label={t("form_fields.proformas.default_tax_percentage.label", "IVA por defecto")}
name="defaultTaxPercentage"
placeholder={t(
"form_fields.proformas.default_tax_percentage.placeholder",
"Selecciona IVA"
)}
readOnly={readOnly || taxCtrl.usesPerLineTax}
serialize={(value) => (typeof value === "number" ? String(value) : "")}
/>
</FormSectionGrid>
</FieldSet>{" "}
</CardContent>
</Card>
<FieldSeparator className="my-4" />
<FieldSet>
<FieldLegend>Impuestos adicionales</FieldLegend>
<FieldDescription>
Activa opciones fiscales adicionales como recargo de equivalencia o retenciones (IRPF),
según el tipo de cliente y normativa aplicable.
</FieldDescription>
<FormSectionGrid>
<CheckboxField
className="md:col-span-12 md:col-start-1"
disabled={disabled}
@ -164,7 +195,7 @@ export const ProformaUpdateTaxEditor = ({
"form_fields.proformas.has_equivalence_surcharge.label",
"Recargo de equivalencia"
)}
name="hasEquivalenceSurcharge"
name="hasRecPercentage"
readOnly={readOnly}
/>

View File

@ -3,7 +3,7 @@ import { ErrorAlert, NotFoundCard, PageHeader } from "@erp/core/components";
import { FormCommitButtonGroup, UnsavedChangesProvider } from "@erp/core/hooks";
import { SelectCustomerDialog } from "@erp/customers";
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
import { useEffect, useMemo } from "react";
import { useMemo } from "react";
import { FormProvider } from "react-hook-form";
import { useNavigate } from "react-router-dom";
@ -19,12 +19,6 @@ export const ProformaUpdatePage = () => {
const { updateCtrl, selectCustomerCtrl } = useUpdateProformaPageController();
useEffect(() => {
console.log("[ProformaUpdatePage] isDirty:", updateCtrl.form.formState.isDirty);
console.log("[ProformaUpdatePage] dirtyFields:", updateCtrl.form.formState.dirtyFields);
console.log("[ProformaUpdatePage] values:", updateCtrl.form.getValues());
}, [updateCtrl.form.formState.isDirty, updateCtrl.form.formState.dirtyFields, updateCtrl.form]);
if (updateCtrl.isLoading) {
return <ProformaUpdateSkeleton />;
}

View File

@ -13,5 +13,8 @@ export const buildProformaItemUpdateDefault = (position: number): ProformaItemUp
quantity: null,
unitAmount: null,
itemDiscountPercentage: null,
taxPercentage: null,
recPercentage: null,
};
};

View File

@ -16,10 +16,19 @@ export const buildProformaUpdateDefault = (): ProformaUpdateForm => {
languageCode: "es",
currencyCode: "EUR",
paymentMethod: "",
globalDiscountPercentage: 0,
taxMode: "single",
taxRegimeCode: "01",
defaultTaxPercentage: 21,
hasRecPercentage: false,
hasRetention: false,
retentionPercentage: null,
paymentMethod: "",
items: [],
};
};

View File

@ -140,8 +140,6 @@ export class UpdateCustomerInputMapper implements IUpdateCustomerInputMapper {
);
}
console.log("Mapped CustomerPatchProps:", customerPatchProps);
return Result.ok(customerPatchProps);
} catch (err: unknown) {
return Result.fail(new DomainError("Customer props mapping failed (update)", { cause: err }));

View File

@ -242,11 +242,12 @@ export default (database: Sequelize) => {
{ name: "idx_company_idx", fields: ["id", "company_id"], unique: true }, // <- para consulta get
{ name: "idx_factuges", fields: ["factuges_id"], unique: true }, // <- para el proceso python
{
// Para búsquedas simples => se hace con el "CriteriaToSequelizeConverter"
/*{
name: "ft_customer",
type: "FULLTEXT",
fields: ["name", "trade_name", "reference", "tin", "email_primary", "mobile_primary"],
},
},*/
],
whereMergeStrategy: "and", // <- cómo tratar el merge de un scope

View File

@ -48,7 +48,6 @@ export const CustomerBasicInfoFields = ({ className, ...props }: CustomerBasicIn
control={control}
name="isCompany"
render={({ field, fieldState }) => {
console.log(field.value);
return (
<Field
className="gap-1 lg:col-span-1 lg:col-start-1"
@ -61,7 +60,6 @@ export const CustomerBasicInfoFields = ({ className, ...props }: CustomerBasicIn
disabled={field.disabled}
name={field.name}
onValueChange={(value) => {
console.log("Pongo ", value);
field.onChange(value === "true");
}}
required

View File

@ -2,6 +2,7 @@ import { ErrorAlert, PageHeader } from "@erp/core/components";
import { FormCommitButtonGroup, UnsavedChangesProvider } from "@erp/core/hooks";
import { AppContent, AppHeader } from "@repo/rdx-ui/components";
import { FormProvider } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../i18n";
import { useCustomerCreatePageController } from "../../controllers";
@ -9,6 +10,7 @@ import { CustomerCreateEditorForm } from "../editor";
export const CustomerCreatePage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { createCtrl } = useCustomerCreatePageController();
const { form, formId, onSubmit, resetForm, isCreating, isCreateError, createError } = createCtrl;
@ -17,15 +19,19 @@ export const CustomerCreatePage = () => {
<UnsavedChangesProvider isDirty={form.formState.isDirty}>
<AppHeader>
<PageHeader
backIcon
description={t("pages.create.description")}
onBackClick={() => navigate("/customers/list")}
rightSlot={
<FormCommitButtonGroup
cancel={{ formId, to: "/customers/list", disabled: isCreating }}
disabled={isCreating}
isLoading={isCreating}
onReset={resetForm}
submit={{ formId, disabled: isCreating }}
cancel={{
to: "/customers/list",
}}
disabled={createCtrl.isCreating}
isLoading={createCtrl.isCreating}
onReset={createCtrl.form.formState.isDirty ? createCtrl.resetForm : undefined}
submit={{
formId: createCtrl.formId,
}}
/>
}
title={t("pages.create.title")}

View File

@ -1,4 +1,4 @@
import { DateHelper } from "@erp/core";
import { DateHelper } from "@repo/rdx-utils";
import { Badge, Button } from "@repo/shadcn-ui/components";
import { FileTextIcon } from "lucide-react";

View File

@ -1,5 +1,5 @@
import type { Customer } from "../../shared";
import { type CustomerUpdateForm, defaultCustomerUpdateForm } from "../entities"; /**
import type { CustomerUpdateForm } from "../entities"; /**
* Mapea un cliente a un formulario de actualización de cliente.
*
* Reglas:
@ -11,10 +11,11 @@ import { type CustomerUpdateForm, defaultCustomerUpdateForm } from "../entities"
* @param customer
* @returns
*/
import { buildCustomerUpdateDefault } from "../utils";
export const mapCustomerToCustomerUpdateForm = (customer: Customer): CustomerUpdateForm => ({
reference: customer.reference ?? "",
isCompany: customer.isCompany ?? defaultCustomerUpdateForm.isCompany,
isCompany: customer.isCompany ?? buildCustomerUpdateDefault().isCompany,
name: customer.name,
tradeName: customer.tradeName ?? "",
tin: customer.tin ?? "",
@ -26,7 +27,7 @@ export const mapCustomerToCustomerUpdateForm = (customer: Customer): CustomerUpd
city: customer.address.city ?? "",
province: customer.address.province ?? "",
postalCode: customer.address.postalCode ?? "",
country: customer.address.country ?? defaultCustomerUpdateForm.country,
country: customer.address.country ?? buildCustomerUpdateDefault().country,
primaryEmail: customer.contact.primaryEmail ?? "",
secondaryEmail: customer.contact.secondaryEmail ?? "",
@ -40,6 +41,6 @@ export const mapCustomerToCustomerUpdateForm = (customer: Customer): CustomerUpd
legalRecord: customer.legalRecord ?? "",
languageCode: customer.languageCode ?? defaultCustomerUpdateForm.languageCode,
currencyCode: customer.currencyCode ?? defaultCustomerUpdateForm.currencyCode,
languageCode: customer.languageCode ?? buildCustomerUpdateDefault().languageCode,
currencyCode: customer.currencyCode ?? buildCustomerUpdateDefault().currencyCode,
});

View File

@ -196,7 +196,6 @@ export const useCustomerUpdateController = (
}
},
(errors: FieldErrors<CustomerUpdateForm>) => {
console.log(errors);
focusFirstInputFormError(form);
showWarningToast(

View File

@ -3,6 +3,7 @@ import { FormCommitButtonGroup, UnsavedChangesProvider } from "@erp/core/hooks";
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
import { Spinner } from "@repo/shadcn-ui/components";
import { FormProvider } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../i18n";
import { useCustomerUpdatePageController } from "../../controllers";
@ -11,7 +12,7 @@ import { CustomerUpdateEditorForm } from "../editor";
export const CustomerUpdatePage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { updateCtrl } = useCustomerUpdatePageController();
if (updateCtrl.isLoading) {
@ -48,55 +49,52 @@ export const CustomerUpdatePage = () => {
);
return (
<UnsavedChangesProvider isDirty={updateCtrl.form.formState.isDirty}>
<AppHeader className="space-y-4 max-w-5xl mx-auto">
<PageHeader
backIcon
description={t("pages.update.description")}
rightSlot={
<FormCommitButtonGroup
cancel={{
formId: updateCtrl.formId,
to: "/customers/list",
disabled: updateCtrl.isUpdating,
}}
disabled={updateCtrl.isUpdating}
isLoading={updateCtrl.isUpdating}
onReset={updateCtrl.resetForm}
submit={{
formId: updateCtrl.formId,
disabled: updateCtrl.isUpdating,
}}
/>
}
title={t("pages.update.title")}
/>
</AppHeader>
<AppContent className="space-y-4 max-w-5xl mx-auto">
{/* Alerta de error de actualización (si ha fallado el último intento) */}
{updateCtrl.isUpdateError && (
<ErrorAlert
message={
(updateCtrl.updateError as Error)?.message ??
t("pages.update.errorMsg", "Revisa los datos e inténtalo de nuevo.")
<FormProvider {...updateCtrl.form}>
<UnsavedChangesProvider isDirty={updateCtrl.form.formState.isDirty}>
<AppHeader className="space-y-4 max-w-5xl mx-auto">
<PageHeader
description={t("pages.update.description")}
onBackClick={() => navigate("/customers/list")}
rightSlot={
<FormCommitButtonGroup
cancel={{
to: "/customers/list",
}}
disabled={updateCtrl.isUpdating}
isLoading={updateCtrl.isUpdating}
onReset={updateCtrl.form.formState.isDirty ? updateCtrl.resetForm : undefined}
submit={{
formId: updateCtrl.formId,
}}
/>
}
title={t("pages.update.errorTitle", "No se pudo guardar los cambios")}
title={t("pages.update.title")}
/>
)}
</AppHeader>
<AppContent className="space-y-4 max-w-5xl mx-auto">
{/* Alerta de error de actualización (si ha fallado el último intento) */}
{updateCtrl.isUpdateError && (
<ErrorAlert
message={
(updateCtrl.updateError as Error)?.message ??
t("pages.update.errorMsg", "Revisa los datos e inténtalo de nuevo.")
}
title={t("pages.update.errorTitle", "No se pudo guardar los cambios")}
/>
)}
{updateCtrl.isLoading && <Spinner />}
{updateCtrl.isLoading && <Spinner />}
{!updateCtrl.isLoading && (
<FormProvider {...updateCtrl.form}>
{!updateCtrl.isLoading && (
<CustomerUpdateEditorForm
formId={updateCtrl.formId}
isSubmitting={updateCtrl.isUpdating}
onReset={updateCtrl.resetForm}
onSubmit={updateCtrl.onSubmit}
/>
</FormProvider>
)}
</AppContent>
</UnsavedChangesProvider>
)}
</AppContent>
</UnsavedChangesProvider>
</FormProvider>
);
};

View File

@ -300,11 +300,13 @@ export default (database: Sequelize) => {
name: "idx_supplier_invoice_document_id",
fields: ["document_id"],
},
{
// Para búsquedas simples => se hace con el "CriteriaToSequelizeConverter"
/*{
name: "ft_supplier_invoice",
type: "FULLTEXT",
fields: ["invoice_number", "description", "notes"],
},
},*/
],
whereMergeStrategy: "and",

View File

@ -311,7 +311,11 @@ export class SupplierInvoiceRepository
const query = criteriaConverter.convert(criteria, {
searchableFields: ["invoice_number", "description", "internal_notes"],
mappings: {},
mappings: {
invoice_number: "SupplierInvoiceModel.invoice_number",
reference: "SupplierInvoiceModel.description",
description: "SupplierInvoiceModel.internal_notes",
},
allowedFields: ["invoice_date", "due_date", "id", "created_at"],
enableFullText: true,
database: this.database,

View File

@ -218,11 +218,12 @@ export default (database: Sequelize) => {
{ name: "idx_tin", fields: ["tin"] }, // <- para servicios externos
{ name: "idx_company_idx", fields: ["id", "company_id"], unique: true }, // <- para consulta get
{
// Para búsquedas simples => se hace con el "CriteriaToSequelizeConverter"
/*{
name: "ft_supplier",
type: "FULLTEXT",
fields: ["name", "trade_name", "reference", "tin", "email_primary", "mobile_primary"],
},
},*/
],
whereMergeStrategy: "and", // <- cómo tratar el merge de un scope

View File

@ -61,12 +61,15 @@ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter {
searchableFields = [],
database,
enableFullText = false,
} = params as ConvertParams & { database?: Sequelize };
fullTextTableAlias,
} = params as ConvertParams & {
database?: Sequelize;
fullTextTableAlias?: string;
};
const term = typeof criteria.quickSearch === "string" ? criteria.quickSearch.trim() : "";
if (!term || searchableFields.length === 0 || !enableFullText) return;
// Validación defensiva
if (!database) {
const msg = `[CriteriaToSequelizeConverter] enableFullText=true pero falta 'database' en params.`;
if (params.strictMode) throw new Error(msg);
@ -80,13 +83,19 @@ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter {
.join(" ");
const mappedFields = searchableFields.map((f) => mappings[f] || f);
const matchExpr = `MATCH(${mappedFields.join(", ")}) AGAINST (${database.escape(
const qualifiedFields = mappedFields.map((field) =>
this.qualifyField(field, fullTextTableAlias)
);
const matchExpr = `MATCH(${qualifiedFields.join(", ")}) AGAINST (${database.escape(
booleanTerm
)} IN BOOLEAN MODE)`;
const matchLiteral = Sequelize.literal(matchExpr);
// Añadir campo virtual "score"
if (!options.attributes) options.attributes = { include: [] };
if (Array.isArray(options.attributes)) {
options.attributes.push([matchLiteral, "score"]);
} else {
@ -94,13 +103,12 @@ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter {
options.attributes.include.push([matchLiteral, "score"]);
}
// WHERE score > 0
const scoreCondition = Sequelize.where(matchLiteral, { [Op.gt]: 0 });
options.where = options.where
? { [Op.and]: [options.where, scoreCondition] }
: { [Op.and]: [scoreCondition] };
// Ordenar por relevancia
prependOrder(options, [Sequelize.literal("score"), "DESC"]);
}
@ -167,4 +175,11 @@ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter {
if (operator === Op.like || operator === Op.notLike) return `%${value}%`;
return value;
}
private qualifyField(field: string, tableAlias?: string): string {
if (field.includes(".") || field.includes("`")) return field;
if (!tableAlias) return field;
return `\`${tableAlias}\`.\`${field}\``;
}
}

View File

@ -40,7 +40,7 @@ export const FormSectionCard = ({
{icon ? (
<div
className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-md",
"flex size-12 shrink-0 items-center justify-center rounded-md",
disabled ? "bg-muted text-muted-foreground" : "bg-primary/10 text-primary"
)}
>
@ -50,8 +50,10 @@ export const FormSectionCard = ({
) : null}
<div className="space-y-1">
{title ? <FieldLegend>{title}</FieldLegend> : null}
{description ? <FieldDescription>{description}</FieldDescription> : null}
{title ? <FieldLegend className="font-semibold">{title}</FieldLegend> : null}
{description ? (
<FieldDescription className="font-medium">{description}</FieldDescription>
) : null}
</div>
</div>
</CardHeader>

View File

@ -6,7 +6,6 @@ import {
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import React from "react";
@ -19,6 +18,8 @@ interface SelectFieldItem {
label: string;
}
type SelectFieldValue = string | null;
type SelectFieldProps<TFormValues extends FieldValues> = {
name: FieldPath<TFormValues>;
@ -35,6 +36,9 @@ type SelectFieldProps<TFormValues extends FieldValues> = {
orientation?: "vertical" | "horizontal" | "responsive";
serialize?: (value: unknown) => string;
deserialize?: (value: SelectFieldValue) => unknown;
className?: string;
inputClassName?: string;
};
@ -55,6 +59,9 @@ export function SelectField<TFormValues extends FieldValues>({
orientation = "vertical",
serialize,
deserialize,
className,
inputClassName,
}: SelectFieldProps<TFormValues>) {
@ -62,18 +69,25 @@ export function SelectField<TFormValues extends FieldValues>({
const { control, formState } = useFormContext<TFormValues>();
const isDisabled = Boolean(disabled || readOnly || formState.isSubmitting);
const serializeValue =
serialize ?? ((value: unknown): string => (typeof value === "string" ? value : ""));
const deserializeValue = deserialize ?? ((value: SelectFieldValue): string => value ?? "");
return (
<Controller
control={control}
name={name}
render={({ field, fieldState }) => {
const fieldValue = typeof field.value === "string" ? field.value.trim() : "";
const fieldValue = serializeValue(field.value);
const normalizedItems =
fieldValue && !items.some((item) => item.value === fieldValue)
? [{ value: fieldValue, label: fieldValue }, ...items]
: items;
const selectedItem = normalizedItems.find((item) => item.value === fieldValue);
return (
<Field
className={cn("gap-1", className)}
@ -88,8 +102,8 @@ export function SelectField<TFormValues extends FieldValues>({
<Select
disabled={isDisabled}
onValueChange={field.onChange}
value={field.value ?? undefined}
onValueChange={(value) => field.onChange(deserializeValue(value))}
value={fieldValue}
>
<SelectTrigger
aria-invalid={fieldState.invalid}
@ -103,11 +117,16 @@ export function SelectField<TFormValues extends FieldValues>({
)}
id={triggerId}
>
<SelectValue
className={"placeholder:font-normal placeholder:italic"}
placeholder={placeholder}
/>
<span
className={cn(
"truncate text-left",
selectedItem ? "" : "text-muted-foreground font-normal italic"
)}
>
{selectedItem?.label ?? placeholder}
</span>
</SelectTrigger>
<SelectContent>
{normalizedItems.map((item) => (
<SelectItem key={`key-${item.value}`} value={item.value}>

View File

@ -5,6 +5,12 @@ const formatPercent = (value: number): string => {
maximumFractionDigits: 2,
}).format(value / 100);
};
const hasPositivePercentage = (value: number | null | undefined): boolean => {
return value !== null && value !== undefined && value > 0;
};
export const PercentageHelper = {
formatPercent,
hasPositivePercentage,
};