From 1e0aadbfc6eda807b0563584a8a785354e15851c Mon Sep 17 00:00:00 2001 From: david Date: Wed, 29 Jul 2026 18:23:57 +0200 Subject: [PATCH] Implement archive and unarchive functionality for proformas, including UI dialogs and logic for handling draft proformas. --- .../proforma-archive-contract.md | 1 + .../proforma-create-contract.md | 1 + .../proforma-delete-contract.md | 8 +- ...lit-proformas-issued-invoices-migration.md | 1 + .../sql/validate-proforma-archive.sql | 5 + .../proformas/services/proforma-deleter.ts | 2 +- .../src/common/locales/en.json | 9 ++ .../src/common/locales/es.json | 9 ++ .../entities/delete-proforma-target.entity.ts | 1 + .../ui/components/delete-proforma-dialog.tsx | 17 ++- .../utils/prepare-delete-proforma-targets.ts | 1 + .../use-list-proformas-page.controller.ts | 132 +++++++++++------- .../use-proforma-grid-columns.tsx | 2 +- .../ui/components/archive-proforma-dialog.tsx | 60 ++++++++ .../web/proformas/list/ui/components/index.ts | 2 + .../components/unarchive-proforma-dialog.tsx | 60 ++++++++ .../list/ui/pages/list-proformas-page.tsx | 33 ++++- 17 files changed, 279 insertions(+), 65 deletions(-) create mode 100644 modules/customer-invoices/src/web/proformas/list/ui/components/archive-proforma-dialog.tsx create mode 100644 modules/customer-invoices/src/web/proformas/list/ui/components/unarchive-proforma-dialog.tsx diff --git a/docs/customer-invoices/proforma-archive-contract.md b/docs/customer-invoices/proforma-archive-contract.md index acf021ca..a1f74be8 100644 --- a/docs/customer-invoices/proforma-archive-contract.md +++ b/docs/customer-invoices/proforma-archive-contract.md @@ -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 diff --git a/docs/customer-invoices/proforma-create-contract.md b/docs/customer-invoices/proforma-create-contract.md index ea6f690f..1e70cd9e 100644 --- a/docs/customer-invoices/proforma-create-contract.md +++ b/docs/customer-invoices/proforma-create-contract.md @@ -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 diff --git a/docs/customer-invoices/proforma-delete-contract.md b/docs/customer-invoices/proforma-delete-contract.md index 3512ef0d..a21dd4ae 100644 --- a/docs/customer-invoices/proforma-delete-contract.md +++ b/docs/customer-invoices/proforma-delete-contract.md @@ -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` diff --git a/docs/customer-invoices/split-proformas-issued-invoices-migration.md b/docs/customer-invoices/split-proformas-issued-invoices-migration.md index b394ffc3..8ba3bbd1 100644 --- a/docs/customer-invoices/split-proformas-issued-invoices-migration.md +++ b/docs/customer-invoices/split-proformas-issued-invoices-migration.md @@ -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` diff --git a/docs/customer-invoices/sql/validate-proforma-archive.sql b/docs/customer-invoices/sql/validate-proforma-archive.sql index bbbdaeff..053df32a 100644 --- a/docs/customer-invoices/sql/validate-proforma-archive.sql +++ b/docs/customer-invoices/sql/validate-proforma-archive.sql @@ -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'; diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-deleter.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-deleter.ts index 09ed5bbb..590b58a1 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/proforma-deleter.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-deleter.ts @@ -24,7 +24,7 @@ export class ProformaDeleter implements IProformaDeleter { id: UniqueID; transaction: unknown; }): Promise> { - const proformaResult = await this.deps.repository.getByIdInCompany( + const proformaResult = await this.deps.repository.getByIdIncludingArchivedInCompany( params.companyId, params.id, params.transaction diff --git a/modules/customer-invoices/src/common/locales/en.json b/modules/customer-invoices/src/common/locales/en.json index e7001262..5df1f7d7 100644 --- a/modules/customer-invoices/src/common/locales/en.json +++ b/modules/customer-invoices/src/common/locales/en.json @@ -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", diff --git a/modules/customer-invoices/src/common/locales/es.json b/modules/customer-invoices/src/common/locales/es.json index 733209e5..c099ac43 100644 --- a/modules/customer-invoices/src/common/locales/es.json +++ b/modules/customer-invoices/src/common/locales/es.json @@ -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", diff --git a/modules/customer-invoices/src/web/proformas/delete/entities/delete-proforma-target.entity.ts b/modules/customer-invoices/src/web/proformas/delete/entities/delete-proforma-target.entity.ts index e07335b9..bd1d6e61 100644 --- a/modules/customer-invoices/src/web/proformas/delete/entities/delete-proforma-target.entity.ts +++ b/modules/customer-invoices/src/web/proformas/delete/entities/delete-proforma-target.entity.ts @@ -1,4 +1,5 @@ export interface DeleteProformaTarget { id: string; reference?: string; + isArchived?: boolean; } diff --git a/modules/customer-invoices/src/web/proformas/delete/ui/components/delete-proforma-dialog.tsx b/modules/customer-invoices/src/web/proformas/delete/ui/components/delete-proforma-dialog.tsx index c36aaa2c..51fe187f 100644 --- a/modules/customer-invoices/src/web/proformas/delete/ui/components/delete-proforma-dialog.tsx +++ b/modules/customer-invoices/src/web/proformas/delete/ui/components/delete-proforma-dialog.tsx @@ -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 = ({

- {t("proformas.delete_proforma_dialog.warning")} + {t(warningKey)}

{total > 1 ? ( diff --git a/modules/customer-invoices/src/web/proformas/delete/utils/prepare-delete-proforma-targets.ts b/modules/customer-invoices/src/web/proformas/delete/utils/prepare-delete-proforma-targets.ts index 313c5ff0..1e7676ec 100644 --- a/modules/customer-invoices/src/web/proformas/delete/utils/prepare-delete-proforma-targets.ts +++ b/modules/customer-invoices/src/web/proformas/delete/utils/prepare-delete-proforma-targets.ts @@ -12,4 +12,5 @@ export const prepareDeleteProformaTargets = ( const mapToDeleteProformaTarget = (proforma: Proforma | ProformaListRow): DeleteProformaTarget => ({ id: proforma.id, reference: proforma.reference, + isArchived: proforma.isArchived, }); 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 d1d5b688..f0bba9d2 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 @@ -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(null); + const [unarchiveTarget, setUnarchiveTarget] = useState(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, }; 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 6e20675e..6c601c92 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 @@ -457,7 +457,7 @@ export function useProformasGridColumns( {/* Eliminar */} - {isDraft && !proforma.isArchived && actionHandlers.onDeleteClick && ( + {isDraft && actionHandlers.onDeleteClick && ( void; + target: ProformaListRow | null; + isSubmitting: boolean; + onConfirm: () => void; +} + +export const ArchiveProformaDialog = ({ + open, + onOpenChange, + target, + isSubmitting, + onConfirm, +}: ArchiveProformaDialogProps) => { + const { t } = useTranslation(); + + return ( + + + + {t("proformas.archive.confirm_title")} + + {t("proformas.archive.confirm_description")} + + + + + + + + + + ); +}; diff --git a/modules/customer-invoices/src/web/proformas/list/ui/components/index.ts b/modules/customer-invoices/src/web/proformas/list/ui/components/index.ts index df695438..d09eb799 100644 --- a/modules/customer-invoices/src/web/proformas/list/ui/components/index.ts +++ b/modules/customer-invoices/src/web/proformas/list/ui/components/index.ts @@ -1 +1,3 @@ +export * from "./archive-proforma-dialog"; export * from "./proforma-status-badge"; +export * from "./unarchive-proforma-dialog"; diff --git a/modules/customer-invoices/src/web/proformas/list/ui/components/unarchive-proforma-dialog.tsx b/modules/customer-invoices/src/web/proformas/list/ui/components/unarchive-proforma-dialog.tsx new file mode 100644 index 00000000..182093ad --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/list/ui/components/unarchive-proforma-dialog.tsx @@ -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 ( + + + + {t("proformas.unarchive.confirm_title")} + + {t("proformas.unarchive.confirm_description")} + + + + + + + + + + ); +}; 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 e823e6f1..31656917 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 @@ -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} /> + + { + if (!open) { + archiveDialogCtrl.closeDialog(); + } + }} + open={archiveDialogCtrl.open} + target={archiveDialogCtrl.target} + /> + + { + if (!open) { + unarchiveDialogCtrl.closeDialog(); + } + }} + open={unarchiveDialogCtrl.open} + target={unarchiveDialogCtrl.target} + /> );