diff --git a/docs/customer-invoices/proforma-archive-contract.md b/docs/customer-invoices/proforma-archive-contract.md index 779102d8..eef65d11 100644 --- a/docs/customer-invoices/proforma-archive-contract.md +++ b/docs/customer-invoices/proforma-archive-contract.md @@ -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. diff --git a/docs/customer-invoices/proforma-delete-contract.md b/docs/customer-invoices/proforma-delete-contract.md index c50965bf..d5983081 100644 --- a/docs/customer-invoices/proforma-delete-contract.md +++ b/docs/customer-invoices/proforma-delete-contract.md @@ -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`. diff --git a/docs/customer-invoices/proforma-duplicate-contract.md b/docs/customer-invoices/proforma-duplicate-contract.md new file mode 100644 index 00000000..b77b7b62 --- /dev/null +++ b/docs/customer-invoices/proforma-duplicate-contract.md @@ -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`. diff --git a/docs/customer-invoices/proforma-list-contract.md b/docs/customer-invoices/proforma-list-contract.md index fc0a37be..6d44151b 100644 --- a/docs/customer-invoices/proforma-list-contract.md +++ b/docs/customer-invoices/proforma-list-contract.md @@ -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`. diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-use-cases.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-use-cases.di.ts index 4da905eb..fdfd0df3 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/proforma-use-cases.di.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-use-cases.di.ts @@ -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; diff --git a/modules/customer-invoices/src/api/application/proformas/use-cases/duplicate-proforma-by-id.use-case.ts b/modules/customer-invoices/src/api/application/proformas/use-cases/duplicate-proforma-by-id.use-case.ts new file mode 100644 index 00000000..a37a5640 --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/use-cases/duplicate-proforma-by-id.use-case.ts @@ -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(), + 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; + transaction?: unknown; + }): Promise, 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; + transaction?: unknown; + }): Promise, 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); + } +} diff --git a/modules/customer-invoices/src/api/application/proformas/use-cases/index.ts b/modules/customer-invoices/src/api/application/proformas/use-cases/index.ts index d1fee044..9a86e40e 100644 --- a/modules/customer-invoices/src/api/application/proformas/use-cases/index.ts +++ b/modules/customer-invoices/src/api/application/proformas/use-cases/index.ts @@ -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"; diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts b/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts index 21704364..c53ca9b7 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts @@ -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, diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/duplicate-proforma-by-id.controller.ts b/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/duplicate-proforma-by-id.controller.ts new file mode 100644 index 00000000..157a2cdf --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/duplicate-proforma-by-id.controller.ts @@ -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) + ); + } +} diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas.routes.ts b/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas.routes.ts index b0d6d664..7cdc1783 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas.routes.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas.routes.ts @@ -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"), diff --git a/modules/customer-invoices/src/common/dto/request/proformas/duplicate-proforma-by-id.request.dto.ts b/modules/customer-invoices/src/common/dto/request/proformas/duplicate-proforma-by-id.request.dto.ts new file mode 100644 index 00000000..4d18ffe4 --- /dev/null +++ b/modules/customer-invoices/src/common/dto/request/proformas/duplicate-proforma-by-id.request.dto.ts @@ -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 +>; diff --git a/modules/customer-invoices/src/common/dto/request/proformas/index.ts b/modules/customer-invoices/src/common/dto/request/proformas/index.ts index 4a938165..a5bd1098 100644 --- a/modules/customer-invoices/src/common/dto/request/proformas/index.ts +++ b/modules/customer-invoices/src/common/dto/request/proformas/index.ts @@ -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"; diff --git a/modules/customer-invoices/src/common/dto/response/proformas/duplicate-proforma-by-id.response.dto.ts b/modules/customer-invoices/src/common/dto/response/proformas/duplicate-proforma-by-id.response.dto.ts new file mode 100644 index 00000000..b897d230 --- /dev/null +++ b/modules/customer-invoices/src/common/dto/response/proformas/duplicate-proforma-by-id.response.dto.ts @@ -0,0 +1,7 @@ +import { + type GetProformaByIdResponseDTO, + GetProformaByIdResponseSchema, +} from "./get-proforma-by-id.response.dto"; + +export const DuplicateProformaByIdResponseSchema = GetProformaByIdResponseSchema; +export type DuplicateProformaByIdResponseDTO = GetProformaByIdResponseDTO; diff --git a/modules/customer-invoices/src/common/dto/response/proformas/index.ts b/modules/customer-invoices/src/common/dto/response/proformas/index.ts index abf2fa1f..730c68de 100644 --- a/modules/customer-invoices/src/common/dto/response/proformas/index.ts +++ b/modules/customer-invoices/src/common/dto/response/proformas/index.ts @@ -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"; diff --git a/modules/customer-invoices/src/common/locales/en.json b/modules/customer-invoices/src/common/locales/en.json index a3f2b97a..53c40a24 100644 --- a/modules/customer-invoices/src/common/locales/en.json +++ b/modules/customer-invoices/src/common/locales/en.json @@ -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": { diff --git a/modules/customer-invoices/src/common/locales/es.json b/modules/customer-invoices/src/common/locales/es.json index d3d12fb8..9a809a34 100644 --- a/modules/customer-invoices/src/common/locales/es.json +++ b/modules/customer-invoices/src/common/locales/es.json @@ -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": { diff --git a/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas-page.controller.ts b/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas-page.controller.ts index 19ddc7dd..b23c19dd 100644 --- a/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas-page.controller.ts +++ b/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas-page.controller.ts @@ -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, diff --git a/modules/customer-invoices/src/web/proformas/list/ui/blocks/proforma-summary-panel/proforma-header.tsx b/modules/customer-invoices/src/web/proformas/list/ui/blocks/proforma-summary-panel/proforma-header.tsx index 604de0b1..6ad38ebf 100644 --- a/modules/customer-invoices/src/web/proformas/list/ui/blocks/proforma-summary-panel/proforma-header.tsx +++ b/modules/customer-invoices/src/web/proformas/list/ui/blocks/proforma-summary-panel/proforma-header.tsx @@ -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 }) => {
- {proforma.isProforma ? ( + {proforma.recipient.tin ? ( <> Empresa diff --git a/modules/customer-invoices/src/web/proformas/list/ui/blocks/proformas-grid/use-proforma-grid-columns.tsx b/modules/customer-invoices/src/web/proformas/list/ui/blocks/proformas-grid/use-proforma-grid-columns.tsx index 20d31480..89bcc0ce 100644 --- a/modules/customer-invoices/src/web/proformas/list/ui/blocks/proformas-grid/use-proforma-grid-columns.tsx +++ b/modules/customer-invoices/src/web/proformas/list/ui/blocks/proformas-grid/use-proforma-grid-columns.tsx @@ -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( )} + {canDuplicate && ( + + + { + event.preventDefault(); + event.stopPropagation(); + actionHandlers.onDuplicateClick?.(proforma); + }} + size="icon" + variant="ghost" + > + + Duplicar + + } + /> + Duplicar + + + )} + {isArchivable && actionHandlers.onArchiveClick && ( @@ -387,36 +415,38 @@ export function useProformasGridColumns( )} {/* Cambiar estado */} - {!isDeletedScope && - !isIssued && + {!(isDeletedScope || isIssued) && availableTransitions.length > 0 && actionHandlers.onChangeStatusClick && ( - - - { - event.preventDefault(); - event.stopPropagation(); + + + { + event.preventDefault(); + event.stopPropagation(); - actionHandlers.onChangeStatusClick?.(proforma, availableTransitions[0]); - }} - size="icon" - variant="ghost" - > - - Cambiar estado - - } - /> - - Cambiar a {t(`catalog.proformas.status.${availableTransitions[0]}.label`)} - - - - )} + actionHandlers.onChangeStatusClick?.( + proforma, + availableTransitions[0] + ); + }} + size="icon" + variant="ghost" + > + + Cambiar estado + + } + /> + + Cambiar a {t(`catalog.proformas.status.${availableTransitions[0]}.label`)} + + + + )} {/* Emitir factura: solo si approved */} {canIssue && ( diff --git a/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx b/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx index 2f1d9412..f72c60f4 100644 --- a/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx +++ b/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx @@ -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, diff --git a/modules/customer-invoices/src/web/proformas/shared/api/duplicate-proforma-by-id.api.ts b/modules/customer-invoices/src/web/proformas/shared/api/duplicate-proforma-by-id.api.ts new file mode 100644 index 00000000..e405c2a3 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/shared/api/duplicate-proforma-by-id.api.ts @@ -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 => { + const { proformaId, signal } = params; + + if (!proformaId) throw new Error("proformaId is required"); + + return dataSource.custom({ + path: `proformas/${proformaId}/duplicate`, + method: "post", + data: undefined, + signal, + }); +}; diff --git a/modules/customer-invoices/src/web/proformas/shared/api/index.ts b/modules/customer-invoices/src/web/proformas/shared/api/index.ts index 6952d6ba..7c26cbc6 100644 --- a/modules/customer-invoices/src/web/proformas/shared/api/index.ts +++ b/modules/customer-invoices/src/web/proformas/shared/api/index.ts @@ -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"; diff --git a/modules/customer-invoices/src/web/proformas/shared/hooks/index.ts b/modules/customer-invoices/src/web/proformas/shared/hooks/index.ts index dd7e848b..0cc12edd 100644 --- a/modules/customer-invoices/src/web/proformas/shared/hooks/index.ts +++ b/modules/customer-invoices/src/web/proformas/shared/hooks/index.ts @@ -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"; diff --git a/modules/customer-invoices/src/web/proformas/shared/hooks/keys.ts b/modules/customer-invoices/src/web/proformas/shared/hooks/keys.ts index 9049bbea..cbb65e2a 100644 --- a/modules/customer-invoices/src/web/proformas/shared/hooks/keys.ts +++ b/modules/customer-invoices/src/web/proformas/shared/hooks/keys.ts @@ -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; diff --git a/modules/customer-invoices/src/web/proformas/shared/hooks/use-duplicate-proforma-by-id-mutation.ts b/modules/customer-invoices/src/web/proformas/shared/hooks/use-duplicate-proforma-by-id-mutation.ts new file mode 100644 index 00000000..2f78f3df --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/shared/hooks/use-duplicate-proforma-by-id-mutation.ts @@ -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>; +}; diff --git a/packages/rdx-ui/src/components/datatable/data-table-toolbar.tsx b/packages/rdx-ui/src/components/datatable/data-table-toolbar.tsx index f267c3e4..3ddb79e8 100644 --- a/packages/rdx-ui/src/components/datatable/data-table-toolbar.tsx +++ b/packages/rdx-ui/src/components/datatable/data-table-toolbar.tsx @@ -75,7 +75,7 @@ export function DataTableToolbar({ // Render principal return (
{/* IZQUIERDA: acciones + contador */}