Duplicado de proforma

This commit is contained in:
David Arranz 2026-07-30 12:45:33 +02:00
parent fed29a71fd
commit c46769e71e
26 changed files with 537 additions and 31 deletions

View File

@ -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.

View File

@ -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`.

View 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`.

View File

@ -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`.

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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";

View File

@ -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,

View File

@ -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)
);
}
}

View File

@ -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"),

View File

@ -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
>;

View File

@ -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";

View File

@ -0,0 +1,7 @@
import {
type GetProformaByIdResponseDTO,
GetProformaByIdResponseSchema,
} from "./get-proforma-by-id.response.dto";
export const DuplicateProformaByIdResponseSchema = GetProformaByIdResponseSchema;
export type DuplicateProformaByIdResponseDTO = GetProformaByIdResponseDTO;

View File

@ -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";

View File

@ -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": {

View File

@ -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": {

View File

@ -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,

View File

@ -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

View File

@ -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"

View File

@ -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,

View File

@ -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,
});
};

View File

@ -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";

View File

@ -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";

View File

@ -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;

View File

@ -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>>;
};

View File

@ -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">