Implement archive and unarchive functionality for proformas, including UI dialogs and logic for handling draft proformas.

This commit is contained in:
David Arranz 2026-07-29 18:23:57 +02:00
parent eb8cbd97f2
commit 1e0aadbfc6
17 changed files with 279 additions and 65 deletions

View File

@ -8,6 +8,7 @@
- Archived proformas remain available through `GET /proformas/:id`.
- Deleted proformas remain excluded from every flow.
- Numbering is never reused and `document_series.next_number` is untouched.
- Archived `draft` proformas can be deleted directly without unarchiving.
## Endpoints

View File

@ -155,3 +155,4 @@ Si devuelve filas:
- una proforma creada nace con `archived_at = NULL`
- el archivado posterior no altera `status`
- una proforma `draft` archivada puede borrarse sin desarchivarse antes

View File

@ -10,8 +10,9 @@ Definir el borrado funcional de proformas sin romper trazabilidad ni numeracion.
- `sent`, `approved`, `rejected` e `issued` devuelven conflicto
- `rejected` no se borra; queda reservado para una futura operacion de archivado
- el borrado es logico mediante `deleted_at`
- las proformas archivadas no se exponen para borrado en UI V1
- si una proforma draft esta archivada, debe desarchivarse antes de borrarla
- el borrado de `draft` no depende de `archived_at`
- una proforma `draft` archivada puede borrarse directamente
- una proforma `rejected` archivada no puede borrarse
- la numeracion no se reutiliza
- no se decrementa `document_series.next_number`
- no se borran fisicamente `proforma_items` ni `proforma_taxes`
@ -38,5 +39,6 @@ Definir el borrado funcional de proformas sin romper trazabilidad ni numeracion.
## UI
- la accion `Eliminar` solo debe mostrarse para proformas `draft`
- en la vista de archivadas, `Eliminar` tambien debe mostrarse para `draft`
- la confirmacion debe advertir que la referencia no se reutilizara
- en esta fase la accion solo esta disponible en el listado
- en esta fase la accion esta disponible en el listado activo y en archivadas para `draft`

View File

@ -170,6 +170,7 @@ Preparar el esquema fisico para separar:
- `DELETE /proformas/:proforma_id` vuelve a estar operativo sobre persistencia V2
- el borrado es logico mediante `deleted_at`
- el archivado es logico mediante `archived_at`
- el borrado de `draft` no depende de `archived_at`
- solo `draft` puede borrarse
- `rejected` sigue fuera del alcance y queda reservado para archivado futuro
- la validacion de borrado comprueba tambien `linked_invoice_id` y `issued_invoices.source_proforma_id`

View File

@ -7,3 +7,8 @@ SELECT id, proforma_reference, status, archived_at, deleted_at
FROM proformas
WHERE archived_at IS NOT NULL
AND deleted_at IS NOT NULL;
SELECT id, proforma_reference, status, archived_at, deleted_at
FROM proformas
WHERE deleted_at IS NOT NULL
AND status <> 'draft';

View File

@ -24,7 +24,7 @@ export class ProformaDeleter implements IProformaDeleter {
id: UniqueID;
transaction: unknown;
}): Promise<Result<void, Error>> {
const proformaResult = await this.deps.repository.getByIdInCompany(
const proformaResult = await this.deps.repository.getByIdIncludingArchivedInCompany(
params.companyId,
params.id,
params.transaction

View File

@ -99,6 +99,9 @@
"archive": {
"confirm_title": "Archive proforma",
"confirm_description": "The proforma will be hidden from the main list but kept for historical lookup.",
"confirm": "Archive",
"cancel": "Cancel",
"submitting": "Archiving...",
"success_title": "Proforma archived",
"success_description": "The proforma was archived successfully.",
"error_title": "The proforma could not be archived",
@ -108,6 +111,9 @@
"unarchive": {
"confirm_title": "Unarchive proforma",
"confirm_description": "The proforma will appear again in the main list.",
"confirm": "Unarchive",
"cancel": "Cancel",
"submitting": "Unarchiving...",
"success_title": "Proforma unarchived",
"success_description": "The proforma was unarchived successfully.",
"error_title": "The proforma could not be unarchived",
@ -130,9 +136,12 @@
"delete_proforma_dialog": {
"single_title": "Delete proforma",
"single_description": "This proforma will be deleted. Its reference will not be reused.",
"archived_single_title": "Delete archived proforma",
"archived_single_description": "The draft proforma will be deleted. Its reference will not be reused.",
"multiple_title": "Delete proformas",
"multiple_description": "{{count}} draft proformas will be deleted. Their references will not be reused.",
"warning": "This action does not delete issued invoices and does not reuse numbering.",
"archived_warning": "This action does not physically erase its data, but it will no longer be available in operational lists.",
"confirm_delete": "Confirm deletion",
"deleting": "Deleting...",
"cancel": "Cancel",

View File

@ -99,6 +99,9 @@
"archive": {
"confirm_title": "Archivar proforma",
"confirm_description": "La proforma se ocultará del listado principal, pero seguirá conservada para consulta histórica.",
"confirm": "Archivar",
"cancel": "Cancelar",
"submitting": "Archivando...",
"success_title": "Proforma archivada",
"success_description": "La proforma se ha archivado correctamente.",
"error_title": "No se pudo archivar la proforma",
@ -108,6 +111,9 @@
"unarchive": {
"confirm_title": "Desarchivar proforma",
"confirm_description": "La proforma volverá a aparecer en el listado principal.",
"confirm": "Desarchivar",
"cancel": "Cancelar",
"submitting": "Desarchivando...",
"success_title": "Proforma desarchivada",
"success_description": "La proforma se ha desarchivado correctamente.",
"error_title": "No se pudo desarchivar la proforma",
@ -131,9 +137,12 @@
"delete_proforma_dialog": {
"single_title": "Eliminar proforma",
"single_description": "Se eliminará la proforma. Su referencia no se reutilizará.",
"archived_single_title": "Eliminar proforma archivada",
"archived_single_description": "Se eliminará la proforma en borrador. Su referencia no se reutilizará.",
"multiple_title": "Eliminar proformas",
"multiple_description": "Se eliminarán {{count}} proformas draft. Sus referencias no se reutilizarán.",
"warning": "Esta acción no elimina facturas emitidas ni reutiliza numeración.",
"archived_warning": "Esta acción no borra físicamente sus datos, pero dejará de estar disponible en los listados operativos.",
"confirm_delete": "Confirmar eliminación",
"deleting": "Eliminando...",
"cancel": "Cancelar",

View File

@ -1,4 +1,5 @@
export interface DeleteProformaTarget {
id: string;
reference?: string;
isArchived?: boolean;
}

View File

@ -34,9 +34,20 @@ export const DeleteProformaDialog = ({
const total = targets.length;
const isSingle = total === 1;
const firstTarget = targets[0];
const isArchivedContext = targets.some((target) => target.isArchived);
const titleKey = isArchivedContext
? "proformas.delete_proforma_dialog.archived_single_title"
: "proformas.delete_proforma_dialog.single_title";
const descriptionKey = isArchivedContext
? "proformas.delete_proforma_dialog.archived_single_description"
: "proformas.delete_proforma_dialog.single_description";
const warningKey = isArchivedContext
? "proformas.delete_proforma_dialog.archived_warning"
: "proformas.delete_proforma_dialog.warning";
const title = isSingle
? t("proformas.delete_proforma_dialog.single_title", {
? t(titleKey, {
reference: getTargetLabel(firstTarget),
})
: t("proformas.delete_proforma_dialog.multiple_title", {
@ -44,7 +55,7 @@ export const DeleteProformaDialog = ({
});
const description = isSingle
? t("proformas.delete_proforma_dialog.single_description")
? t(descriptionKey)
: t("proformas.delete_proforma_dialog.multiple_description", {
count: total,
});
@ -64,7 +75,7 @@ export const DeleteProformaDialog = ({
</AlertDialogHeader>
<p className="text-sm text-muted-foreground">
{t("proformas.delete_proforma_dialog.warning")}
{t(warningKey)}
</p>
{total > 1 ? (

View File

@ -12,4 +12,5 @@ export const prepareDeleteProformaTargets = (
const mapToDeleteProformaTarget = (proforma: Proforma | ProformaListRow): DeleteProformaTarget => ({
id: proforma.id,
reference: proforma.reference,
isArchived: proforma.isArchived,
});

View File

@ -1,6 +1,6 @@
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
import type { RightPanelMode } from "@repo/rdx-ui/hooks";
import { useCallback } from "react";
import { useCallback, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "../../../i18n";
@ -46,73 +46,99 @@ export const useListProformasPageController = () => {
initialOpen: proformaId !== "",
});
const handleArchive = useCallback(
async (proforma: ProformaListRow) => {
const confirmed = window.confirm(
`${t("proformas.archive.confirm_title")}\n\n${t("proformas.archive.confirm_description")}`
const [archiveTarget, setArchiveTarget] = useState<ProformaListRow | null>(null);
const [unarchiveTarget, setUnarchiveTarget] = useState<ProformaListRow | null>(null);
const openArchiveDialog = useCallback((proforma: ProformaListRow) => {
setArchiveTarget(proforma);
}, []);
const closeArchiveDialog = useCallback(() => {
if (archiveMutation.isPending) return;
setArchiveTarget(null);
}, [archiveMutation.isPending]);
const confirmArchive = useCallback(async () => {
if (!archiveTarget) {
return;
}
try {
await archiveMutation.mutateAsync({ proformaId: archiveTarget.id });
showSuccessToast(
t("proformas.archive.success_title"),
t("proformas.archive.success_description")
);
if (!confirmed) return;
try {
await archiveMutation.mutateAsync({ proformaId: proforma.id });
showSuccessToast(
t("proformas.archive.success_title"),
t("proformas.archive.success_description")
);
} catch (error: unknown) {
console.error(error);
const status = (error as { status?: number } | undefined)?.status;
showErrorToast(
t("proformas.archive.error_title"),
status === 409
? t("proformas.archive.error_conflict_message")
: t("proformas.archive.error_unknown_message")
);
}
},
[archiveMutation, t]
);
const handleUnarchive = useCallback(
async (proforma: ProformaListRow) => {
const confirmed = window.confirm(
`${t("proformas.unarchive.confirm_title")}\n\n${t("proformas.unarchive.confirm_description")}`
setArchiveTarget(null);
} catch (error: unknown) {
const status = (error as { status?: number } | undefined)?.status;
showErrorToast(
t("proformas.archive.error_title"),
status === 409
? t("proformas.archive.error_conflict_message")
: t("proformas.archive.error_unknown_message")
);
}
}, [archiveMutation, archiveTarget, t]);
if (!confirmed) return;
const openUnarchiveDialog = useCallback((proforma: ProformaListRow) => {
setUnarchiveTarget(proforma);
}, []);
try {
await unarchiveMutation.mutateAsync({ proformaId: proforma.id });
showSuccessToast(
t("proformas.unarchive.success_title"),
t("proformas.unarchive.success_description")
);
} catch (error: unknown) {
const status = (error as { status?: number } | undefined)?.status;
showErrorToast(
t("proformas.unarchive.error_title"),
status === 409
? t("proformas.unarchive.error_conflict_message")
: t("proformas.unarchive.error_unknown_message")
);
}
},
[t, unarchiveMutation]
);
const closeUnarchiveDialog = useCallback(() => {
if (unarchiveMutation.isPending) return;
setUnarchiveTarget(null);
}, [unarchiveMutation.isPending]);
const confirmUnarchive = useCallback(async () => {
if (!unarchiveTarget) {
return;
}
try {
await unarchiveMutation.mutateAsync({ proformaId: unarchiveTarget.id });
showSuccessToast(
t("proformas.unarchive.success_title"),
t("proformas.unarchive.success_description")
);
setUnarchiveTarget(null);
} catch (error: unknown) {
const status = (error as { status?: number } | undefined)?.status;
showErrorToast(
t("proformas.unarchive.error_title"),
status === 409
? t("proformas.unarchive.error_conflict_message")
: t("proformas.unarchive.error_unknown_message")
);
}
}, [t, unarchiveMutation, unarchiveTarget]);
return {
listCtrl,
panelCtrl,
archiveDialogCtrl: {
open: archiveTarget !== null,
target: archiveTarget,
isSubmitting: archiveMutation.isPending,
openDialog: openArchiveDialog,
closeDialog: closeArchiveDialog,
confirmArchive,
},
deleteDialogCtrl,
issueDialogCtrl,
changeStatusDialogCtrl,
unarchiveDialogCtrl: {
open: unarchiveTarget !== null,
target: unarchiveTarget,
isSubmitting: unarchiveMutation.isPending,
openDialog: openUnarchiveDialog,
closeDialog: closeUnarchiveDialog,
confirmUnarchive,
},
downloadPDFCtrl,
handleArchive,
handleDownloadPDF,
handleUnarchive,
pdfDownloadingId: downloadPDFCtrl.loadingId,
isPDFDownloading: downloadPDFCtrl.isLoading,
};

View File

@ -457,7 +457,7 @@ export function useProformasGridColumns(
</TooltipProvider>
{/* Eliminar */}
{isDraft && !proforma.isArchived && actionHandlers.onDeleteClick && (
{isDraft && actionHandlers.onDeleteClick && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger

View File

@ -0,0 +1,60 @@
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
Spinner,
} from "@repo/shadcn-ui/components";
import { useTranslation } from "../../../../i18n";
import type { ProformaListRow } from "../../../shared";
interface ArchiveProformaDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
target: ProformaListRow | null;
isSubmitting: boolean;
onConfirm: () => void;
}
export const ArchiveProformaDialog = ({
open,
onOpenChange,
target,
isSubmitting,
onConfirm,
}: ArchiveProformaDialogProps) => {
const { t } = useTranslation();
return (
<AlertDialog onOpenChange={onOpenChange} open={open}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("proformas.archive.confirm_title")}</AlertDialogTitle>
<AlertDialogDescription>
{t("proformas.archive.confirm_description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<Button disabled={isSubmitting} onClick={() => onOpenChange(false)} variant="outline">
{t("proformas.archive.cancel")}
</Button>
<Button disabled={isSubmitting || !target} onClick={onConfirm}>
{isSubmitting ? (
<>
<Spinner aria-hidden className="mr-2 size-4" />
{t("proformas.archive.submitting")}
</>
) : (
t("proformas.archive.confirm")
)}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@ -1 +1,3 @@
export * from "./archive-proforma-dialog";
export * from "./proforma-status-badge";
export * from "./unarchive-proforma-dialog";

View File

@ -0,0 +1,60 @@
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
Spinner,
} from "@repo/shadcn-ui/components";
import { useTranslation } from "../../../../i18n";
import type { ProformaListRow } from "../../../shared";
interface UnarchiveProformaDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
target: ProformaListRow | null;
isSubmitting: boolean;
onConfirm: () => void;
}
export const UnarchiveProformaDialog = ({
open,
onOpenChange,
target,
isSubmitting,
onConfirm,
}: UnarchiveProformaDialogProps) => {
const { t } = useTranslation();
return (
<AlertDialog onOpenChange={onOpenChange} open={open}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("proformas.unarchive.confirm_title")}</AlertDialogTitle>
<AlertDialogDescription>
{t("proformas.unarchive.confirm_description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<Button disabled={isSubmitting} onClick={() => onOpenChange(false)} variant="outline">
{t("proformas.unarchive.cancel")}
</Button>
<Button disabled={isSubmitting || !target} onClick={onConfirm}>
{isSubmitting ? (
<>
<Spinner aria-hidden className="mr-2 size-4" />
{t("proformas.unarchive.submitting")}
</>
) : (
t("proformas.unarchive.confirm")
)}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@ -34,6 +34,7 @@ import { prepareIssueProformaTarget } from "../../../issue-proforma/utils";
import type { ProformaListRow } from "../../../shared";
import { useListProformasPageController } from "../../controllers";
import { ProformaSummaryPanel, ProformasGrid, useProformasGridColumns } from "../blocks";
import { ArchiveProformaDialog, UnarchiveProformaDialog } from "../components";
import { ProformaStatusBadge } from "../components";
export const ListProformasPage = () => {
@ -50,9 +51,9 @@ export const ListProformasPage = () => {
deleteDialogCtrl,
issueDialogCtrl,
changeStatusDialogCtrl,
handleArchive,
archiveDialogCtrl,
handleDownloadPDF,
handleUnarchive,
unarchiveDialogCtrl,
isPDFDownloading: isPdfDownloading,
pdfDownloadingId,
} = useListProformasPageController();
@ -70,12 +71,12 @@ export const ListProformasPage = () => {
onEditClick: (proforma) => handleEditClick(proforma.id),
onIssueClick: (proformaRow) =>
issueDialogCtrl.openDialog(prepareIssueProformaTarget(proformaRow)),
onArchiveClick: handleArchive,
onArchiveClick: archiveDialogCtrl.openDialog,
onDeleteClick: (proformaRow: ProformaListRow) =>
deleteDialogCtrl.openDialog(prepareDeleteProformaTargets(proformaRow)),
onChangeStatusClick: (proforma) =>
changeStatusDialogCtrl.openDialog(prepareChangeProformaStatusTargets(proforma)),
onUnarchiveClick: handleUnarchive,
onUnarchiveClick: unarchiveDialogCtrl.openDialog,
onDownloadPdf: handleDownloadPDF,
pdfDownloadingId,
@ -360,6 +361,30 @@ export const ListProformasPage = () => {
open={deleteDialogCtrl.open}
targets={deleteDialogCtrl.targets}
/>
<ArchiveProformaDialog
isSubmitting={archiveDialogCtrl.isSubmitting}
onConfirm={archiveDialogCtrl.confirmArchive}
onOpenChange={(open) => {
if (!open) {
archiveDialogCtrl.closeDialog();
}
}}
open={archiveDialogCtrl.open}
target={archiveDialogCtrl.target}
/>
<UnarchiveProformaDialog
isSubmitting={unarchiveDialogCtrl.isSubmitting}
onConfirm={unarchiveDialogCtrl.confirmUnarchive}
onOpenChange={(open) => {
if (!open) {
unarchiveDialogCtrl.closeDialog();
}
}}
open={unarchiveDialogCtrl.open}
target={unarchiveDialogCtrl.target}
/>
</>
</div>
);