PROFORMAS ISSUE

This commit is contained in:
David Arranz 2026-04-05 22:17:11 +02:00
parent 994417e48f
commit b2dee4ae8b
21 changed files with 225 additions and 184 deletions

View File

@ -96,14 +96,30 @@
}
},
"proformas": {
"issue_proforma_dialog": {
"title": "Issue to invoice",
"description": "Are you sure you want to issue an invoice from proforma {{reference}}?",
"warning": "This action will create a final invoice and the proforma will be marked as \"Issued\". It will no longer be editable or deletable.",
"confirm": "Issue invoice",
"submitting": "Issuing...",
"success_title": "Proforma successfully issued",
"success_description": "Proforma {{reference}} has been successfully issued to an invoice.",
"error_title": "Error issuing proforma",
"not_allowed_title": "Action not allowed",
"not_allowed_description": "Only proformas in \"Approved\" status can be issued.",
"unknown_error": "An unexpected error occurred"
},
"delete_proforma_dialog": {
"title": "Delete proforma",
"description": "Are you sure you want to delete proforma <strong>{{proformaRef}}</strong>? This action cannot be undone.",
"cancel": "Cancel",
"delete": "Delete",
"single_title": "Delete proforma {{reference}}",
"single_description": "Are you sure you want to delete this proforma? This action cannot be undone.",
"second_confirm_single_title": "Additional confirmation",
"second_confirm_single_description": "You are about to delete the proforma. This action cannot be undone. Are you sure you want to continue?",
"multiple_description": "Are you sure you want to delete the {{total}} selected proformas? This action cannot be undone.",
"confirm_delete": "Confirm deletion",
"deleting": "Deleting...",
"success_title": "Proforma deleted",
"error_title": "Error deleting proforma"
"cancel": "Cancel",
"continue": "Delete",
"list_item": "Proforma {{reference}}"
}
},
"pages": {

View File

@ -96,20 +96,30 @@
}
},
"proformas": {
"issue_proforma_dialog": {
"title": "Emitir a factura",
"description": "¿Seguro que quieres emitir la factura desde la proforma {{reference}}?",
"warning": "Esta acción creará una nueva factura definitiva y la proforma pasará al estado \"Emitida\", no pudiendo modificarse ni eliminarse posteriormente.",
"confirm": "Emitir factura",
"submitting": "Emitiendo...",
"success_title": "Proforma emitida correctamente",
"success_description": "La proforma {{reference}} se ha emitido a factura correctamente.",
"error_title": "Error al emitir la proforma",
"not_allowed_title": "Acción no permitida",
"not_allowed_description": "Solo se pueden emitir proformas en estado \"Aprobada\".",
"unknown_error": "Ha ocurrido un error inesperado",
"cancel": "Cancelar"
},
"delete_proforma_dialog": {
"title": "Eliminar proforma",
"description": "¿Seguro que deseas eliminar la proforma <strong>{{proformaRef}}</strong>? Esta acción no se puede deshacer.",
"cancel": "Cancelar",
"delete": "Eliminar",
"deleting": "Eliminando...",
"success_title": "Proforma eliminada",
"error_title": "Error al eliminar la proforma",
"single_title": "Eliminar proforma {{reference}}",
"second_confirm_title": "Confirmación adicional",
"multiple_title": "Eliminar {{count}} proformas",
"second_confirm_description": "Estás a punto de borrar {{total}} proformas. Esta acción masiva no se puede deshacer. ¿Seguro que quieres continuar?",
"single_description": "¿Seguro que deseas eliminar esta proforma? Esta acción no se puede deshacer.",
"second_confirm_single_title": "Confirmación adicional",
"second_confirm_single_description": "Estás a punto de borrar la proforma. Esta acción masiva no se puede deshacer. ¿Seguro que quieres continuar?",
"multiple_description": "¿Seguro que deseas eliminar las {{total}} proformas seleccionadas? Esta acción no se puede deshacer.",
"confirm_delete": "Confirmar eliminación",
"deleting": "Eliminando...",
"cancel": "Cancelar",
"continue": "Eliminar",
"list_item": "Proforma {{reference}}"
}
},

View File

@ -59,8 +59,8 @@ export const useDeleteProformaDialogController = () => {
(targets: DeleteProformaTarget[]) => {
if (targets.length === 1) {
showSuccessToast(
t("pages.proformas.delete.successTitle"),
t("pages.proformas.delete.successSingleMessage", {
t("proformas.delete_proforma_dialog.success_title"),
t("proformas.delete_proforma_dialog.success_single_message", {
reference: getProformaLabel(targets[0]),
})
);
@ -68,8 +68,8 @@ export const useDeleteProformaDialogController = () => {
}
showSuccessToast(
t("pages.proformas.delete.successTitle"),
t("pages.proformas.delete.successMultipleMessage", {
t("proformas.delete_proforma_dialog.success_title"),
t("proformas.delete_proforma_dialog.success_multiple_message", {
count: targets.length,
})
);
@ -81,15 +81,15 @@ export const useDeleteProformaDialogController = () => {
(targets: DeleteProformaTarget[], errorCount: number) => {
if (targets.length === 1) {
showErrorToast(
t("pages.proformas.delete.errorTitle"),
t("pages.proformas.delete.errorSingleMessage")
t("proformas.delete_proforma_dialog.error_title"),
t("proformas.delete_proforma_dialog.error_single_message")
);
return;
}
showErrorToast(
t("pages.proformas.delete.errorTitle"),
t("pages.proformas.delete.errorMultipleMessage", {
t("proformas.delete_proforma_dialog.error_title"),
t("proformas.delete_proforma_dialog.error_multiple_message", {
count: errorCount,
})
);
@ -143,8 +143,8 @@ export const useDeleteProformaDialogController = () => {
await submitDelete();
} catch {
showErrorToast(
t("pages.proformas.delete.errorTitle"),
t("pages.proformas.delete.errorUnexpectedMessage")
t("proformas.delete.error_title"),
t("proformas.delete.error_unexpected_message")
);
}
}, [deleteMutation.isPending, moveToSecondConfirmStep, state, submitDelete, t]);

View File

@ -39,29 +39,29 @@ export const DeleteProformaDialog = ({
const title = isSecondConfirmStep
? isSingle
? t("components.delete_proforma_dialog.second_confirm_single_title", {
? t("proformas.delete_proforma_dialog.second_confirm_single_title", {
reference: getTargetLabel(firstTarget),
})
: t("components.delete_proforma_dialog.second_confirm_multiple_title", {
: t("proformas.delete_proforma_dialog.second_confirm_multiple_title", {
count: total,
})
: isSingle
? t("components.delete_proforma_dialog.single_title", {
? t("proformas.delete_proforma_dialog.single_title", {
reference: getTargetLabel(firstTarget),
})
: t("components.delete_proforma_dialog.multiple_title", {
: t("proformas.delete_proforma_dialog.multiple_title", {
count: total,
});
const description = isSecondConfirmStep
? isSingle
? t("components.delete_proforma_dialog.second_confirm_single_description")
: t("components.delete_proforma_dialog.second_confirm_multiple_description", {
? t("proformas.delete_proforma_dialog.second_confirm_single_description")
: t("proformas.delete_proforma_dialog.second_confirm_multiple_description", {
count: total,
})
: isSingle
? t("components.delete_proforma_dialog.single_description")
: t("components.delete_proforma_dialog.multiple_description", {
? t("proformas.delete_proforma_dialog.single_description")
: t("proformas.delete_proforma_dialog.multiple_description", {
count: total,
});
@ -84,7 +84,7 @@ export const DeleteProformaDialog = ({
<ul className="space-y-1">
{targets.map((target) => (
<li className="text-muted-foreground" key={target.id}>
{t("components.delete_proforma_dialog.list_item", {
{t("proformas.delete_proforma_dialog.list_item", {
reference: getTargetLabel(target),
})}
</li>
@ -95,19 +95,19 @@ export const DeleteProformaDialog = ({
<AlertDialogFooter className="sm:justify-between">
<Button disabled={isSubmitting} onClick={() => onOpenChange(false)} variant="outline">
{t("components.delete_proforma_dialog.cancel")}
{t("proformas.delete_proforma_dialog.cancel")}
</Button>
<Button disabled={isSubmitting || total === 0} onClick={onConfirm} variant="destructive">
{isSubmitting ? (
<>
<Spinner className="mr-2 size-4" />
{t("components.delete_proforma_dialog.deleting")}
{t("proformas.delete_proforma_dialog.deleting")}
</>
) : isSecondConfirmStep ? (
t("components.delete_proforma_dialog.confirm_delete")
t("proformas.delete_proforma_dialog.confirm_delete")
) : (
t("components.delete_proforma_dialog.continue")
t("proformas.delete_proforma_dialog.continue")
)}
</Button>
</AlertDialogFooter>

View File

@ -1,61 +1,91 @@
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
import * as React from "react";
import { useState } from "react";
import type { ProformaSummaryData } from "../../types";
import { useIssueProformaMutation } from "../hooks/use-issue-proforma-mutation";
import { useTranslation } from "../../../i18n";
import { useProformaIssueMutation } from "../../shared";
import type { IssueProformaTarget } from "../entities";
interface State {
interface IssueDialogState {
open: boolean;
proforma: ProformaSummaryData | null;
loading: boolean;
target: IssueProformaTarget | null;
}
export function useProformaIssueDialogController() {
const { issueProforma, isPending } = useIssueProformaMutation();
const canIssueProforma = (target: IssueProformaTarget) => target.status === "approved";
const [state, setState] = React.useState<State>({
export const useIssueProformaDialogController = () => {
const { t } = useTranslation();
const issueMutation = useProformaIssueMutation();
const [state, setState] = useState<IssueDialogState>({
open: false,
proforma: null,
loading: false,
target: null,
});
// abrir diálogo
const openDialog = (proforma: ProformaSummaryData) => {
setState({ open: true, proforma: proforma, loading: false });
};
const openDialog = (target: IssueProformaTarget) => {
if (!canIssueProforma(target)) {
showErrorToast(
t("proformas.issue_proforma_dialog.not_allowed_title"),
t("proformas.issue_proforma_dialog.not_allowed_description")
);
return;
}
// cerrar diálogo
const closeDialog = () => {
setState((s) => ({ ...s, open: false }));
};
// confirmar emisión
const confirmIssue = async () => {
if (!state.proforma) return;
setState((s) => ({ ...s, loading: true }));
await issueProforma(state.proforma.id, {
onSuccess: () => {
showSuccessToast("Proforma emitida a factura");
},
onError: (err: unknown) => {
const error = err as Error;
showErrorToast("Error al emitir la proforma a factura", error.message);
},
setState({
open: true,
target,
});
};
setState((s) => ({ ...s, loading: false }));
closeDialog();
const closeDialog = () => {
setState((current) => ({
...current,
open: false,
}));
};
const confirmIssue = async () => {
const target = state.target;
if (!target) {
return;
}
if (!canIssueProforma(target)) {
showErrorToast(
t("proformas.issue_proforma_dialog.not_allowed_title"),
t("proformas.issue_proforma_dialog.not_allowed_description")
);
return;
}
try {
await issueMutation.mutateAsync({ id: target.id });
showSuccessToast(
t("proformas.issue_proforma_dialog.success_title"),
t("proformas.issue_proforma_dialog.success_description", {
reference: target.reference,
})
);
setState({
open: false,
target: null,
});
} catch (error) {
const message =
error instanceof Error ? error.message : t("proformas.issue_proforma_dialog.unknown_error");
showErrorToast(t("proformas.issue_proforma_dialog.error_title"), message);
}
};
return {
open: state.open,
proforma: state.proforma,
isSubmitting: isPending,
target: state.target,
isSubmitting: issueMutation.isPending,
openDialog,
closeDialog,
confirmIssue,
};
}
};

View File

@ -0,0 +1 @@
export * from './issue-proforma-target.entity';

View File

@ -0,0 +1,8 @@
import type { ProformaStatus } from "../../shared";
// proformas/issue/entities/issue-proforma-target.entity.ts
export interface IssueProformaTarget {
id: string;
reference: string;
status: ProformaStatus;
}

View File

@ -1 +0,0 @@
export * from "./use-issue-proforma-mutation";

View File

@ -1,48 +0,0 @@
import { useDataSource } from "@erp/core/hooks";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { type IssueProformaInvoiceResponse, issueProformaInvoiceApi } from "../api";
interface IssueProformaPayload {
proformaId: string;
}
interface IssueProformaOptions {
onSuccess?: () => void;
onError?: (err: unknown) => void;
onLoadingChange?: (loading: boolean) => void;
}
export function useIssueProformaMutation() {
const dataSource = useDataSource();
const queryClient = useQueryClient();
const mutation = useMutation<IssueProformaInvoiceResponse, DefaultError, IssueProformaPayload>({
mutationFn: ({ proformaId }) => issueProformaInvoiceApi(dataSource, proformaId),
onSuccess(_data, _vars, _ctx) {
// Refresca el listado de proformas
queryClient.invalidateQueries({ queryKey: ["proformas"] });
// Opcional: refrescar facturas si existe la feature
queryClient.invalidateQueries({ queryKey: ["invoices"] });
},
});
async function issueProforma(proformaId: string, opts?: IssueProformaOptions) {
try {
opts?.onLoadingChange?.(true);
await mutation.mutateAsync({ proformaId });
opts?.onSuccess?.();
} catch (err) {
opts?.onError?.(err);
} finally {
opts?.onLoadingChange?.(false);
}
}
return {
issueProforma,
isPending: mutation.isPending,
};
}

View File

@ -1,2 +1,3 @@
export * from "./controllers";
export * from "./ui";
export * from "./utils";

View File

@ -10,59 +10,58 @@ import {
} from "@repo/shadcn-ui/components";
import { useTranslation } from "../../../i18n";
import type { ProformaSummaryData } from "../../types";
import type { IssueProformaTarget } from "../entities";
interface ProformaIssueDialogProps {
interface IssueProformaDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
proforma: ProformaSummaryData | null;
target: IssueProformaTarget | null;
isSubmitting: boolean;
onConfirm: (proforma?: ProformaSummaryData) => void;
onConfirm: () => void;
}
export function ProformaIssueDialog({
export const IssueProformaDialog = ({
open,
onOpenChange,
proforma,
target,
isSubmitting,
onConfirm,
}: ProformaIssueDialogProps) {
}: IssueProformaDialogProps) => {
const { t } = useTranslation();
return (
<AlertDialog onOpenChange={onOpenChange} open={open}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Emitir a factura</AlertDialogTitle>
<AlertDialogTitle>{t("proformas.issue_proforma_dialog.title")}</AlertDialogTitle>
<AlertDialogDescription className="space-y-4">
<p>
¿Seguro que quieres emitir la factura desde la proforma{" "}
<strong>{proforma?.reference}</strong>?
</p>
<p>
Esta acción creará una nueva factura definitiva y la proforma pasará al estado
"Emitida", no pudiendo modificarse ni eliminarse posteriormente.
</p>
{t("proformas.issue_proforma_dialog.description", {
reference: target?.reference ?? "",
})}
<br />
<br />
{t("proformas.issue_proforma_dialog.warning")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<Button disabled={isSubmitting} onClick={() => onOpenChange(false)} variant="outline">
Cancelar
{t("proformas.issue_proforma_dialog.cancel")}
</Button>
<Button disabled={isSubmitting} onClick={() => onConfirm(proforma)}>
<Button disabled={isSubmitting} onClick={onConfirm}>
{isSubmitting ? (
<>
<Spinner className="mr-2 size-4" />
Emitiendo...
<Spinner aria-hidden className="mr-2 size-4" />
<span aria-live="polite">{t("proformas.issue_proforma_dialog.submitting")}</span>
</>
) : (
<>Emitir factura</>
t("proformas.issue_proforma_dialog.confirm")
)}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
};

View File

@ -0,0 +1 @@
export * from './prepare-issue-proforma-targets';

View File

@ -0,0 +1,25 @@
// proformas/issue/utils/prepare-issue-proforma-targets.ts
import type { Proforma, ProformaListRow } from "../../shared";
import type { IssueProformaTarget } from "../entities";
type SupportedIssueSource = Proforma | ProformaListRow;
type SupportedIssueInput = SupportedIssueSource | SupportedIssueSource[];
const mapToIssueProformaTarget = (proforma: SupportedIssueSource): IssueProformaTarget => ({
id: proforma.id,
reference: proforma.reference,
status: proforma.status,
});
export const prepareIssueProformaTargets = (input: SupportedIssueInput): IssueProformaTarget[] => {
const items = Array.isArray(input) ? input : [input];
return items.map(mapToIssueProformaTarget);
};
export const prepareIssueProformaTarget = (
input: Proforma | ProformaListRow
): IssueProformaTarget => ({
id: input.id,
reference: input.reference,
status: input.status,
});

View File

@ -2,6 +2,7 @@ import type { RightPanelMode } from "@repo/rdx-ui/hooks";
import { useSearchParams } from "react-router-dom";
import { useDeleteProformaDialogController } from "../../delete";
import { useIssueProformaDialogController } from "../../issue-proforma";
import { useListProformasController } from "./use-list-proformas.controller";
import { useProformaSummaryPanelController } from "./use-proforma-summary-panel.controller";
@ -9,6 +10,7 @@ import { useProformaSummaryPanelController } from "./use-proforma-summary-panel.
export const useListProformasPageController = () => {
const listCtrl = useListProformasController();
const deleteDialogCtrl = useDeleteProformaDialogController();
const issueDialogCtrl = useIssueProformaDialogController();
const [searchParams] = useSearchParams();
@ -26,5 +28,6 @@ export const useListProformasPageController = () => {
panelCtrl,
deleteDialogCtrl,
issueDialogCtrl,
};
};

View File

@ -53,6 +53,7 @@ export const ProformasGrid = ({
manualPagination
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
//onRowClick={(row) => onRowClick?.(row.id)}
pageIndex={pageIndex}
pageSize={pageSize}
totalItems={totalItems}

View File

@ -18,6 +18,7 @@ import * as React from "react";
import { useTranslation } from "../../../../../i18n";
import {
PROFORMA_STATUS,
PROFORMA_STATUS_TRANSITIONS,
type ProformaListRow,
type ProformaStatus,
@ -227,12 +228,12 @@ export function useProformasGridColumns(
enableSorting: false,
cell: ({ row }) => {
const proforma = row.original;
const isIssued = proforma.status === "issued";
const isApproved = proforma.status === "approved";
const isIssued = proforma.status === PROFORMA_STATUS.ISSUED;
const isApproved = proforma.status === PROFORMA_STATUS.APPROVED;
const availableTransitions =
PROFORMA_STATUS_TRANSITIONS[proforma.status as ProformaStatus] ?? [];
console.log(availableTransitions);
console.log(availableTransitions, proforma.status);
return (
<div className="flex items-center gap-1">
@ -280,7 +281,7 @@ export function useProformasGridColumns(
)}
{/* Emitir factura: solo si approved */}
{!isIssued && proforma.status === "approved" && actionHandlers.onIssueClick && (
{!isIssued && isApproved && actionHandlers.onIssueClick && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>

View File

@ -17,6 +17,8 @@ import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../../i18n";
import { DeleteProformaDialog } from "../../../delete";
import { prepareDeleteProformaTargets } from "../../../delete/utils";
import { IssueProformaDialog } from "../../../issue-proforma";
import { prepareIssueProformaTarget } from "../../../issue-proforma/utils";
import type { ProformaListRow } from "../../../shared";
import { useListProformasPageController } from "../../controllers";
import { ProformaSummaryPanel, ProformasGrid, useProformasGridColumns } from "../blocks";
@ -25,16 +27,15 @@ export const ListProformasPage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { listCtrl, panelCtrl, deleteDialogCtrl } = useListProformasPageController();
const handleDeleteProforma = (proformaRow: ProformaListRow) => {
deleteDialogCtrl.openDialog(prepareDeleteProformaTargets(proformaRow));
};
const { listCtrl, panelCtrl, deleteDialogCtrl, issueDialogCtrl } =
useListProformasPageController();
const columns = useProformasGridColumns({
onEditClick: (proforma) => navigate(`/proformas/${proforma.id}/edit`),
//onIssueClick: handleIssueProforma,
onDeleteClick: handleDeleteProforma,
onIssueClick: (proformaRow) =>
issueDialogCtrl.openDialog(prepareIssueProformaTarget(proformaRow)),
onDeleteClick: (proformaRow: ProformaListRow) =>
deleteDialogCtrl.openDialog(prepareDeleteProformaTargets(proformaRow)),
//onChangeStatusClick: handleChangeStatusProforma,
});
@ -148,6 +149,19 @@ export const ListProformasPage = () => {
<div className="flex min-h-0 flex-1 overflow-hidden">{listContent}</div>
)}
<>
{/* Issue */}
<IssueProformaDialog
isSubmitting={issueDialogCtrl.isSubmitting}
onConfirm={issueDialogCtrl.confirmIssue}
onOpenChange={(open) => {
if (!open) {
issueDialogCtrl.closeDialog();
}
}}
open={issueDialogCtrl.open}
target={issueDialogCtrl.target}
/>
{/* Eliminar */}
<DeleteProformaDialog
isSecondConfirmStep={deleteDialogCtrl.isSecondConfirmStep}

View File

@ -1,6 +1,8 @@
export * from "./keys";
export * from "./use-proforma-change-status-mutation";
export * from "./use-proforma-create-mutation";
export * from "./use-proforma-delete-mutation";
export * from "./use-proforma-get-query";
export * from "./use-proforma-issue-mutation";
export * from "./use-proforma-update-mutation";
export * from "./use-proformas-list-query";

View File

@ -16,7 +16,7 @@ export const useProformaIssueMutation = () => {
const queryClient = useQueryClient();
const dataSource = useDataSource();
useMutation<
return useMutation<
IssueProformaByIdResult,
DefaultError,
IssueProformaByIdParams,

View File

@ -192,16 +192,6 @@
}
},
"components": {
"delete_proforma_dialog": {
"single_title": "Eliminar proforoma",
"single_description": "",
"second_confirm_single_title": "Confirmar eliminación",
"second_confirm_single_description": "",
"confirm_delete": "Confirmar eliminación",
"deleting": "Eliminando...",
"cancel": "Cancelar",
"continue": "Eliminar"
},
"entity_selector": {
"close": "Close",
"select_entity": "Select entity",

View File

@ -194,18 +194,6 @@
}
},
"components": {
"delete_proforma_dialog": {
"single_title": "Eliminar proforoma",
"single_description": "",
"second_confirm_single_title": "Confirmar eliminación",
"second_confirm_single_description": "",
"multiple_description": "",
"confirm_delete": "Confirmar eliminación",
"deleting": "Eliminando...",
"cancel": "Cancelar",
"continue": "Eliminar",
"list_item": ""
},
"entity_selector": {
"close": "Cerrar",
"select_entity": "Seleccionar entidad",