Implement archive and unarchive functionality for proformas, including UI dialogs and logic for handling draft proformas.
This commit is contained in:
parent
eb8cbd97f2
commit
1e0aadbfc6
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
export interface DeleteProformaTarget {
|
||||
id: string;
|
||||
reference?: string;
|
||||
isArchived?: boolean;
|
||||
}
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -12,4 +12,5 @@ export const prepareDeleteProformaTargets = (
|
||||
const mapToDeleteProformaTarget = (proforma: Proforma | ProformaListRow): DeleteProformaTarget => ({
|
||||
id: proforma.id,
|
||||
reference: proforma.reference,
|
||||
isArchived: proforma.isArchived,
|
||||
});
|
||||
|
||||
@ -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,22 +46,31 @@ 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);
|
||||
|
||||
if (!confirmed) return;
|
||||
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: proforma.id });
|
||||
await archiveMutation.mutateAsync({ proformaId: archiveTarget.id });
|
||||
showSuccessToast(
|
||||
t("proformas.archive.success_title"),
|
||||
t("proformas.archive.success_description")
|
||||
);
|
||||
setArchiveTarget(null);
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
const status = (error as { status?: number } | undefined)?.status;
|
||||
showErrorToast(
|
||||
t("proformas.archive.error_title"),
|
||||
@ -70,24 +79,29 @@ export const useListProformasPageController = () => {
|
||||
: t("proformas.archive.error_unknown_message")
|
||||
);
|
||||
}
|
||||
},
|
||||
[archiveMutation, t]
|
||||
);
|
||||
}, [archiveMutation, archiveTarget, t]);
|
||||
|
||||
const handleUnarchive = useCallback(
|
||||
async (proforma: ProformaListRow) => {
|
||||
const confirmed = window.confirm(
|
||||
`${t("proformas.unarchive.confirm_title")}\n\n${t("proformas.unarchive.confirm_description")}`
|
||||
);
|
||||
const openUnarchiveDialog = useCallback((proforma: ProformaListRow) => {
|
||||
setUnarchiveTarget(proforma);
|
||||
}, []);
|
||||
|
||||
if (!confirmed) return;
|
||||
const closeUnarchiveDialog = useCallback(() => {
|
||||
if (unarchiveMutation.isPending) return;
|
||||
setUnarchiveTarget(null);
|
||||
}, [unarchiveMutation.isPending]);
|
||||
|
||||
const confirmUnarchive = useCallback(async () => {
|
||||
if (!unarchiveTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await unarchiveMutation.mutateAsync({ proformaId: proforma.id });
|
||||
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(
|
||||
@ -97,22 +111,34 @@ export const useListProformasPageController = () => {
|
||||
: t("proformas.unarchive.error_unknown_message")
|
||||
);
|
||||
}
|
||||
},
|
||||
[t, unarchiveMutation]
|
||||
);
|
||||
}, [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,
|
||||
};
|
||||
|
||||
@ -457,7 +457,7 @@ export function useProformasGridColumns(
|
||||
</TooltipProvider>
|
||||
|
||||
{/* Eliminar */}
|
||||
{isDraft && !proforma.isArchived && actionHandlers.onDeleteClick && (
|
||||
{isDraft && actionHandlers.onDeleteClick && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -1 +1,3 @@
|
||||
export * from "./archive-proforma-dialog";
|
||||
export * from "./proforma-status-badge";
|
||||
export * from "./unarchive-proforma-dialog";
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user