Completar columnas en listado de proformas recurrentes

This commit is contained in:
David Arranz 2026-09-02 12:04:05 +02:00
parent f6002d7367
commit 727543f14f
33 changed files with 1131 additions and 209 deletions

View File

@ -1,4 +1,5 @@
import type { DiscountPercentage } from "@erp/core/api"; import type { DiscountPercentage } from "@erp/core/api";
import type { CustomerPublicSummary } from "@erp/customers/api";
import type { CurrencyCode, LanguageCode, UniqueID } from "@repo/rdx-ddd"; import type { CurrencyCode, LanguageCode, UniqueID } from "@repo/rdx-ddd";
import type { import type {
@ -21,10 +22,13 @@ export type RecurringProformaScheduleReadModel = {
export type RecurringProformaListItemReadModel = { export type RecurringProformaListItemReadModel = {
id: UniqueID; id: UniqueID;
name: string; name: string;
description: string | null;
customerId: UniqueID | null; customerId: UniqueID | null;
customer: CustomerPublicSummary | null;
status: RecurringProformaStatus; status: RecurringProformaStatus;
schedule: RecurringProformaScheduleReadModel | null; schedule: RecurringProformaScheduleReadModel | null;
nextOccurrenceDate: Date | null; nextOccurrenceDate: Date | null;
hasIssue: boolean;
updatedAt: Date; updatedAt: Date;
}; };
@ -88,8 +92,11 @@ export type ListRecurringProformasQuery = {
companyId: UniqueID; companyId: UniqueID;
page: number; page: number;
limit: number; limit: number;
status?: RecurringProformaStatus; status?: Exclude<RecurringProformaStatus, "archived">;
search?: string; search?: string;
scope?: "default" | "archived";
orderBy?: "name" | "updated_at";
order?: "asc" | "desc";
}; };
export type ListRecurringProformaOccurrencesQuery = { export type ListRecurringProformaOccurrencesQuery = {

View File

@ -15,7 +15,10 @@ import {
RecurringProformaOccurrence, RecurringProformaOccurrence,
} from "../../../domain"; } from "../../../domain";
import { toRecurringProformaAuditTarget } from "../audit"; import { toRecurringProformaAuditTarget } from "../audit";
import { RecurringProformaCommands, RecurringProformaReader } from "./recurring-proforma-capabilities"; import {
RecurringProformaCommands,
RecurringProformaReader,
} from "./recurring-proforma-capabilities";
const required = <T>(result: { data: T }): T => result.data; const required = <T>(result: { data: T }): T => result.data;
@ -36,30 +39,49 @@ const buildRecurring = () => {
const currency = required(CurrencyCode.create("EUR")); const currency = required(CurrencyCode.create("EUR"));
const language = required(LanguageCode.create("es")); const language = required(LanguageCode.create("es"));
const discount = required(DiscountPercentage.create({ value: 500 })); const discount = required(DiscountPercentage.create({ value: 500 }));
const schedule = required(RecurrenceSchedule.create({ startDate: new Date("2027-01-15T00:00:00.000Z"), endDate: null, unit: "month", interval: 1, monthlyRule: "same_day" })); const schedule = required(
return required(RecurringProforma.create({ RecurrenceSchedule.create({
companyId: companyA, startDate: new Date("2027-01-15T00:00:00.000Z"),
name: "Cuota mensual", endDate: null,
customerId: Maybe.none(), unit: "month",
description: Maybe.none(), interval: 1,
paymentTermId: Maybe.none(), monthlyRule: "same_day",
paymentMethodId: Maybe.none(), })
language: Maybe.none(), );
currency: Maybe.none(), return required(
proformaSeries: Maybe.none(), RecurringProforma.create(
issuedInvoiceSeries: Maybe.none(), {
globalDiscountPercentage: Maybe.some(discount), companyId: companyA,
schedule: Maybe.some(schedule), name: "Cuota mensual",
items: [{ customerId: Maybe.none(),
description: Maybe.some(required(ItemDescription.create("Servicio"))), description: Maybe.none(),
quantity: Maybe.some(required(ItemQuantity.create({ value: 2 }))), paymentTermId: Maybe.none(),
unitAmount: Maybe.some(required(ItemAmount.create({ value: 1000, currency_code: currency.code }))), paymentMethodId: Maybe.none(),
itemDiscountPercentage: Maybe.some(discount), language: Maybe.none(),
ivaCode: Maybe.some("general"), recCode: Maybe.some("rec"), retentionCode: Maybe.some("ret"), currency: Maybe.none(),
}], proformaSeries: Maybe.none(),
createdAt: new Date("2027-01-01T00:00:00.000Z"), issuedInvoiceSeries: Maybe.none(),
updatedAt: new Date("2027-01-02T00:00:00.000Z"), globalDiscountPercentage: Maybe.some(discount),
}, recurringId)); schedule: Maybe.some(schedule),
items: [
{
description: Maybe.some(required(ItemDescription.create("Servicio"))),
quantity: Maybe.some(required(ItemQuantity.create({ value: 2 }))),
unitAmount: Maybe.some(
required(ItemAmount.create({ value: 1000, currency_code: currency.code }))
),
itemDiscountPercentage: Maybe.some(discount),
ivaCode: Maybe.some("general"),
recCode: Maybe.some("rec"),
retentionCode: Maybe.some("ret"),
},
],
createdAt: new Date("2027-01-01T00:00:00.000Z"),
updatedAt: new Date("2027-01-02T00:00:00.000Z"),
},
recurringId
)
);
}; };
test("toRecurringProformaAuditTarget incluye el nombre como label", () => { test("toRecurringProformaAuditTarget incluye el nombre como label", () => {
@ -72,31 +94,72 @@ test("toRecurringProformaAuditTarget incluye el nombre como label", () => {
test("RecurringProformaReader propaga company, filtros, paginación y conserva el listado", async () => { test("RecurringProformaReader propaga company, filtros, paginación y conserva el listado", async () => {
let receivedQuery: unknown; let receivedQuery: unknown;
let receivedTransaction: unknown; let receivedTransaction: unknown;
const item = { id: recurringId, name: "Cuota mensual", customerId: null, status: "draft" as const, schedule: null, nextOccurrenceDate: null, updatedAt: new Date("2027-01-02T00:00:00.000Z") }; const item = {
const reader = new RecurringProformaReader({ id: recurringId,
listInCompany: async (query, currentTransaction) => { name: "Cuota mensual",
receivedQuery = query; receivedTransaction = currentTransaction; description: null,
return Result.ok({ page: 2, perPage: 5, totalItems: 6, totalPages: 2, items: [item] }); customerId: null,
}, customer: null,
} as never, {} as never, transactionManager()); status: "draft" as const,
const result = await reader.list({ companyId: companyA, page: 2, limit: 5, status: "draft", search: "Cuota" }); schedule: null,
nextOccurrenceDate: null,
hasIssue: false,
updatedAt: new Date("2027-01-02T00:00:00.000Z"),
};
const reader = new RecurringProformaReader(
{
listInCompany: async (query, currentTransaction) => {
receivedQuery = query;
receivedTransaction = currentTransaction;
return Result.ok({ page: 2, perPage: 5, totalItems: 6, totalPages: 2, items: [item] });
},
} as never,
{} as never,
{} as never,
transactionManager()
);
const result = await reader.list({
companyId: companyA,
page: 2,
limit: 5,
status: "draft",
search: "Cuota",
});
assert.equal(result.isSuccess, true); assert.equal(result.isSuccess, true);
assert.deepEqual(receivedQuery, { companyId: companyA, page: 2, limit: 5, status: "draft", search: "Cuota" }); assert.deepEqual(receivedQuery, {
companyId: companyA,
page: 2,
limit: 5,
status: "draft",
search: "Cuota",
});
assert.equal(receivedTransaction, transaction); assert.equal(receivedTransaction, transaction);
assert.deepEqual(result.data, { page: 2, perPage: 5, totalItems: 6, totalPages: 2, items: [item] }); assert.deepEqual(result.data, {
page: 2,
perPage: 5,
totalItems: 6,
totalPages: 2,
items: [item],
});
assert.equal((receivedQuery as { companyId: UniqueID }).companyId.equals(companyB), false); assert.equal((receivedQuery as { companyId: UniqueID }).companyId.equals(companyB), false);
}); });
test("RecurringProformaReader conserva overrides nulos, schedule e items ordenados en detail", async () => { test("RecurringProformaReader conserva overrides nulos, schedule e items ordenados en detail", async () => {
let receivedCompany: UniqueID | undefined; let receivedCompany: UniqueID | undefined;
const recurring = buildRecurring(); const recurring = buildRecurring();
const reader = new RecurringProformaReader({ const reader = new RecurringProformaReader(
findByIdInCompany: async (companyId: UniqueID, id: UniqueID, currentTransaction: unknown) => { {
receivedCompany = companyId; findByIdInCompany: async (companyId: UniqueID, id: UniqueID, currentTransaction: unknown) => {
assert.equal(id.equals(recurringId), true); assert.equal(currentTransaction, transaction); receivedCompany = companyId;
return Result.ok(recurring); assert.equal(id.equals(recurringId), true);
}, assert.equal(currentTransaction, transaction);
} as never, {} as never, transactionManager()); return Result.ok(recurring);
},
} as never,
{} as never,
{} as never,
transactionManager()
);
const result = await reader.detail({ companyId: companyA, id: recurringId }); const result = await reader.detail({ companyId: companyA, id: recurringId });
assert.equal(result.isSuccess, true); assert.equal(result.isSuccess, true);
assert.equal(receivedCompany?.equals(companyA), true); assert.equal(receivedCompany?.equals(companyA), true);
@ -114,15 +177,92 @@ test("RecurringProformaReader conserva overrides nulos, schedule e items ordenad
test("RecurringProformaReader propaga parent, empresa y paginación de occurrences", async () => { test("RecurringProformaReader propaga parent, empresa y paginación de occurrences", async () => {
let receivedQuery: unknown; let receivedQuery: unknown;
const proformaId = UniqueID.generateNewID(); const proformaId = UniqueID.generateNewID();
const generated = required(RecurringProformaOccurrence.create({ companyId: companyA, recurringProformaId: recurringId, scheduledDate: new Date("2027-02-15T00:00:00.000Z"), status: "generated", attemptCount: 1, proformaId: Maybe.some(proformaId), generatedAt: Maybe.some(new Date("2027-02-16T00:00:00.000Z")), createdAt: new Date(), updatedAt: new Date() })); const generated = required(
const failed = required(RecurringProformaOccurrence.create({ companyId: companyA, recurringProformaId: recurringId, scheduledDate: new Date("2027-01-15T00:00:00.000Z"), status: "failed", attemptCount: 2, lastAttemptAt: Maybe.some(new Date("2027-01-16T00:00:00.000Z")), lastErrorCode: Maybe.some("failed"), lastErrorMessage: Maybe.some("Error"), createdAt: new Date(), updatedAt: new Date() })); RecurringProformaOccurrence.create({
const toRead = (source: typeof generated) => ({ id: source.id, scheduledDate: source.scheduledDate, status: source.status, attemptCount: source.attemptCount, lastAttemptAt: source.lastAttemptAt.match((value) => value, () => null), processingStartedAt: null, lastErrorCode: source.lastErrorCode.match((value) => value, () => null), lastErrorMessage: source.lastErrorMessage.match((value) => value, () => null), proformaId: source.proformaId.match((value) => value, () => null), generatedAt: source.generatedAt.match((value) => value, () => null), createdAt: source.createdAt, updatedAt: source.updatedAt }); companyId: companyA,
const reader = new RecurringProformaReader({} as never, { recurringProformaId: recurringId,
listByRecurringProformaInCompany: async (query) => { receivedQuery = query; return Result.ok({ page: 1, perPage: 10, totalItems: 2, totalPages: 1, items: [toRead(generated), toRead(failed)] }); }, scheduledDate: new Date("2027-02-15T00:00:00.000Z"),
} as never, transactionManager()); status: "generated",
const result = await reader.occurrences({ companyId: companyA, recurringProformaId: recurringId, page: 1, limit: 10 }); attemptCount: 1,
proformaId: Maybe.some(proformaId),
generatedAt: Maybe.some(new Date("2027-02-16T00:00:00.000Z")),
createdAt: new Date(),
updatedAt: new Date(),
})
);
const failed = required(
RecurringProformaOccurrence.create({
companyId: companyA,
recurringProformaId: recurringId,
scheduledDate: new Date("2027-01-15T00:00:00.000Z"),
status: "failed",
attemptCount: 2,
lastAttemptAt: Maybe.some(new Date("2027-01-16T00:00:00.000Z")),
lastErrorCode: Maybe.some("failed"),
lastErrorMessage: Maybe.some("Error"),
createdAt: new Date(),
updatedAt: new Date(),
})
);
const toRead = (source: typeof generated) => ({
id: source.id,
scheduledDate: source.scheduledDate,
status: source.status,
attemptCount: source.attemptCount,
lastAttemptAt: source.lastAttemptAt.match(
(value) => value,
() => null
),
processingStartedAt: null,
lastErrorCode: source.lastErrorCode.match(
(value) => value,
() => null
),
lastErrorMessage: source.lastErrorMessage.match(
(value) => value,
() => null
),
proformaId: source.proformaId.match(
(value) => value,
() => null
),
generatedAt: source.generatedAt.match(
(value) => value,
() => null
),
createdAt: source.createdAt,
updatedAt: source.updatedAt,
});
const reader = new RecurringProformaReader(
{} as never,
{
listByRecurringProformaInCompany: async (query) => {
receivedQuery = query;
return Result.ok({
page: 1,
perPage: 10,
totalItems: 2,
totalPages: 1,
items: [toRead(generated), toRead(failed)],
});
},
} as never,
{} as never,
transactionManager()
);
const result = await reader.occurrences({
companyId: companyA,
recurringProformaId: recurringId,
page: 1,
limit: 10,
});
assert.equal(result.isSuccess, true); assert.equal(result.isSuccess, true);
assert.deepEqual(receivedQuery, { companyId: companyA, recurringProformaId: recurringId, page: 1, limit: 10 }); assert.deepEqual(receivedQuery, {
companyId: companyA,
recurringProformaId: recurringId,
page: 1,
limit: 10,
});
assert.equal(result.data.items[0]?.proformaId?.equals(proformaId), true); assert.equal(result.data.items[0]?.proformaId?.equals(proformaId), true);
assert.equal(result.data.items[1]?.lastErrorCode, "failed"); assert.equal(result.data.items[1]?.lastErrorCode, "failed");
assert.equal(result.data.items[1]?.attemptCount, 2); assert.equal(result.data.items[1]?.attemptCount, 2);
@ -131,29 +271,73 @@ test("RecurringProformaReader propaga parent, empresa y paginación de occurrenc
test("RecurringProformaCommands posee la transacción y propaga las transiciones", async () => { test("RecurringProformaCommands posee la transacción y propaga las transiciones", async () => {
const calls: Array<{ action: string; transaction: unknown }> = []; const calls: Array<{ action: string; transaction: unknown }> = [];
const commands = new RecurringProformaCommands({ const commands = new RecurringProformaCommands({
statusChanger: { change: async (params: { action: string; transaction: unknown; companyId: UniqueID; id: UniqueID }) => { statusChanger: {
assert.equal(params.companyId.equals(companyA), true); assert.equal(params.id.equals(recurringId), true); change: async (params: {
calls.push({ action: params.action, transaction: params.transaction }); return Result.ok(buildRecurring()); action: string;
} }, transaction: unknown;
creator: {}, updater: {}, deleter: {}, duplicator: {}, fromProformaCreator: {}, readinessChecker: {}, repository: {}, companyId: UniqueID;
id: UniqueID;
}) => {
assert.equal(params.companyId.equals(companyA), true);
assert.equal(params.id.equals(recurringId), true);
calls.push({ action: params.action, transaction: params.transaction });
return Result.ok(buildRecurring());
},
},
creator: {},
updater: {},
deleter: {},
duplicator: {},
fromProformaCreator: {},
readinessChecker: {},
repository: {},
transactionManager: transactionManager(), transactionManager: transactionManager(),
} as never); } as never);
for (const action of ["activate", "pause", "resume", "complete", "archive"] as const) { for (const action of ["activate", "pause", "resume", "complete", "archive"] as const) {
const result = await commands[action]({ companyId: companyA, id: recurringId }); const result = await commands[action]({ companyId: companyA, id: recurringId });
assert.equal(result.isSuccess, true); assert.equal(result.isSuccess, true);
} }
assert.deepEqual(calls.map((call) => call.action), ["activate", "pause", "resume", "complete", "archive"]); assert.deepEqual(
assert.equal(calls.every((call) => call.transaction === transaction), true); calls.map((call) => call.action),
["activate", "pause", "resume", "complete", "archive"]
);
assert.equal(
calls.every((call) => call.transaction === transaction),
true
);
}); });
test("RecurringProformaCommands propaga name y schedule al facade fromProforma", async () => { test("RecurringProformaCommands propaga name y schedule al facade fromProforma", async () => {
let received: { name: string; schedule: unknown; transaction: unknown } | undefined; let received: { name: string; schedule: unknown; transaction: unknown } | undefined;
const schedule = { startDate: new Date("2027-01-15T00:00:00.000Z"), endDate: null, unit: "month" as const, interval: 1, monthlyRule: "same_day" as const }; const schedule = {
startDate: new Date("2027-01-15T00:00:00.000Z"),
endDate: null,
unit: "month" as const,
interval: 1,
monthlyRule: "same_day" as const,
};
const commands = new RecurringProformaCommands({ const commands = new RecurringProformaCommands({
fromProformaCreator: { create: async (params: { name: string; schedule: typeof schedule; transaction: unknown }) => { received = params; return Result.ok(buildRecurring()); } }, fromProformaCreator: {
creator: {}, updater: {}, statusChanger: {}, deleter: {}, duplicator: {}, readinessChecker: {}, repository: {}, transactionManager: transactionManager(), create: async (params: { name: string; schedule: typeof schedule; transaction: unknown }) => {
received = params;
return Result.ok(buildRecurring());
},
},
creator: {},
updater: {},
statusChanger: {},
deleter: {},
duplicator: {},
readinessChecker: {},
repository: {},
transactionManager: transactionManager(),
} as never); } as never);
const result = await commands.fromProforma({ companyId: companyA, proformaId: recurringId, name: "Desde proforma", schedule }); const result = await commands.fromProforma({
companyId: companyA,
proformaId: recurringId,
name: "Desde proforma",
schedule,
});
assert.equal(result.isSuccess, true); assert.equal(result.isSuccess, true);
assert.equal(received?.name, "Desde proforma"); assert.equal(received?.name, "Desde proforma");
assert.deepEqual(received?.schedule, schedule); assert.deepEqual(received?.schedule, schedule);
@ -164,24 +348,96 @@ test("RecurringProformaCommands propaga create, update, duplicate y delete sin n
const calls: Record<string, unknown> = {}; const calls: Record<string, unknown> = {};
const patch = { paymentTermId: null }; const patch = { paymentTermId: null };
const commands = new RecurringProformaCommands({ const commands = new RecurringProformaCommands({
creator: { create: async (params: { companyId: UniqueID; name: string; description?: Maybe<string>; transaction: unknown }) => { calls.create = params; return Result.ok(buildRecurring()); } }, creator: {
updater: { update: async (params: { companyId: UniqueID; id: UniqueID; patch: typeof patch; transaction: unknown }) => { calls.update = params; return Result.ok(buildRecurring()); } }, create: async (params: {
duplicator: { duplicate: async (params: { companyId: UniqueID; sourceId: UniqueID; transaction: unknown }) => { calls.duplicate = params; return Result.ok(buildRecurring()); } }, companyId: UniqueID;
deleter: { delete: async (params: { companyId: UniqueID; id: UniqueID; transaction: unknown }) => { calls.delete = params; return Result.ok(true); } }, name: string;
statusChanger: {}, fromProformaCreator: {}, readinessChecker: {}, repository: {}, transactionManager: transactionManager(), description?: Maybe<string>;
transaction: unknown;
}) => {
calls.create = params;
return Result.ok(buildRecurring());
},
},
updater: {
update: async (params: {
companyId: UniqueID;
id: UniqueID;
patch: typeof patch;
transaction: unknown;
}) => {
calls.update = params;
return Result.ok(buildRecurring());
},
},
duplicator: {
duplicate: async (params: {
companyId: UniqueID;
sourceId: UniqueID;
transaction: unknown;
}) => {
calls.duplicate = params;
return Result.ok(buildRecurring());
},
},
deleter: {
delete: async (params: { companyId: UniqueID; id: UniqueID; transaction: unknown }) => {
calls.delete = params;
return Result.ok(true);
},
},
statusChanger: {},
fromProformaCreator: {},
readinessChecker: {},
repository: {},
transactionManager: transactionManager(),
} as never); } as never);
assert.equal((await commands.create({ companyId: companyA, name: "Nueva", description: "Descripción" })).isSuccess, true); assert.equal(
assert.equal((await commands.update({ companyId: companyA, id: recurringId, patch })).isSuccess, true); (await commands.create({ companyId: companyA, name: "Nueva", description: "Descripción" }))
assert.equal((await commands.duplicate({ companyId: companyA, sourceId: recurringId })).isSuccess, true); .isSuccess,
true
);
assert.equal(
(await commands.update({ companyId: companyA, id: recurringId, patch })).isSuccess,
true
);
assert.equal(
(await commands.duplicate({ companyId: companyA, sourceId: recurringId })).isSuccess,
true
);
assert.equal((await commands.delete({ companyId: companyA, id: recurringId })).data, true); assert.equal((await commands.delete({ companyId: companyA, id: recurringId })).data, true);
const create = calls.create as { companyId: UniqueID; name: string; description?: Maybe<string>; transaction: unknown }; const create = calls.create as {
const update = calls.update as { companyId: UniqueID; id: UniqueID; patch: typeof patch; transaction: unknown }; companyId: UniqueID;
const duplicate = calls.duplicate as { companyId: UniqueID; sourceId: UniqueID; transaction: unknown }; name: string;
description?: Maybe<string>;
transaction: unknown;
};
const update = calls.update as {
companyId: UniqueID;
id: UniqueID;
patch: typeof patch;
transaction: unknown;
};
const duplicate = calls.duplicate as {
companyId: UniqueID;
sourceId: UniqueID;
transaction: unknown;
};
const deleted = calls.delete as { companyId: UniqueID; id: UniqueID; transaction: unknown }; const deleted = calls.delete as { companyId: UniqueID; id: UniqueID; transaction: unknown };
assert.equal(create.companyId.equals(companyA), true); assert.equal(create.name, "Nueva"); assert.equal(create.description?.unwrap(), "Descripción"); assert.equal(create.transaction, transaction); assert.equal(create.companyId.equals(companyA), true);
assert.equal(update.companyId.equals(companyA), true); assert.equal(update.id.equals(recurringId), true); assert.equal(update.patch.paymentTermId, null); assert.equal(update.transaction, transaction); assert.equal(create.name, "Nueva");
assert.equal(duplicate.companyId.equals(companyA), true); assert.equal(duplicate.sourceId.equals(recurringId), true); assert.equal(duplicate.transaction, transaction); assert.equal(create.description?.unwrap(), "Descripción");
assert.equal(deleted.companyId.equals(companyA), true); assert.equal(deleted.id.equals(recurringId), true); assert.equal(deleted.transaction, transaction); assert.equal(create.transaction, transaction);
assert.equal(update.companyId.equals(companyA), true);
assert.equal(update.id.equals(recurringId), true);
assert.equal(update.patch.paymentTermId, null);
assert.equal(update.transaction, transaction);
assert.equal(duplicate.companyId.equals(companyA), true);
assert.equal(duplicate.sourceId.equals(recurringId), true);
assert.equal(duplicate.transaction, transaction);
assert.equal(deleted.companyId.equals(companyA), true);
assert.equal(deleted.id.equals(recurringId), true);
assert.equal(deleted.transaction, transaction);
}); });
test("RecurringProformaCommands propaga readiness con blockers sin interpretarlos", async () => { test("RecurringProformaCommands propaga readiness con blockers sin interpretarlos", async () => {
@ -190,9 +446,35 @@ test("RecurringProformaCommands propaga readiness con blockers sin interpretarlo
const readiness = { canActivate: false, blockers: [{ code: "missing_customer" as const }] }; const readiness = { canActivate: false, blockers: [{ code: "missing_customer" as const }] };
const recurring = buildRecurring(); const recurring = buildRecurring();
const commands = new RecurringProformaCommands({ const commands = new RecurringProformaCommands({
repository: { findByIdInCompany: async (companyId: UniqueID, id: UniqueID, currentTransaction: unknown) => { repositoryCalls += 1; assert.equal(companyId.equals(companyA), true); assert.equal(id.equals(recurringId), true); assert.equal(currentTransaction, transaction); return Result.ok(recurring); } }, repository: {
readinessChecker: { check: async (params: { companyId: UniqueID; recurringProforma: RecurringProforma; transaction: unknown }) => { checkerCalls += 1; assert.equal(params.companyId.equals(companyA), true); assert.equal(params.recurringProforma, recurring); assert.equal(params.transaction, transaction); return Result.ok(readiness); } }, findByIdInCompany: async (companyId: UniqueID, id: UniqueID, currentTransaction: unknown) => {
creator: {}, updater: {}, statusChanger: {}, deleter: {}, duplicator: {}, fromProformaCreator: {}, transactionManager: transactionManager(), repositoryCalls += 1;
assert.equal(companyId.equals(companyA), true);
assert.equal(id.equals(recurringId), true);
assert.equal(currentTransaction, transaction);
return Result.ok(recurring);
},
},
readinessChecker: {
check: async (params: {
companyId: UniqueID;
recurringProforma: RecurringProforma;
transaction: unknown;
}) => {
checkerCalls += 1;
assert.equal(params.companyId.equals(companyA), true);
assert.equal(params.recurringProforma, recurring);
assert.equal(params.transaction, transaction);
return Result.ok(readiness);
},
},
creator: {},
updater: {},
statusChanger: {},
deleter: {},
duplicator: {},
fromProformaCreator: {},
transactionManager: transactionManager(),
} as never); } as never);
const result = await commands.activationReadiness({ companyId: companyA, id: recurringId }); const result = await commands.activationReadiness({ companyId: companyA, id: recurringId });
assert.equal(result.isSuccess, true); assert.equal(result.isSuccess, true);

View File

@ -1,4 +1,5 @@
import type { ITransactionManager } from "@erp/core/api"; import type { ITransactionManager } from "@erp/core/api";
import type { ICustomerPublicServices } from "@erp/customers/api";
import { UniqueID } from "@repo/rdx-ddd"; import { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils"; import { Maybe, Result } from "@repo/rdx-utils";
@ -34,13 +35,43 @@ export class RecurringProformaReader {
public constructor( public constructor(
private readonly repository: IRecurringProformaRepository, private readonly repository: IRecurringProformaRepository,
private readonly occurrenceRepository: IRecurringProformaOccurrenceRepository, private readonly occurrenceRepository: IRecurringProformaOccurrenceRepository,
private readonly customerServices: ICustomerPublicServices,
private readonly transactionManager: ITransactionManager private readonly transactionManager: ITransactionManager
) {} ) {}
public list(query: ListRecurringProformasQuery) { public list(query: ListRecurringProformasQuery) {
return this.transactionManager.complete((transaction) => return this.transactionManager.complete(async (transaction) => {
this.repository.listInCompany(query, transaction) const page = await this.repository.listInCompany(query, transaction);
); if (page.isFailure) return Result.fail(page.error);
const customerIds = [
...new Map(
page.data.items
.flatMap((item) => (item.customerId ? [item.customerId] : []))
.map((customerId) => [customerId.toPrimitive(), customerId])
).values(),
];
if (customerIds.length === 0) return page;
const customers = await this.customerServices.findSummariesByIds(customerIds, {
companyId: query.companyId,
transaction,
});
if (customers.isFailure) return Result.fail(customers.error);
const customersById = new Map(
customers.data.map((customer) => [customer.id.toPrimitive(), customer])
);
return Result.ok({
...page.data,
items: page.data.items.map((item) => ({
...item,
customer: item.customerId
? (customersById.get(item.customerId.toPrimitive()) ?? null)
: null,
})),
});
});
} }
public detail(params: { companyId: UniqueID; id: UniqueID }) { public detail(params: { companyId: UniqueID; id: UniqueID }) {

View File

@ -16,6 +16,18 @@ import {
type ListProformasUseCase, type ListProformasUseCase,
type PreviewProformaReportUseCase, type PreviewProformaReportUseCase,
ProformaIssuanceReadinessChecker, ProformaIssuanceReadinessChecker,
ProformaTaxConfigResolver,
RecurringProformaActivationReadinessChecker,
RecurringProformaCommands,
RecurringProformaCreator,
RecurringProformaDeleter,
RecurringProformaDuplicator,
RecurringProformaFromProformaCreator,
RecurringProformaMaterializer,
type RecurringProformaOccurrenceProcessor,
RecurringProformaReader,
RecurringProformaStatusChanger,
RecurringProformaUpdater,
type ReportProformaPdfUseCase, type ReportProformaPdfUseCase,
type UnarchiveProformaByIdUseCase, type UnarchiveProformaByIdUseCase,
type UpdateProformaByIdUseCase, type UpdateProformaByIdUseCase,
@ -47,23 +59,15 @@ import {
buildUnarchiveProformaByIdUseCase, buildUnarchiveProformaByIdUseCase,
buildUpdateProformaUseCase, buildUpdateProformaUseCase,
createRecurringProformaOccurrenceProcessor, createRecurringProformaOccurrenceProcessor,
ProformaTaxConfigResolver,
RecurringProformaMaterializer,
RecurringProformaActivationReadinessChecker,
RecurringProformaCommands,
RecurringProformaCreator,
RecurringProformaDeleter,
RecurringProformaDuplicator,
RecurringProformaFromProformaCreator,
type RecurringProformaOccurrenceProcessor,
RecurringProformaReader,
RecurringProformaStatusChanger,
RecurringProformaUpdater,
} from "../../../application"; } from "../../../application";
import { import {
CompanyVerifactuProfileDomainMapper, CompanyVerifactuProfileDomainMapper,
SequelizeCompanyVerifactuProfileRepository, SequelizeCompanyVerifactuProfileRepository,
} from "../../company-verifactu-profile/persistence"; } from "../../company-verifactu-profile/persistence";
import {
SequelizeRecurringProformaOccurrenceRepository,
SequelizeRecurringProformaRepository,
} from "../../recurring-proformas/persistence/sequelize/repositories";
import { CompanyReportProfileFinder } from "../adapters/company-report-profile-finder"; import { CompanyReportProfileFinder } from "../adapters/company-report-profile-finder";
import { DocumentSeriesProformaSeriesValidator } from "../services/document-series-proforma-series-validator"; import { DocumentSeriesProformaSeriesValidator } from "../services/document-series-proforma-series-validator";
import { DocumentSeriesProformaTargetInvoiceSeriesValidator } from "../services/document-series-proforma-target-invoice-series-validator"; import { DocumentSeriesProformaTargetInvoiceSeriesValidator } from "../services/document-series-proforma-target-invoice-series-validator";
@ -71,10 +75,6 @@ import { DocumentSeriesProformaTargetInvoiceSeriesValidator } from "../services/
import { buildProformaDocumentServices } from "./proforma-documents.di"; import { buildProformaDocumentServices } from "./proforma-documents.di";
import { buildV2ProformaPersistence } from "./proforma-persistence.di"; import { buildV2ProformaPersistence } from "./proforma-persistence.di";
import { resolveProformaCatalogsDeps } from "./proforrma-catalog-deps.di"; import { resolveProformaCatalogsDeps } from "./proforrma-catalog-deps.di";
import {
SequelizeRecurringProformaOccurrenceRepository,
SequelizeRecurringProformaRepository,
} from "../../recurring-proformas/persistence/sequelize/repositories";
export type ProformasInternalDeps = { export type ProformasInternalDeps = {
recurringProformas: { recurringProformas: {
@ -218,6 +218,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
const recurringReader = new RecurringProformaReader( const recurringReader = new RecurringProformaReader(
recurringProformaRepository, recurringProformaRepository,
occurrenceRepository, occurrenceRepository,
customerServices,
transactionManager transactionManager
); );
const recurringCommands = new RecurringProformaCommands({ const recurringCommands = new RecurringProformaCommands({
@ -425,7 +426,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
transactionManager, transactionManager,
}), }),
issueProforma: (publicServices: { issuedInvoiceServices: IIssuedInvoicePublicServices }) => issueProforma: (publicServices: { issuedInvoiceServices: IIssuedInvoicePublicServices }) =>
buildIssueProformaUseCase({ buildIssueProformaUseCase({
publicServices, publicServices,
finder, finder,

View File

@ -69,14 +69,15 @@ abstract class RecurringProformaController extends ExpressController {
export class ListRecurringProformasController extends RecurringProformaController { export class ListRecurringProformasController extends RecurringProformaController {
public constructor( public constructor(
private readonly list: any, private readonly list: any,
private readonly presenter = new RecurringProformaListPresenter() private readonly presenter = new RecurringProformaListPresenter(),
private readonly scope: "default" | "archived" = "default"
) { ) {
super(); super();
} }
protected async executeImpl() { protected async executeImpl() {
const companyId = this.company(); const companyId = this.company();
if (!companyId) return this.forbiddenError("Tenant ID not found"); if (!companyId) return this.forbiddenError("Tenant ID not found");
const result = await this.list({ companyId, ...this.req.query }); const result = await this.list({ companyId, ...this.req.query, scope: this.scope });
return result.match( return result.match(
(data: any) => this.ok(this.presenter.present(data)), (data: any) => this.ok(this.presenter.present(data)),
(error: Error) => this.handleError(error) (error: Error) => this.handleError(error)

View File

@ -12,7 +12,14 @@ export class RecurringProformaListPresenter {
items: page.items.map((item: any) => ({ items: page.items.map((item: any) => ({
id: id(item.id), id: id(item.id),
name: item.name, name: item.name,
customer_id: id(item.customerId), description: item.description,
customer: item.customer
? {
id: id(item.customer.id),
name: item.customer.name,
tin: item.customer.tin,
}
: null,
status: item.status, status: item.status,
schedule: item.schedule schedule: item.schedule
? { ? {
@ -24,6 +31,7 @@ export class RecurringProformaListPresenter {
} }
: null, : null,
next_occurrence_date: dateOnly(item.nextOccurrenceDate), next_occurrence_date: dateOnly(item.nextOccurrenceDate),
has_issue: item.hasIssue,
updated_at: item.updatedAt.toISOString(), updated_at: item.updatedAt.toISOString(),
})), })),
}; };

View File

@ -25,7 +25,13 @@ test("HTTP list presenta paginación vacía y fila con schedule date-only", () =
{ {
id: UniqueID.generateNewID(), id: UniqueID.generateNewID(),
name: "Mantenimiento", name: "Mantenimiento",
description: "Servicio preventivo",
customerId: UniqueID.generateNewID(), customerId: UniqueID.generateNewID(),
customer: {
id: UniqueID.generateNewID(),
name: "ACME",
tin: "B12345678",
},
status: "active", status: "active",
schedule: { schedule: {
startDate: new Date("2027-02-03T00:00:00.000Z"), startDate: new Date("2027-02-03T00:00:00.000Z"),
@ -35,12 +41,16 @@ test("HTTP list presenta paginación vacía y fila con schedule date-only", () =
monthlyRule: "same_day", monthlyRule: "same_day",
}, },
nextOccurrenceDate: new Date("2027-03-03T00:00:00.000Z"), nextOccurrenceDate: new Date("2027-03-03T00:00:00.000Z"),
hasIssue: true,
updatedAt: new Date("2027-02-01T10:00:00.000Z"), updatedAt: new Date("2027-02-01T10:00:00.000Z"),
}, },
], ],
}); });
assert.equal(dto.items[0]?.schedule.start_date, "2027-02-03"); assert.equal(dto.items[0]?.schedule.start_date, "2027-02-03");
assert.equal(dto.items[0]?.next_occurrence_date, "2027-03-03"); assert.equal(dto.items[0]?.next_occurrence_date, "2027-03-03");
assert.equal(dto.items[0]?.description, "Servicio preventivo");
assert.equal(dto.items[0]?.customer?.name, "ACME");
assert.equal(dto.items[0]?.has_issue, true);
}); });
test("HTTP detail conserva null inheritance, overrides, orden y date-only", () => { test("HTTP detail conserva null inheritance, overrides, orden y date-only", () => {

View File

@ -40,6 +40,12 @@ export const recurringProformasRouter = (params: StartParams) => {
(req: Request, res: Response, next: NextFunction) => (req: Request, res: Response, next: NextFunction) =>
new ListRecurringProformasController(deps.list).execute(req, res, next) new ListRecurringProformasController(deps.list).execute(req, res, next)
); );
router.get(
"/archived",
validateRequest(ListRecurringProformasRequestSchema, "query"),
(req: Request, res: Response, next: NextFunction) =>
new ListRecurringProformasController(deps.list, undefined, "archived").execute(req, res, next)
);
router.post( router.post(
"/", "/",
validateRequest(CreateRecurringProformaRequestSchema, "body"), validateRequest(CreateRecurringProformaRequestSchema, "body"),

View File

@ -52,7 +52,10 @@ test("SequelizeRecurringProformaRepository reemplaza items en la transacción su
); );
const originalUpsert = RecurringProformaModel.upsert; const originalUpsert = RecurringProformaModel.upsert;
const originalDestroy = RecurringProformaItemModel.destroy; const originalDestroy = RecurringProformaItemModel.destroy;
RecurringProformaModel.upsert = (async () => [{} as never, true]) as typeof RecurringProformaModel.upsert; RecurringProformaModel.upsert = (async () => [
{} as never,
true,
]) as typeof RecurringProformaModel.upsert;
RecurringProformaItemModel.destroy = (async () => 0) as typeof RecurringProformaItemModel.destroy; RecurringProformaItemModel.destroy = (async () => 0) as typeof RecurringProformaItemModel.destroy;
try { try {
assert.equal((await repository.save(recurringProforma, {})).isSuccess, true); assert.equal((await repository.save(recurringProforma, {})).isSuccess, true);
@ -76,10 +79,17 @@ test("findByIdInCompanyForUpdate exige transacción y limita por company", async
return null; return null;
}) as typeof RecurringProformaModel.findOne; }) as typeof RecurringProformaModel.findOne;
try { try {
assert.equal((await repository.findByIdInCompanyForUpdate(companyId, recurringProformaId, transaction)).isFailure, true); assert.equal(
(await repository.findByIdInCompanyForUpdate(companyId, recurringProformaId, transaction))
.isFailure,
true
);
assert.equal(options?.lock, true); assert.equal(options?.lock, true);
assert.equal(options?.transaction, transaction); assert.equal(options?.transaction, transaction);
assert.deepEqual(options?.where, { id: recurringProformaId.toPrimitive(), company_id: companyId.toPrimitive() }); assert.deepEqual(options?.where, {
id: recurringProformaId.toPrimitive(),
company_id: companyId.toPrimitive(),
});
} finally { } finally {
RecurringProformaModel.findOne = originalFindOne; RecurringProformaModel.findOne = originalFindOne;
} }
@ -97,13 +107,58 @@ test("listInCompany limita tenant, filtros, paginación y orden determinista", a
return { count: 0, rows: [] } as never; return { count: 0, rows: [] } as never;
}) as typeof RecurringProformaModel.findAndCountAll; }) as typeof RecurringProformaModel.findAndCountAll;
try { try {
const result = await repository.listInCompany({ companyId, status: "draft", search: "Cuota", page: 2, limit: 10 }); const result = await repository.listInCompany({
companyId,
status: "draft",
search: "Cuota",
page: 2,
limit: 10,
});
assert.equal(result.isSuccess, true); assert.equal(result.isSuccess, true);
assert.deepEqual(options?.where, { company_id: companyId.toPrimitive(), status: "draft", name: { [Op.like]: "%Cuota%" } }); assert.deepEqual(options?.where, {
assert.deepEqual(options?.order, [["updated_at", "DESC"], ["id", "DESC"]]); company_id: companyId.toPrimitive(),
status: "draft",
name: { [Op.like]: "%Cuota%" },
});
assert.deepEqual(options?.order, [
["updated_at", "DESC"],
["id", "DESC"],
]);
assert.equal(options?.limit, 10); assert.equal(options?.limit, 10);
assert.equal(options?.offset, 10); assert.equal(options?.offset, 10);
} finally { } finally {
RecurringProformaModel.findAndCountAll = originalFindAndCountAll; RecurringProformaModel.findAndCountAll = originalFindAndCountAll;
} }
}); });
test("listInCompany excluye archivadas y permite ordenar por nombre", async () => {
const repository = new SequelizeRecurringProformaRepository(
new Sequelize("mysql://root:pass@localhost:3306/erp_test", { logging: false })
);
const companyId = required(UniqueID.create("01900000-0000-7000-8000-000000000501"));
const originalFindAndCountAll = RecurringProformaModel.findAndCountAll;
let options: { where?: Record<string, unknown>; order?: unknown } | undefined;
RecurringProformaModel.findAndCountAll = (async (value) => {
options = value;
return { count: 0, rows: [] } as never;
}) as typeof RecurringProformaModel.findAndCountAll;
try {
await repository.listInCompany({
companyId,
page: 1,
limit: 20,
orderBy: "name",
order: "asc",
});
assert.deepEqual(options?.where, {
company_id: companyId.toPrimitive(),
status: { [Op.ne]: "archived" },
});
assert.deepEqual(options?.order, [
["name", "ASC"],
["id", "DESC"],
]);
} finally {
RecurringProformaModel.findAndCountAll = originalFindAndCountAll;
}
});

View File

@ -1,7 +1,7 @@
import { EntityNotFoundError, translateSequelizeError } from "@erp/core/api"; import { EntityNotFoundError, translateSequelizeError } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd"; import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { Op, type Transaction } from "sequelize"; import { literal, Op, type Transaction } from "sequelize";
import type { import type {
DueRecurringProforma, DueRecurringProforma,
@ -166,7 +166,11 @@ export class SequelizeRecurringProformaRepository implements IRecurringProformaR
try { try {
const where = { const where = {
company_id: query.companyId.toPrimitive(), company_id: query.companyId.toPrimitive(),
...(query.status ? { status: query.status } : {}), ...(query.scope === "archived"
? { status: "archived" }
: query.status
? { status: query.status }
: { status: { [Op.ne]: "archived" } }),
...(query.search ? { name: { [Op.like]: `%${query.search}%` } } : {}), ...(query.search ? { name: { [Op.like]: `%${query.search}%` } } : {}),
}; };
const limit = Math.max(1, query.limit); const limit = Math.max(1, query.limit);
@ -175,6 +179,7 @@ export class SequelizeRecurringProformaRepository implements IRecurringProformaR
attributes: [ attributes: [
"id", "id",
"name", "name",
"description",
"customer_id", "customer_id",
"status", "status",
"schedule_start_date", "schedule_start_date",
@ -184,10 +189,20 @@ export class SequelizeRecurringProformaRepository implements IRecurringProformaR
"schedule_monthly_rule", "schedule_monthly_rule",
"next_occurrence_date", "next_occurrence_date",
"updated_at", "updated_at",
[
literal(`EXISTS (
SELECT 1
FROM recurring_proforma_occurrences AS occurrence
WHERE occurrence.recurring_proforma_id = RecurringProformaModel.id
AND occurrence.company_id = RecurringProformaModel.company_id
AND occurrence.status = 'failed'
)`),
"has_issue",
],
], ],
where, where,
order: [ order: [
["updated_at", "DESC"], [query.orderBy ?? "updated_at", (query.order ?? "desc").toUpperCase()],
["id", "DESC"], ["id", "DESC"],
], ],
limit, limit,
@ -204,7 +219,9 @@ export class SequelizeRecurringProformaRepository implements IRecurringProformaR
items.push({ items.push({
id: id.data, id: id.data,
name: row.name, name: row.name,
description: row.description ?? null,
customerId: customerId?.data ?? null, customerId: customerId?.data ?? null,
customer: null,
status: row.status as RecurringProformaListItemReadModel["status"], status: row.status as RecurringProformaListItemReadModel["status"],
schedule: hasSchedule schedule: hasSchedule
? { ? {
@ -222,6 +239,7 @@ export class SequelizeRecurringProformaRepository implements IRecurringProformaR
nextOccurrenceDate: row.next_occurrence_date nextOccurrenceDate: row.next_occurrence_date
? this.toDateOnly(row.next_occurrence_date) ? this.toDateOnly(row.next_occurrence_date)
: null, : null,
hasIssue: Boolean(row.get("has_issue")),
updatedAt: row.updated_at, updatedAt: row.updated_at,
}); });
} }

View File

@ -3,9 +3,11 @@ import { z } from "zod/v4";
export const RecurringProformaIdParamsSchema = z.object({ recurring_proforma_id: z.uuid() }); export const RecurringProformaIdParamsSchema = z.object({ recurring_proforma_id: z.uuid() });
export const ListRecurringProformasRequestSchema = z.object({ export const ListRecurringProformasRequestSchema = z.object({
search: z.string().min(1).optional(), search: z.string().min(1).optional(),
status: z.enum(["draft", "active", "paused", "completed", "archived"]).optional(), status: z.enum(["draft", "active", "paused", "completed"]).optional(),
page: z.coerce.number().int().positive().default(1), page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20), limit: z.coerce.number().int().positive().max(100).default(20),
orderBy: z.enum(["name", "updated_at"]).optional(),
order: z.enum(["asc", "desc"]).optional(),
}); });
export const ListRecurringProformaOccurrencesRequestSchema = z.object({ export const ListRecurringProformaOccurrencesRequestSchema = z.object({
page: z.coerce.number().int().positive().default(1), page: z.coerce.number().int().positive().default(1),

View File

@ -31,9 +31,18 @@
"empty": "There are no recurring proformas.", "empty": "There are no recurring proformas.",
"columns": { "columns": {
"name": "Name", "name": "Name",
"customer": "Customer",
"status": "Status", "status": "Status",
"schedule": "Recurrence", "schedule": "Recurrence",
"updated_at": "Updated" "frequency": "Frequency",
"next_occurrence": "Next generation",
"issues": "Issues",
"updated_at": "Last updated",
"actions": "Actions"
},
"issues": {
"none": "No issues",
"issue": "Issue"
}, },
"schedule": { "schedule": {
"none": "No recurrence", "none": "No recurrence",
@ -52,6 +61,15 @@
"retry": "Retry" "retry": "Retry"
} }
}, },
"actions": {
"activate": "Activate",
"pause": "Pause",
"resume": "Resume",
"complete": "Complete",
"archive": "Archive",
"duplicate": "Duplicate",
"delete": "Delete"
},
"status": { "status": {
"all": "All", "all": "All",
"draft": "Draft", "draft": "Draft",

View File

@ -31,9 +31,18 @@
"empty": "No hay proformas recurrentes.", "empty": "No hay proformas recurrentes.",
"columns": { "columns": {
"name": "Nombre", "name": "Nombre",
"customer": "Cliente",
"status": "Estado", "status": "Estado",
"schedule": "Recurrencia", "schedule": "Recurrencia",
"updated_at": "Actualizada" "frequency": "Frecuencia",
"next_occurrence": "Próxima generación",
"issues": "Incidencias",
"updated_at": "Última actualización",
"actions": "Acciones"
},
"issues": {
"none": "Sin incidencias",
"issue": "Incidencia"
}, },
"schedule": { "schedule": {
"none": "Sin recurrencia", "none": "Sin recurrencia",
@ -52,6 +61,15 @@
"retry": "Reintentar" "retry": "Reintentar"
} }
}, },
"actions": {
"activate": "Activar",
"pause": "Pausar",
"resume": "Reanudar",
"complete": "Completar",
"archive": "Archivar",
"duplicate": "Duplicar",
"delete": "Eliminar"
},
"status": { "status": {
"all": "Todos", "all": "Todos",
"draft": "Borrador", "draft": "Borrador",

View File

@ -145,6 +145,18 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[]
</RecurringProformaLayout> </RecurringProformaLayout>
), ),
}, },
{
path: "recurring-proformas/archived",
handle: {
layout: "app-sidebar",
protected: true,
},
element: (
<RecurringProformaLayout>
<RecurringProformasListPage />
</RecurringProformaLayout>
),
},
{ {
path: "recurring-proformas/create", path: "recurring-proformas/create",
handle: { handle: {

View File

@ -1,6 +1,7 @@
import { useDebounce } from "@erp/core/hooks"; import { useDebounce } from "@erp/core/hooks";
import type { DataTableSort, DataTableSortDirection } from "@repo/rdx-ui/components";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "../../../i18n"; import { useTranslation } from "../../../i18n";
import { useRecurringProformas } from "../../shared/hooks/use-recurring-proformas"; import { useRecurringProformas } from "../../shared/hooks/use-recurring-proformas";
@ -11,8 +12,10 @@ import type {
} from "../../shared/recurring-proforma-list"; } from "../../shared/recurring-proforma-list";
import { buildRecurringProformaDetailPath } from "../../shared/recurring-proforma-navigation"; import { buildRecurringProformaDetailPath } from "../../shared/recurring-proforma-navigation";
const STATUSES = ["draft", "active", "paused", "completed", "archived"] as const; const STATUSES = ["draft", "active", "paused", "completed"] as const;
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
const DEFAULT_SORT = { field: "updatedAt", direction: "desc" } satisfies DataTableSort;
const SORT_FIELDS = { name: "name", updatedAt: "updated_at" } as const;
type RecurringProformaStatusFilter = "all" | RecurringProformaStatus; type RecurringProformaStatusFilter = "all" | RecurringProformaStatus;
@ -23,9 +26,9 @@ interface RecurringProformaPageErrorViewModel {
const EMPTY_RECURRING_PROFORMAS_LIST: RecurringProformaList = { const EMPTY_RECURRING_PROFORMAS_LIST: RecurringProformaList = {
page: 1, page: 1,
per_page: PAGE_SIZE, perPage: PAGE_SIZE,
total_items: 0, totalItems: 0,
total_pages: 0, totalPages: 0,
items: [], items: [],
}; };
@ -38,6 +41,7 @@ const resolveStatusFilter = (value: string | null): RecurringProformaStatusFilte
export const useListRecurringProformasPageController = () => { export const useListRecurringProformasPageController = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [search, setSearch] = useState(searchParams.get("search") ?? ""); const [search, setSearch] = useState(searchParams.get("search") ?? "");
const [statusFilter, setStatusFilter] = useState<RecurringProformaStatusFilter>( const [statusFilter, setStatusFilter] = useState<RecurringProformaStatusFilter>(
@ -47,6 +51,15 @@ export const useListRecurringProformasPageController = () => {
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1); const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
const limit = Math.max(1, Number(searchParams.get("limit") ?? String(PAGE_SIZE)) || PAGE_SIZE); const limit = Math.max(1, Number(searchParams.get("limit") ?? String(PAGE_SIZE)) || PAGE_SIZE);
const debouncedSearch = useDebounce(search, 300); const debouncedSearch = useDebounce(search, 300);
const sortField = searchParams.get("sortField");
const sortDirection = searchParams.get("sortDirection");
const sort =
sortField !== null &&
sortField in SORT_FIELDS &&
(sortDirection === "asc" || sortDirection === "desc")
? { field: sortField, direction: sortDirection as DataTableSortDirection }
: DEFAULT_SORT;
const scope = location.pathname.endsWith("/archived") ? "archived" : "default";
useEffect(() => { useEffect(() => {
setSearch(searchParams.get("search") ?? ""); setSearch(searchParams.get("search") ?? "");
@ -59,8 +72,11 @@ export const useListRecurringProformasPageController = () => {
limit, limit,
...(debouncedSearch ? { search: debouncedSearch } : {}), ...(debouncedSearch ? { search: debouncedSearch } : {}),
...(statusFilter === "all" ? {} : { status: statusFilter }), ...(statusFilter === "all" ? {} : { status: statusFilter }),
orderBy: SORT_FIELDS[sort.field as keyof typeof SORT_FIELDS] ?? SORT_FIELDS.updatedAt,
order: sort.direction,
scope,
}), }),
[debouncedSearch, limit, page, statusFilter] [debouncedSearch, limit, page, scope, sort.direction, sort.field, statusFilter]
); );
const query = useRecurringProformas(filters); const query = useRecurringProformas(filters);
@ -134,6 +150,15 @@ export const useListRecurringProformasPageController = () => {
[limit, updateSearchParams] [limit, updateSearchParams]
); );
const setSort = useCallback(
(nextSort: DataTableSort) => {
const field = nextSort.field in SORT_FIELDS ? nextSort.field : DEFAULT_SORT.field;
const direction = nextSort.direction === "asc" ? "asc" : "desc";
updateSearchParams({ sortField: field, sortDirection: direction });
},
[updateSearchParams]
);
const retryList = useCallback(async () => { const retryList = useCallback(async () => {
await query.refetch(); await query.refetch();
}, [query]); }, [query]);
@ -192,6 +217,9 @@ export const useListRecurringProformasPageController = () => {
statusFilter, statusFilter,
setStatusFilter: setStatusFilterValue, setStatusFilter: setStatusFilterValue,
status: statusFilter === "all" ? undefined : statusFilter, status: statusFilter === "all" ? undefined : statusFilter,
sort,
setSort,
scope,
}, },
pageError, pageError,
refreshError, refreshError,

View File

@ -14,9 +14,9 @@ interface ControllerState {
listCtrl: { listCtrl: {
data: { data: {
page: number; page: number;
per_page: number; perPage: number;
total_items: number; totalItems: number;
total_pages: number; totalPages: number;
items: Array<Record<string, unknown>>; items: Array<Record<string, unknown>>;
}; };
isLoading: boolean; isLoading: boolean;
@ -29,6 +29,8 @@ interface ControllerState {
setPageSize: (value: number) => void; setPageSize: (value: number) => void;
statusFilter: string; statusFilter: string;
setStatusFilter: (value: string) => void; setStatusFilter: (value: string) => void;
sort: { field: string; direction: "asc" | "desc" };
setSort: (value: { field: string; direction: "asc" | "desc" }) => void;
}; };
pageError: { title: string; description: string } | null; pageError: { title: string; description: string } | null;
refreshError: { title: string; message: string } | null; refreshError: { title: string; message: string } | null;
@ -72,9 +74,9 @@ const createControllerState = (): ControllerState => ({
listCtrl: { listCtrl: {
data: { data: {
page: 1, page: 1,
per_page: 20, perPage: 20,
total_items: 0, totalItems: 0,
total_pages: 0, totalPages: 0,
items: [], items: [],
}, },
isLoading: false, isLoading: false,
@ -95,6 +97,8 @@ const createControllerState = (): ControllerState => ({
setStatusFilter: (value) => { setStatusFilter: (value) => {
events.status = value; events.status = value;
}, },
sort: { field: "updatedAt", direction: "desc" },
setSort: () => undefined,
}, },
pageError: null, pageError: null,
refreshError: null, refreshError: null,
@ -167,6 +171,12 @@ mock.module("@repo/shadcn-ui/components", {
return <button type="button">{children}</button>; return <button type="button">{children}</button>;
}, },
Badge: ({ children }: { children: ReactNode }) => <span>{children}</span>, Badge: ({ children }: { children: ReactNode }) => <span>{children}</span>,
DropdownMenu: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuItem: ({ children }: { children: ReactNode }) => (
<button type="button">{children}</button>
),
DropdownMenuTrigger: ({ render }: { render: ReactNode }) => <>{render}</>,
ToggleGroup: ({ children, onValueChange }: ToggleGroupProps & { children: ReactNode }) => { ToggleGroup: ({ children, onValueChange }: ToggleGroupProps & { children: ReactNode }) => {
capturedToggleGroupProps = { onValueChange }; capturedToggleGroupProps = { onValueChange };
return <div>{children}</div>; return <div>{children}</div>;
@ -177,6 +187,12 @@ mock.module("@repo/shadcn-ui/components", {
}, },
}); });
mock.module("../shared/hooks/use-recurring-proforma-action-mutation", {
namedExports: {
useRecurringProformaActionMutation: () => ({ isPending: false, mutate: () => undefined }),
},
});
const renderPage = async () => { const renderPage = async () => {
const { ListRecurringProformasPage } = await import("./list-recurring-proformas-page"); const { ListRecurringProformasPage } = await import("./list-recurring-proformas-page");
@ -230,24 +246,26 @@ test("conecta CTA, detalle, búsqueda, filtro y paginación con el controller",
controllerState = createControllerState(); controllerState = createControllerState();
controllerState.listCtrl.data = { controllerState.listCtrl.data = {
page: 1, page: 1,
per_page: 20, perPage: 20,
total_items: 1, totalItems: 1,
total_pages: 1, totalPages: 1,
items: [ items: [
{ {
id: "rec-1", id: "rec-1",
name: "Cuota mensual", name: "Cuota mensual",
customer_id: null, description: null,
customer: null,
status: "active", status: "active",
schedule: { schedule: {
start_date: "2026-09-01", startDate: "2026-09-01",
end_date: null, endDate: null,
unit: "month", unit: "month",
interval: 1, interval: 1,
monthly_rule: "same_day", monthlyRule: "same_day",
}, },
next_occurrence_date: null, nextOccurrenceDate: null,
updated_at: "2026-09-01", hasIssue: false,
updatedAt: "2026-09-01",
}, },
], ],
}; };

View File

@ -1,24 +1,53 @@
import { PageErrorState, SimpleSearchInput } from "@erp/core/components"; import { PageErrorState, SimpleSearchInput } from "@erp/core/components";
import { DataTable, DataTableColumnHeader, RdxPageContainer } from "@repo/rdx-ui/components"; import { DataTable, DataTableColumnHeader, RdxPageContainer } from "@repo/rdx-ui/components";
import { Badge, Button } from "@repo/shadcn-ui/components"; import {
Badge,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@repo/shadcn-ui/components";
import type { ColumnDef } from "@tanstack/react-table"; import type { ColumnDef } from "@tanstack/react-table";
import { CircleIcon, MoreHorizontalIcon } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
import type { RecurringProformaListItem } from "../shared/recurring-proforma-list"; import type { RecurringProformaListRow } from "../shared/recurring-proforma-list";
import { buildRecurringProformaDetailPath } from "../shared/recurring-proforma-navigation"; import { buildRecurringProformaDetailPath } from "../shared/recurring-proforma-navigation";
import {
type RecurringProformaAction,
useRecurringProformaActionMutation,
} from "../shared/hooks/use-recurring-proforma-action-mutation";
import { useListRecurringProformasPageController } from "./controllers/use-list-recurring-proformas-page-controller"; import { useListRecurringProformasPageController } from "./controllers/use-list-recurring-proformas-page-controller";
import { import {
formatRecurringProformaSchedule, formatRecurringProformaSchedule,
formatRecurringProformaDateOnly,
formatRecurringProformaDateTime,
getRecurringProformaStatusKey, getRecurringProformaStatusKey,
} from "./recurring-proforma-list-utils"; } from "./recurring-proforma-list-utils";
import { RecurringProformaStatusSegmentedFilter } from "./ui/components/recurring-proforma-status-segmented-filter"; import { RecurringProformaStatusSegmentedFilter } from "./ui/components/recurring-proforma-status-segmented-filter";
import { ScopedListRecurringProformasPageView } from "./ui/pages/scoped-list-recurring-proformas-page-view"; import { ScopedListRecurringProformasPageView } from "./ui/pages/scoped-list-recurring-proformas-page-view";
const getActions = (status: RecurringProformaListRow["status"]): RecurringProformaAction[] => {
switch (status) {
case "draft":
return ["activate", "complete", "duplicate", "delete"];
case "active":
return ["pause", "complete", "duplicate"];
case "paused":
return ["resume", "complete", "duplicate"];
case "completed":
return ["archive", "duplicate"];
case "archived":
return ["duplicate"];
}
};
export const ListRecurringProformasPage = () => { export const ListRecurringProformasPage = () => {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const [isSearchPending, setIsSearchPending] = useState(false); const [isSearchPending, setIsSearchPending] = useState(false);
const { const {
listCtrl, listCtrl,
@ -30,12 +59,14 @@ export const ListRecurringProformasPage = () => {
openCreatePage, openCreatePage,
openDetailPage, openDetailPage,
} = useListRecurringProformasPageController(); } = useListRecurringProformasPageController();
const actionMutation = useRecurringProformaActionMutation();
const dateLocale = i18n.language === "en" ? "en-US" : "es-ES";
const columns = useMemo<ColumnDef<RecurringProformaListItem>[]>( const columns = useMemo<ColumnDef<RecurringProformaListRow>[]>(
() => [ () => [
{ {
accessorKey: "name", accessorKey: "name",
enableSorting: false, enableSorting: true,
header: ({ column }) => ( header: ({ column }) => (
<DataTableColumnHeader <DataTableColumnHeader
column={column} column={column}
@ -47,10 +78,35 @@ export const ListRecurringProformasPage = () => {
className="font-medium text-primary underline-offset-4 hover:underline" className="font-medium text-primary underline-offset-4 hover:underline"
to={buildRecurringProformaDetailPath(row.original.id)} to={buildRecurringProformaDetailPath(row.original.id)}
> >
{row.original.name} <span className="block font-semibold">{row.original.name}</span>
{row.original.description ? (
<span className="mt-0.5 block text-sm font-normal text-muted-foreground">
{row.original.description}
</span>
) : null}
</Link> </Link>
), ),
}, },
{
id: "customer",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t("recurring_proformas.list.columns.customer")}
/>
),
cell: ({ row }) =>
row.original.customer ? (
<div>
<span className="block font-medium">{row.original.customer.name}</span>
<span className="block text-sm text-muted-foreground">
{row.original.customer.tin ?? "—"}
</span>
</div>
) : (
"—"
),
},
{ {
accessorKey: "status", accessorKey: "status",
enableSorting: false, enableSorting: false,
@ -65,12 +121,12 @@ export const ListRecurringProformasPage = () => {
), ),
}, },
{ {
id: "schedule", id: "frequency",
enableSorting: false, enableSorting: false,
header: ({ column }) => ( header: ({ column }) => (
<DataTableColumnHeader <DataTableColumnHeader
column={column} column={column}
title={t("recurring_proformas.list.columns.schedule")} title={t("recurring_proformas.list.columns.frequency")}
/> />
), ),
cell: ({ row }) => cell: ({ row }) =>
@ -79,18 +135,98 @@ export const ListRecurringProformasPage = () => {
), ),
}, },
{ {
accessorKey: "updated_at", id: "nextOccurrenceDate",
enableSorting: false, header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t("recurring_proformas.list.columns.next_occurrence")}
/>
),
cell: ({ row }) =>
formatRecurringProformaDateOnly(row.original.nextOccurrenceDate, dateLocale),
},
{
id: "hasIssue",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t("recurring_proformas.list.columns.issues")}
/>
),
cell: ({ row }) => (
<span className="flex items-center gap-2">
<CircleIcon
aria-hidden="true"
className={
row.original.hasIssue
? "size-2 fill-red-500 text-red-500"
: "size-2 fill-emerald-500 text-emerald-500"
}
/>
{t(
row.original.hasIssue
? "recurring_proformas.list.issues.issue"
: "recurring_proformas.list.issues.none"
)}
</span>
),
},
{
accessorKey: "updatedAt",
enableSorting: true,
header: ({ column }) => ( header: ({ column }) => (
<DataTableColumnHeader <DataTableColumnHeader
column={column} column={column}
title={t("recurring_proformas.list.columns.updated_at")} title={t("recurring_proformas.list.columns.updated_at")}
/> />
), ),
cell: ({ row }) => <span className="tabular-nums">{row.original.updated_at}</span>, cell: ({ row }) => (
<span className="tabular-nums">
{formatRecurringProformaDateTime(row.original.updatedAt, dateLocale)}
</span>
),
},
{
id: "actions",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t("recurring_proformas.list.columns.actions")}
/>
),
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
aria-label={t("recurring_proformas.list.columns.actions")}
size="icon"
type="button"
variant="ghost"
>
<MoreHorizontalIcon className="size-4" />
</Button>
}
/>
<DropdownMenuContent align="end">
{getActions(row.original.status).map((action) => (
<DropdownMenuItem
disabled={actionMutation.isPending}
key={action}
onClick={(event) => {
event.stopPropagation();
actionMutation.mutate({ id: row.original.id, action });
}}
>
{t(`recurring_proformas.actions.${action}`)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
),
}, },
], ],
[t] [actionMutation, dateLocale, t]
); );
if (hasBlockingError && pageError) { if (hasBlockingError && pageError) {
@ -123,12 +259,15 @@ export const ListRecurringProformasPage = () => {
isFetching={listCtrl.isFetching} isFetching={listCtrl.isFetching}
loading={listCtrl.isLoading} loading={listCtrl.isLoading}
manualPagination manualPagination
manualSorting
onPageChange={listCtrl.setPageIndex} onPageChange={listCtrl.setPageIndex}
onPageSizeChange={listCtrl.setPageSize} onPageSizeChange={listCtrl.setPageSize}
onRowClick={(row) => openDetailPage(row.id)} onRowClick={(row) => openDetailPage(row.id)}
onSortChange={listCtrl.setSort}
pageIndex={listCtrl.pageIndex} pageIndex={listCtrl.pageIndex}
pageSize={listCtrl.pageSize} pageSize={listCtrl.pageSize}
searching={isSearchPending} searching={isSearchPending}
sort={listCtrl.sort}
topContent={ topContent={
<div className="space-y-5"> <div className="space-y-5">
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between"> <div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
@ -150,10 +289,10 @@ export const ListRecurringProformasPage = () => {
/> />
</div> </div>
} }
totalItems={listCtrl.data.total_items} totalItems={listCtrl.data.totalItems}
/> />
{listCtrl.data.total_items === 0 ? ( {listCtrl.data.totalItems === 0 ? (
<div className="rounded border border-dashed border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground"> <div className="rounded border border-dashed border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
{t("recurring_proformas.list.empty")} {t("recurring_proformas.list.empty")}
</div> </div>

View File

@ -2,34 +2,53 @@ import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { import {
formatRecurringProformaDateOnly,
formatRecurringProformaSchedule, formatRecurringProformaSchedule,
getRecurringProformaStatusKey, getRecurringProformaStatusKey,
getRecurringProformaListFeedbackState, getRecurringProformaListFeedbackState,
} from "./recurring-proforma-list-utils"; } from "./recurring-proforma-list-utils";
test("resuelve loading, error, empty y rows para el listado", () => { test("resuelve loading, error, empty y rows para el listado", () => {
assert.equal(getRecurringProformaListFeedbackState({ isLoading: true, isError: false, totalItems: 0 }), "loading"); assert.equal(
assert.equal(getRecurringProformaListFeedbackState({ isLoading: false, isError: true, totalItems: 0 }), "error"); getRecurringProformaListFeedbackState({ isLoading: true, isError: false, totalItems: 0 }),
assert.equal(getRecurringProformaListFeedbackState({ isLoading: false, isError: false, totalItems: 0 }), "empty"); "loading"
assert.equal(getRecurringProformaListFeedbackState({ isLoading: false, isError: false, totalItems: 1 }), "ready"); );
assert.equal(
getRecurringProformaListFeedbackState({ isLoading: false, isError: true, totalItems: 0 }),
"error"
);
assert.equal(
getRecurringProformaListFeedbackState({ isLoading: false, isError: false, totalItems: 0 }),
"empty"
);
assert.equal(
getRecurringProformaListFeedbackState({ isLoading: false, isError: false, totalItems: 1 }),
"ready"
);
}); });
test("presenta la recurrencia mensual de una fila sin calcular ocurrencias", () => { test("presenta la recurrencia mensual de una fila sin calcular ocurrencias", () => {
const result = formatRecurringProformaSchedule( const result = formatRecurringProformaSchedule(
{ {
start_date: "2026-09-01", startDate: "2026-09-01",
end_date: null, endDate: null,
unit: "month", unit: "month",
interval: 1, interval: 1,
monthly_rule: "same_day", monthlyRule: "same_day",
}, },
(key) => ({ (key) =>
"recurring_proformas.list.schedule.month": "Cada mes", ({
"recurring_proformas.list.schedule.same_day": "Mismo dia", "recurring_proformas.list.schedule.month": "Cada mes",
})[key] ?? key "recurring_proformas.list.schedule.same_day": "Mismo dia",
})[key] ?? key
); );
assert.equal(result, "Cada mes, Mismo dia"); assert.equal(result, "Cada mes · Mismo dia");
});
test("presenta fechas civiles sin desplazar el día y usa un valor vacío consistente", () => {
assert.equal(formatRecurringProformaDateOnly("2026-09-01", "es-ES"), "1/9/26");
assert.equal(formatRecurringProformaDateOnly(null, "es-ES"), "—");
}); });
test("resuelve la clave i18n del estado que presenta cada fila", () => { test("resuelve la clave i18n del estado que presenta cada fila", () => {

View File

@ -1,5 +1,6 @@
import type { import type {
RecurringProformaListItem, RecurringProformaDetail,
RecurringProformaListRow,
RecurringProformaStatus, RecurringProformaStatus,
} from "../shared/recurring-proforma-list"; } from "../shared/recurring-proforma-list";
@ -9,7 +10,7 @@ export const getRecurringProformaStatusKey = (status: RecurringProformaStatus) =
`recurring_proformas.status.${status}`; `recurring_proformas.status.${status}`;
export const formatRecurringProformaSchedule = ( export const formatRecurringProformaSchedule = (
schedule: RecurringProformaListItem["schedule"], schedule: RecurringProformaListRow["schedule"] | RecurringProformaDetail["schedule"],
t: Translate t: Translate
) => { ) => {
if (!schedule) return t("recurring_proformas.list.schedule.none"); if (!schedule) return t("recurring_proformas.list.schedule.none");
@ -17,14 +18,27 @@ export const formatRecurringProformaSchedule = (
const cadence = t(`recurring_proformas.list.schedule.${schedule.unit}`, { const cadence = t(`recurring_proformas.list.schedule.${schedule.unit}`, {
count: schedule.interval, count: schedule.interval,
}); });
const monthlyRule = "monthlyRule" in schedule ? schedule.monthlyRule : schedule.monthly_rule;
const rule = const rule =
schedule.monthly_rule === null monthlyRule === null ? "" : ` · ${t(`recurring_proformas.list.schedule.${monthlyRule}`)}`;
? ""
: `, ${t(`recurring_proformas.list.schedule.${schedule.monthly_rule}`)}`;
return `${cadence}${rule}`; return `${cadence}${rule}`;
}; };
export const formatRecurringProformaDateTime = (value: string, locale: string) =>
new Intl.DateTimeFormat(locale, {
dateStyle: "short",
timeStyle: "short",
}).format(new Date(value));
export const formatRecurringProformaDateOnly = (value: string | null, locale: string) => {
if (!value) return "—";
const [year, month, day] = value.split("-").map(Number);
return new Intl.DateTimeFormat(locale, { dateStyle: "short" }).format(
new Date(year, month - 1, day)
);
};
export const getRecurringProformaListFeedbackState = ({ export const getRecurringProformaListFeedbackState = ({
isLoading, isLoading,
isError, isError,

View File

@ -1,12 +1,6 @@
import { ToggleGroup, ToggleGroupItem } from "@repo/shadcn-ui/components"; import { ToggleGroup, ToggleGroupItem } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils"; import { cn } from "@repo/shadcn-ui/lib/utils";
import { import { CheckCircle2Icon, CirclePauseIcon, FileTextIcon, PlayCircleIcon } from "lucide-react";
ArchiveIcon,
CheckCircle2Icon,
CirclePauseIcon,
FileTextIcon,
PlayCircleIcon,
} from "lucide-react";
import type { ElementType } from "react"; import type { ElementType } from "react";
import { useTranslation } from "../../../../i18n"; import { useTranslation } from "../../../../i18n";
@ -56,13 +50,6 @@ const RECURRING_PROFORMA_STATUS_FILTER_OPTIONS = [
activeClassName: activeClassName:
"data-[state=on]:border-sky-300 data-[state=on]:bg-sky-50 data-[state=on]:text-sky-700", "data-[state=on]:border-sky-300 data-[state=on]:bg-sky-50 data-[state=on]:text-sky-700",
}, },
{
value: "archived",
labelKey: "recurring_proformas.status.archived",
icon: ArchiveIcon,
activeClassName:
"data-[state=on]:border-muted-foreground data-[state=on]:bg-muted data-[state=on]:text-foreground",
},
] as const satisfies readonly { ] as const satisfies readonly {
value: RecurringProformaStatusFilter; value: RecurringProformaStatusFilter;
labelKey: string; labelKey: string;

View File

@ -0,0 +1,35 @@
import type {
RecurringProformaList,
RecurringProformaListDTO,
RecurringProformaListRow,
} from "../recurring-proforma-list";
const toRow = (dto: RecurringProformaListDTO["items"][number]): RecurringProformaListRow => ({
id: dto.id,
name: dto.name,
description: dto.description,
customer: dto.customer,
status: dto.status,
schedule: dto.schedule
? {
startDate: dto.schedule.start_date,
endDate: dto.schedule.end_date,
unit: dto.schedule.unit,
interval: dto.schedule.interval,
monthlyRule: dto.schedule.monthly_rule,
}
: null,
nextOccurrenceDate: dto.next_occurrence_date,
hasIssue: dto.has_issue,
updatedAt: dto.updated_at,
});
export const ListRecurringProformasAdapter = {
fromDto: (dto: RecurringProformaListDTO): RecurringProformaList => ({
page: dto.page,
perPage: dto.per_page,
totalItems: dto.total_items,
totalPages: dto.total_pages,
items: dto.items.map(toRow),
}),
};

View File

@ -1,13 +1,21 @@
import type { IDataSource } from "@erp/core/client"; import type { IDataSource } from "@erp/core/client";
import type { RecurringProformaList, RecurringProformaListFilters } from "../recurring-proforma-list"; import type {
RecurringProformaListDTO,
RecurringProformaListFilters,
} from "../recurring-proforma-list";
export const listRecurringProformas = ( export const listRecurringProformas = (
dataSource: IDataSource, dataSource: IDataSource,
filters: RecurringProformaListFilters, filters: RecurringProformaListFilters,
signal?: AbortSignal signal?: AbortSignal
): Promise<RecurringProformaList> => ): Promise<RecurringProformaListDTO> => {
dataSource.getList<RecurringProformaList>("recurring-proformas", { const { scope = "default", ...query } = filters;
...filters, return dataSource.getList<RecurringProformaListDTO>(
signal, scope === "archived" ? "recurring-proformas/archived" : "recurring-proformas",
}); {
...query,
signal,
}
);
};

View File

@ -0,0 +1,31 @@
import { useDataSource } from "@erp/core/hooks";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { recurringProformaQueryKeys } from "./recurring-proforma-query-keys";
export type RecurringProformaAction =
| "activate"
| "pause"
| "resume"
| "complete"
| "archive"
| "duplicate"
| "delete";
export const useRecurringProformaActionMutation = () => {
const dataSource = useDataSource();
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, action }: { id: string; action: RecurringProformaAction }) =>
dataSource.custom({
path:
action === "delete" ? `recurring-proformas/${id}` : `recurring-proformas/${id}/${action}`,
method: action === "delete" ? "delete" : "post",
data: undefined,
}),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: recurringProformaQueryKeys.lists() });
},
});
};

View File

@ -2,6 +2,7 @@ import { useDataSource } from "@erp/core/hooks";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { listRecurringProformas } from "../api/list-recurring-proformas.api"; import { listRecurringProformas } from "../api/list-recurring-proformas.api";
import { ListRecurringProformasAdapter } from "../adapters/list-recurring-proformas.adapter";
import type { RecurringProformaListFilters } from "../recurring-proforma-list"; import type { RecurringProformaListFilters } from "../recurring-proforma-list";
import { recurringProformaQueryKeys } from "./recurring-proforma-query-keys"; import { recurringProformaQueryKeys } from "./recurring-proforma-query-keys";
@ -11,7 +12,10 @@ export const useRecurringProformas = (filters: RecurringProformaListFilters) =>
return useQuery({ return useQuery({
queryKey: recurringProformaQueryKeys.list(filters), queryKey: recurringProformaQueryKeys.list(filters),
queryFn: ({ signal }) => listRecurringProformas(dataSource, filters, signal), queryFn: async ({ signal }) =>
ListRecurringProformasAdapter.fromDto(
await listRecurringProformas(dataSource, filters, signal)
),
placeholderData: (previousData) => previousData, placeholderData: (previousData) => previousData,
}); });
}; };

View File

@ -1,21 +1,24 @@
export type RecurringProformaStatus = export type RecurringProformaStatus = "draft" | "active" | "paused" | "completed" | "archived";
| "draft"
| "active"
| "paused"
| "completed"
| "archived";
export type RecurringProformaListFilters = { export type RecurringProformaListFilters = {
page: number; page: number;
limit: number; limit: number;
search?: string; search?: string;
status?: RecurringProformaStatus; status?: RecurringProformaStatus;
orderBy?: "name" | "updated_at";
order?: "asc" | "desc";
scope?: "default" | "archived";
}; };
export type RecurringProformaListItem = { export type RecurringProformaListItemDTO = {
id: string; id: string;
name: string; name: string;
customer_id: string | null; description: string | null;
customer: {
id: string;
name: string;
tin: string | null;
} | null;
status: RecurringProformaStatus; status: RecurringProformaStatus;
schedule: { schedule: {
start_date: string; start_date: string;
@ -25,15 +28,46 @@ export type RecurringProformaListItem = {
monthly_rule: "same_day" | "last_day_of_month" | null; monthly_rule: "same_day" | "last_day_of_month" | null;
} | null; } | null;
next_occurrence_date: string | null; next_occurrence_date: string | null;
has_issue: boolean;
updated_at: string; updated_at: string;
}; };
export type RecurringProformaList = { export type RecurringProformaListDTO = {
page: number; page: number;
per_page: number; per_page: number;
total_items: number; total_items: number;
total_pages: number; total_pages: number;
items: RecurringProformaListItem[]; items: RecurringProformaListItemDTO[];
};
export type RecurringProformaListRow = {
id: string;
name: string;
description: string | null;
customer: {
id: string;
name: string;
tin: string | null;
} | null;
status: RecurringProformaStatus;
schedule: {
startDate: string;
endDate: string | null;
unit: "week" | "month" | "year";
interval: number;
monthlyRule: "same_day" | "last_day_of_month" | null;
} | null;
nextOccurrenceDate: string | null;
hasIssue: boolean;
updatedAt: string;
};
export type RecurringProformaList = {
page: number;
perPage: number;
totalItems: number;
totalPages: number;
items: RecurringProformaListRow[];
}; };
export type RecurringProformaPercentage = { export type RecurringProformaPercentage = {
@ -60,18 +94,14 @@ export type RecurringProformaDetail = {
proforma_series: string | null; proforma_series: string | null;
issued_invoice_series: string | null; issued_invoice_series: string | null;
global_discount_percentage: RecurringProformaPercentage | null; global_discount_percentage: RecurringProformaPercentage | null;
schedule: RecurringProformaListItem["schedule"]; schedule: RecurringProformaListItemDTO["schedule"];
next_occurrence_date: string | null; next_occurrence_date: string | null;
items: RecurringProformaDetailItem[]; items: RecurringProformaDetailItem[];
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}; };
export type RecurringProformaOccurrenceStatus = export type RecurringProformaOccurrenceStatus = "pending" | "processing" | "failed" | "generated";
| "pending"
| "processing"
| "failed"
| "generated";
export type RecurringProformaOccurrencesFilters = { export type RecurringProformaOccurrencesFilters = {
page: number; page: number;

View File

@ -0,0 +1,8 @@
import type { UniqueID } from "@repo/rdx-ddd";
/** Proyección mínima de cliente para consumidores de otros módulos. */
export type CustomerPublicSummary = {
id: UniqueID;
name: string;
tin: string | null;
};

View File

@ -1,5 +1,6 @@
export * from "./customer-full-read-model"; export * from "./customer-full-read-model";
export * from "./customer-payment-method-read.model"; export * from "./customer-payment-method-read.model";
export * from "./customer-payment-term-read.model"; export * from "./customer-payment-term-read.model";
export * from "./customer-public-summary";
export * from "./customer-summary"; export * from "./customer-summary";
export * from "./customer-tax-regime-full-read.model"; export * from "./customer-tax-regime-full-read.model";

View File

@ -3,13 +3,19 @@ import type { TINNumber, UniqueID } from "@repo/rdx-ddd";
import type { Collection, Result } from "@repo/rdx-utils"; import type { Collection, Result } from "@repo/rdx-utils";
import type { Customer } from "../../domain/aggregates"; import type { Customer } from "../../domain/aggregates";
import type { CustomerSummary } from "../models"; import type { CustomerPublicSummary, CustomerSummary } from "../models";
/** /**
* Interfaz del repositorio para el agregado `Customer`. * Interfaz del repositorio para el agregado `Customer`.
* El escopado multitenant está representado por `companyId`. * El escopado multitenant está representado por `companyId`.
*/ */
export interface ICustomerRepository { export interface ICustomerRepository {
findPublicSummariesByIdsInCompany(
companyId: UniqueID,
ids: readonly UniqueID[],
transaction?: unknown
): Promise<Result<readonly CustomerPublicSummary[], Error>>;
/** /**
* *
* Crea un nuevo cliente * Crea un nuevo cliente

View File

@ -2,6 +2,7 @@ import type { TINNumber, UniqueID } from "@repo/rdx-ddd";
import type { Result } from "@repo/rdx-utils"; import type { Result } from "@repo/rdx-utils";
import type { Customer, ICustomerCreateProps } from "../../domain"; import type { Customer, ICustomerCreateProps } from "../../domain";
import type { CustomerPublicSummary } from "../models";
export interface ICustomerServicesContext { export interface ICustomerServicesContext {
transaction: unknown; transaction: unknown;
@ -9,6 +10,11 @@ export interface ICustomerServicesContext {
} }
export interface ICustomerPublicServices { export interface ICustomerPublicServices {
findSummariesByIds: (
customerIds: readonly UniqueID[],
context: ICustomerServicesContext
) => Promise<Result<readonly CustomerPublicSummary[], Error>>;
findCustomerByTIN: ( findCustomerByTIN: (
tin: TINNumber, tin: TINNumber,
context: ICustomerServicesContext context: ICustomerServicesContext

View File

@ -30,6 +30,14 @@ export function buildCustomerPublicServices(
const creator = buildCustomerCreator({ repository, audit: params.audit }); const creator = buildCustomerCreator({ repository, audit: params.audit });
return { return {
findSummariesByIds: async (customerIds, context) => {
return repository.findPublicSummariesByIdsInCompany(
context.companyId,
customerIds,
context.transaction
);
},
findCustomerByTIN: async (tin: TINNumber, context: ICustomerServicesContext) => { findCustomerByTIN: async (tin: TINNumber, context: ICustomerServicesContext) => {
const { companyId, transaction } = context; const { companyId, transaction } = context;

View File

@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import test from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
import { Op, Sequelize } from "sequelize";
import { CustomerModel } from "../models/sequelize-customer.model";
import { CustomerRepository } from "./customer.repository";
const required = <T>(result: { data: T }): T => result.data;
test("findPublicSummariesByIdsInCompany proyecta clientes únicos y mantiene el scope", async () => {
const repository = new CustomerRepository(
{} as never,
{} as never,
new Sequelize("mysql://root:pass@localhost:3306/erp_test", { logging: false })
);
const companyId = required(UniqueID.create("01900000-0000-7000-8000-000000000501"));
const customerId = required(UniqueID.create("01900000-0000-7000-8000-000000000502"));
const originalFindAll = CustomerModel.findAll;
let received: { attributes?: unknown; where?: Record<string, unknown> } | undefined;
CustomerModel.findAll = (async (options) => {
received = options;
return [{ id: customerId.toPrimitive(), name: "ACME", tin: "B12345678" }] as never;
}) as typeof CustomerModel.findAll;
try {
const result = await repository.findPublicSummariesByIdsInCompany(companyId, [
customerId,
customerId,
]);
assert.ok(result.isSuccess, result.isFailure ? result.error.message : "");
assert.deepEqual(received?.attributes, ["id", "name", "tin"]);
assert.deepEqual(received?.where, {
company_id: companyId.toPrimitive(),
id: { [Op.in]: [customerId.toPrimitive()] },
});
assert.deepEqual(
result.data.map((customer) => ({ ...customer, id: customer.id.toPrimitive() })),
[{ id: customerId.toPrimitive(), name: "ACME", tin: "B12345678" }]
);
} finally {
CustomerModel.findAll = originalFindAll;
}
});
test("findPublicSummariesByIdsInCompany no consulta cuando no hay IDs", async () => {
const repository = new CustomerRepository(
{} as never,
{} as never,
new Sequelize("mysql://root:pass@localhost:3306/erp_test", { logging: false })
);
const companyId = required(UniqueID.create("01900000-0000-7000-8000-000000000501"));
const originalFindAll = CustomerModel.findAll;
let calls = 0;
CustomerModel.findAll = (async () => {
calls += 1;
return [];
}) as typeof CustomerModel.findAll;
try {
const result = await repository.findPublicSummariesByIdsInCompany(companyId, []);
assert.equal(result.isSuccess, true);
assert.deepEqual(result.data, []);
assert.equal(calls, 0);
} finally {
CustomerModel.findAll = originalFindAll;
}
});

View File

@ -1,10 +1,22 @@
import { EntityNotFoundError, SequelizeRepository, translateSequelizeError } from "@erp/core/api"; import { EntityNotFoundError, SequelizeRepository, translateSequelizeError } from "@erp/core/api";
import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server"; import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
import type { TINNumber, UniqueID } from "@repo/rdx-ddd"; import { UniqueID } from "@repo/rdx-ddd";
import type { TINNumber } from "@repo/rdx-ddd";
import { type Collection, Result } from "@repo/rdx-utils"; import { type Collection, Result } from "@repo/rdx-utils";
import type { FindOptions, InferAttributes, OrderItem, Sequelize, Transaction } from "sequelize"; import {
Op,
type FindOptions,
type InferAttributes,
type OrderItem,
type Sequelize,
type Transaction,
} from "sequelize";
import type { CustomerSummary, ICustomerRepository } from "../../../../application"; import type {
CustomerPublicSummary,
CustomerSummary,
ICustomerRepository,
} from "../../../../application";
import type { Customer } from "../../../../domain"; import type { Customer } from "../../../../domain";
import type { SequelizeCustomerDomainMapper, SequelizeCustomerSummaryMapper } from "../mappers"; import type { SequelizeCustomerDomainMapper, SequelizeCustomerSummaryMapper } from "../mappers";
import { CustomerModel } from "../models/sequelize-customer.model"; import { CustomerModel } from "../models/sequelize-customer.model";
@ -21,6 +33,35 @@ export class CustomerRepository
super({ database }); super({ database });
} }
public async findPublicSummariesByIdsInCompany(
companyId: UniqueID,
ids: readonly UniqueID[],
transaction?: unknown
): Promise<Result<readonly CustomerPublicSummary[], Error>> {
try {
const uniqueIds = [...new Set(ids.map((id) => id.toPrimitive()))];
if (uniqueIds.length === 0) return Result.ok([]);
const rows = await CustomerModel.findAll({
attributes: ["id", "name", "tin"],
where: {
company_id: companyId.toPrimitive(),
id: { [Op.in]: uniqueIds },
},
transaction: transaction as Transaction | undefined,
});
const summaries: CustomerPublicSummary[] = [];
for (const row of rows) {
const id = UniqueID.create(row.id);
if (id.isFailure) return Result.fail(id.error);
summaries.push({ id: id.data, name: row.name, tin: row.tin ?? null });
}
return Result.ok(summaries);
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
/** /**
* *
* Crea un nuevo cliente * Crea un nuevo cliente

View File

@ -4,7 +4,7 @@ import type { ICustomerPublicServices } from "./application";
import { buildCustomerPublicServices, buildCustomersDependencies } from "./infrastructure/di"; import { buildCustomerPublicServices, buildCustomersDependencies } from "./infrastructure/di";
import { models } from "./infrastructure/persistence/sequelize"; import { models } from "./infrastructure/persistence/sequelize";
export type { ICustomerPublicServices } from "./application"; export type { CustomerPublicSummary, ICustomerPublicServices } from "./application";
export * from "./infrastructure/persistence/sequelize"; export * from "./infrastructure/persistence/sequelize";
/** Manifest neutral de clientes: composición y servicio público sin HTTP. */ /** Manifest neutral de clientes: composición y servicio público sin HTTP. */