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:
parent
cf5a797532
commit
080dcaaa61
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
100
docs/customer-invoices/proforma-issuance-readiness-contract.md
Normal file
100
docs/customer-invoices/proforma-issuance-readiness-contract.md
Normal 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.
|
||||
@ -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");
|
||||
});
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
import type { GetProformaIssuanceReadinessResponseDTO } from "../../../../common";
|
||||
|
||||
export interface ProformaIssuanceReadinessModel {
|
||||
canIssue: boolean;
|
||||
blockers: Array<{
|
||||
code: GetProformaIssuanceReadinessResponseDTO["blockers"][number]["code"];
|
||||
}>;
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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"]
|
||||
);
|
||||
});
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
export * from "./full";
|
||||
export * from "./issuance-readiness";
|
||||
export * from "./issue";
|
||||
export * from "./report";
|
||||
export * from "./summary";
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export * from "./proforma-issuance-readiness.snapshot-builder";
|
||||
@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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"]
|
||||
);
|
||||
});
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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" }],
|
||||
});
|
||||
});
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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
|
||||
>;
|
||||
@ -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";
|
||||
|
||||
@ -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
|
||||
>;
|
||||
@ -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";
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
})),
|
||||
};
|
||||
},
|
||||
};
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
}>;
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
]);
|
||||
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./proforma-layout";
|
||||
export * from "./proforma-issuance-readiness-alert";
|
||||
export * from "./proforma-page-header";
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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";
|
||||
|
||||
@ -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)),
|
||||
}));
|
||||
};
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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();
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user