feat: implement GetProformaIssuanceReadiness use case and associated controller

- Add GetProformaIssuanceReadinessUseCase to check proforma issuance readiness.
- Create GetProformaIssuanceReadinessController to handle requests and responses.
- Introduce request and response DTOs for proforma issuance readiness.
- Develop tests for the use case and controller to ensure functionality.
- Implement adapter and hooks for integrating issuance readiness in the web application.
- Create UI components for displaying issuance readiness alerts and help popover.
- Add utility functions for translating blocker messages and details.
This commit is contained in:
David Arranz 2026-08-18 23:03:04 +02:00
parent cf5a797532
commit 080dcaaa61
69 changed files with 2628 additions and 218 deletions

View File

@ -98,6 +98,26 @@ Consumidores actuales auditados:
`factuges` no bloquea delete porque resuelve métodos de pago durante la importación y no mantiene una referencia persistente viva auditada en este flujo.
## Emision de Proformas
`customer-invoices` consume `PaymentMethod` mediante servicios publicos de `catalogs`.
Semantica vigente en emision:
- la Proforma mantiene una referencia viva `paymentMethodId`;
- la Issued Invoice persiste un snapshot historico del metodo de pago emitido;
- si `paymentMethodId` no existe dentro de la company activa, la emision falla;
- si el metodo existe pero esta inactivo, la emision falla;
- este bloqueo aplica solo al boundary de emision y no convierte automaticamente `paymentMethodId` en obligatorio durante create draft.
Datos copiados al snapshot historico de `issued_invoices`:
- `payment_method_id`
- `payment_method_name`
- `payment_method_description`
Tras emitir, el detalle historico no depende del catalogo vivo para representar la forma de pago usada.
## Frontend Settings
La gestión administrativa frontend vive en:

View File

@ -10,6 +10,7 @@
- [Diseño backend del perfil VeriFactu empresarial](./settings/verifactu-company-profile-backend-design.md)
- [Contrato de series de proformas](./proforma-series-contract.md)
- [Contrato de create de proformas](./proforma-create-contract.md)
- [Contrato de readiness de emisión de proformas](./proforma-issuance-readiness-contract.md)
- [Configuración fiscal persistida en proformas](./proforma-tax-config.md)
- [Contrato de listado de proformas](./proforma-list-contract.md)
- [Contrato de archivado de proformas](./proforma-archive-contract.md)

View File

@ -109,6 +109,30 @@ Adaptando nombres exactos al contrato publico actual:
- al existir lineas valoradas, `update` recalcula `taxes` y `totals`
- `issue` genera la `issued_invoice` materializada usando `target_invoice_series_code` si existe y, si no, la default activa de `issued_invoice`
## Diferencia entre draft y emision
`create` y `update` no deben imponer automaticamente todas las precondiciones de emision.
Una Proforma puede existir validamente como borrador incompleto.
En concreto:
- `proforma_date` forma parte de la estructura base del documento y debe existir desde create;
- `operation_date` no se exige en create ni en update general;
- `description` no se exige en create ni en update general;
- `payment_method_id` puede seguir vacio mientras el documento permanezca en draft.
La obligatoriedad de esos campos pertenece al boundary de `issue`.
Precondiciones funcionales de emision auditadas en backend:
- `description` obligatoria;
- `operation_date` obligatoria;
- `payment_method_id` obligatorio y resoluble dentro de la company activa;
- el `payment_method` debe seguir activo al emitir;
- `payment_term_id` sigue siendo opcional;
- la emision sigue usando `invoice_date = today` como fecha documental de la factura emitida.
## Relacion con borrado
- una proforma creada en `draft` puede borrarse logicamente mas adelante

View File

@ -0,0 +1,100 @@
# Contrato de readiness de emisión de proformas
## Objetivo
La readiness de emisión es una lectura informativa del estado persistido de una proforma.
Su propósito es responder:
```txt
¿Puede emitirse esta proforma ahora mismo?
```
No sustituye la validación transaccional final de:
```txt
PUT /proformas/:id/issue
```
La emisión sigue revalidando siempre en backend para cubrir concurrencia y cambios entre consulta y submit.
## Endpoint vigente
```http
GET /proformas/:proforma_id/issuance-readiness
```
Comportamiento:
```txt
- 200 cuando la proforma existe dentro del scope de company
- 200 aunque can_issue sea false
- blockers con códigos estables orientados a frontend
- sin textos i18n en backend
```
No se expone readiness completa en el listado de proformas para evitar N+1 HTTP y resolución externa por fila.
## Contrato HTTP
```json
{
"can_issue": false,
"blockers": [
{ "code": "missing_operation_date" },
{ "code": "missing_description" }
]
}
```
## Códigos públicos
| Code | Significado |
| ---- | ----------- |
| `already_issued` | La proforma ya tiene emisión materializada o enlazada. |
| `invalid_status` | El estado actual no permite emitir; hoy debe estar aprobada. |
| `missing_description` | Falta la descripción obligatoria para emitir. |
| `missing_operation_date` | Falta la fecha de operación. |
| `incomplete_recipient` | Faltan datos fiscales mínimos del destinatario. |
| `missing_items` | No existen líneas de detalle persistidas. |
| `missing_payment_method` | Falta la forma de pago. |
| `missing_tax_regime` | Falta el régimen fiscal. |
| `payment_method_unavailable` | La referencia company-scoped de forma de pago no se puede resolver. |
| `payment_method_inactive` | La forma de pago resuelta existe, pero está inactiva. |
## Fuente de verdad
La semántica se reparte de forma explícita pero sin duplicar reglas:
```txt
Proforma.getIssuanceBlockers()
-> blockers internos de dominio
Proforma.validateCanBeIssued()
-> consume el primer blocker compatible con el contrato legacy de error
ProformaIssuanceReadinessChecker
-> añade blockers externos dependientes de catálogos
```
Esto permite:
```txt
readiness
-> enumerar blockers útiles
issue
-> seguir fallando de forma autoritativa
```
## Semántica importante en frontend
- La readiness representa el estado guardado en backend.
- Los cambios no guardados del editor no alteran este read model.
- Tras guardar, cambiar estado o emitir, la query de readiness debe invalidarse.
## Pendientes fuera de esta fase
- exponer readiness en listado sin introducir N+1;
- migrar la ruta legacy de emisión de `PUT` a un contrato nuevo si se aprueba;
- deep-link o focus directo al campo bloqueado desde frontend.

View File

@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DiscountPercentage } from "@erp/core/api";
import { UniqueID, type UtcDate as UtcDateType } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { CompanyVerifactuProfileStatus } from "../../../domain";
import { ProformaToIssuedInvoiceConverter } from "./proforma-to-issued-invoice-props-converter";
test("toCreateProps normaliza verifactu effectiveAt a YYYY-MM-DD antes de crear UtcDate", () => {
const converter = new ProformaToIssuedInvoiceConverter();
Object.assign(converter as object, {
resolveRecipient: () => Result.ok({} as never),
resolveItems: () => Result.ok([]),
resolveTaxes: () => Result.ok([]),
resolvePayment: () => Result.ok({} as never),
resolvePaymentTerm: () => Result.ok(Maybe.none()),
resolveTaxRegime: () => Result.ok({} as never),
resolveFiscalClassification: () => Result.ok({} as never),
});
const result = converter.toCreateProps({
proforma: {
companyId: UniqueID.generateNewID(),
id: UniqueID.generateNewID(),
targetInvoiceSeriesCode: Maybe.none(),
operationDate: Maybe.none(),
description: {
unwrap: () => "Servicio mensual",
},
languageCode: {} as never,
currencyCode: {} as never,
notes: Maybe.none(),
reference: Maybe.none(),
customerId: UniqueID.generateNewID(),
totals: () => ({
subtotalAmount: {} as never,
itemsDiscountAmount: {} as never,
globalDiscountAmount: {} as never,
totalDiscountAmount: {} as never,
taxableAmount: {} as never,
ivaAmount: {} as never,
recAmount: {} as never,
retentionAmount: {} as never,
taxesAmount: {} as never,
totalAmount: {} as never,
}),
globalDiscountPercentage: DiscountPercentage.create({ value: 0 }).data,
} as never,
paymentMethod: {
id: UniqueID.generateNewID(),
name: "Transferencia",
description: Maybe.none(),
},
paymentTerm: Maybe.none(),
taxRegime: {
code: "01",
description: "Regimen general",
},
fiscalClassification: {
operationQualificationCode: null,
exemptionReasonCode: null,
},
verifactuPolicy: {
profileStatus: CompanyVerifactuProfileStatus.ObligatedWithVerifactu,
usesVerifactu: true,
effectiveAt: new Date("2026-08-20T08:30:00.000Z"),
},
});
assert.equal(result.isSuccess, true);
const effectiveAt = result.data.verifactuPolicy.unwrap().effectiveAt.unwrap() as UtcDateType;
assert.equal(effectiveAt.toPrimitive(), "2026-08-20");
});

View File

@ -104,7 +104,7 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
invoiceDate: UtcDate.today(),
operationDate: proforma.operationDate,
description: proforma.description.getOrUndefined()!,
description: proforma.description.unwrap(),
languageCode: proforma.languageCode,
currencyCode: proforma.currencyCode,
@ -369,7 +369,7 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
if (source.verifactuPolicy.effectiveAt != null) {
const effectiveAtResult = UtcDate.createFromISO(
source.verifactuPolicy.effectiveAt.toISOString()
source.verifactuPolicy.effectiveAt.toISOString().slice(0, 10)
);
if (effectiveAtResult.isFailure) {

View File

@ -3,6 +3,7 @@
import {
IssueProformaResponseSnapshotBuilder,
ProformaFullSnapshotBuilder,
ProformaIssuanceReadinessSnapshotBuilder,
ProformaItemReportSnapshotBuilder,
ProformaItemsFullSnapshotBuilder,
ProformaRecipientFullSnapshotBuilder,
@ -34,10 +35,12 @@ export function buildProformaSnapshotBuilders() {
taxesReportBuilder
);
const issueResponseSnapshotBuilder = new IssueProformaResponseSnapshotBuilder();
const issuanceReadinessSnapshotBuilder = new ProformaIssuanceReadinessSnapshotBuilder();
return {
full: fullSnapshotBuilder,
issue: issueResponseSnapshotBuilder,
issuanceReadiness: issuanceReadinessSnapshotBuilder,
summary: summarySnapshotBuilder,
report: reportSnapshotBuilder,
};

View File

@ -13,6 +13,7 @@ import type {
IProformaDeleter,
IProformaFinder,
IProformaFullReadModelAssembler,
IProformaIssuanceReadinessChecker,
IProformaIssueReadModelAssembler,
IProformaIssuer,
IProformaStatusChanger,
@ -22,6 +23,7 @@ import type {
import type {
IIssueProformaResponseSnapshotBuilder,
IProformaFullSnapshotBuilder,
IProformaIssuanceReadinessSnapshotBuilder,
IProformaReportSnapshotBuilder,
IProformaSummarySnapshotBuilder,
} from "../snapshot-builders";
@ -32,6 +34,7 @@ import {
DeleteProformaByIdUseCase,
DuplicateProformaByIdUseCase,
GetProformaByIdUseCase,
GetProformaIssuanceReadinessUseCase,
IssueProformaUseCase,
ListProformasUseCase,
PreviewProformaReportUseCase,
@ -54,6 +57,20 @@ export function buildGetProformaByIdUseCase(deps: {
});
}
export function buildGetProformaIssuanceReadinessUseCase(deps: {
finder: IProformaFinder;
readinessChecker: IProformaIssuanceReadinessChecker;
snapshotBuilder: IProformaIssuanceReadinessSnapshotBuilder;
transactionManager: ITransactionManager;
}) {
return new GetProformaIssuanceReadinessUseCase({
finder: deps.finder,
readinessChecker: deps.readinessChecker,
snapshotBuilder: deps.snapshotBuilder,
transactionManager: deps.transactionManager,
});
}
export function buildListProformasUseCase(deps: {
finder: IProformaFinder;
summarySnapshotBuilder: IProformaSummarySnapshotBuilder;

View File

@ -1,6 +1,7 @@
export * from "./company-report-profile.model";
export * from "./proforma-create-input.model";
export * from "./proforma-full-read.model";
export * from "./proforma-issuance-readiness.model";
export * from "./proforma-item-tax-codes-input.model";
export * from "./proforma-status-counts";
export * from "./proforma-summary";

View File

@ -0,0 +1,8 @@
import type { GetProformaIssuanceReadinessResponseDTO } from "../../../../common";
export interface ProformaIssuanceReadinessModel {
canIssue: boolean;
blockers: Array<{
code: GetProformaIssuanceReadinessResponseDTO["blockers"][number]["code"];
}>;
}

View File

@ -7,6 +7,14 @@ import { Maybe, Result } from "@repo/rdx-utils";
import { ProformaIssueReadModelAssembler } from "./proforma-issue-read-model.assembler";
const buildAssembler = (overrides?: {
paymentMethodResult?: Result<{
id: UniqueID;
companyId: UniqueID;
name: string;
description: Maybe<string>;
isActive: boolean;
isDefault: boolean;
}, Error>;
paymentTermResult?: Maybe<{
id: UniqueID;
companyId: UniqueID;
@ -28,10 +36,14 @@ const buildAssembler = (overrides?: {
const assembler = new ProformaIssueReadModelAssembler({
paymentMethodFinder: {
getByIdInCompany: async () =>
overrides?.paymentMethodResult ??
Result.ok({
id: paymentMethodId.toString(),
id: paymentMethodId,
companyId: UniqueID.generateNewID(),
name: "Transferencia",
description: null,
description: Maybe.none(),
isActive: true,
isDefault: false,
}),
} as never,
paymentTermFinder: {
@ -143,3 +155,41 @@ test("assemble rechaza una referencia inconsistente de payment term", async () =
assert.equal(result.isFailure, true);
assert.match(result.error.message, /inconsistent/i);
});
test("assemble rechaza emitir con payment method inactivo", async () => {
const paymentMethodId = UniqueID.generateNewID();
const { assembler } = buildAssembler({
paymentMethodResult: Result.ok({
id: paymentMethodId,
companyId: UniqueID.generateNewID(),
name: "Transferencia",
description: Maybe.none(),
isActive: false,
isDefault: false,
}),
});
const result = await assembler.assemble({
companyId: UniqueID.generateNewID(),
proforma: buildProforma(),
transaction: undefined,
});
assert.equal(result.isFailure, true);
assert.match(result.error.message, /inactive/i);
});
test("assemble propaga el fallo si el payment method no existe o no pertenece a la company", async () => {
const { assembler } = buildAssembler({
paymentMethodResult: Result.fail(new Error("Payment method not found in company scope")),
});
const result = await assembler.assemble({
companyId: UniqueID.generateNewID(),
proforma: buildProforma(),
transaction: undefined,
});
assert.equal(result.isFailure, true);
assert.match(result.error.message, /not found/i);
});

View File

@ -59,6 +59,14 @@ export class ProformaIssueReadModelAssembler implements IProformaIssueReadModelA
return Result.fail(paymentMethodResult.error);
}
if (!paymentMethodResult.data.isActive) {
return Result.fail(
new Error(
`Payment method is inactive and cannot be used to issue proforma: ${paymentMethodId.toString()}`
)
);
}
const paymentTermResult = await this.resolvePaymentTerm(params);
if (paymentTermResult.isFailure) {

View File

@ -8,6 +8,7 @@ export * from "./proforma-document-properties-factory";
export * from "./proforma-deleter";
export * from "./proforma-finder";
export * from "./proforma-issuer";
export * from "./proforma-issuance-readiness-checker";
export * from "./proforma-number-generator.interface";
export * from "./proforma-public-services.interface";
export * from "./proforma-series-validator.interface";

View File

@ -0,0 +1,199 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DiscountPercentage } from "@erp/core/api";
import { CurrencyCode, LanguageCode, UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import {
InvoiceNumber,
InvoiceStatus,
Proforma,
ProformaItems,
ProformaTaxConfig,
} from "../../../domain";
import { ProformaIssuanceReadinessChecker } from "./proforma-issuance-readiness-checker";
const buildApprovedProforma = (params?: {
operationDate?: Maybe<UtcDate>;
description?: Maybe<string>;
paymentMethodId?: Maybe<UniqueID>;
taxRegimeCode?: Maybe<string>;
itemsCount?: number;
}) => {
const proformaDateResult = UtcDate.createFromISO("2026-08-16");
const operationDateResult = UtcDate.createFromISO("2026-08-17");
const currencyCodeResult = CurrencyCode.create("EUR");
const languageCodeResult = LanguageCode.create("es");
const referenceResult = InvoiceNumber.create("PF-2026-99");
const discountResult = DiscountPercentage.create({ value: 0 });
const taxConfigResult = ProformaTaxConfig.create({
taxMode: "single",
defaultIvaCode: Maybe.none(),
usesEquivalenceSurcharge: false,
defaultRecCode: Maybe.none(),
usesRetention: false,
defaultRetentionCode: Maybe.none(),
});
assert.equal(proformaDateResult.isSuccess, true);
assert.equal(operationDateResult.isSuccess, true);
assert.equal(currencyCodeResult.isSuccess, true);
assert.equal(languageCodeResult.isSuccess, true);
assert.equal(referenceResult.isSuccess, true);
assert.equal(discountResult.isSuccess, true);
assert.equal(taxConfigResult.isSuccess, true);
return Proforma.rehydrate(
{
companyId: UniqueID.generateNewID(),
status: InvoiceStatus.approved(),
archivedAt: Maybe.none(),
documentSeriesId: Maybe.none(),
proformaSeriesCode: Maybe.none(),
proformaNumber: Maybe.none(),
proformaReference: referenceResult.data,
targetInvoiceSeriesCode: Maybe.none(),
proformaDate: proformaDateResult.data,
operationDate: params?.operationDate ?? Maybe.some(operationDateResult.data),
customerId: UniqueID.generateNewID(),
recipient: Maybe.some({
name: "Acme",
tin: Maybe.some("ESB12345678" as never),
street: Maybe.some("Calle Mayor 1" as never),
street2: Maybe.none(),
city: Maybe.some("Madrid" as never),
postalCode: Maybe.some("28001" as never),
province: Maybe.some("Madrid" as never),
country: Maybe.some("ES" as never),
} as never),
reference: Maybe.none(),
description: params?.description ?? Maybe.some("Servicio mensual"),
notes: Maybe.none(),
languageCode: languageCodeResult.data,
currencyCode: currencyCodeResult.data,
linkedInvoiceId: Maybe.none(),
paymentMethodId: params?.paymentMethodId ?? Maybe.some(UniqueID.generateNewID()),
paymentTermId: Maybe.none(),
taxRegimeCode: params?.taxRegimeCode ?? Maybe.some("01"),
taxConfig: taxConfigResult.data,
globalDiscountPercentage: discountResult.data,
},
ProformaItems.create({
items: Array.from({ length: params?.itemsCount ?? 1 }, () => ({}) as never),
}),
UniqueID.generateNewID()
);
};
test("check devuelve canIssue true cuando la proforma y el payment method son válidos", async () => {
const checker = new ProformaIssuanceReadinessChecker({
paymentMethodFinder: {
findByIdInCompany: async () =>
Result.ok(
Maybe.some({
id: UniqueID.generateNewID(),
companyId: UniqueID.generateNewID(),
name: "Transferencia",
description: Maybe.none(),
isActive: true,
isDefault: false,
})
),
} as never,
});
const result = await checker.check({
companyId: UniqueID.generateNewID(),
proforma: buildApprovedProforma(),
transaction: undefined,
});
assert.equal(result.isSuccess, true);
assert.equal(result.data.canIssue, true);
assert.deepEqual(result.data.blockers, []);
});
test("check acumula blockers internos sin abortar en el primero", async () => {
const checker = new ProformaIssuanceReadinessChecker({
paymentMethodFinder: {
findByIdInCompany: async () => Result.ok(Maybe.none()),
} as never,
});
const result = await checker.check({
companyId: UniqueID.generateNewID(),
proforma: buildApprovedProforma({
operationDate: Maybe.none(),
description: Maybe.none(),
paymentMethodId: Maybe.none(),
taxRegimeCode: Maybe.none(),
itemsCount: 0,
}),
transaction: undefined,
});
assert.equal(result.isSuccess, true);
assert.equal(result.data.canIssue, false);
assert.deepEqual(
result.data.blockers.map((blocker) => blocker.code),
[
"missing_description",
"missing_operation_date",
"missing_items",
"missing_payment_method",
"missing_tax_regime",
]
);
});
test("check informa payment_method_unavailable cuando el finder no resuelve la referencia company-scoped", async () => {
const checker = new ProformaIssuanceReadinessChecker({
paymentMethodFinder: {
findByIdInCompany: async () => Result.ok(Maybe.none()),
} as never,
});
const result = await checker.check({
companyId: UniqueID.generateNewID(),
proforma: buildApprovedProforma(),
transaction: undefined,
});
assert.equal(result.isSuccess, true);
assert.deepEqual(
result.data.blockers.map((blocker) => blocker.code),
["payment_method_unavailable"]
);
});
test("check informa payment_method_inactive cuando la referencia resuelta está inactiva", async () => {
const checker = new ProformaIssuanceReadinessChecker({
paymentMethodFinder: {
findByIdInCompany: async () =>
Result.ok(
Maybe.some({
id: UniqueID.generateNewID(),
companyId: UniqueID.generateNewID(),
name: "Transferencia",
description: Maybe.none(),
isActive: false,
isDefault: false,
})
),
} as never,
});
const result = await checker.check({
companyId: UniqueID.generateNewID(),
proforma: buildApprovedProforma(),
transaction: undefined,
});
assert.equal(result.isSuccess, true);
assert.deepEqual(
result.data.blockers.map((blocker) => blocker.code),
["payment_method_inactive"]
);
});

View File

@ -0,0 +1,63 @@
import type { IPaymentMethodPublicFinder } from "@erp/catalogs/api";
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { Proforma } from "../../../domain";
import type { ProformaIssuanceReadinessModel } from "../models";
export interface IProformaIssuanceReadinessChecker {
check(params: {
companyId: UniqueID;
proforma: Proforma;
transaction?: unknown;
}): Promise<Result<ProformaIssuanceReadinessModel, Error>>;
}
export class ProformaIssuanceReadinessChecker implements IProformaIssuanceReadinessChecker {
public constructor(
private readonly deps: {
paymentMethodFinder: IPaymentMethodPublicFinder;
}
) {}
public async check(params: {
companyId: UniqueID;
proforma: Proforma;
transaction?: unknown;
}): Promise<Result<ProformaIssuanceReadinessModel, Error>> {
const blockers: ProformaIssuanceReadinessModel["blockers"] = params.proforma
.getIssuanceBlockers()
.map((blocker) => ({
code: blocker.code,
}));
if (params.proforma.paymentMethodId.isSome()) {
const paymentMethodId = params.proforma.paymentMethodId.unwrap();
const paymentMethodResult = await this.deps.paymentMethodFinder.findByIdInCompany({
companyId: params.companyId,
id: paymentMethodId,
transaction: params.transaction,
});
if (paymentMethodResult.isFailure) {
return Result.fail(paymentMethodResult.error);
}
if (paymentMethodResult.data.isNone()) {
blockers.push({
code: "payment_method_unavailable",
});
} else if (!paymentMethodResult.data.unwrap().isActive) {
blockers.push({
code: "payment_method_inactive",
});
}
}
return Result.ok({
canIssue: blockers.length === 0,
blockers,
});
}
}

View File

@ -1,4 +1,5 @@
export * from "./full";
export * from "./issuance-readiness";
export * from "./issue";
export * from "./report";
export * from "./summary";

View File

@ -0,0 +1 @@
export * from "./proforma-issuance-readiness.snapshot-builder";

View File

@ -0,0 +1,22 @@
import type { ISnapshotBuilder } from "@erp/core/api";
import type { GetProformaIssuanceReadinessResponseDTO } from "../../../../../common";
import type { ProformaIssuanceReadinessModel } from "../../models";
export interface IProformaIssuanceReadinessSnapshotBuilder
extends ISnapshotBuilder<ProformaIssuanceReadinessModel, GetProformaIssuanceReadinessResponseDTO> {}
export class ProformaIssuanceReadinessSnapshotBuilder
implements IProformaIssuanceReadinessSnapshotBuilder
{
public toOutput(
source: ProformaIssuanceReadinessModel
): GetProformaIssuanceReadinessResponseDTO {
return {
can_issue: source.canIssue,
blockers: source.blockers.map((blocker) => ({
code: blocker.code,
})),
};
}
}

View File

@ -0,0 +1,85 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { GetProformaIssuanceReadinessUseCase } from "./get-proforma-issuance-readiness.use-case";
const buildTransactionManager = (): ITransactionManager => ({
start: async () => undefined,
getTransaction: () => undefined,
complete: async <T>(work: (transaction: unknown) => Promise<T>) => work(undefined),
commit: async () => undefined,
rollback: async () => undefined,
});
test("execute devuelve el snapshot de readiness para una proforma encontrada", async () => {
const companyId = UniqueID.generateNewID();
const proformaId = UniqueID.generateNewID();
const readiness = {
canIssue: false,
blockers: [{ code: "missing_operation_date" as const }],
};
const useCase = new GetProformaIssuanceReadinessUseCase({
finder: {
findProformaByIdIncludingArchived: async (
receivedCompanyId: UniqueID,
receivedProformaId: UniqueID
) => {
assert.equal(receivedCompanyId.equals(companyId), true);
assert.equal(receivedProformaId.equals(proformaId), true);
return Result.ok({ id: proformaId } as never);
},
} as never,
readinessChecker: {
check: async ({
companyId: receivedCompanyId,
proforma,
}: {
companyId: UniqueID;
proforma: { id: UniqueID };
}) => {
assert.equal(receivedCompanyId.equals(companyId), true);
assert.equal((proforma as { id: UniqueID }).id.equals(proformaId), true);
return Result.ok(readiness);
},
} as never,
snapshotBuilder: {
toOutput: (input: typeof readiness) => ({
can_issue: input.canIssue,
blockers: input.blockers,
}),
} as never,
transactionManager: buildTransactionManager(),
});
const result = await useCase.execute({
companyId,
proforma_id: proformaId.toPrimitive(),
});
assert.equal(result.isSuccess, true);
assert.deepEqual(result.data, {
can_issue: false,
blockers: [{ code: "missing_operation_date" }],
});
});
test("execute falla si el identificador de proforma no es válido", async () => {
const useCase = new GetProformaIssuanceReadinessUseCase({
finder: {} as never,
readinessChecker: {} as never,
snapshotBuilder: {} as never,
transactionManager: buildTransactionManager(),
});
const result = await useCase.execute({
companyId: UniqueID.generateNewID(),
proforma_id: "invalid-id",
});
assert.equal(result.isFailure, true);
});

View File

@ -0,0 +1,60 @@
import type { ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { IProformaFinder, IProformaIssuanceReadinessChecker } from "../services";
import type { IProformaIssuanceReadinessSnapshotBuilder } from "../snapshot-builders";
type GetProformaIssuanceReadinessUseCaseInput = {
companyId: UniqueID;
proforma_id: string;
};
export class GetProformaIssuanceReadinessUseCase {
public constructor(
private readonly deps: {
finder: IProformaFinder;
readinessChecker: IProformaIssuanceReadinessChecker;
snapshotBuilder: IProformaIssuanceReadinessSnapshotBuilder;
transactionManager: ITransactionManager;
}
) {}
public execute(params: GetProformaIssuanceReadinessUseCaseInput) {
const idOrError = UniqueID.create(params.proforma_id);
if (idOrError.isFailure) {
return Result.fail(idOrError.error);
}
const proformaId = idOrError.data;
return this.deps.transactionManager.complete(async (transaction) => {
try {
const proformaResult = await this.deps.finder.findProformaByIdIncludingArchived(
params.companyId,
proformaId,
transaction
);
if (proformaResult.isFailure) {
return Result.fail(proformaResult.error);
}
const readinessResult = await this.deps.readinessChecker.check({
companyId: params.companyId,
proforma: proformaResult.data,
transaction,
});
if (readinessResult.isFailure) {
return Result.fail(readinessResult.error);
}
return Result.ok(this.deps.snapshotBuilder.toOutput(readinessResult.data));
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
}

View File

@ -4,6 +4,7 @@ export * from "./create-proforma.use-case";
export * from "./duplicate-proforma-by-id.use-case";
export * from "./delete-proforma-by-id.use-case";
export * from "./get-proforma-by-id.use-case";
export * from "./get-proforma-issuance-readiness.use-case";
export * from "./issue-proforma.use-case";
export * from "./list-proformas.use-case";
export * from "./preview-proforma-report.use-case";

View File

@ -1,10 +1,20 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DiscountPercentage } from "@erp/core/api";
import type { ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { CurrencyCode, LanguageCode, UtcDate, UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import {
CompanyVerifactuProfileStatus,
InvoiceNumber,
InvoiceStatus,
Proforma,
ProformaItems,
ProformaTaxConfig,
} from "../../../domain";
import { ProformaIssuer } from "../services/proforma-issuer";
import { IssueProformaUseCase } from "./issue-proforma.use-case";
const buildTransactionManager = (): ITransactionManager => ({
@ -15,6 +25,72 @@ const buildTransactionManager = (): ITransactionManager => ({
rollback: async () => undefined,
});
const buildIssueableProforma = (params?: {
operationDate?: Maybe<UtcDate>;
}) => {
const proformaDateResult = UtcDate.createFromISO("2026-08-16");
const operationDateResult = UtcDate.createFromISO("2026-08-17");
const languageCodeResult = LanguageCode.create("es");
const currencyCodeResult = CurrencyCode.create("EUR");
const discountResult = DiscountPercentage.create({ value: 0 });
const proformaReferenceResult = InvoiceNumber.create("PF-2026-1");
const taxConfigResult = ProformaTaxConfig.create({
taxMode: "single",
defaultIvaCode: Maybe.none(),
usesEquivalenceSurcharge: false,
defaultRecCode: Maybe.none(),
usesRetention: false,
defaultRetentionCode: Maybe.none(),
});
assert.equal(proformaDateResult.isSuccess, true);
assert.equal(operationDateResult.isSuccess, true);
assert.equal(languageCodeResult.isSuccess, true);
assert.equal(currencyCodeResult.isSuccess, true);
assert.equal(discountResult.isSuccess, true);
assert.equal(proformaReferenceResult.isSuccess, true);
assert.equal(taxConfigResult.isSuccess, true);
return Proforma.rehydrate(
{
companyId: UniqueID.generateNewID(),
status: InvoiceStatus.approved(),
archivedAt: Maybe.none(),
documentSeriesId: Maybe.none(),
proformaSeriesCode: Maybe.none(),
proformaNumber: Maybe.none(),
proformaReference: proformaReferenceResult.data,
targetInvoiceSeriesCode: Maybe.none(),
proformaDate: proformaDateResult.data,
operationDate: params?.operationDate ?? Maybe.some(operationDateResult.data),
customerId: UniqueID.generateNewID(),
recipient: Maybe.some({
name: "Acme",
tin: Maybe.some("ESB12345678" as never),
street: Maybe.some("Calle Mayor 1" as never),
street2: Maybe.none(),
city: Maybe.some("Madrid" as never),
postalCode: Maybe.some("28001" as never),
province: Maybe.some("Madrid" as never),
country: Maybe.some("ES" as never),
} as never),
reference: Maybe.none(),
description: Maybe.some("Servicio mensual"),
notes: Maybe.none(),
languageCode: languageCodeResult.data,
currencyCode: currencyCodeResult.data,
linkedInvoiceId: Maybe.none(),
paymentMethodId: Maybe.some(UniqueID.generateNewID()),
paymentTermId: Maybe.none(),
taxRegimeCode: Maybe.some("01"),
taxConfig: taxConfigResult.data,
globalDiscountPercentage: discountResult.data,
},
ProformaItems.create({ items: [{} as never] }),
UniqueID.generateNewID()
);
};
test("issue rechaza un payment term cross-tenant y no crea factura parcial", async () => {
const companyId = UniqueID.generateNewID();
const proformaId = UniqueID.generateNewID();
@ -72,3 +148,74 @@ test("issue rechaza un payment term cross-tenant y no crea factura parcial", asy
assert.equal(createIssuedInvoiceCalls, 0);
assert.equal(markAsIssuedCalls, 0);
});
test("issue falla antes de persistir si la proforma no tiene operation date", async () => {
const proforma = buildIssueableProforma({
operationDate: Maybe.none(),
});
let createIssuedInvoiceCalls = 0;
let markAsIssuedCalls = 0;
const useCase = new IssueProformaUseCase({
issuedInvoiceServices: {
createIssuedInvoice: async () => {
createIssuedInvoiceCalls += 1;
return Result.ok({ id: UniqueID.generateNewID() } as never);
},
} as never,
finder: {
findProformaById: async () => Result.ok(proforma),
} as never,
issuer: new ProformaIssuer({
proformaConverter: {
toCreateProps: () => Result.fail(new Error("converter should not be reached")),
},
}),
issueReadModelAssembler: {
assemble: async () =>
Result.ok({
proforma,
paymentMethod: {
id: UniqueID.generateNewID(),
name: "Transferencia",
description: Maybe.none(),
},
paymentTerm: Maybe.none(),
taxRegime: {
code: "01",
description: "Régimen general",
},
fiscalClassification: {
operationQualificationCode: null,
exemptionReasonCode: null,
},
verifactuPolicy: {
profileStatus: CompanyVerifactuProfileStatus.NotConfigured,
usesVerifactu: false,
effectiveAt: null,
},
}),
} as never,
issueResponseSnapshotBuilder: {
toOutput: () => ({ id: "unused" }),
} as never,
repository: {
markAsIssuedByIdInCompany: async () => {
markAsIssuedCalls += 1;
return Result.ok(undefined);
},
} as never,
transactionManager: buildTransactionManager(),
});
const result = await useCase.execute({
companyId: proforma.companyId.toPrimitive(),
proformaId: proforma.id.toPrimitive(),
});
assert.equal(result.isFailure, true);
assert.match(result.error.message, /operation date is required/i);
assert.equal(createIssuedInvoiceCalls, 0);
assert.equal(markAsIssuedCalls, 0);
});

View File

@ -0,0 +1,161 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DiscountPercentage } from "@erp/core/api";
import {
CurrencyCode,
DomainValidationError,
LanguageCode,
UniqueID,
UtcDate,
} from "@repo/rdx-ddd";
import { Maybe } from "@repo/rdx-utils";
import { InvoiceNumber, InvoiceStatus } from "../../common";
import { ProformaItems } from "../entities";
import { ProformaTaxConfig } from "../value-objects";
import { Proforma } from "./proforma.aggregate";
const buildApprovedProforma = (params?: {
description?: Maybe<string>;
operationDate?: Maybe<UtcDate>;
paymentMethodId?: Maybe<UniqueID>;
itemsCount?: number;
status?: InvoiceStatus;
linkedInvoiceId?: Maybe<UniqueID>;
}) => {
const proformaDateResult = UtcDate.createFromISO("2026-08-16");
const operationDateResult = UtcDate.createFromISO("2026-08-17");
const currencyCodeResult = CurrencyCode.create("EUR");
const languageCodeResult = LanguageCode.create("es");
const proformaReferenceResult = InvoiceNumber.create("PF-2026-1");
const discountResult = DiscountPercentage.create({ value: 0 });
const taxConfigResult = ProformaTaxConfig.create({
taxMode: "single",
defaultIvaCode: Maybe.none(),
usesEquivalenceSurcharge: false,
defaultRecCode: Maybe.none(),
usesRetention: false,
defaultRetentionCode: Maybe.none(),
});
assert.equal(proformaDateResult.isSuccess, true);
assert.equal(operationDateResult.isSuccess, true);
assert.equal(currencyCodeResult.isSuccess, true);
assert.equal(languageCodeResult.isSuccess, true);
assert.equal(proformaReferenceResult.isSuccess, true);
assert.equal(discountResult.isSuccess, true);
assert.equal(taxConfigResult.isSuccess, true);
const items = Array.from({ length: params?.itemsCount ?? 1 }, () => ({}) as never);
return Proforma.rehydrate(
{
companyId: UniqueID.generateNewID(),
status: params?.status ?? InvoiceStatus.approved(),
archivedAt: Maybe.none(),
documentSeriesId: Maybe.none(),
proformaSeriesCode: Maybe.none(),
proformaNumber: Maybe.none(),
proformaReference: proformaReferenceResult.data,
targetInvoiceSeriesCode: Maybe.none(),
proformaDate: proformaDateResult.data,
operationDate: params?.operationDate ?? Maybe.some(operationDateResult.data),
customerId: UniqueID.generateNewID(),
recipient: Maybe.some({
name: "Acme",
tin: Maybe.some("ESB12345678" as never),
street: Maybe.some("Calle Mayor 1" as never),
street2: Maybe.none(),
city: Maybe.some("Madrid" as never),
postalCode: Maybe.some("28001" as never),
province: Maybe.some("Madrid" as never),
country: Maybe.some("ES" as never),
} as never),
reference: Maybe.none(),
description: params?.description ?? Maybe.some("Servicio mensual"),
notes: Maybe.none(),
languageCode: languageCodeResult.data,
currencyCode: currencyCodeResult.data,
linkedInvoiceId: params?.linkedInvoiceId ?? Maybe.none(),
paymentMethodId: params?.paymentMethodId ?? Maybe.some(UniqueID.generateNewID()),
paymentTermId: Maybe.none(),
taxRegimeCode: Maybe.some("01"),
taxConfig: taxConfigResult.data,
globalDiscountPercentage: discountResult.data,
},
ProformaItems.create({ items }),
UniqueID.generateNewID()
);
};
test("markAsIssued rechaza proformas sin operationDate", () => {
const proforma = buildApprovedProforma({
operationDate: Maybe.none(),
});
const result = proforma.markAsIssued();
assert.equal(result.isFailure, true);
assert.equal(result.error instanceof DomainValidationError, true);
assert.equal((result.error as DomainValidationError).field, "operationDate");
assert.equal(
(result.error as DomainValidationError).detail,
"Operation date is required to issue the proforma"
);
});
test("markAsIssued mantiene el rechazo cuando falta description", () => {
const proforma = buildApprovedProforma({
description: Maybe.none(),
});
const result = proforma.markAsIssued();
assert.equal(result.isFailure, true);
assert.equal(result.error instanceof DomainValidationError, true);
assert.equal((result.error as DomainValidationError).field, "description");
assert.equal(
(result.error as DomainValidationError).detail,
"Description is required to issue the proforma"
);
});
test("markAsIssued acepta la proforma cuando cumple todas las precondiciones", () => {
const proforma = buildApprovedProforma();
const result = proforma.markAsIssued();
assert.equal(result.isSuccess, true);
assert.equal(proforma.status.isIssued(), true);
});
test("getIssuanceBlockers enumera varios blockers simultáneamente", () => {
const proforma = buildApprovedProforma({
description: Maybe.none(),
operationDate: Maybe.none(),
paymentMethodId: Maybe.none(),
itemsCount: 0,
});
const blockers = proforma.getIssuanceBlockers();
assert.deepEqual(
blockers.map((blocker) => blocker.code),
["missing_description", "missing_operation_date", "missing_items", "missing_payment_method"]
);
});
test("getIssuanceBlockers informa already_issued cuando existe linked invoice", () => {
const proforma = buildApprovedProforma({
linkedInvoiceId: Maybe.some(UniqueID.generateNewID()),
});
const blockers = proforma.getIssuanceBlockers();
assert.deepEqual(
blockers.map((blocker) => blocker.code),
["already_issued"]
);
});

View File

@ -101,6 +101,31 @@ export interface IProformaTotals {
totalAmount: InvoiceAmount;
}
export type ProformaIssuanceBlockerCode =
| "already_issued"
| "invalid_status"
| "missing_description"
| "missing_operation_date"
| "incomplete_recipient"
| "missing_items"
| "missing_payment_method"
| "missing_tax_regime";
export interface ProformaIssuanceBlocker {
code: ProformaIssuanceBlockerCode;
errorCode:
| "ALREADY_ISSUED"
| "INVALID_STATUS"
| "MISSING_DESCRIPTION"
| "MISSING_OPERATION_DATE"
| "MISSING_RECIPIENT"
| "NO_ITEMS"
| "MISSING_PAYMENT_METHOD"
| "MISSING_TAX_REGIME";
field: string;
detail: string;
}
export interface IProforma {
companyId: UniqueID;
status: InvoiceStatus;
@ -387,26 +412,6 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
}
public markAsIssued(): Result<void, Error> {
const currentStatus = this.status;
if (currentStatus.isIssued()) {
return Result.ok();
}
// Antes de cambiar el estado de la proforma,
// comprobamos que se cumplen las condiciones
// necesarias.
if (!currentStatus.isApproved()) {
return Result.fail(
new InvalidProformaTransitionError(
currentStatus.toPrimitive(),
INVOICE_STATUS.ISSUED,
this.id.toString()
)
);
}
const validationResult = this.validateCanBeIssued();
if (validationResult.isFailure) {
@ -418,6 +423,102 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
return Result.ok();
}
public getIssuanceBlockers(): ProformaIssuanceBlocker[] {
const blockers: ProformaIssuanceBlocker[] = [];
const currentStatus = this.status;
if (currentStatus.isIssued() || this.linkedInvoiceId.isSome()) {
blockers.push({
code: "already_issued",
errorCode: "ALREADY_ISSUED",
field: "linkedInvoiceId",
detail: "Proforma is already linked to an invoice",
});
} else if (!currentStatus.isApproved()) {
blockers.push({
code: "invalid_status",
errorCode: "INVALID_STATUS",
field: "status",
detail: `Invalid transition for proforma ${this.id.toString()}: ${currentStatus.toPrimitive()} → ${INVOICE_STATUS.ISSUED}`,
});
}
if (this.description.isNone()) {
blockers.push({
code: "missing_description",
errorCode: "MISSING_DESCRIPTION",
field: "description",
detail: "Description is required to issue the proforma",
});
}
if (this.operationDate.isNone()) {
blockers.push({
code: "missing_operation_date",
errorCode: "MISSING_OPERATION_DATE",
field: "operationDate",
detail: "Operation date is required to issue the proforma",
});
}
if (this.recipient.isNone()) {
blockers.push({
code: "incomplete_recipient",
errorCode: "MISSING_RECIPIENT",
field: "recipient",
detail: "Recipient is required to issue the proforma",
});
} else {
const recipientValue = this.recipient.unwrap();
if (
!recipientValue.name ||
recipientValue.tin.isNone() ||
recipientValue.street.isNone() ||
recipientValue.city.isNone() ||
recipientValue.postalCode.isNone() ||
recipientValue.province.isNone() ||
recipientValue.country.isNone()
) {
blockers.push({
code: "incomplete_recipient",
errorCode: "MISSING_RECIPIENT",
field: "recipient",
detail: "Recipient is required to issue the proforma",
});
}
}
if (this.items.size() === 0) {
blockers.push({
code: "missing_items",
errorCode: "NO_ITEMS",
field: "items",
detail: "At least one item is required to issue the proforma",
});
}
if (this.paymentMethodId.isNone()) {
blockers.push({
code: "missing_payment_method",
errorCode: "MISSING_PAYMENT_METHOD",
field: "paymentMethod",
detail: "Payment method is required to issue the proforma",
});
}
if (this.taxRegimeCode.isNone()) {
blockers.push({
code: "missing_tax_regime",
errorCode: "MISSING_TAX_REGIME",
field: "taxRegime",
detail: "Tax regime is required to issue the proforma",
});
}
return blockers;
}
public ensureCanBeDeleted(): Result<void, Error> {
if (!this.status.isDraft()) {
return Result.fail(
@ -486,114 +587,21 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
}
private validateCanBeIssued(): Result<void, Error> {
if (this.description.isNone()) {
return Result.fail(
new DomainValidationError(
"MISSING_DESCRIPTION",
"description",
"Description is required to issue the proforma"
)
);
}
const blocker = this.getIssuanceBlockers()[0];
if (this.recipient.isNone()) {
return Result.fail(
new DomainValidationError(
"MISSING_RECIPIENT",
"recipient",
"Recipient is required to issue the proforma"
)
);
}
if (blocker) {
if (blocker.code === "invalid_status") {
return Result.fail(
new InvalidProformaTransitionError(
this.status.toPrimitive(),
INVOICE_STATUS.ISSUED,
this.id.toString()
)
);
}
const recipientValue = this.recipient.unwrap();
if (
!recipientValue.name ||
recipientValue.tin.isNone() ||
recipientValue.street.isNone() ||
recipientValue.city.isNone() ||
recipientValue.postalCode.isNone() ||
recipientValue.province.isNone() ||
recipientValue.country.isNone()
) {
return Result.fail(
new DomainValidationError(
"MISSING_RECIPIENT",
"recipient",
"Recipient is required to issue the proforma"
)
);
}
if (this.items.size() === 0) {
return Result.fail(
new DomainValidationError(
"NO_ITEMS",
"items",
"At least one item is required to issue the proforma"
)
);
}
/*const invalidItem = this.items.find((item) => !item.isValued());
if (invalidItem) {
return Result.fail(
new DomainValidationError(
"INVALID_ITEM",
"items",
`Item with id ${invalidItem.id} is not valid for invoicing`
)
);
}*/
if (this.paymentMethodId.isNone()) {
return Result.fail(
new DomainValidationError(
"MISSING_PAYMENT_METHOD",
"paymentMethod",
"Payment method is required to issue the proforma"
)
);
}
if (this.taxRegimeCode.isNone()) {
return Result.fail(
new DomainValidationError(
"MISSING_TAX_REGIME",
"taxRegime",
"Tax regime is required to issue the proforma"
)
);
}
/*if (this.operationDate.isSome() && this.operationDate.unwrap() > new Date()) {
return Result.fail(
new DomainValidationError(
"INVALID_OPERATION_DATE",
"operationDate",
"Operation date cannot be in the future"
)
);
}
if (this.operationDate.isSome() && this.operationDate.unwrap() < this.invoiceDate) {
return Result.fail(
new DomainValidationError(
"INVALID_OPERATION_DATE",
"operationDate",
"Operation date cannot be before invoice date"
)
);
}*/
if (this.linkedInvoiceId.isSome()) {
return Result.fail(
new DomainValidationError(
"LINKED_INVOICE_NOT_ALLOWED",
"linkedInvoiceId",
"Proforma is already linked to an invoice"
)
new DomainValidationError(blocker.errorCode, blocker.field, blocker.detail)
);
}

View File

@ -10,6 +10,7 @@ import {
type DeleteProformaByIdUseCase,
type DuplicateProformaByIdUseCase,
type GetProformaByIdUseCase,
type GetProformaIssuanceReadinessUseCase,
type IIssuedInvoicePublicServices,
type IssueProformaUseCase,
type ListProformasUseCase,
@ -23,6 +24,7 @@ import {
buildDeleteProformaByIdUseCase,
buildDuplicateProformaByIdUseCase,
buildGetProformaByIdUseCase,
buildGetProformaIssuanceReadinessUseCase,
buildIssueProformaUseCase,
buildListProformasUseCase,
buildPreviewProformaReportUseCase,
@ -33,6 +35,7 @@ import {
buildProformaFinder,
buildProformaInputMappers,
buildProformaIssueReadModelAssembler,
ProformaIssuanceReadinessChecker,
buildProformaIssuer,
buildProformaReadModelAssemblers,
buildProformaSnapshotBuilders,
@ -59,6 +62,7 @@ export type ProformasInternalDeps = {
useCases: {
listProformas: () => ListProformasUseCase;
getProformaById: () => GetProformaByIdUseCase;
getProformaIssuanceReadiness: () => GetProformaIssuanceReadinessUseCase;
reportProformaPdf: () => ReportProformaPdfUseCase;
previewProformaReport: () => PreviewProformaReportUseCase;
createProforma: () => CreateProformaUseCase;
@ -162,6 +166,9 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
customerServices,
companyVerifactuProfileRepository,
});
const issuanceReadinessChecker = new ProformaIssuanceReadinessChecker({
paymentMethodFinder: catalogs.paymentMethod.finder,
});
const companyReportProfileFinder = new CompanyReportProfileFinder({
companiesServices,
@ -185,6 +192,14 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
transactionManager,
}),
getProformaIssuanceReadiness: () =>
buildGetProformaIssuanceReadinessUseCase({
finder,
readinessChecker: issuanceReadinessChecker,
snapshotBuilder: snapshotBuilders.issuanceReadiness,
transactionManager,
}),
createProforma: () =>
buildCreateProformaUseCase({
creator,

View File

@ -0,0 +1,129 @@
import assert from "node:assert/strict";
import test from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { NextFunction, Request, Response } from "express";
import { GetProformaIssuanceReadinessController } from "./get-proforma-issuance-readiness.controller";
function createResponseDouble() {
return {
statusCode: 200,
headers: {} as Record<string, string>,
body: undefined as unknown,
type() {
return this;
},
status(code: number) {
this.statusCode = code;
return this;
},
json(payload: unknown) {
this.body = payload;
return this;
},
sendStatus(code: number) {
this.statusCode = code;
return this;
},
setHeader(key: string, value: string) {
this.headers[key] = value;
return this;
},
set(values: Record<string, string>) {
Object.assign(this.headers, values);
return this;
},
send(payload?: unknown) {
this.body = payload;
return this;
},
};
}
function createRequestDouble(overrides: Partial<Request> = {}): Request {
return {
method: "GET",
protocol: "http",
originalUrl: "/api/proformas/11111111-1111-4111-8111-111111111111/issuance-readiness",
query: {},
params: {
proforma_id: "11111111-1111-4111-8111-111111111111",
},
body: {},
get: () => undefined,
...overrides,
} as Request;
}
test("controller devuelve 401 sin autenticación", async () => {
const controller = new GetProformaIssuanceReadinessController({
execute: async () => {
throw new Error("should not run");
},
} as never);
const res = createResponseDouble();
await controller.execute(
createRequestDouble(),
res as never as Response,
(() => undefined) as NextFunction
);
assert.equal(res.statusCode, 401);
});
test("controller devuelve 403 si falta company context", async () => {
const controller = new GetProformaIssuanceReadinessController({
execute: async () => {
throw new Error("should not run");
},
} as never);
const res = createResponseDouble();
const req = createRequestDouble({
user: {
userId: UniqueID.generateNewID(),
},
} as Partial<Request>);
await controller.execute(req, res as never as Response, (() => undefined) as NextFunction);
assert.equal(res.statusCode, 403);
});
test("controller devuelve 200 con can_issue false y blockers tipados", async () => {
const companyId = UniqueID.generateNewID();
const controller = new GetProformaIssuanceReadinessController({
execute: async ({
companyId: receivedCompanyId,
proforma_id,
}: {
companyId: UniqueID;
proforma_id: string;
}) => {
assert.equal(receivedCompanyId.equals(companyId), true);
assert.equal(proforma_id, "11111111-1111-4111-8111-111111111111");
return Result.ok({
can_issue: false,
blockers: [{ code: "missing_operation_date" as const }],
});
},
} as never);
const res = createResponseDouble();
const req = createRequestDouble({
user: {
userId: UniqueID.generateNewID(),
companyId,
},
} as Partial<Request>);
await controller.execute(req, res as never as Response, (() => undefined) as NextFunction);
assert.equal(res.statusCode, 200);
assert.deepEqual(res.body, {
can_issue: false,
blockers: [{ code: "missing_operation_date" }],
});
});

View File

@ -0,0 +1,43 @@
import {
ExpressController,
forbidQueryFieldGuard,
requireAuthenticatedGuard,
requireCompanyContextGuard,
} from "@erp/core/api";
import { GetProformaIssuanceReadinessResponseSchema } from "../../../../../common";
import type { GetProformaIssuanceReadinessUseCase } from "../../../../application";
import { proformasApiErrorMapper } from "../proformas-api-error-mapper";
export class GetProformaIssuanceReadinessController extends ExpressController {
public constructor(private readonly useCase: GetProformaIssuanceReadinessUseCase) {
super();
this.errorMapper = proformasApiErrorMapper;
this.registerGuards(
requireAuthenticatedGuard(),
requireCompanyContextGuard(),
forbidQueryFieldGuard("companyId")
);
}
protected async executeImpl() {
const companyId = this.getTenantId();
if (!companyId) {
return this.forbiddenError("Tenant ID not found");
}
const { proforma_id } = this.req.params;
const result = await this.useCase.execute({
proforma_id,
companyId,
});
return result.match(
(data) => this.ok(GetProformaIssuanceReadinessResponseSchema.parse(data)),
(err) => this.handleError(err)
);
}
}

View File

@ -3,6 +3,7 @@ export * from "./archive-proforma-by-id.controller";
//export * from "./create-proforma.controller";
export * from "./delete-proforma.controller";
export * from "./get-proforma-by-id.controller";
export * from "./get-proforma-issuance-readiness.controller";
export * from "./issue-proforma.controller";
export * from "./list-proformas.controller";
export * from "./preview-proforma-report.controller";

View File

@ -6,6 +6,7 @@ import {
ArchiveProformaByIdController,
DeleteProformaController,
GetProformaByIdController,
GetProformaIssuanceReadinessController,
IssueProformaController,
ListProformasController,
PreviewProformaReportController,
@ -22,6 +23,7 @@ import {
DeleteProformaByIdParamsRequestSchema,
DuplicateProformaByIdParamsRequestSchema,
GetProformaByIdRequestSchema,
GetProformaIssuanceReadinessRequestSchema,
IssueProformaByIdParamsRequestSchema,
LegacyIssueProformaByIdParamsRequestSchema,
ListProformasRequestSchema,
@ -64,7 +66,10 @@ export const proformasRouter = (params: StartParams) => {
validateRequest(ListProformasRequestSchema, "query"),
async (req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.listProformas();
const controller = new ListProformasController(useCase, "normal" /*, deps.presenters.list */);
const controller = new ListProformasController(
useCase,
"normal" /*, deps.presenters.list */
);
return controller.execute(req, res, next);
}
);
@ -87,7 +92,10 @@ export const proformasRouter = (params: StartParams) => {
validateRequest(ListProformasRequestSchema, "query"),
async (req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.listProformas();
const controller = new ListProformasController(useCase, "deleted" /*, deps.presenters.list */);
const controller = new ListProformasController(
useCase,
"deleted" /*, deps.presenters.list */
);
return controller.execute(req, res, next);
}
);
@ -103,6 +111,16 @@ export const proformasRouter = (params: StartParams) => {
}
);
router.get(
"/:proforma_id/issuance-readiness",
validateRequest(GetProformaIssuanceReadinessRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.getProformaIssuanceReadiness();
const controller = new GetProformaIssuanceReadinessController(useCase);
return controller.execute(req, res, next);
}
);
router.get(
"/:proforma_id/report/pdf",
validateRequest(ReportProformaPdfByIdParamsRequestSchema, "params"),

View File

@ -0,0 +1,9 @@
import { z } from "zod/v4";
export const GetProformaIssuanceReadinessRequestSchema = z.object({
proforma_id: z.uuid(),
});
export type GetProformaIssuanceReadinessRequestDTO = z.infer<
typeof GetProformaIssuanceReadinessRequestSchema
>;

View File

@ -4,6 +4,7 @@ export * from "./create-proforma.request.dto";
export * from "./delete-proforma-by-id.request.dto";
export * from "./duplicate-proforma-by-id.request.dto";
export * from "./get-proforma-by-id.request.dto";
export * from "./get-proforma-issuance-readiness.request.dto";
export * from "./issue-proforma-by-id.request.dto";
export * from "./list-proformas.request.dto";
export * from "./preview-proforma-report-by-id.request.dto";

View File

@ -0,0 +1,27 @@
import { z } from "zod/v4";
export const ProformaIssuanceBlockerCodeSchema = z.enum([
"already_issued",
"invalid_status",
"missing_description",
"missing_operation_date",
"incomplete_recipient",
"missing_items",
"missing_payment_method",
"missing_tax_regime",
"payment_method_unavailable",
"payment_method_inactive",
]);
export const ProformaIssuanceBlockerSchema = z.object({
code: ProformaIssuanceBlockerCodeSchema,
});
export const GetProformaIssuanceReadinessResponseSchema = z.object({
can_issue: z.boolean(),
blockers: z.array(ProformaIssuanceBlockerSchema),
});
export type GetProformaIssuanceReadinessResponseDTO = z.infer<
typeof GetProformaIssuanceReadinessResponseSchema
>;

View File

@ -1,6 +1,7 @@
export * from "./create-proforma.response.dto";
export * from "./duplicate-proforma-by-id.response.dto";
export * from "./get-proforma-by-id.response.dto";
export * from "./get-proforma-issuance-readiness.response.dto";
export * from "./issue-proforma-by-id.response.dto";
export * from "./list-proformas.response.dto";
export * from "./update-proformas-by-id.response.dto";

View File

@ -141,7 +141,88 @@
"error_title": "Error issuing proforma",
"not_allowed_title": "Action not allowed",
"not_allowed_description": "Only proformas in \"Approved\" status can be issued.",
"unknown_error": "An unexpected error occurred"
"unknown_error": "An unexpected error occurred",
"cancel": "Cancel"
},
"issuance_readiness": {
"blocked_title": "Cannot be issued yet",
"detail_description": "Complete the pending data before issuing this proforma.",
"update_description": "This check reflects the currently saved state. Save your changes to refresh it.",
"load_error_title": "Could not verify issuance readiness",
"load_error_description": "Please try again in a moment.",
"retry": "Retry",
"edit_action": "Edit proforma",
"dialog": {
"loading": "Checking whether the proforma is ready to be issued...",
"blocked_title": "Cannot be issued yet",
"blocked_alert_title": "Complete the following before issuing",
"blocked_description": "Proforma {{reference}} does not yet meet the requirements to issue the invoice."
},
"update": {
"summary_one": "{{count}} requirement is still missing before this proforma can be issued",
"summary_other": "{{count}} requirements are still missing before this proforma can be issued",
"saved_state_hint": "Based on the latest saved changes.",
"dirty_state_hint": "This check reflects the latest saved state. Save your changes to refresh it.",
"ready_title": "Ready to issue",
"ready_description": "The proforma currently meets the requirements needed to be turned into an invoice.",
"loading": "Checking issuance requirements…",
"unavailable_title": "The issuance status could not be checked.",
"unavailable_description": "Try again to review the pending requirements."
},
"blockers": {
"already_issued": "This proforma has already been issued and cannot be issued again.",
"invalid_status": "The proforma must be approved before it can be issued.",
"missing_description": "Add a description to the proforma.",
"missing_operation_date": "Fill in the operation date.",
"incomplete_recipient": "Complete the customer's fiscal details.",
"missing_items": "Add at least one line to the proforma.",
"missing_payment_method": "Select a payment method.",
"missing_tax_regime": "Select a tax regime.",
"payment_method_unavailable": "The selected payment method is no longer available.",
"payment_method_inactive": "The selected payment method is inactive. Choose an active payment method."
},
"blocker_details": {
"already_issued": {
"title": "Already issued",
"description": "This proforma has already been turned into an invoice."
},
"invalid_status": {
"title": "Document status",
"description": "It must be approved before it can be issued."
},
"missing_description": {
"title": "Description",
"description": "Add a description to the proforma."
},
"missing_operation_date": {
"title": "Operation date",
"description": "The operation date must be provided."
},
"incomplete_recipient": {
"title": "Customer fiscal details",
"description": "Complete the customer's fiscal details."
},
"missing_items": {
"title": "Line items",
"description": "Add at least one line to the proforma."
},
"missing_payment_method": {
"title": "Payment method",
"description": "Select a payment method."
},
"missing_tax_regime": {
"title": "Tax regime",
"description": "Select a tax regime."
},
"payment_method_unavailable": {
"title": "Payment method unavailable",
"description": "The selected payment method is no longer available."
},
"payment_method_inactive": {
"title": "Inactive payment method",
"description": "Select an active payment method."
}
}
},
"delete_proforma_dialog": {
"single_title": "Delete proforma",
@ -551,6 +632,21 @@
"error_title": "Customer could not be changed",
"apply_error": "The proforma could not be adapted to the new customer."
}
},
"header": {
"help": {
"aria_label": "Information and issuance status of the proforma",
"aria_label_with_blockers_one": "Proforma information. {{count}} issuance requirement pending.",
"aria_label_with_blockers_other": "Proforma information. {{count}} issuance requirements pending.",
"title": "Proforma information",
"issuance_section_title": "Issuance status",
"fiscal_section_title": "About proformas",
"description": "This proforma has no fiscal validity. You can turn it into an invoice once it has been accepted by the customer."
}
},
"items": {
"title": "Proforma details",
"description": "Manage the product or service lines included in this proforma."
}
},
"delete": {

View File

@ -144,6 +144,86 @@
"unknown_error": "Ha ocurrido un error inesperado",
"cancel": "Cancelar"
},
"issuance_readiness": {
"blocked_title": "No se puede emitir todavía",
"detail_description": "Completa los datos pendientes antes de emitir esta proforma.",
"update_description": "Esta comprobación refleja el estado guardado actualmente. Guarda los cambios para actualizarla.",
"load_error_title": "No se pudo comprobar la preparación para emitir",
"load_error_description": "Inténtalo de nuevo en unos instantes.",
"retry": "Reintentar",
"edit_action": "Editar proforma",
"dialog": {
"loading": "Comprobando si la proforma está lista para emitir...",
"blocked_title": "No se puede emitir todavía",
"blocked_alert_title": "Completa los siguientes datos antes de emitir",
"blocked_description": "La proforma {{reference}} todavía no cumple los requisitos para emitir la factura."
},
"update": {
"summary_one": "Falta {{count}} requisito para poder emitir esta proforma",
"summary_other": "Faltan {{count}} requisitos para poder emitir esta proforma",
"saved_state_hint": "Basado en los últimos cambios guardados.",
"dirty_state_hint": "Esta comprobación refleja el último estado guardado. Guarda los cambios para actualizarla.",
"ready_title": "Lista para emitir",
"ready_description": "La proforma cumple actualmente los requisitos necesarios para convertirse en factura.",
"loading": "Comprobando requisitos de emisión…",
"unavailable_title": "No se ha podido comprobar el estado de emisión.",
"unavailable_description": "Inténtalo de nuevo para revisar los requisitos pendientes."
},
"blockers": {
"already_issued": "Esta proforma ya fue emitida y no puede volver a emitirse.",
"invalid_status": "La proforma debe estar aprobada antes de emitirla.",
"missing_description": "Añade una descripción a la proforma.",
"missing_operation_date": "Completa la fecha de operación.",
"incomplete_recipient": "Completa los datos fiscales del cliente.",
"missing_items": "Añade al menos una línea a la proforma.",
"missing_payment_method": "Selecciona una forma de pago.",
"missing_tax_regime": "Selecciona un régimen fiscal.",
"payment_method_unavailable": "La forma de pago seleccionada ya no está disponible.",
"payment_method_inactive": "La forma de pago seleccionada está inactiva. Elige una forma de pago activa."
},
"blocker_details": {
"already_issued": {
"title": "Ya emitida",
"description": "Esta proforma ya se convirtió en factura."
},
"invalid_status": {
"title": "Estado documental",
"description": "Debe estar aprobada antes de emitirla."
},
"missing_description": {
"title": "Descripción",
"description": "Añade una descripción a la proforma."
},
"missing_operation_date": {
"title": "Fecha de operación",
"description": "Debe indicar la fecha de operación."
},
"incomplete_recipient": {
"title": "Datos fiscales del cliente",
"description": "Completa los datos fiscales del cliente."
},
"missing_items": {
"title": "Líneas de detalle",
"description": "Añade al menos una línea a la proforma."
},
"missing_payment_method": {
"title": "Forma de pago",
"description": "Selecciona una forma de pago."
},
"missing_tax_regime": {
"title": "Régimen fiscal",
"description": "Selecciona un régimen fiscal."
},
"payment_method_unavailable": {
"title": "Forma de pago no disponible",
"description": "La forma de pago seleccionada ya no está disponible."
},
"payment_method_inactive": {
"title": "Forma de pago inactiva",
"description": "Selecciona una forma de pago activa."
}
}
},
"delete_proforma_dialog": {
"single_title": "Eliminar proforma",
"single_description": "Se eliminará la proforma. Su referencia no se reutilizará.",
@ -553,6 +633,21 @@
"error_title": "No se pudo cambiar el cliente",
"apply_error": "No se pudo adaptar la proforma al nuevo cliente."
}
},
"header": {
"help": {
"aria_label": "Información y estado de emisión de la proforma",
"aria_label_with_blockers_one": "Información de la proforma. {{count}} requisito pendiente de emisión.",
"aria_label_with_blockers_other": "Información de la proforma. {{count}} requisitos pendientes de emisión.",
"title": "Información de la proforma",
"issuance_section_title": "Estado para emisión",
"fiscal_section_title": "Sobre las proformas",
"description": "Esta proforma no tiene validez fiscal. Puedes convertirla en factura cuando sea aceptada por el cliente."
}
},
"items": {
"title": "Detalle de la proforma",
"description": "Gestiona las líneas de productos o servicios de esta proforma."
}
},
"delete": {

View File

@ -13,9 +13,11 @@ import { useDownloadProformaPDFQuery } from "../../download-pdf";
import { useIssueProformaDialogController } from "../../issue-proforma";
import { prepareIssueProformaTarget } from "../../issue-proforma/utils";
import {
getProformaIssuanceBlockerMessages,
useDuplicateProformaByIdMutation,
useProformaArchiveMutation,
useProformaGetQuery,
useProformaIssuanceReadinessQuery,
useProformaUnarchiveMutation,
} from "../../shared";
import type {
@ -25,6 +27,7 @@ import type {
ProformaDetailDocumentViewModel,
ProformaDetailGeneralViewModel,
ProformaDetailHeaderViewModel,
ProformaDetailIssuanceReadinessViewModel,
ProformaDetailPageState,
} from "../entities";
import {
@ -87,6 +90,10 @@ export const useProformaDetailPageController = () => {
id,
enabled: Boolean(id),
});
const issuanceReadinessQuery = useProformaIssuanceReadinessQuery({
id,
enabled: Boolean(id),
});
const issueDialogCtrl = useIssueProformaDialogController();
const changeStatusDialogCtrl = useChangeProformaStatusDialogController();
@ -125,6 +132,7 @@ export const useProformaDetailPageController = () => {
}, [paymentMethodsQuery.data?.items]);
const proforma = proformaQuery.data;
const issuanceReadiness = issuanceReadinessQuery.data;
const paymentMethodLabel =
paymentMethodOptions.find((option) => option.value === proforma?.paymentMethodId)?.label ??
null;
@ -397,6 +405,32 @@ export const useProformaDetailPageController = () => {
? buildProformaDetailGeneralViewModel(proforma, paymentContext)
: null;
const issuanceReadinessViewModel: ProformaDetailIssuanceReadinessViewModel | null =
issuanceReadinessQuery.isError && issuanceReadinessQuery.error instanceof Error
? {
title: t("proformas.issuance_readiness.load_error_title"),
description: issuanceReadinessQuery.error.message,
blockers: [],
retryLabel: t("proformas.issuance_readiness.retry"),
onRetry: () => {
issuanceReadinessQuery.refetch().catch((error: unknown) => {
showErrorToast(
t("proformas.issuance_readiness.load_error_title"),
error instanceof Error
? error.message
: t("proformas.issuance_readiness.load_error_description")
);
});
},
}
: issuanceReadiness && !issuanceReadiness.canIssue
? {
title: t("proformas.issuance_readiness.blocked_title"),
description: t("proformas.issuance_readiness.detail_description"),
blockers: getProformaIssuanceBlockerMessages(issuanceReadiness, t),
}
: null;
const actions: ProformaDetailActionsViewModel = useMemo(() => {
if (!proforma) {
return {
@ -407,6 +441,7 @@ export const useProformaDetailPageController = () => {
return buildProformaDetailActions({
status: proforma.status,
isArchived: proforma.isArchived,
canIssue: issuanceReadiness?.canIssue ?? false,
labels: {
edit: t("pages.proformas.list.actions.edit"),
changeStatus: t("pages.proformas.list.actions.change_status"),
@ -423,7 +458,7 @@ export const useProformaDetailPageController = () => {
editArchived: t("proformas.detail.actions.disabled_reasons.edit_archived"),
statusIssued: t("pages.proformas.list.actions.disabled_reasons.status_issued"),
statusArchived: t("proformas.detail.actions.disabled_reasons.status_archived"),
issueNotAllowed: t("proformas.issue_proforma_dialog.not_allowed_description"),
issueNotAllowed: t("proformas.issuance_readiness.detail_description"),
operationInProgress: t(
"pages.proformas.list.actions.disabled_reasons.operation_in_progress"
),
@ -445,6 +480,7 @@ export const useProformaDetailPageController = () => {
previewPdfBlob,
previewPdfQuery.isFetching,
proforma,
issuanceReadiness?.canIssue,
t,
unarchiveMutation.isPending,
]);
@ -499,6 +535,7 @@ export const useProformaDetailPageController = () => {
header,
document,
general,
issuanceReadiness: issuanceReadinessViewModel,
actions,
navigation: {
currentReturnTo,

View File

@ -155,3 +155,11 @@ export interface ProformaDetailActionViewModel {
export interface ProformaDetailActionsViewModel {
items: ProformaDetailActionViewModel[];
}
export interface ProformaDetailIssuanceReadinessViewModel {
title: string;
description: string;
blockers: string[];
retryLabel?: string;
onRetry?: () => void;
}

View File

@ -1,18 +1,23 @@
import { RdxContextPanel } from "@repo/rdx-ui/components";
import { useTranslation } from "../../../../i18n";
import type { ProformaDetailGeneralViewModel } from "../../entities";
import type {
ProformaDetailGeneralViewModel,
ProformaDetailIssuanceReadinessViewModel,
} from "../../entities";
import { ProformaGeneralPanel } from "./proforma-general-panel";
type ProformaDetailInspectorProps = {
general: ProformaDetailGeneralViewModel;
issuanceReadiness: ProformaDetailIssuanceReadinessViewModel | null;
onOpenCustomer: () => void;
onOpenLinkedInvoice: () => void;
};
export const ProformaDetailInspector = ({
general,
issuanceReadiness,
onOpenCustomer,
onOpenLinkedInvoice,
}: ProformaDetailInspectorProps) => {
@ -26,6 +31,7 @@ export const ProformaDetailInspector = ({
>
<ProformaGeneralPanel
general={general}
issuanceReadiness={issuanceReadiness}
onOpenCustomer={onOpenCustomer}
onOpenLinkedInvoice={onOpenLinkedInvoice}
/>

View File

@ -2,11 +2,16 @@ import { Button } from "@repo/shadcn-ui/components";
import { ExternalLinkIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n";
import type { ProformaDetailGeneralViewModel } from "../../entities";
import { ProformaIssuanceReadinessAlert } from "../../../shared";
import type {
ProformaDetailGeneralViewModel,
ProformaDetailIssuanceReadinessViewModel,
} from "../../entities";
import { ProformaDetailField } from "../components";
export interface ProformaGeneralPanelProps {
general: ProformaDetailGeneralViewModel;
issuanceReadiness: ProformaDetailIssuanceReadinessViewModel | null;
onOpenCustomer: () => void;
onOpenLinkedInvoice: () => void;
}
@ -15,6 +20,7 @@ const EMPTY_VALUE = "\u2014";
export const ProformaGeneralPanel = ({
general,
issuanceReadiness,
onOpenCustomer,
onOpenLinkedInvoice,
}: ProformaGeneralPanelProps) => {
@ -22,6 +28,16 @@ export const ProformaGeneralPanel = ({
return (
<div className="space-y-5 px-4 py-4 sm:px-5">
{issuanceReadiness ? (
<ProformaIssuanceReadinessAlert
blockers={issuanceReadiness.blockers}
description={issuanceReadiness.description}
onRetry={issuanceReadiness.onRetry}
retryLabel={issuanceReadiness.retryLabel}
title={issuanceReadiness.title}
/>
) : null}
<div className="space-y-2">
<p className="text-3xl font-semibold tracking-tight text-foreground tabular-nums">
{general.summary.totalLabel}

View File

@ -35,11 +35,15 @@ export const ProformaDetailPage = () => {
);
}
if (!(ctrl.header && ctrl.document && ctrl.general)) {
return null;
}
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden">
<ProformaDetailHeader
actions={ctrl.actions}
header={ctrl.header!}
header={ctrl.header}
onBack={ctrl.navigation.navigateBack}
onExecuteAction={ctrl.handlers.executeAction}
/>
@ -47,7 +51,7 @@ export const ProformaDetailPage = () => {
<ProformaDetailContent
documentPane={
<ProformaDocumentPane
document={ctrl.document!}
document={ctrl.document}
onDownload={ctrl.viewer.handleDownload}
onOpen={ctrl.viewer.handleOpenPreviewPdf}
onPrint={ctrl.viewer.handlePrintPreview}
@ -57,7 +61,8 @@ export const ProformaDetailPage = () => {
}
inspector={
<ProformaDetailInspector
general={ctrl.general!}
general={ctrl.general}
issuanceReadiness={ctrl.issuanceReadiness}
onOpenCustomer={ctrl.navigation.openCustomerInNewTab}
onOpenLinkedInvoice={ctrl.navigation.openLinkedInvoiceInNewTab}
/>
@ -65,14 +70,20 @@ export const ProformaDetailPage = () => {
/>
<IssueProformaDialog
blockerMessages={ctrl.dialogs.issue.blockerMessages}
canIssue={ctrl.dialogs.issue.readiness?.canIssue ?? false}
isReadinessLoading={ctrl.dialogs.issue.isReadinessLoading}
isSubmitting={ctrl.dialogs.issue.isSubmitting}
onConfirm={ctrl.dialogs.issue.confirmIssue}
onEdit={ctrl.dialogs.issue.openEdit}
onOpenChange={(open) => {
if (!open) {
ctrl.dialogs.issue.closeDialog();
}
}}
onRetryReadiness={ctrl.dialogs.issue.retryReadiness}
open={ctrl.dialogs.issue.open}
readinessError={ctrl.dialogs.issue.readinessError}
target={ctrl.dialogs.issue.target}
/>

View File

@ -30,6 +30,7 @@ export interface ProformaDetailActionLabels {
export interface BuildProformaDetailActionsParams {
status: ProformaStatus;
isArchived: boolean;
canIssue: boolean;
labels: ProformaDetailActionLabels;
loading: Partial<Record<ProformaDetailActionKey, boolean>>;
}
@ -66,12 +67,11 @@ const buildAction = (
export const buildProformaDetailActions = ({
status,
isArchived,
canIssue,
labels,
loading,
}: BuildProformaDetailActionsParams): ProformaDetailActionsViewModel => {
const isDraft = status === "draft";
const isSent = status === "sent";
const isApproved = status === "approved";
const isRejected = status === "rejected";
const isIssued = status === "issued";
const canChangeStatus = hasManualTransition(status);
@ -80,7 +80,7 @@ export const buildProformaDetailActions = ({
buildAction(
"edit",
labels.edit,
isApproved ? "secondary" : "primary",
"secondary",
"visible",
!(isArchived || isIssued),
loading.edit ?? false,
@ -107,10 +107,10 @@ export const buildProformaDetailActions = ({
"issue",
labels.issue,
"primary",
isApproved || isSent ? "visible" : "hidden",
isApproved && !isArchived,
"visible",
canIssue && !isArchived,
loading.issue ?? false,
isApproved ? null : labels.disabledReasons.issueNotAllowed
canIssue ? null : labels.disabledReasons.issueNotAllowed
),
buildAction(
"duplicate",

View File

@ -1,8 +1,13 @@
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
import { useState } from "react";
import { createSearchParams, useLocation, useNavigate } from "react-router-dom";
import { useTranslation } from "../../../i18n";
import { useProformaIssueMutation } from "../../shared";
import {
getProformaIssuanceBlockerMessages,
useProformaIssuanceReadinessQuery,
useProformaIssueMutation,
} from "../../shared";
import type { IssueProformaTarget } from "../entities";
interface IssueDialogState {
@ -10,10 +15,10 @@ interface IssueDialogState {
target: IssueProformaTarget | null;
}
const canIssueProforma = (target: IssueProformaTarget) => target.status === "approved";
export const useIssueProformaDialogController = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const issueMutation = useProformaIssueMutation();
const [state, setState] = useState<IssueDialogState>({
@ -21,15 +26,15 @@ export const useIssueProformaDialogController = () => {
target: null,
});
const openDialog = (target: IssueProformaTarget) => {
if (!canIssueProforma(target)) {
showErrorToast(
t("proformas.issue_proforma_dialog.not_allowed_title"),
t("proformas.issue_proforma_dialog.not_allowed_description")
);
return;
}
const readinessQuery = useProformaIssuanceReadinessQuery({
id: state.target?.id,
enabled: state.open && Boolean(state.target?.id),
});
const readiness = readinessQuery.data;
const blockerMessages = getProformaIssuanceBlockerMessages(readiness, t);
const openDialog = (target: IssueProformaTarget) => {
setState({
open: true,
target,
@ -50,7 +55,7 @@ export const useIssueProformaDialogController = () => {
return;
}
if (!canIssueProforma(target)) {
if (!readiness?.canIssue) {
showErrorToast(
t("proformas.issue_proforma_dialog.not_allowed_title"),
t("proformas.issue_proforma_dialog.not_allowed_description")
@ -73,6 +78,8 @@ export const useIssueProformaDialogController = () => {
target: null,
});
} catch (error) {
await readinessQuery.refetch();
const message =
error instanceof Error ? error.message : t("proformas.issue_proforma_dialog.unknown_error");
@ -80,12 +87,41 @@ export const useIssueProformaDialogController = () => {
}
};
const openEdit = () => {
const target = state.target;
if (!target) {
return;
}
const returnTo = `${location.pathname}${location.search}`;
setState({
open: false,
target: null,
});
navigate({
pathname: `/proformas/${target.id}/edit`,
search: createSearchParams({ returnTo }).toString(),
});
};
return {
open: state.open,
target: state.target,
isSubmitting: issueMutation.isPending,
readiness,
isReadinessLoading: readinessQuery.isLoading || readinessQuery.isFetching,
readinessError:
readinessQuery.isError && readinessQuery.error instanceof Error
? readinessQuery.error.message
: null,
blockerMessages,
openDialog,
closeDialog,
confirmIssue,
retryReadiness: readinessQuery.refetch,
openEdit,
};
};

View File

@ -10,6 +10,7 @@ import {
} from "@repo/shadcn-ui/components";
import { useTranslation } from "../../../i18n";
import { ProformaIssuanceReadinessAlert } from "../../shared";
import type { IssueProformaTarget } from "../entities";
interface IssueProformaDialogProps {
@ -17,7 +18,13 @@ interface IssueProformaDialogProps {
onOpenChange: (open: boolean) => void;
target: IssueProformaTarget | null;
isSubmitting: boolean;
isReadinessLoading: boolean;
readinessError: string | null;
canIssue: boolean;
blockerMessages: string[];
onConfirm: () => void;
onRetryReadiness: () => void;
onEdit: () => void;
}
export const IssueProformaDialog = ({
@ -25,24 +32,59 @@ export const IssueProformaDialog = ({
onOpenChange,
target,
isSubmitting,
isReadinessLoading,
readinessError,
canIssue,
blockerMessages,
onConfirm,
onRetryReadiness,
onEdit,
}: IssueProformaDialogProps) => {
const { t } = useTranslation();
const isBlocked = !(isReadinessLoading || readinessError || canIssue);
return (
<AlertDialog onOpenChange={onOpenChange} open={open}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("proformas.issue_proforma_dialog.title")}</AlertDialogTitle>
<AlertDialogTitle>
{isBlocked
? t("proformas.issuance_readiness.dialog.blocked_title")
: t("proformas.issue_proforma_dialog.title")}
</AlertDialogTitle>
<AlertDialogDescription className="space-y-4">
{t("proformas.issue_proforma_dialog.description", {
reference: target?.reference ?? "",
})}
<br />
<br />
{t("proformas.issue_proforma_dialog.warning")}
</AlertDialogDescription>
{isReadinessLoading ? (
<AlertDialogDescription className="flex items-center gap-2">
<Spinner aria-hidden className="size-4" />
<span>{t("proformas.issuance_readiness.dialog.loading")}</span>
</AlertDialogDescription>
) : readinessError ? (
<ProformaIssuanceReadinessAlert
description={readinessError}
onRetry={onRetryReadiness}
retryLabel={t("proformas.issuance_readiness.retry")}
title={t("proformas.issuance_readiness.load_error_title")}
/>
) : isBlocked ? (
<ProformaIssuanceReadinessAlert
actionLabel={t("proformas.issuance_readiness.edit_action")}
blockers={blockerMessages}
description={t("proformas.issuance_readiness.dialog.blocked_description", {
reference: target?.reference ?? "",
})}
onAction={onEdit}
title={t("proformas.issuance_readiness.dialog.blocked_alert_title")}
/>
) : (
<AlertDialogDescription className="space-y-4">
{t("proformas.issue_proforma_dialog.description", {
reference: target?.reference ?? "",
})}
<br />
<br />
{t("proformas.issue_proforma_dialog.warning")}
</AlertDialogDescription>
)}
</AlertDialogHeader>
<AlertDialogFooter>
@ -50,16 +92,18 @@ export const IssueProformaDialog = ({
{t("proformas.issue_proforma_dialog.cancel")}
</Button>
<Button disabled={isSubmitting} onClick={onConfirm}>
{isSubmitting ? (
<>
<Spinner aria-hidden className="mr-2 size-4" />
<span aria-live="polite">{t("proformas.issue_proforma_dialog.submitting")}</span>
</>
) : (
t("proformas.issue_proforma_dialog.confirm")
)}
</Button>
{!(isReadinessLoading || readinessError) && canIssue ? (
<Button disabled={isSubmitting} onClick={onConfirm}>
{isSubmitting ? (
<>
<Spinner aria-hidden className="mr-2 size-4" />
<span aria-live="polite">{t("proformas.issue_proforma_dialog.submitting")}</span>
</>
) : (
t("proformas.issue_proforma_dialog.confirm")
)}
</Button>
) : null}
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

View File

@ -0,0 +1,13 @@
import type { GetProformaIssuanceReadinessResponseDTO } from "../../../../common";
import type { ProformaIssuanceReadiness } from "../entities";
export const GetProformaIssuanceReadinessAdapter = {
fromDto(dto: GetProformaIssuanceReadinessResponseDTO): ProformaIssuanceReadiness {
return {
canIssue: dto.can_issue,
blockers: dto.blockers.map((blocker) => ({
code: blocker.code,
})),
};
},
};

View File

@ -1,4 +1,5 @@
export * from "./get-proforma-by-id.adapter";
export * from "./get-proforma-issuance-readiness.adapter";
export * from "./list-document-series.adapter";
export * from "./list-proformas.adapter";
export * from "./proforma-to-list-row-patch.adapter";

View File

@ -0,0 +1,28 @@
import type { IDataSource } from "@erp/core/client";
import type { GetProformaIssuanceReadinessResponseDTO } from "../../../../common";
export interface GetProformaIssuanceReadinessParams {
id: string;
signal?: AbortSignal;
}
export type GetProformaIssuanceReadinessResult = GetProformaIssuanceReadinessResponseDTO;
export const getProformaIssuanceReadiness = (
dataSource: IDataSource,
params: GetProformaIssuanceReadinessParams
): Promise<GetProformaIssuanceReadinessResult> => {
const { id, signal } = params;
if (!id) {
throw new Error("proformaId is required");
}
return dataSource.custom<undefined, GetProformaIssuanceReadinessResponseDTO>({
path: `proformas/${id}/issuance-readiness`,
method: "get",
data: undefined,
signal,
});
};

View File

@ -4,6 +4,7 @@ export * from "./create-proforma.api";
export * from "./delete-proforma-by-id.api";
export * from "./duplicate-proforma-by-id.api";
export * from "./get-proforma-by-id.api";
export * from "./get-proforma-issuance-readiness.api";
export * from "./issue-proforma-by-id.api";
export * from "./list-document-series.api";
export * from "./list-proformas-by-criteria.api";

View File

@ -5,6 +5,7 @@ export * from "./proforma.entity";
export * from "./proforma-item.entity";
export * from "./proforma-list.entity";
export * from "./proforma-list-row.entity";
export * from "./proforma-issuance-readiness.entity";
export * from "./proforma-recipient.entity";
export * from "./proforma-status.entity";
export * from "./proforma-status-counts.entity";

View File

@ -0,0 +1,18 @@
export type ProformaIssuanceBlockerCode =
| "already_issued"
| "invalid_status"
| "missing_description"
| "missing_operation_date"
| "incomplete_recipient"
| "missing_items"
| "missing_payment_method"
| "missing_tax_regime"
| "payment_method_unavailable"
| "payment_method_inactive";
export interface ProformaIssuanceReadiness {
canIssue: boolean;
blockers: Array<{
code: ProformaIssuanceBlockerCode;
}>;
}

View File

@ -7,6 +7,7 @@ export * from "./use-proforma-create-mutation";
export * from "./use-proforma-delete-mutation";
export * from "./use-proforma-get-query";
export * from "./use-proforma-issue-mutation";
export * from "./use-proforma-issuance-readiness-query";
export * from "./use-proforma-unarchive-mutation";
export * from "./use-proforma-update-mutation";
export * from "./use-proformas-list-query";

View File

@ -38,6 +38,14 @@ export const PROFORMA_QUERY_KEY = (proformaId?: string): QueryKey => [
{ proformaId },
];
export const PROFORMAS_ISSUANCE_READINESS_QUERY_KEY_PREFIX = [
"proformas:issuance-readiness",
] as const;
export const PROFORMA_ISSUANCE_READINESS_QUERY_KEY = (proformaId?: string): QueryKey => [
...PROFORMAS_ISSUANCE_READINESS_QUERY_KEY_PREFIX,
{ proformaId },
];
export const PROFORMAS_PDF_QUERY_KEY_PREFIX = ["proformas:pdf"] as const;
export const PROFORMA_PDF_QUERY_KEY = (proformaId?: string): QueryKey => [
...PROFORMAS_PDF_QUERY_KEY_PREFIX,

View File

@ -6,8 +6,9 @@ import type { Proforma, ProformaList, ProformaListRow } from "../entities";
import {
LIST_PROFORMAS_QUERY_KEY_PREFIX,
PROFORMA_PDF_QUERY_KEY,
PROFORMAS_PDF_QUERY_KEY_PREFIX,
PROFORMA_ISSUANCE_READINESS_QUERY_KEY,
PROFORMA_PDF_QUERY_KEY,
PROFORMA_QUERY_KEY,
} from "./keys";
@ -20,6 +21,10 @@ export interface DeleteProformaCacheContext {
snapshots: ProformaListCacheSnapshot[];
}
const hasListItems = (page: ProformaList | undefined): page is ProformaList => {
return Array.isArray(page?.items);
};
// Primitivas
export function cancelProformaListQueries(queryClient: QueryClient) {
@ -54,6 +59,16 @@ export function invalidateProformaPdfQuery(queryClient: QueryClient, proformaId:
});
}
export function invalidateProformaIssuanceReadinessQuery(
queryClient: QueryClient,
proformaId: string
) {
return queryClient.invalidateQueries({
queryKey: PROFORMA_ISSUANCE_READINESS_QUERY_KEY(proformaId),
exact: true,
});
}
export function removeProformaPdfQuery(queryClient: QueryClient, proformaId: string) {
return queryClient.removeQueries({
queryKey: PROFORMA_PDF_QUERY_KEY(proformaId),
@ -100,7 +115,7 @@ export function upsertProformaInListCaches(
for (const key of keys) {
const page = queryClient.getQueryData<ProformaList>(key);
if (!page) continue;
if (!hasListItems(page)) continue;
const index = page.items.findIndex((row) => row.id === proforma.id);
if (index === -1) continue;
@ -130,7 +145,7 @@ export function removeProformaFromListCaches(
}));
for (const { key, page } of snapshots) {
if (!page) continue;
if (!hasListItems(page)) continue;
queryClient.setQueryData<ProformaList>(key, {
...page,
@ -166,6 +181,7 @@ export function syncUpdatedProformaCaches(queryClient: QueryClient, proforma: Pr
return Promise.all([
invalidateProformaListQueries(queryClient),
invalidateProformaIssuanceReadinessQuery(queryClient, proforma.id),
invalidateProformaPdfQuery(queryClient, proforma.id),
]);
}
@ -224,6 +240,7 @@ export async function syncChangedProformaStatusCaches(
await Promise.all([
invalidateProformaListQueries(queryClient),
invalidateProformaIssuanceReadinessQuery(queryClient, proformaId),
invalidateProformaPdfQuery(queryClient, proformaId),
]);
}
@ -242,6 +259,7 @@ export async function syncIssuedProformaCaches(
await Promise.all([
invalidateProformaListQueries(queryClient),
invalidateProformaIssuanceReadinessQuery(queryClient, proformaId),
invalidateIssuedInvoiceListQueries(queryClient),
invalidateProformaPdfQuery(queryClient, proformaId),
]);

View File

@ -0,0 +1,38 @@
import { useDataSource } from "@erp/core/hooks";
import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query";
import { GetProformaIssuanceReadinessAdapter } from "../adapters";
import { getProformaIssuanceReadiness } from "../api";
import type { ProformaIssuanceReadiness } from "../entities";
import { PROFORMA_ISSUANCE_READINESS_QUERY_KEY } from "./keys";
export interface UseProformaIssuanceReadinessQueryOptions {
id?: string;
enabled?: boolean;
}
export const useProformaIssuanceReadinessQuery = (
options: UseProformaIssuanceReadinessQueryOptions
): UseQueryResult<ProformaIssuanceReadiness, DefaultError> => {
const dataSource = useDataSource();
const id = options.id;
const enabled = options.enabled ?? Boolean(id);
return useQuery({
queryKey: PROFORMA_ISSUANCE_READINESS_QUERY_KEY(id),
queryFn: async ({ signal }) => {
if (!id) {
throw new Error("proformaId is required");
}
const dto = await getProformaIssuanceReadiness(dataSource, {
id,
signal,
});
return GetProformaIssuanceReadinessAdapter.fromDto(dto);
},
enabled,
});
};

View File

@ -1,2 +1,3 @@
export * from "./proforma-layout";
export * from "./proforma-issuance-readiness-alert";
export * from "./proforma-page-header";

View File

@ -0,0 +1,57 @@
import { Alert, AlertDescription, AlertTitle, Button } from "@repo/shadcn-ui/components";
import { AlertCircleIcon, RefreshCwIcon } from "lucide-react";
export interface ProformaIssuanceReadinessAlertProps {
title: string;
description?: string;
blockers?: string[];
retryLabel?: string;
onRetry?: () => void;
actionLabel?: string;
onAction?: () => void;
}
export const ProformaIssuanceReadinessAlert = ({
title,
description,
blockers = [],
retryLabel,
onRetry,
actionLabel,
onAction,
}: ProformaIssuanceReadinessAlertProps) => {
return (
<Alert className="border-amber-200 bg-amber-50 text-amber-950">
<AlertCircleIcon aria-hidden className="size-4" />
<AlertTitle>{title}</AlertTitle>
<AlertDescription className="space-y-3">
{description ? <p>{description}</p> : null}
{blockers.length > 0 ? (
<ul className="list-disc space-y-1 pl-5">
{blockers.map((blocker) => (
<li key={blocker}>{blocker}</li>
))}
</ul>
) : null}
{onRetry || onAction ? (
<div className="flex flex-wrap gap-2">
{onRetry ? (
<Button onClick={onRetry} size="sm" type="button" variant="outline">
<RefreshCwIcon aria-hidden className="mr-2 size-4" />
{retryLabel}
</Button>
) : null}
{onAction ? (
<Button onClick={onAction} size="sm" type="button" variant="outline">
{actionLabel}
</Button>
) : null}
</div>
) : null}
</AlertDescription>
</Alert>
);
};

View File

@ -3,3 +3,4 @@ export * from "./build-proforma-payment-term-select-items";
export * from "./calculate-proforma-due-dates";
export * from "./ensure-current-value-in-select-items";
export * from "./proforma-fiscal-options.utils";
export * from "./proforma-issuance-readiness";

View File

@ -0,0 +1,95 @@
import type { ProformaIssuanceBlockerCode, ProformaIssuanceReadiness } from "../entities";
const BLOCKER_TRANSLATION_KEYS: Record<ProformaIssuanceBlockerCode, string> = {
already_issued: "proformas.issuance_readiness.blockers.already_issued",
invalid_status: "proformas.issuance_readiness.blockers.invalid_status",
missing_description: "proformas.issuance_readiness.blockers.missing_description",
missing_operation_date: "proformas.issuance_readiness.blockers.missing_operation_date",
incomplete_recipient: "proformas.issuance_readiness.blockers.incomplete_recipient",
missing_items: "proformas.issuance_readiness.blockers.missing_items",
missing_payment_method: "proformas.issuance_readiness.blockers.missing_payment_method",
missing_tax_regime: "proformas.issuance_readiness.blockers.missing_tax_regime",
payment_method_unavailable: "proformas.issuance_readiness.blockers.payment_method_unavailable",
payment_method_inactive: "proformas.issuance_readiness.blockers.payment_method_inactive",
};
const BLOCKER_TITLE_TRANSLATION_KEYS: Record<ProformaIssuanceBlockerCode, string> = {
already_issued: "proformas.issuance_readiness.blocker_details.already_issued.title",
invalid_status: "proformas.issuance_readiness.blocker_details.invalid_status.title",
missing_description: "proformas.issuance_readiness.blocker_details.missing_description.title",
missing_operation_date:
"proformas.issuance_readiness.blocker_details.missing_operation_date.title",
incomplete_recipient: "proformas.issuance_readiness.blocker_details.incomplete_recipient.title",
missing_items: "proformas.issuance_readiness.blocker_details.missing_items.title",
missing_payment_method:
"proformas.issuance_readiness.blocker_details.missing_payment_method.title",
missing_tax_regime: "proformas.issuance_readiness.blocker_details.missing_tax_regime.title",
payment_method_unavailable:
"proformas.issuance_readiness.blocker_details.payment_method_unavailable.title",
payment_method_inactive:
"proformas.issuance_readiness.blocker_details.payment_method_inactive.title",
};
const BLOCKER_DESCRIPTION_TRANSLATION_KEYS: Record<ProformaIssuanceBlockerCode, string> = {
already_issued: "proformas.issuance_readiness.blocker_details.already_issued.description",
invalid_status: "proformas.issuance_readiness.blocker_details.invalid_status.description",
missing_description:
"proformas.issuance_readiness.blocker_details.missing_description.description",
missing_operation_date:
"proformas.issuance_readiness.blocker_details.missing_operation_date.description",
incomplete_recipient:
"proformas.issuance_readiness.blocker_details.incomplete_recipient.description",
missing_items: "proformas.issuance_readiness.blocker_details.missing_items.description",
missing_payment_method:
"proformas.issuance_readiness.blocker_details.missing_payment_method.description",
missing_tax_regime: "proformas.issuance_readiness.blocker_details.missing_tax_regime.description",
payment_method_unavailable:
"proformas.issuance_readiness.blocker_details.payment_method_unavailable.description",
payment_method_inactive:
"proformas.issuance_readiness.blocker_details.payment_method_inactive.description",
};
export type ProformaIssuanceTranslate = (
key: string,
params?: Record<string, string | number | null | undefined>
) => string;
export interface ProformaIssuanceBlockerDetail {
code: ProformaIssuanceBlockerCode;
title: string;
description: string;
message: string;
}
export const getProformaIssuanceBlockerTranslationKey = (code: ProformaIssuanceBlockerCode) => {
return BLOCKER_TRANSLATION_KEYS[code];
};
export const getProformaIssuanceBlockerMessages = (
readiness: ProformaIssuanceReadiness | undefined,
translate: ProformaIssuanceTranslate
): string[] => {
if (!readiness) {
return [];
}
return readiness.blockers.map((blocker) =>
translate(getProformaIssuanceBlockerTranslationKey(blocker.code))
);
};
export const getProformaIssuanceBlockerDetails = (
readiness: ProformaIssuanceReadiness | undefined,
translate: ProformaIssuanceTranslate
): ProformaIssuanceBlockerDetail[] => {
if (!readiness) {
return [];
}
return readiness.blockers.map((blocker) => ({
code: blocker.code,
title: translate(BLOCKER_TITLE_TRANSLATION_KEYS[blocker.code]),
description: translate(BLOCKER_DESCRIPTION_TRANSLATION_KEYS[blocker.code]),
message: translate(getProformaIssuanceBlockerTranslationKey(blocker.code)),
}));
};

View File

@ -118,7 +118,6 @@ export const useUpdateProformaController = (
useEffect(() => {
if (!proformaData) return;
console.log("Reseteando form con datos de la proforma:", proformaData);
form.reset(mapProformaToProformaUpdateForm(proformaData), {
keepDirty: false, // <-- importante: no marca el form como "dirty" al cargar los datos reales
});
@ -224,7 +223,6 @@ export const useUpdateProformaController = (
options?.onUpdated?.(updated);
} catch (error: unknown) {
console.log(error);
const normalizedError = normalizeSubmitError(error);
// No revierto el form para que no se pierdan los cambios que
@ -242,8 +240,6 @@ export const useUpdateProformaController = (
if (isValidationErrorCollection(normalizedError)) {
applyValidationErrorCollection(form, normalizedError);
console.log("Errores de validación aplicados al form:", form.formState.errors);
focusFirstInputFormError(form.formState.errors, form);
if (options?.errorToasts !== false) {

View File

@ -4,6 +4,10 @@ import { useCallback, useMemo } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "../../../i18n";
import {
getProformaIssuanceBlockerMessages,
useProformaIssuanceReadinessQuery,
} from "../../shared";
import {
buildProformaUpdateMissingEntityPageErrorViewModel,
buildProformaUpdatePageErrorViewModel,
@ -20,6 +24,10 @@ export const useUpdateProformaPageController = () => {
const [searchParams] = useSearchParams();
const updateCtrl = useUpdateProformaController(proformaId);
const issuanceReadinessQuery = useProformaIssuanceReadinessQuery({
id: proformaId,
enabled: Boolean(proformaId),
});
const returnTo = searchParams.get("returnTo") ?? "/proformas";
const feedbackState = resolveProformaUpdatePageFeedbackState({
@ -70,6 +78,11 @@ export const useUpdateProformaPageController = () => {
return null;
}, [feedbackState.hasBlockingError, feedbackState.hasDataInconsistency, t, updateCtrl.loadError]);
const issuanceReadinessMessages = getProformaIssuanceBlockerMessages(
issuanceReadinessQuery.data,
t
);
return {
updateCtrl,
customerChangeCtrl,
@ -81,5 +94,13 @@ export const useUpdateProformaPageController = () => {
goBackToList,
isRetrying: updateCtrl.isFetching && !updateCtrl.isLoading,
isInitialLoading: feedbackState.isInitialLoading,
issuanceReadiness: issuanceReadinessQuery.data,
isIssuanceReadinessLoading: issuanceReadinessQuery.isLoading && !issuanceReadinessQuery.data,
issuanceReadinessError:
issuanceReadinessQuery.isError && issuanceReadinessQuery.error instanceof Error
? issuanceReadinessQuery.error.message
: null,
retryIssuanceReadiness: issuanceReadinessQuery.refetch,
issuanceReadinessMessages,
};
};

View File

@ -35,6 +35,7 @@ export interface LineEditorColumn<TLine> {
export interface LineEditorProps<TLine> {
title: React.ReactNode;
description?: React.ReactNode;
lines: TLine[];
getLineKey: (line: TLine, index: number) => React.Key;
@ -69,6 +70,7 @@ export interface LineEditorProps<TLine> {
export const LineEditor = <TLine,>({
title,
description,
lines,
getLineKey,
@ -103,8 +105,11 @@ export const LineEditor = <TLine,>({
}: LineEditorProps<TLine>) => {
return (
<div className={cn("w-full space-y-4", className)}>
<div className="flex items-center justify-between gap-4">
<h3 className="text-lg font-semibold">{title}</h3>
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-1">
<h3 className="text-lg font-semibold">{title}</h3>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
<div className="flex gap-2">
<Button

View File

@ -253,6 +253,10 @@ export const ProformaLineEditor = ({
addAtStartLabel={t("common.add_at_start", "Al inicio")}
className={className}
columns={columns}
description={t(
"pages.proformas.update.items.description",
"Gestiona las líneas de productos o servicios de esta proforma."
)}
disabled={disabled}
duplicateLabel={t("common.duplicate", "Duplicar")}
getLineErrorMessage={(_, index) => {
@ -282,7 +286,7 @@ export const ProformaLineEditor = ({
totals={totals}
/>
)}
title={t("form_fields.items.title", "Líneas de detalle")}
title={t("pages.proformas.update.items.title", "Detalle de la proforma")}
/>
);
};

View File

@ -1,4 +1,3 @@
import { PageKeyboardShortcutsButton } from "@erp/core/components";
import {
CancelActionButton,
FormActionsBar,
@ -11,7 +10,7 @@ import type { ReactNode } from "react";
import { ProformaPageHeader } from "../../../shared";
export interface ProformaUpdateHeaderLabels {
title: string;
title: ReactNode;
back: string;
modified: string;
cancel: string;
@ -40,6 +39,7 @@ export interface ProformaUpdateHeaderProps {
hasChanges?: boolean;
showFormActions?: boolean;
helpAction?: ReactNode;
className?: string;
children?: ReactNode;
}
@ -56,6 +56,7 @@ export const ProformaUpdateHeader = ({
isSaving = false,
hasChanges = false,
showFormActions = true,
helpAction,
className,
children,
}: ProformaUpdateHeaderProps) => {
@ -88,22 +89,16 @@ export const ProformaUpdateHeader = ({
onSelect: () => onDelete?.(),
},*/
];
const hasSecondaryActions = secondaryActions.some((action) => !action.hidden);
return (
<ProformaPageHeader
actions={
<FormActionsBar align="end" reverseOnMobile={false}>
<PageKeyboardShortcutsButton
className="hidden sm:flex"
label={labels.keyboardShortcuts}
shortcuts={[
{ keys: "Ctrl+S", label: labels.save },
{ keys: "Esc", label: labels.cancel },
]}
/>
{showFormActions ? (
<>
{helpAction}
<CancelActionButton
className="hidden sm:flex"
disabled={computedDisabled}
@ -120,11 +115,13 @@ export const ProformaUpdateHeader = ({
label={labels.save}
/>
<FormSecondaryActionsMenu
actions={secondaryActions}
disabled={computedDisabled}
label={labels.moreActions}
/>
{hasSecondaryActions ? (
<FormSecondaryActionsMenu
actions={secondaryActions}
disabled={computedDisabled}
label={labels.moreActions}
/>
) : null}
</>
) : null}
</FormActionsBar>

View File

@ -1,3 +1,4 @@
export * from "./proforma-info-alert";
export * from "./proforma-update-customer-change-dialog";
export * from "./proforma-update-help-popover";
export * from "./proforma-update-skeleton";

View File

@ -0,0 +1,163 @@
import assert from "node:assert/strict";
import { mock, test } from "node:test";
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
const translationMap: Record<string, string> = {
"pages.proformas.update.header.help.aria_label": "Información y estado de emisión de la proforma",
"pages.proformas.update.header.help.aria_label_with_blockers_one":
"Información de la proforma. {{count}} requisito pendiente de emisión.",
"pages.proformas.update.header.help.aria_label_with_blockers_other":
"Información de la proforma. {{count}} requisitos pendientes de emisión.",
"pages.proformas.update.header.help.title": "Información de la proforma",
"pages.proformas.update.header.help.issuance_section_title": "Estado para emisión",
"pages.proformas.update.header.help.fiscal_section_title": "Sobre las proformas",
"pages.proformas.update.header.help.description":
"Esta proforma no tiene validez fiscal. Puedes convertirla en factura cuando sea aceptada por el cliente.",
"proformas.issuance_readiness.update.summary_one":
"Falta {{count}} requisito para poder emitir esta proforma",
"proformas.issuance_readiness.update.summary_other":
"Faltan {{count}} requisitos para poder emitir esta proforma",
"proformas.issuance_readiness.update.saved_state_hint":
"Basado en los últimos cambios guardados.",
"proformas.issuance_readiness.update.dirty_state_hint":
"Esta comprobación refleja el último estado guardado. Guarda los cambios para actualizarla.",
"proformas.issuance_readiness.update.ready_title": "Lista para emitir",
"proformas.issuance_readiness.update.ready_description":
"La proforma cumple actualmente los requisitos necesarios para convertirse en factura.",
"proformas.issuance_readiness.update.loading": "Comprobando requisitos de emisión…",
"proformas.issuance_readiness.update.unavailable_title":
"No se ha podido comprobar el estado de emisión.",
"proformas.issuance_readiness.update.unavailable_description":
"Inténtalo de nuevo para revisar los requisitos pendientes.",
"proformas.issuance_readiness.retry": "Reintentar",
};
const translate = (key: string, params?: Record<string, string | number | null | undefined>) => {
const template = translationMap[key] ?? key;
return template.replace(/\{\{(\w+)\}\}/g, (_match, token) => {
const value = params?.[token];
return value == null ? "" : String(value);
});
};
test("integra readiness y ayuda fiscal en un unico popover", async () => {
let blockerDetails = [
{
code: "missing_operation_date",
title: "Fecha de operación",
description: "Completa la fecha de operación.",
message: "Completa la fecha de operación.",
},
{
code: "missing_description",
title: "Descripción",
description: "Añade una descripción a la proforma.",
message: "Añade una descripción a la proforma.",
},
];
mock.module("@repo/shadcn-ui/components", {
namedExports: {
Button: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
<button {...props}>{children}</button>
),
Badge: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
<span {...props}>{children}</span>
),
Popover: ({ children }: { children: ReactNode }) => <div>{children}</div>,
PopoverContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
PopoverDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
PopoverHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
PopoverTitle: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
Separator: () => <hr />,
},
});
mock.module("lucide-react", {
namedExports: {
AlertCircleIcon: () => null,
CheckCircle2Icon: () => null,
CircleDashedIcon: () => null,
CircleHelpIcon: () => null,
},
});
mock.module("../../../../i18n", {
namedExports: {
useTranslation: () => ({
t: translate,
}),
},
});
mock.module("../../../shared", {
namedExports: {
getProformaIssuanceBlockerDetails: () => blockerDetails,
},
});
const { ProformaUpdateHelpPopover } = await import("./proforma-update-help-popover.tsx");
const blockedMarkup = renderToStaticMarkup(
<ProformaUpdateHelpPopover
isDirty={true}
readiness={{
canIssue: false,
blockers: [{ code: "missing_operation_date" }, { code: "missing_description" }],
}}
/>
);
assert.match(blockedMarkup, /Información de la proforma\. 2 requisitos pendientes de emisión\./);
assert.match(blockedMarkup, /Estado para emisión/);
assert.match(blockedMarkup, />2</);
assert.match(blockedMarkup, /Faltan 2 requisitos para poder emitir esta proforma/);
assert.match(blockedMarkup, /Fecha de operación/);
assert.match(blockedMarkup, /Completa la fecha de operación\./);
assert.match(blockedMarkup, /Descripción/);
assert.match(blockedMarkup, /Añade una descripción a la proforma\./);
assert.match(
blockedMarkup,
/Esta comprobación refleja el último estado guardado. Guarda los cambios para actualizarla\./
);
assert.match(blockedMarkup, /Sobre las proformas/);
assert.match(blockedMarkup, /Esta proforma no tiene validez fiscal/);
blockerDetails = [];
const issuableMarkup = renderToStaticMarkup(
<ProformaUpdateHelpPopover readiness={{ canIssue: true, blockers: [] }} />
);
assert.match(issuableMarkup, /Información y estado de emisión de la proforma/);
assert.match(issuableMarkup, /Lista para emitir/);
assert.doesNotMatch(issuableMarkup, />0</);
assert.match(
issuableMarkup,
/La proforma cumple actualmente los requisitos necesarios para convertirse en factura\./
);
assert.match(issuableMarkup, /Basado en los últimos cambios guardados\./);
const loadingMarkup = renderToStaticMarkup(
<ProformaUpdateHelpPopover isReadinessLoading={true} />
);
assert.match(loadingMarkup, /Comprobando requisitos de emisión…/);
assert.match(loadingMarkup, /Información y estado de emisión de la proforma/);
assert.doesNotMatch(loadingMarkup, />\d+</);
assert.doesNotMatch(loadingMarkup, /Lista para emitir/);
const errorMarkup = renderToStaticMarkup(
<ProformaUpdateHelpPopover onRetryReadiness={() => undefined} readinessError="network error" />
);
assert.match(errorMarkup, /No se ha podido comprobar el estado de emisión\./);
assert.match(errorMarkup, /Reintentar/);
assert.match(errorMarkup, /Información y estado de emisión de la proforma/);
assert.doesNotMatch(errorMarkup, />\d+</);
assert.doesNotMatch(errorMarkup, /Lista para emitir/);
mock.restoreAll();
});

View File

@ -0,0 +1,243 @@
import {
Badge,
Button,
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
Separator,
} from "@repo/shadcn-ui/components";
import { AlertCircleIcon, CheckCircle2Icon, CircleDashedIcon, CircleHelpIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n";
import type { ProformaIssuanceReadiness } from "../../../shared";
import { getProformaIssuanceBlockerDetails } from "../../../shared";
interface ProformaUpdateHelpPopoverProps {
readiness?: ProformaIssuanceReadiness;
readinessError?: string | null;
isReadinessLoading?: boolean;
isDirty?: boolean;
onRetryReadiness?: () => void;
}
interface HelpTriggerProps {
ariaLabel: string;
pendingBlockersCount: number;
}
interface HelpReadinessSectionProps {
blockers: ReturnType<typeof getProformaIssuanceBlockerDetails>;
readiness?: ProformaIssuanceReadiness;
readinessError?: string | null;
isReadinessLoading: boolean;
savedStateHint: string;
onRetryReadiness?: () => void;
summaryKey: string;
translate: (key: string, params?: Record<string, string | number | null | undefined>) => string;
}
const HelpTrigger = ({ ariaLabel, pendingBlockersCount }: HelpTriggerProps) => {
return (
<Button aria-label={ariaLabel} className="relative" size="icon" type="button" variant="outline">
<CircleHelpIcon aria-hidden="true" className="size-4" />
{pendingBlockersCount > 0 ? (
<Badge
className="pointer-events-none absolute -right-1 -top-1 min-w-5 rounded-full border-amber-200 bg-amber-50 px-1 text-[11px] leading-4 text-amber-700 shadow-none"
variant="outline"
>
{pendingBlockersCount}
</Badge>
) : null}
</Button>
);
};
const HelpReadinessSection = ({
blockers,
readiness,
readinessError,
isReadinessLoading,
savedStateHint,
onRetryReadiness,
summaryKey,
translate,
}: HelpReadinessSectionProps) => {
if (isReadinessLoading) {
return (
<div className="flex items-start gap-3 rounded-lg border bg-muted/30 px-3 py-3">
<CircleDashedIcon
aria-hidden="true"
className="mt-0.5 size-4 shrink-0 animate-spin text-muted-foreground"
/>
<p className="text-sm text-muted-foreground">
{translate("proformas.issuance_readiness.update.loading")}
</p>
</div>
);
}
if (readinessError) {
return (
<div className="space-y-3 rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-3">
<div className="flex items-start gap-3">
<AlertCircleIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-destructive" />
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">
{translate("proformas.issuance_readiness.update.unavailable_title")}
</p>
<p className="text-sm text-muted-foreground">
{translate("proformas.issuance_readiness.update.unavailable_description")}
</p>
</div>
</div>
{onRetryReadiness ? (
<div className="flex justify-start">
<Button onClick={onRetryReadiness} size="sm" type="button" variant="outline">
{translate("proformas.issuance_readiness.retry")}
</Button>
</div>
) : null}
</div>
);
}
if (readiness?.canIssue) {
return (
<div className="space-y-3 rounded-lg border border-emerald-200 bg-emerald-50/70 px-3 py-3">
<div className="flex items-start gap-3">
<CheckCircle2Icon
aria-hidden="true"
className="mt-0.5 size-4 shrink-0 text-emerald-700"
/>
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">
{translate("proformas.issuance_readiness.update.ready_title")}
</p>
<p className="text-sm text-muted-foreground">
{translate("proformas.issuance_readiness.update.ready_description")}
</p>
</div>
</div>
<PopoverDescription>{savedStateHint}</PopoverDescription>
</div>
);
}
if (readiness) {
return (
<div className="space-y-3 rounded-lg border border-amber-200 bg-amber-50/70 px-3 py-3">
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">
{translate(summaryKey, { count: blockers.length })}
</p>
<PopoverDescription>{savedStateHint}</PopoverDescription>
</div>
<div className="space-y-3">
{blockers.map((blocker) => (
<div className="space-y-1" key={blocker.code}>
<p className="text-sm font-medium text-foreground">{blocker.title}</p>
<p className="text-sm text-muted-foreground">{blocker.description}</p>
</div>
))}
</div>
</div>
);
}
return (
<div className="rounded-lg border bg-muted/30 px-3 py-3">
<p className="text-sm text-muted-foreground">
{translate("proformas.issuance_readiness.update.loading")}
</p>
</div>
);
};
export const ProformaUpdateHelpPopover = ({
readiness,
readinessError,
isReadinessLoading = false,
isDirty = false,
onRetryReadiness,
}: ProformaUpdateHelpPopoverProps) => {
const { t } = useTranslation();
const blockers = getProformaIssuanceBlockerDetails(readiness, t);
const pendingBlockersCount =
!(isReadinessLoading || readinessError) && readiness && !readiness.canIssue
? readiness.blockers.length
: 0;
const summaryKey =
blockers.length === 1
? "proformas.issuance_readiness.update.summary_one"
: "proformas.issuance_readiness.update.summary_other";
const blockersAriaLabelKey =
pendingBlockersCount === 1
? "pages.proformas.update.header.help.aria_label_with_blockers_one"
: "pages.proformas.update.header.help.aria_label_with_blockers_other";
const triggerAriaLabel =
pendingBlockersCount > 0
? t(blockersAriaLabelKey, { count: pendingBlockersCount })
: t("pages.proformas.update.header.help.aria_label");
const savedStateHint = isDirty
? t("proformas.issuance_readiness.update.dirty_state_hint")
: t("proformas.issuance_readiness.update.saved_state_hint");
return (
<Popover>
<PopoverTrigger>
<HelpTrigger ariaLabel={triggerAriaLabel} pendingBlockersCount={pendingBlockersCount} />
</PopoverTrigger>
<PopoverContent
align="end"
className="w-[28rem] max-w-[calc(100vw-2rem)] space-y-0"
side="bottom"
>
<PopoverHeader className="gap-2">
<PopoverTitle>{t("pages.proformas.update.header.help.title")}</PopoverTitle>
</PopoverHeader>
<div className="space-y-4 px-4 pb-4 pt-1">
<section className="space-y-3">
<div className="space-y-2">
<p className="text-sm font-semibold text-foreground">
{t("pages.proformas.update.header.help.issuance_section_title")}
</p>
<Separator />
</div>
<HelpReadinessSection
blockers={blockers}
isReadinessLoading={isReadinessLoading}
onRetryReadiness={onRetryReadiness}
readiness={readiness}
readinessError={readinessError}
savedStateHint={savedStateHint}
summaryKey={summaryKey}
translate={t}
/>
</section>
<section className="space-y-3">
<div className="space-y-2">
<p className="text-sm font-semibold text-foreground">
{t("pages.proformas.update.header.help.fiscal_section_title")}
</p>
<Separator />
</div>
<PopoverDescription>
{t("pages.proformas.update.header.help.description")}
</PopoverDescription>
</section>
</div>
</PopoverContent>
</Popover>
);
};

View File

@ -138,6 +138,9 @@ test("renderiza el patrón complejo con main, aside y un único formulario", ()
assert.match(html, /Configuración fiscal/);
assert.match(html, /Condiciones de pago/);
assert.match(html, /Líneas de detalle/);
assert.match(html, /Detalle de la proforma/);
assert.match(html, /Gestiona las líneas de productos o servicios de esta proforma/);
assert.doesNotMatch(html, /max-w-5xl/);
assert.ok(html.indexOf("Líneas de detalle") < html.indexOf("<aside"));
assert.equal((html.match(/Líneas de detalle/g) ?? []).length, 1);
});

View File

@ -13,6 +13,7 @@ test("conecta la alta rápida directa y la alta rápida desde selector con callb
};
let capturedEditorProps: Record<string, unknown> | undefined;
let capturedHeaderProps: Record<string, unknown> | undefined;
let capturedSelectDialogProps: Record<string, unknown> | undefined;
let capturedQuickCreateDialogProps: Record<string, unknown> | undefined;
@ -69,6 +70,12 @@ test("conecta la alta rápida directa y la alta rápida desde selector con callb
},
});
mock.module("../../../shared", {
namedExports: {
ProformaIssuanceReadinessAlert: () => null,
},
});
mock.module("../../controllers/use-update-proforma-page-controller", {
namedExports: {
useUpdateProformaPageController: () => ({
@ -114,6 +121,10 @@ test("conecta la alta rápida directa y la alta rápida desde selector con callb
totals: {},
},
currencyCode: "EUR",
proformaData: {
proformaReference: "PF-0088",
status: "draft",
},
},
customerChangeCtrl: {
open: false,
@ -139,6 +150,13 @@ test("conecta la alta rápida directa y la alta rápida desde selector con callb
goBackToList: () => undefined,
isRetrying: false,
isInitialLoading: false,
issuanceReadiness: {
canIssue: false,
blockers: [{ code: "missing_description" }],
},
isIssuanceReadinessLoading: false,
issuanceReadinessError: null,
retryIssuanceReadiness: async () => undefined,
}),
},
});
@ -146,14 +164,17 @@ test("conecta la alta rápida directa y la alta rápida desde selector con callb
mock.module("../blocks", {
namedExports: {
ProformaCompactTotals: () => null,
ProformaUpdateHeader: ({ children }: { children: ReactNode }) => children,
ProformaUpdateHeader: (props: Record<string, unknown>) => {
capturedHeaderProps = props;
return (props.children as ReactNode) ?? null;
},
},
});
mock.module("../components", {
namedExports: {
ProformaInfoAlert: ({ children }: { children: ReactNode }) => children,
ProformaUpdateCustomerChangeDialog: () => null,
ProformaUpdateHelpPopover: () => null,
ProformaUpdateSkeleton: () => null,
},
});
@ -174,6 +195,7 @@ test("conecta la alta rápida directa y la alta rápida desde selector con callb
assert.equal(capturedEditorProps?.onChangeCustomerClick, openSelectDialog);
assert.equal(capturedEditorProps?.onCreateCustomerClick, openQuickCreateDialog);
assert.equal(typeof capturedEditorProps?.onPaymentTermChange, "function");
assert.ok(capturedHeaderProps?.helpAction);
assert.equal(capturedSelectDialogProps?.onCreateNewCustomerClick, openQuickCreateFromSelection);
assert.equal(capturedQuickCreateDialogProps?.ctrl, quickCreateCtrl);

View File

@ -8,14 +8,17 @@ import {
RdxEditorStickyFooter,
RhfSubmitActionButton,
} from "@repo/rdx-ui/components";
import { showErrorToast } from "@repo/rdx-ui/helpers";
import { Badge } from "@repo/shadcn-ui/components";
import { FormProvider } from "react-hook-form";
import { useTranslation } from "../../../../i18n";
import { ProformaStatusBadge } from "../../../list/ui/components";
import { useUpdateProformaPageController } from "../../controllers/use-update-proforma-page-controller";
import { ProformaCompactTotals, ProformaUpdateHeader } from "../blocks";
import {
ProformaInfoAlert,
ProformaUpdateCustomerChangeDialog,
ProformaUpdateHelpPopover,
ProformaUpdateSkeleton,
} from "../components";
import { ProformaUpdateEditorForm } from "../editors";
@ -25,12 +28,23 @@ export const ProformaUpdatePage = () => {
const ctrl = useUpdateProformaPageController();
const { updateCtrl, customerChangeCtrl, selectCustomerCtrl, returnTo } = ctrl;
const proforma = updateCtrl.proformaData;
const { navigateBack } = useReturnToNavigation({
fallbackPath: returnTo,
});
const handleCancel = () => navigateBack();
const handleRetryIssuanceReadiness = () => {
ctrl.retryIssuanceReadiness().catch((error: unknown) => {
showErrorToast(
t("proformas.issuance_readiness.load_error_title"),
error instanceof Error
? error.message
: t("proformas.issuance_readiness.load_error_description")
);
});
};
if (ctrl.isInitialLoading) {
return <ProformaUpdateSkeleton />;
@ -85,9 +99,28 @@ export const ProformaUpdatePage = () => {
<ProformaUpdateHeader
formId={updateCtrl.formId}
hasChanges={updateCtrl.form.formState.isDirty}
helpAction={
<ProformaUpdateHelpPopover
isDirty={updateCtrl.form.formState.isDirty}
isReadinessLoading={ctrl.isIssuanceReadinessLoading}
onRetryReadiness={handleRetryIssuanceReadiness}
readiness={ctrl.issuanceReadiness}
readinessError={ctrl.issuanceReadinessError}
/>
}
isSaving={updateCtrl.form.formState.isSubmitting}
labels={{
title: "Editar proforma",
title: proforma ? (
<span className="flex flex-wrap items-center gap-2">
<span>{t("pages.proformas.update.title", "Editar proforma")}</span>
<Badge className="border-border/80 bg-muted px-2.5 py-1 font-mono text-base text-muted-foreground shadow-none">
{proforma.proformaReference}
</Badge>
<ProformaStatusBadge className="text-base" status={proforma.status} />
</span>
) : (
t("pages.proformas.update.title", "Editar proforma")
),
back: "Volver",
modified: "Modificada",
cancel: "Cancelar",
@ -103,13 +136,7 @@ export const ProformaUpdatePage = () => {
//onDelete={openDeleteDialog}
//onDuplicate={handleDuplicate}
//onExportPdf={handleExportPdf}
>
{/* Alerta informativa */}
<ProformaInfoAlert>
<span className="font-semibold">Importante: </span>Esta proforma no tiene validez
fiscal. Puedes convertirla en factura cuando sea aceptada por el cliente.
</ProformaInfoAlert>
</ProformaUpdateHeader>
/>
<div className="flex min-h-0 flex-1 flex-col bg-muted/20">
<div className="min-h-0 flex-1 overflow-y-auto">

View File

@ -9,7 +9,6 @@ import {
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { useTranslation } from "../../../../../web/i18n";
import type { CustomerSelectionOption } from "../../entities";
import { SelectCustomerEmptyCard } from "./select-customer-empty-card";
@ -27,10 +26,7 @@ export const SelectCustomerSelectionList = ({
selectedCustomer,
onSelect,
}: SelectCustomerSelectionListProps) => {
const { t } = useTranslation();
//const columns = useCustomersGridColumns({});
console.log(selectedCustomer);
if (isLoading) {
return (