Duplicado de proforma
This commit is contained in:
parent
fed29a71fd
commit
c46769e71e
@ -24,3 +24,4 @@
|
||||
- Ruta frontend: `/proformas/archived`
|
||||
- Reutiliza el listado compartido con scope `archived`.
|
||||
- Mantiene selector de estado, paginación y orden.
|
||||
- Muestra la acción de fila `Duplicar`; el duplicado vuelve a nacer activo.
|
||||
|
||||
@ -24,3 +24,4 @@
|
||||
- Ruta frontend: `/proformas/deleted`
|
||||
- Vista read-only V1.
|
||||
- No implementa restauración, purge ni force delete.
|
||||
- No muestra la acción `Duplicar`.
|
||||
|
||||
28
docs/customer-invoices/proforma-duplicate-contract.md
Normal file
28
docs/customer-invoices/proforma-duplicate-contract.md
Normal file
@ -0,0 +1,28 @@
|
||||
# Duplicado de proformas
|
||||
|
||||
## Endpoint
|
||||
|
||||
- `POST /proformas/:proforma_id/duplicate`
|
||||
|
||||
## Semántica
|
||||
|
||||
- Permite duplicar cualquier proforma no eliminada.
|
||||
- La proforma origen puede estar archivada.
|
||||
- Si la proforma origen está eliminada lógicamente, el endpoint responde como no disponible.
|
||||
- El duplicado siempre nace en `draft`, activo, con `archived_at = NULL`, `deleted_at = NULL` y `linked_invoice_id = NULL`.
|
||||
- La numeración siempre es nueva y se consume desde `document-series`.
|
||||
- Se intenta reutilizar la misma serie documental del origen si sigue activa; si no, se usa la serie por defecto de `document_type=proforma`.
|
||||
- `target_invoice_series_code` solo se conserva si sigue siendo una serie activa de `issued_invoice`; si no, pasa a `NULL`.
|
||||
- Se copian datos comerciales y fiscales del origen.
|
||||
- Se copian las líneas con nuevos IDs y se recalculan impuestos y totales con el motor actual.
|
||||
|
||||
## Response
|
||||
|
||||
- `201 Created`
|
||||
- Devuelve el snapshot completo de la nueva proforma.
|
||||
|
||||
## UI
|
||||
|
||||
- Acción de fila `Duplicar` en `/proformas` y `/proformas/archived`.
|
||||
- No muestra `Duplicar` en `/proformas/deleted`.
|
||||
- Tras duplicar redirige a `/proformas/:id/edit`.
|
||||
@ -24,3 +24,4 @@
|
||||
- Ruta frontend: `/proformas`
|
||||
- Mantiene búsqueda, paginación, orden y selector de estado.
|
||||
- No muestra selector visual de archivadas/todas.
|
||||
- Muestra la acción de fila `Duplicar`.
|
||||
|
||||
@ -26,6 +26,7 @@ import {
|
||||
ArchiveProformaByIdUseCase,
|
||||
ChangeStatusProformaUseCase,
|
||||
CreateProformaUseCase,
|
||||
DuplicateProformaByIdUseCase,
|
||||
DeleteProformaByIdUseCase,
|
||||
GetProformaByIdUseCase,
|
||||
IssueProformaUseCase,
|
||||
@ -120,6 +121,24 @@ export function buildCreateProformaUseCase(deps: {
|
||||
});
|
||||
}
|
||||
|
||||
export function buildDuplicateProformaByIdUseCase(deps: {
|
||||
creator: IProformaCreator;
|
||||
finder: IProformaFinder;
|
||||
fullReadModelAssembler: IProformaFullReadModelAssembler;
|
||||
fullSnapshotBuilder: IProformaFullSnapshotBuilder;
|
||||
documentSeriesServices: import("@erp/document-series/api").DocumentSeriesPublicServicesType;
|
||||
transactionManager: ITransactionManager;
|
||||
}) {
|
||||
return new DuplicateProformaByIdUseCase({
|
||||
creator: deps.creator,
|
||||
finder: deps.finder,
|
||||
fullReadModelAssembler: deps.fullReadModelAssembler,
|
||||
fullSnapshotBuilder: deps.fullSnapshotBuilder,
|
||||
documentSeriesServices: deps.documentSeriesServices,
|
||||
transactionManager: deps.transactionManager,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildIssueProformaUseCase(deps: {
|
||||
publicServices: {
|
||||
issuedInvoiceServices: IIssuedInvoicePublicServices;
|
||||
|
||||
@ -0,0 +1,232 @@
|
||||
import type { ITransactionManager } from "@erp/core/api";
|
||||
import type { DocumentSeriesPublicServicesType } from "@erp/document-series/api";
|
||||
import { UniqueID, UtcDate } from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { DuplicateProformaByIdParamsRequestDTO } from "../../../../common";
|
||||
import { InvoiceSerie, InvoiceStatus, type ProformaRecipient } from "../../../domain";
|
||||
import { InvoiceSeriesNotFoundError } from "../../invoice-series";
|
||||
import type { ProformaCreateInputProps } from "../models";
|
||||
import type {
|
||||
IProformaCreator,
|
||||
IProformaFinder,
|
||||
IProformaFullReadModelAssembler,
|
||||
} from "../services";
|
||||
import type { IProformaFullSnapshotBuilder } from "../snapshot-builders";
|
||||
|
||||
type DuplicateProformaByIdUseCaseInput = {
|
||||
companyId: UniqueID;
|
||||
params: DuplicateProformaByIdParamsRequestDTO;
|
||||
};
|
||||
|
||||
export class DuplicateProformaByIdUseCase {
|
||||
public constructor(
|
||||
private readonly deps: {
|
||||
creator: IProformaCreator;
|
||||
finder: IProformaFinder;
|
||||
fullReadModelAssembler: IProformaFullReadModelAssembler;
|
||||
fullSnapshotBuilder: IProformaFullSnapshotBuilder;
|
||||
documentSeriesServices: DocumentSeriesPublicServicesType;
|
||||
transactionManager: ITransactionManager;
|
||||
}
|
||||
) {}
|
||||
|
||||
public async execute(params: DuplicateProformaByIdUseCaseInput) {
|
||||
const proformaIdResult = UniqueID.create(params.params.proforma_id);
|
||||
if (proformaIdResult.isFailure) {
|
||||
return Result.fail(proformaIdResult.error);
|
||||
}
|
||||
|
||||
return this.deps.transactionManager.complete(async (transaction: unknown) => {
|
||||
try {
|
||||
const sourceResult = await this.deps.finder.findProformaByIdIncludingArchived(
|
||||
params.companyId,
|
||||
proformaIdResult.data,
|
||||
transaction
|
||||
);
|
||||
|
||||
if (sourceResult.isFailure) {
|
||||
return Result.fail(sourceResult.error);
|
||||
}
|
||||
|
||||
const source = sourceResult.data;
|
||||
const duplicateId = UniqueID.generateNewID();
|
||||
const proformaDateResult = UtcDate.createFromISO(new Date().toISOString().slice(0, 10));
|
||||
|
||||
if (proformaDateResult.isFailure) {
|
||||
return Result.fail(proformaDateResult.error);
|
||||
}
|
||||
|
||||
const proformaSeriesCodeResult = await this.resolveDuplicateSeriesCode({
|
||||
companyId: params.companyId,
|
||||
sourceDocumentSeriesId: source.documentSeriesId,
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (proformaSeriesCodeResult.isFailure) {
|
||||
return Result.fail(proformaSeriesCodeResult.error);
|
||||
}
|
||||
|
||||
const targetInvoiceSeriesCodeResult = await this.resolveValidTargetInvoiceSeriesCode({
|
||||
companyId: params.companyId,
|
||||
targetInvoiceSeriesCode: source.targetInvoiceSeriesCode,
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (targetInvoiceSeriesCodeResult.isFailure) {
|
||||
return Result.fail(targetInvoiceSeriesCodeResult.error);
|
||||
}
|
||||
|
||||
const props: ProformaCreateInputProps = {
|
||||
companyId: params.companyId,
|
||||
status: InvoiceStatus.draft(),
|
||||
proformaSeriesCode: proformaSeriesCodeResult.data,
|
||||
targetInvoiceSeriesCode: targetInvoiceSeriesCodeResult.data,
|
||||
proformaDate: proformaDateResult.data,
|
||||
operationDate: Maybe.none(),
|
||||
customerId: source.customerId,
|
||||
recipient: Maybe.none<ProformaRecipient>(),
|
||||
reference: source.reference,
|
||||
description: source.description,
|
||||
notes: source.notes,
|
||||
languageCode: source.languageCode,
|
||||
currencyCode: source.currencyCode,
|
||||
linkedInvoiceId: Maybe.none(),
|
||||
paymentMethodId: source.paymentMethodId,
|
||||
taxRegimeCode: source.taxRegimeCode,
|
||||
taxConfig: {
|
||||
taxMode: source.taxConfig.taxMode,
|
||||
defaultIvaCode: source.taxConfig.defaultIvaCode,
|
||||
usesEquivalenceSurcharge: source.taxConfig.usesEquivalenceSurcharge,
|
||||
defaultRecCode: source.taxConfig.defaultRecCode,
|
||||
usesRetention: source.taxConfig.usesRetention,
|
||||
defaultRetentionCode: source.taxConfig.defaultRetentionCode,
|
||||
},
|
||||
globalDiscountPercentage: source.globalDiscountPercentage,
|
||||
archivedAt: Maybe.none(),
|
||||
items: source.items.getAll().map((item) => ({
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unitAmount: item.unitAmount,
|
||||
itemDiscountPercentage: item.itemDiscountPercentage,
|
||||
taxes: {
|
||||
ivaCode: item.ivaCode(),
|
||||
recCode: item.recCode(),
|
||||
retentionCode: item.retentionCode(),
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
const createResult = await this.deps.creator.create({
|
||||
companyId: params.companyId,
|
||||
id: duplicateId,
|
||||
props,
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (createResult.isFailure) {
|
||||
return Result.fail(createResult.error);
|
||||
}
|
||||
|
||||
const duplicatedResult = await this.deps.finder.findProformaById(
|
||||
params.companyId,
|
||||
createResult.data.id,
|
||||
transaction
|
||||
);
|
||||
|
||||
if (duplicatedResult.isFailure) {
|
||||
return Result.fail(duplicatedResult.error);
|
||||
}
|
||||
|
||||
const readModelResult = await this.deps.fullReadModelAssembler.assemble({
|
||||
companyId: params.companyId,
|
||||
proforma: duplicatedResult.data,
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (readModelResult.isFailure) {
|
||||
return Result.fail(readModelResult.error);
|
||||
}
|
||||
|
||||
return Result.ok(this.deps.fullSnapshotBuilder.toOutput(readModelResult.data));
|
||||
} catch (error: unknown) {
|
||||
return Result.fail(error as Error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveDuplicateSeriesCode(params: {
|
||||
companyId: UniqueID;
|
||||
sourceDocumentSeriesId: Maybe<string>;
|
||||
transaction?: unknown;
|
||||
}): Promise<Result<Maybe<InvoiceSerie>, Error>> {
|
||||
const listResult = await this.deps.documentSeriesServices.listActiveSeries({
|
||||
companyId: params.companyId.toString(),
|
||||
documentType: "proforma",
|
||||
transaction: params.transaction,
|
||||
});
|
||||
|
||||
if (listResult.isFailure) {
|
||||
return Result.fail(listResult.error);
|
||||
}
|
||||
|
||||
const sourceSeriesId = params.sourceDocumentSeriesId.getOrUndefined();
|
||||
const sourceSeries = sourceSeriesId
|
||||
? listResult.data.find((series) => series.id === sourceSeriesId)
|
||||
: undefined;
|
||||
|
||||
if (sourceSeries) {
|
||||
const seriesCodeResult = InvoiceSerie.create(sourceSeries.code);
|
||||
if (seriesCodeResult.isFailure) {
|
||||
return Result.fail(seriesCodeResult.error);
|
||||
}
|
||||
|
||||
return Result.ok(Maybe.some(seriesCodeResult.data));
|
||||
}
|
||||
|
||||
const defaultSeries = listResult.data.find((series) => series.is_default);
|
||||
if (!defaultSeries) {
|
||||
return Result.fail(
|
||||
new InvoiceSeriesNotFoundError({
|
||||
cause: "No active proforma document series is available.",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const defaultSeriesCodeResult = InvoiceSerie.create(defaultSeries.code);
|
||||
if (defaultSeriesCodeResult.isFailure) {
|
||||
return Result.fail(defaultSeriesCodeResult.error);
|
||||
}
|
||||
|
||||
return Result.ok(Maybe.some(defaultSeriesCodeResult.data));
|
||||
}
|
||||
|
||||
private async resolveValidTargetInvoiceSeriesCode(params: {
|
||||
companyId: UniqueID;
|
||||
targetInvoiceSeriesCode: Maybe<InvoiceSerie>;
|
||||
transaction?: unknown;
|
||||
}): Promise<Result<Maybe<InvoiceSerie>, Error>> {
|
||||
if (params.targetInvoiceSeriesCode.isNone()) {
|
||||
return Result.ok(Maybe.none());
|
||||
}
|
||||
|
||||
const listResult = await this.deps.documentSeriesServices.listActiveSeries({
|
||||
companyId: params.companyId.toString(),
|
||||
documentType: "issued_invoice",
|
||||
transaction: params.transaction,
|
||||
});
|
||||
|
||||
if (listResult.isFailure) {
|
||||
return Result.fail(listResult.error);
|
||||
}
|
||||
|
||||
const targetCode = params.targetInvoiceSeriesCode.unwrap().toPrimitive();
|
||||
const isValid = listResult.data.some((series) => series.code === targetCode);
|
||||
|
||||
if (!isValid) {
|
||||
return Result.ok(Maybe.none());
|
||||
}
|
||||
|
||||
return Result.ok(params.targetInvoiceSeriesCode);
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
export * from "./archive-proforma-by-id.use-case";
|
||||
export * from "./change-status-proforma.use-case";
|
||||
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 "./issue-proforma.use-case";
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
type ChangeStatusProformaUseCase,
|
||||
type CreateProformaUseCase,
|
||||
type DeleteProformaByIdUseCase,
|
||||
type DuplicateProformaByIdUseCase,
|
||||
type GetProformaByIdUseCase,
|
||||
type IIssuedInvoicePublicServices,
|
||||
type IssueProformaUseCase,
|
||||
@ -20,6 +21,7 @@ import {
|
||||
buildChangeStatusProformaUseCase,
|
||||
buildCreateProformaUseCase,
|
||||
buildDeleteProformaByIdUseCase,
|
||||
buildDuplicateProformaByIdUseCase,
|
||||
buildGetProformaByIdUseCase,
|
||||
buildIssueProformaUseCase,
|
||||
buildListProformasUseCase,
|
||||
@ -56,6 +58,7 @@ export type ProformasInternalDeps = {
|
||||
reportProformaPdf: () => ReportProformaPdfUseCase;
|
||||
previewProformaReport: () => PreviewProformaReportUseCase;
|
||||
createProforma: () => CreateProformaUseCase;
|
||||
duplicateProforma: () => DuplicateProformaByIdUseCase;
|
||||
issueProforma: (publicServices: {
|
||||
issuedInvoiceServices: IIssuedInvoicePublicServices;
|
||||
}) => IssueProformaUseCase;
|
||||
@ -180,6 +183,16 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
|
||||
transactionManager,
|
||||
}),
|
||||
|
||||
duplicateProforma: () =>
|
||||
buildDuplicateProformaByIdUseCase({
|
||||
creator,
|
||||
finder,
|
||||
fullReadModelAssembler: readModelAssemblers.full,
|
||||
fullSnapshotBuilder: snapshotBuilders.full,
|
||||
documentSeriesServices,
|
||||
transactionManager,
|
||||
}),
|
||||
|
||||
updateProforma: () =>
|
||||
buildUpdateProformaUseCase({
|
||||
updater,
|
||||
|
||||
@ -0,0 +1,48 @@
|
||||
import {
|
||||
ExpressController,
|
||||
forbidQueryFieldGuard,
|
||||
requireAuthenticatedGuard,
|
||||
requireCompanyContextGuard,
|
||||
} from "@erp/core/api";
|
||||
|
||||
import {
|
||||
DuplicateProformaByIdParamsRequestSchema,
|
||||
DuplicateProformaByIdResponseSchema,
|
||||
type DuplicateProformaByIdParamsRequestDTO,
|
||||
} from "../../../../../common";
|
||||
import type { DuplicateProformaByIdUseCase } from "../../../../application";
|
||||
import { proformasApiErrorMapper } from "../proformas-api-error-mapper";
|
||||
|
||||
export class DuplicateProformaByIdController extends ExpressController {
|
||||
public constructor(private readonly useCase: DuplicateProformaByIdUseCase) {
|
||||
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 params = DuplicateProformaByIdParamsRequestSchema.parse(
|
||||
this.req.params
|
||||
) satisfies DuplicateProformaByIdParamsRequestDTO;
|
||||
|
||||
const result = await this.useCase.execute({
|
||||
companyId,
|
||||
params,
|
||||
});
|
||||
|
||||
return result.match(
|
||||
(data) => this.created(DuplicateProformaByIdResponseSchema.parse(data)),
|
||||
(err) => this.handleError(err)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -20,6 +20,7 @@ import {
|
||||
ChangeStatusProformaByIdRequestSchema,
|
||||
CreateProformaRequestSchema,
|
||||
DeleteProformaByIdParamsRequestSchema,
|
||||
DuplicateProformaByIdParamsRequestSchema,
|
||||
GetProformaByIdRequestSchema,
|
||||
IssueProformaByIdParamsRequestSchema,
|
||||
LegacyIssueProformaByIdParamsRequestSchema,
|
||||
@ -34,6 +35,7 @@ import type { IIssuedInvoicePublicServices } from "../../../application";
|
||||
|
||||
import { ChangeStatusProformaController } from "./controllers/change-status-proforma.controller";
|
||||
import { CreateProformaController } from "./controllers/create-proforma.controller";
|
||||
import { DuplicateProformaByIdController } from "./controllers/duplicate-proforma-by-id.controller";
|
||||
import { UpdateProformaController } from "./controllers/update-proforma.controller";
|
||||
import { IssueProformaByIdRequestMapper } from "./mappers";
|
||||
|
||||
@ -146,6 +148,16 @@ export const proformasRouter = (params: StartParams) => {
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/:proforma_id/duplicate",
|
||||
validateRequest(DuplicateProformaByIdParamsRequestSchema, "params"),
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const useCase = deps.useCases.duplicateProforma();
|
||||
const controller = new DuplicateProformaByIdController(useCase);
|
||||
return controller.execute(req, res, next);
|
||||
}
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/:proforma_id",
|
||||
validateRequest(DeleteProformaByIdParamsRequestSchema, "params"),
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const DuplicateProformaByIdParamsRequestSchema = z.object({
|
||||
proforma_id: z.uuid(),
|
||||
});
|
||||
|
||||
export type DuplicateProformaByIdParamsRequestDTO = z.infer<
|
||||
typeof DuplicateProformaByIdParamsRequestSchema
|
||||
>;
|
||||
@ -2,6 +2,7 @@ export * from "./archive-proforma-by-id.request.dto";
|
||||
export * from "./change-status-proforma-by-id.request.dto";
|
||||
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 "./issue-proforma-by-id.request.dto";
|
||||
export * from "./list-proformas.request.dto";
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
import {
|
||||
type GetProformaByIdResponseDTO,
|
||||
GetProformaByIdResponseSchema,
|
||||
} from "./get-proforma-by-id.response.dto";
|
||||
|
||||
export const DuplicateProformaByIdResponseSchema = GetProformaByIdResponseSchema;
|
||||
export type DuplicateProformaByIdResponseDTO = GetProformaByIdResponseDTO;
|
||||
@ -1,4 +1,5 @@
|
||||
export * from "./create-proforma.response.dto";
|
||||
export * from "./duplicate-proforma-by-id.response.dto";
|
||||
export * from "./get-proforma-by-id.response.dto";
|
||||
export * from "./issue-proforma-by-id.response.dto";
|
||||
export * from "./list-proformas.response.dto";
|
||||
|
||||
@ -175,6 +175,14 @@
|
||||
"available": "Available statuses",
|
||||
"unavailable": "Unavailable statuses"
|
||||
}
|
||||
},
|
||||
"duplicate": {
|
||||
"success_title": "Proforma duplicated",
|
||||
"success_description": "The proforma was duplicated successfully.",
|
||||
"success_with_reference": "Proforma duplicated as {{reference}}.",
|
||||
"error_title": "The proforma could not be duplicated",
|
||||
"error_not_found_message": "The proforma no longer exists or is not available.",
|
||||
"error_unknown_message": "An unexpected error occurred while duplicating the proforma."
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
|
||||
@ -176,6 +176,14 @@
|
||||
"available": "Estados disponibles",
|
||||
"unavailable": "Estados no disponibles"
|
||||
}
|
||||
},
|
||||
"duplicate": {
|
||||
"success_title": "Proforma duplicada",
|
||||
"success_description": "La proforma se ha duplicado correctamente.",
|
||||
"success_with_reference": "Proforma duplicada como {{reference}}.",
|
||||
"error_title": "No se pudo duplicar la proforma",
|
||||
"error_not_found_message": "La proforma ya no existe o no está disponible.",
|
||||
"error_unknown_message": "Ha ocurrido un error inesperado al duplicar la proforma."
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
|
||||
@ -10,6 +10,7 @@ import { useDownloadProformaPDFController } from "../../download-pdf/controller"
|
||||
import { useIssueProformaDialogController } from "../../issue-proforma";
|
||||
import {
|
||||
type ProformaListRow,
|
||||
useDuplicateProformaByIdMutation,
|
||||
useProformaArchiveMutation,
|
||||
useProformaUnarchiveMutation,
|
||||
} from "../../shared";
|
||||
@ -25,6 +26,7 @@ export const useListProformasPageController = (scope: ProformaListScope) => {
|
||||
const issueDialogCtrl = useIssueProformaDialogController();
|
||||
const changeStatusDialogCtrl = useChangeProformaStatusDialogController();
|
||||
const archiveMutation = useProformaArchiveMutation();
|
||||
const duplicateMutation = useDuplicateProformaByIdMutation();
|
||||
const unarchiveMutation = useProformaUnarchiveMutation();
|
||||
|
||||
const downloadPDFCtrl = useDownloadProformaPDFController();
|
||||
@ -126,6 +128,7 @@ export const useListProformasPageController = (scope: ProformaListScope) => {
|
||||
closeDialog: closeArchiveDialog,
|
||||
confirmArchive,
|
||||
},
|
||||
duplicateMutation,
|
||||
deleteDialogCtrl,
|
||||
issueDialogCtrl,
|
||||
changeStatusDialogCtrl,
|
||||
|
||||
@ -8,7 +8,7 @@ import { ProformaStatusBadge } from "../../components";
|
||||
export const ProformaHeader = ({ proforma }: { proforma: Proforma }) => {
|
||||
const handleCopyTin = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(proforma.recipient.tin);
|
||||
await navigator.clipboard.writeText(proforma.recipient.tin ?? "");
|
||||
} catch {
|
||||
// Silencio o toast fuera de este componente
|
||||
}
|
||||
@ -31,7 +31,7 @@ export const ProformaHeader = ({ proforma }: { proforma: Proforma }) => {
|
||||
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Badge className="gap-1" variant="outline">
|
||||
{proforma.isProforma ? (
|
||||
{proforma.recipient.tin ? (
|
||||
<>
|
||||
<Building2Icon className="size-3" />
|
||||
Empresa
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import {
|
||||
CopyIcon,
|
||||
ExternalLinkIcon,
|
||||
FileDownIcon,
|
||||
FileTextIcon,
|
||||
@ -38,6 +39,7 @@ type GridActionHandlers = {
|
||||
onEditClick?: (proforma: ProformaListRow) => void;
|
||||
onIssueClick?: (proforma: ProformaListRow) => void;
|
||||
onArchiveClick?: (proforma: ProformaListRow) => void;
|
||||
onDuplicateClick?: (proforma: ProformaListRow) => void;
|
||||
onUnarchiveClick?: (proforma: ProformaListRow) => void;
|
||||
onDeleteClick?: (proforma: ProformaListRow) => void;
|
||||
onChangeStatusClick?: (proforma: ProformaListRow, nextStatus: ProformaStatus) => void;
|
||||
@ -298,10 +300,11 @@ export function useProformasGridColumns(
|
||||
(proforma.status === PROFORMA_STATUS.DRAFT ||
|
||||
proforma.status === PROFORMA_STATUS.REJECTED);
|
||||
const availableTransitions = isNormalScope
|
||||
? PROFORMA_STATUS_TRANSITIONS[proforma.status as ProformaStatus] ?? []
|
||||
? (PROFORMA_STATUS_TRANSITIONS[proforma.status as ProformaStatus] ?? [])
|
||||
: [];
|
||||
|
||||
const canEdit = isNormalScope && !isIssued && actionHandlers.onEditClick;
|
||||
const canDuplicate = !isDeletedScope && actionHandlers.onDuplicateClick;
|
||||
const canIssue = isNormalScope && !isIssued && isApproved && actionHandlers.onIssueClick;
|
||||
const canDelete = !isDeletedScope && isDraft && actionHandlers.onDeleteClick;
|
||||
const canDownloadPdf = !isDeletedScope && actionHandlers.onDownloadPdf;
|
||||
@ -338,6 +341,31 @@ export function useProformasGridColumns(
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{canDuplicate && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
className="size-7 cursor-pointer text-muted-foreground hover:text-primary"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
actionHandlers.onDuplicateClick?.(proforma);
|
||||
}}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<CopyIcon className="size-4" />
|
||||
<span className="sr-only">Duplicar</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>Duplicar</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{isArchivable && actionHandlers.onArchiveClick && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
@ -387,8 +415,7 @@ export function useProformasGridColumns(
|
||||
)}
|
||||
|
||||
{/* Cambiar estado */}
|
||||
{!isDeletedScope &&
|
||||
!isIssued &&
|
||||
{!(isDeletedScope || isIssued) &&
|
||||
availableTransitions.length > 0 &&
|
||||
actionHandlers.onChangeStatusClick && (
|
||||
<TooltipProvider key={availableTransitions[0]}>
|
||||
@ -401,7 +428,10 @@ export function useProformasGridColumns(
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
actionHandlers.onChangeStatusClick?.(proforma, availableTransitions[0]);
|
||||
actionHandlers.onChangeStatusClick?.(
|
||||
proforma,
|
||||
availableTransitions[0]
|
||||
);
|
||||
}}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { ErrorAlert, PageHeader, SimpleSearchInput } from "@erp/core/components";
|
||||
import { useReturnToNavigation } from "@erp/core/hooks";
|
||||
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
|
||||
import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
|
||||
import {
|
||||
Button,
|
||||
@ -93,6 +94,7 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => {
|
||||
issueDialogCtrl,
|
||||
changeStatusDialogCtrl,
|
||||
archiveDialogCtrl,
|
||||
duplicateMutation,
|
||||
handleDownloadPDF,
|
||||
unarchiveDialogCtrl,
|
||||
isPDFDownloading: isPdfDownloading,
|
||||
@ -108,8 +110,35 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleDuplicateClick = async (proforma: ProformaListRow) => {
|
||||
try {
|
||||
const duplicated = await duplicateMutation.mutateAsync({ proformaId: proforma.id });
|
||||
|
||||
showSuccessToast(
|
||||
t("proformas.duplicate.success_title"),
|
||||
duplicated.proformaReference
|
||||
? t("proformas.duplicate.success_with_reference", {
|
||||
reference: duplicated.proformaReference,
|
||||
})
|
||||
: t("proformas.duplicate.success_description")
|
||||
);
|
||||
|
||||
handleEditClick(duplicated.id);
|
||||
} catch (error: unknown) {
|
||||
const status = (error as { status?: number } | undefined)?.status;
|
||||
|
||||
showErrorToast(
|
||||
t("proformas.duplicate.error_title"),
|
||||
status === 404
|
||||
? t("proformas.duplicate.error_not_found_message")
|
||||
: t("proformas.duplicate.error_unknown_message")
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useProformasGridColumns(scope, {
|
||||
onEditClick: (proforma) => handleEditClick(proforma.id),
|
||||
onDuplicateClick: handleDuplicateClick,
|
||||
onIssueClick: (proformaRow) =>
|
||||
issueDialogCtrl.openDialog(prepareIssueProformaTarget(proformaRow)),
|
||||
onArchiveClick: archiveDialogCtrl.openDialog,
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
import type { IDataSource } from "@erp/core/client";
|
||||
|
||||
import type { DuplicateProformaByIdResponseDTO } from "../../../../common";
|
||||
|
||||
export interface DuplicateProformaByIdParams {
|
||||
proformaId: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export type DuplicateProformaByIdResult = DuplicateProformaByIdResponseDTO;
|
||||
|
||||
export const duplicateProformaById = (
|
||||
dataSource: IDataSource,
|
||||
params: DuplicateProformaByIdParams
|
||||
): Promise<DuplicateProformaByIdResult> => {
|
||||
const { proformaId, signal } = params;
|
||||
|
||||
if (!proformaId) throw new Error("proformaId is required");
|
||||
|
||||
return dataSource.custom<undefined, DuplicateProformaByIdResponseDTO>({
|
||||
path: `proformas/${proformaId}/duplicate`,
|
||||
method: "post",
|
||||
data: undefined,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
@ -2,6 +2,7 @@ export * from "./archive-proforma-by-id.api";
|
||||
export * from "./change-proforma-status-by-id.api";
|
||||
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 "./issue-proforma-by-id.api";
|
||||
export * from "./list-document-series.api";
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
export * from "./keys";
|
||||
export * from "./use-document-series-query";
|
||||
export * from "./use-duplicate-proforma-by-id-mutation";
|
||||
export * from "./use-proforma-archive-mutation";
|
||||
export * from "./use-proforma-change-status-mutation";
|
||||
export * from "./use-proforma-create-mutation";
|
||||
|
||||
@ -44,6 +44,7 @@ export const PROFORMA_QUERY_KEY = (proformaId?: string): QueryKey => [
|
||||
export const CREATE_PROFORMA_MUTATION_KEY = ["proformas:create"] as const;
|
||||
export const UPDATE_PROFORMA_MUTATION_KEY = ["proformas:update"] as const;
|
||||
export const DELETE_PROFORMA_MUTATION_KEY = ["proformas:delete"] as const;
|
||||
export const DUPLICATE_PROFORMA_MUTATION_KEY = ["proformas:duplicate"] as const;
|
||||
export const ARCHIVE_PROFORMA_MUTATION_KEY = ["proformas:archive"] as const;
|
||||
export const UNARCHIVE_PROFORMA_MUTATION_KEY = ["proformas:unarchive"] as const;
|
||||
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
import { useDataSource } from "@erp/core/hooks";
|
||||
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { GetProformaByIdAdapter } from "../adapters";
|
||||
import { type DuplicateProformaByIdParams, duplicateProformaById } from "../api";
|
||||
import type { Proforma } from "../entities";
|
||||
|
||||
import { DUPLICATE_PROFORMA_MUTATION_KEY } from "./keys";
|
||||
import { syncCreatedProformaCaches } from "./proforma-cache-strategy";
|
||||
|
||||
export const useDuplicateProformaByIdMutation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const dataSource = useDataSource();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: DUPLICATE_PROFORMA_MUTATION_KEY,
|
||||
mutationFn: async (params: DuplicateProformaByIdParams) => {
|
||||
const dto = await duplicateProformaById(dataSource, params);
|
||||
return GetProformaByIdAdapter.fromDto(dto);
|
||||
},
|
||||
onSuccess: async (proforma) => {
|
||||
await syncCreatedProformaCaches(queryClient, proforma);
|
||||
},
|
||||
}) as ReturnType<typeof useMutation<Proforma, DefaultError, DuplicateProformaByIdParams>>;
|
||||
};
|
||||
@ -75,7 +75,7 @@ export function DataTableToolbar<TData>({
|
||||
// Render principal
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between gap-2 px-2 py-2 bg-transparent", className)}
|
||||
className={cn("flex items-center justify-between gap-2 px-2 py-2 bg-background", className)}
|
||||
>
|
||||
{/* IZQUIERDA: acciones + contador */}
|
||||
<div className="flex flex-1 items-center gap-3 flex-wrap">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user