.
This commit is contained in:
parent
700445499b
commit
132408ac23
@ -12,6 +12,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "../../i18n";
|
||||
|
||||
type SimpleSearchInputProps = {
|
||||
value: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
loading?: boolean;
|
||||
maxHistory?: number;
|
||||
@ -20,12 +21,13 @@ type SimpleSearchInputProps = {
|
||||
const SEARCH_HISTORY_KEY = "search_history";
|
||||
|
||||
export const SimpleSearchInput = ({
|
||||
value,
|
||||
onSearchChange,
|
||||
loading = false,
|
||||
maxHistory = 8,
|
||||
}: SimpleSearchInputProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [searchValue, setSearchValue] = useState(value);
|
||||
const [lastSearch, setLastSearch] = useState("");
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
5
modules/core/src/web/lib/helpers/http-url-utils.ts
Normal file
5
modules/core/src/web/lib/helpers/http-url-utils.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export function safeHTTPUrl(url: string) {
|
||||
if (!url) return "#";
|
||||
if (/^https?:\/\//i.test(url)) return url;
|
||||
return `https://${url}`;
|
||||
}
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./date-func";
|
||||
export * from "./form-utils";
|
||||
export * from "./http-url-utils";
|
||||
|
||||
@ -103,7 +103,14 @@
|
||||
"delete": "Eliminar",
|
||||
"deleting": "Eliminando...",
|
||||
"success_title": "Proforma eliminada",
|
||||
"error_title": "Error al eliminar la proforma"
|
||||
"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.",
|
||||
"multiple_description": "¿Seguro que deseas eliminar las {{total}} proformas seleccionadas? Esta acción no se puede deshacer.",
|
||||
"list_item": "Proforma {{reference}}"
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
|
||||
@ -2,19 +2,17 @@ import type { ModuleClientParams } from "@erp/core/client";
|
||||
import { lazy } from "react";
|
||||
import { Outlet, type RouteObject } from "react-router-dom";
|
||||
|
||||
import { ProformaCreatePage } from "./proformas/create";
|
||||
|
||||
const ProformaLayout = lazy(() =>
|
||||
import("./proformas/ui").then((m) => ({ default: m.ProformaLayout }))
|
||||
import("./proformas/shared").then((m) => ({ default: m.ProformaLayout }))
|
||||
);
|
||||
|
||||
const ProformasListPage = lazy(() =>
|
||||
import("./proformas/list").then((m) => ({ default: m.ProformaListPage }))
|
||||
);
|
||||
|
||||
const ProformasCreatePage = lazy(() =>
|
||||
/*const ProformasCreatePage = lazy(() =>
|
||||
import("./proformas/create").then((m) => ({ default: m.ProformaCreatePage }))
|
||||
);
|
||||
);*/
|
||||
|
||||
/*const InvoiceUpdatePage = lazy(() =>
|
||||
import("./pages").then((m) => ({ default: m.InvoiceUpdatePage }))
|
||||
@ -40,7 +38,7 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[]
|
||||
children: [
|
||||
{ path: "", index: true, element: <ProformasListPage /> }, // index
|
||||
{ path: "list", element: <ProformasListPage /> },
|
||||
{ path: "create", element: <ProformaCreatePage /> },
|
||||
//{ path: "create", element: <ProformaCreatePage /> },
|
||||
//{ path: ":id/edit", element: <InvoiceUpdatePage /> },
|
||||
],
|
||||
},
|
||||
|
||||
@ -1 +0,0 @@
|
||||
export * from "./use-download-invoice-pdf-query";
|
||||
@ -1 +0,0 @@
|
||||
export * from "./use-issued-invoice-list-query";
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./use-download-invoice-pdf-query";
|
||||
export * from "./use-issued-invoice-list-query";
|
||||
@ -3,7 +3,7 @@
|
||||
import { useDataSource } from "@erp/core/hooks";
|
||||
import { type QueryKey, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { downloadInvoicePDFApi } from "../api";
|
||||
import { downloadInvoicePDFApi } from "../../download-pdf/api";
|
||||
|
||||
export const ISSUED_INVOICE_QUERY_KEY = (id: string): QueryKey => ["issued_invoice", id] as const;
|
||||
|
||||
@ -3,8 +3,8 @@ import { useDataSource } from "@erp/core/hooks";
|
||||
import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "@repo/rdx-criteria";
|
||||
import { type DefaultError, type QueryKey, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { getIssuedInvoiceListApi } from "../../list/api";
|
||||
import type { IssuedInvoiceSummaryPage } from "../../types";
|
||||
import { getIssuedInvoiceListApi } from "../api";
|
||||
|
||||
export const ISSUED_INVOICES_QUERY_KEY = (criteria?: CriteriaDTO): QueryKey => [
|
||||
"issued_invoices",
|
||||
@ -0,0 +1 @@
|
||||
export * from "./hooks";
|
||||
@ -38,8 +38,8 @@ import { useFieldArray, useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
|
||||
import { useTranslation } from "../../i18n";
|
||||
import { CustomerInvoicePricesCard } from "../../shared/ui/components";
|
||||
import { CustomerInvoiceItemsCardEditor } from "../../shared/ui/components/items";
|
||||
import { CustomerInvoicePricesCard } from "../../proformas/shared/ui/components";
|
||||
import { CustomerInvoiceItemsCardEditor } from "../../proformas/shared/ui/components/items";
|
||||
|
||||
import type { CustomerInvoiceData } from "./customer-invoice.schema";
|
||||
import { formatCurrency } from "./utils";
|
||||
|
||||
@ -1 +0,0 @@
|
||||
export * from "./change-proforma-status.api";
|
||||
@ -1 +1 @@
|
||||
export * from "./use-change-status-dialog-controller";
|
||||
export * from "./use-change-proforma-status-dialog-controller";
|
||||
|
||||
@ -0,0 +1,160 @@
|
||||
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
|
||||
import * as React from "react";
|
||||
|
||||
import { useTranslation } from "../../../i18n";
|
||||
import type { ProformaListRow } from "../../shared";
|
||||
import type { PROFORMA_STATUS } from "../../shared/entities";
|
||||
import { useChangeProformaStatusMutation } from "../../shared/hooks";
|
||||
|
||||
interface ChangeStatusDialogState {
|
||||
open: boolean;
|
||||
proformas: ProformaListRow[];
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
const INITIAL_STATE: ChangeStatusDialogState = {
|
||||
open: false,
|
||||
proformas: [],
|
||||
isSubmitting: false,
|
||||
};
|
||||
|
||||
function canChangeStatus(isSubmitting: boolean, proformas: ProformaListRow[]): boolean {
|
||||
return !isSubmitting && proformas.length > 0;
|
||||
}
|
||||
|
||||
export function useChangeProformaStatusDialogController() {
|
||||
const { t } = useTranslation();
|
||||
const { changeProformaStatus } = useChangeProformaStatusMutation();
|
||||
|
||||
const [state, setState] = React.useState<ChangeStatusDialogState>(INITIAL_STATE);
|
||||
const { isSubmitting, proformas } = state;
|
||||
|
||||
const openDialog = React.useCallback((proformas: ProformaListRow[]) => {
|
||||
setState({
|
||||
open: true,
|
||||
proformas,
|
||||
isSubmitting: false,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const closeDialog = React.useCallback(() => {
|
||||
setState(INITIAL_STATE);
|
||||
}, []);
|
||||
|
||||
const changeStatusSelectedProformas = React.useCallback(
|
||||
async (proformas: ProformaListRow[], newStatus: PROFORMA_STATUS) => {
|
||||
const results = await Promise.allSettled(
|
||||
proformas.map((proforma) =>
|
||||
changeProformaStatus({
|
||||
proformaId: proforma.id,
|
||||
newStatus,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const successCount = results.filter((result) => result.status === "fulfilled").length;
|
||||
const errorCount = results.length - successCount;
|
||||
|
||||
return {
|
||||
successCount,
|
||||
errorCount,
|
||||
};
|
||||
},
|
||||
[changeProformaStatus]
|
||||
);
|
||||
|
||||
const notifyChangeResult = React.useCallback(
|
||||
(proformas: ProformaListRow[], successCount: number, errorCount: number) => {
|
||||
if (proformas.length === 1 && successCount === 1) {
|
||||
const proforma = proformas[0];
|
||||
|
||||
showSuccessToast(
|
||||
t("pages.proformas.change_status.successTitle"),
|
||||
t("pages.proformas.change_status.successSingleMessage", {
|
||||
reference: proforma.reference || `#${proforma.id}`,
|
||||
})
|
||||
);
|
||||
} else if (successCount > 0) {
|
||||
showSuccessToast(
|
||||
t("pages.proformas.change_status.successTitle"),
|
||||
t("pages.proformas.change_status.successMultipleMessage", {
|
||||
count: successCount,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (errorCount > 0) {
|
||||
showErrorToast(
|
||||
t("pages.proformas.change_status.errorTitle"),
|
||||
proformas.length === 1
|
||||
? t("pages.proformas.change_status.errorSingleMessage")
|
||||
: t("pages.proformas.change_status.errorMultipleMessage", {
|
||||
count: errorCount,
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const changeStatus = React.useCallback(
|
||||
async (newStatus: PROFORMA_STATUS) => {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
isSubmitting: true,
|
||||
}));
|
||||
|
||||
try {
|
||||
const { successCount, errorCount } = await changeStatusSelectedProformas(
|
||||
proformas,
|
||||
newStatus
|
||||
);
|
||||
|
||||
notifyChangeResult(proformas, successCount, errorCount);
|
||||
|
||||
if (errorCount === 0) {
|
||||
closeDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
setState((current) => ({
|
||||
...current,
|
||||
isSubmitting: false,
|
||||
}));
|
||||
} catch {
|
||||
showErrorToast(
|
||||
t("pages.proformas.change_status.errorTitle"),
|
||||
t("pages.proformas.change_status.errorUnexpectedMessage")
|
||||
);
|
||||
|
||||
setState((current) => ({
|
||||
...current,
|
||||
isSubmitting: false,
|
||||
}));
|
||||
}
|
||||
},
|
||||
[closeDialog, changeStatusSelectedProformas, notifyChangeResult, proformas, t]
|
||||
);
|
||||
|
||||
const confirmChangeStatus = React.useCallback(
|
||||
async (newStatus: PROFORMA_STATUS) => {
|
||||
if (!canChangeStatus(isSubmitting, proformas)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await changeStatus(newStatus);
|
||||
},
|
||||
[changeStatus, isSubmitting, proformas]
|
||||
);
|
||||
|
||||
return {
|
||||
open: state.open,
|
||||
proformas: state.proformas,
|
||||
isSubmitting: state.isSubmitting,
|
||||
isBulkDelete: state.proformas.length > 1,
|
||||
|
||||
openDialog,
|
||||
closeDialog,
|
||||
confirmChangeStatus,
|
||||
};
|
||||
}
|
||||
@ -1,64 +0,0 @@
|
||||
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
|
||||
import * as React from "react";
|
||||
|
||||
import type { PROFORMA_STATUS, ProformaSummaryData } from "../../types";
|
||||
import { useChangeProformaStatus } from "../hooks/use-change-status";
|
||||
|
||||
interface ChangeStatusDialogState {
|
||||
open: boolean;
|
||||
proformas: ProformaSummaryData[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function useChangeStatusDialogController() {
|
||||
const { changeStatus, isPending } = useChangeProformaStatus();
|
||||
|
||||
const [state, setState] = React.useState<ChangeStatusDialogState>({
|
||||
open: false,
|
||||
proformas: [] as ProformaSummaryData[],
|
||||
loading: false,
|
||||
});
|
||||
|
||||
const openDialog = (proformas: ProformaSummaryData[]) => {
|
||||
setState({
|
||||
open: true,
|
||||
proformas,
|
||||
loading: false,
|
||||
});
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setState((s) => ({ ...s, open: false }));
|
||||
};
|
||||
|
||||
const confirmChangeStatus = async (status: PROFORMA_STATUS) => {
|
||||
if (!state.proformas.length) return;
|
||||
|
||||
setState((s) => ({ ...s, loading: true }));
|
||||
|
||||
for (const proforma of state.proformas) {
|
||||
await changeStatus(proforma.id, status, {
|
||||
onSuccess: () => {
|
||||
showSuccessToast("Estado cambiado");
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const error = err as Error;
|
||||
showErrorToast("Error cambiando estado", error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setState((s) => ({ ...s, loading: false }));
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
return {
|
||||
open: state.open,
|
||||
proformas: state.proformas,
|
||||
isSubmitting: state.loading,
|
||||
|
||||
openDialog,
|
||||
closeDialog,
|
||||
confirmChangeStatus,
|
||||
};
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export * from "./use-change-status";
|
||||
@ -1,51 +0,0 @@
|
||||
import { useDataSource } from "@erp/core/hooks";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import type { PROFORMA_STATUS } from "../../types";
|
||||
import { changeProformaStatusApi } from "../api/change-proforma-status.api";
|
||||
|
||||
interface ChangeProformaStatusOptions {
|
||||
onSuccess?: () => void;
|
||||
onError?: (err: unknown) => void;
|
||||
onLoadingChange?: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
interface ChangeProformaStatusPayload {
|
||||
proformaId: string;
|
||||
newStatus: PROFORMA_STATUS;
|
||||
}
|
||||
|
||||
export function useChangeProformaStatus() {
|
||||
const dataSource = useDataSource();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: ({ proformaId, newStatus }: ChangeProformaStatusPayload) =>
|
||||
changeProformaStatusApi(dataSource, proformaId, newStatus),
|
||||
|
||||
onSuccess() {
|
||||
queryClient.invalidateQueries({ queryKey: ["proformas"] });
|
||||
},
|
||||
});
|
||||
|
||||
async function changeStatus(
|
||||
proformaId: string,
|
||||
newStatus: string,
|
||||
opts?: ChangeProformaStatusOptions
|
||||
) {
|
||||
try {
|
||||
opts?.onLoadingChange?.(true);
|
||||
await mutation.mutateAsync({ proformaId, newStatus: newStatus as PROFORMA_STATUS });
|
||||
opts?.onSuccess?.();
|
||||
} catch (err) {
|
||||
opts?.onError?.(err);
|
||||
} finally {
|
||||
opts?.onLoadingChange?.(false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
changeStatus,
|
||||
isPending: mutation.isPending,
|
||||
};
|
||||
}
|
||||
@ -119,7 +119,13 @@ export function ChangeStatusDialog({
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<Dialog
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (isSubmitting) return;
|
||||
onOpenChange(nextOpen);
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
<DialogContent className="sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Cambiar estado de la proforma</DialogTitle>
|
||||
|
||||
@ -1 +0,0 @@
|
||||
export * from "./delete-proforma.api";
|
||||
@ -2,76 +2,182 @@ import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
|
||||
import React from "react";
|
||||
|
||||
import { useTranslation } from "../../../i18n";
|
||||
import type { ProformaSummaryData } from "../../types";
|
||||
import { useDeleteProforma } from "../hooks";
|
||||
import { type ProformaListRow, useDeleteProformaMutation } from "../../shared";
|
||||
|
||||
interface DeleteProformaDialogState {
|
||||
open: boolean;
|
||||
proformas: ProformaSummaryData[];
|
||||
loading: boolean;
|
||||
requireSecondConfirm: boolean;
|
||||
proformas: ProformaListRow[];
|
||||
isSubmitting: boolean;
|
||||
requiresSecondConfirm: boolean;
|
||||
confirmStep: "initial" | "second";
|
||||
}
|
||||
|
||||
const INITIAL_STATE: DeleteProformaDialogState = {
|
||||
open: false,
|
||||
proformas: [],
|
||||
isSubmitting: false,
|
||||
requiresSecondConfirm: false,
|
||||
confirmStep: "initial",
|
||||
};
|
||||
|
||||
const SECOND_CONFIRM_THRESHOLD = 5;
|
||||
|
||||
function canSubmitDelete(isSubmitting: boolean, proformas: ProformaListRow[]): boolean {
|
||||
return !isSubmitting && proformas.length > 0;
|
||||
}
|
||||
|
||||
function shouldMoveToSecondConfirmStep(
|
||||
requiresSecondConfirm: boolean,
|
||||
confirmStep: "initial" | "second"
|
||||
): boolean {
|
||||
return requiresSecondConfirm && confirmStep === "initial";
|
||||
}
|
||||
|
||||
export function useDeleteProformaDialogController() {
|
||||
const { t } = useTranslation();
|
||||
const { deleteProforma } = useDeleteProforma();
|
||||
const { deleteProforma } = useDeleteProformaMutation();
|
||||
|
||||
const [state, setState] = React.useState<DeleteProformaDialogState>({
|
||||
open: false,
|
||||
proformas: [],
|
||||
loading: false,
|
||||
requireSecondConfirm: false,
|
||||
});
|
||||
const [state, setState] = React.useState<DeleteProformaDialogState>(INITIAL_STATE);
|
||||
const { isSubmitting, proformas, requiresSecondConfirm, confirmStep } = state;
|
||||
|
||||
const openDialog = React.useCallback((proformas: ProformaListRow[]) => {
|
||||
const requiresSecondConfirm = proformas.length > SECOND_CONFIRM_THRESHOLD;
|
||||
|
||||
const openDialog = (proformas: ProformaSummaryData[]) => {
|
||||
const needDoubleCheck = proformas.length > 5;
|
||||
setState({
|
||||
open: true,
|
||||
proformas,
|
||||
loading: false,
|
||||
requireSecondConfirm: needDoubleCheck,
|
||||
isSubmitting: false,
|
||||
requiresSecondConfirm,
|
||||
confirmStep: "initial",
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
const closeDialog = () => {
|
||||
setState((s) => ({ ...s, open: false }));
|
||||
};
|
||||
const closeDialog = React.useCallback(() => {
|
||||
setState(INITIAL_STATE);
|
||||
}, []);
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (state.proformas.length === 0) return;
|
||||
const moveToSecondConfirmStep = React.useCallback(() => {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
confirmStep: "second",
|
||||
}));
|
||||
}, []);
|
||||
|
||||
if (state.requireSecondConfirm) {
|
||||
setState((s) => ({ ...s, requireSecondConfirm: false }));
|
||||
return; // ahora el UI mostrará un segundo mensaje de confirmación
|
||||
const deleteSelectedProformas = React.useCallback(
|
||||
async (proformas: ProformaListRow[]) => {
|
||||
const results = await Promise.allSettled(
|
||||
proformas.map((proforma) =>
|
||||
deleteProforma({
|
||||
proformaId: proforma.id,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const successCount = results.filter((result) => result.status === "fulfilled").length;
|
||||
const errorCount = results.length - successCount;
|
||||
|
||||
return {
|
||||
successCount,
|
||||
errorCount,
|
||||
};
|
||||
},
|
||||
[deleteProforma]
|
||||
);
|
||||
|
||||
const notifyDeleteResult = React.useCallback(
|
||||
(proformas: ProformaListRow[], successCount: number, errorCount: number) => {
|
||||
if (proformas.length === 1 && successCount === 1) {
|
||||
const proforma = proformas[0];
|
||||
|
||||
showSuccessToast(
|
||||
t("pages.proformas.delete.successTitle"),
|
||||
t("pages.proformas.delete.successSingleMessage", {
|
||||
reference: proforma.reference || `#${proforma.id}`,
|
||||
})
|
||||
);
|
||||
} else if (successCount > 0) {
|
||||
showSuccessToast(
|
||||
t("pages.proformas.delete.successTitle"),
|
||||
t("pages.proformas.delete.successMultipleMessage", {
|
||||
count: successCount,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (errorCount > 0) {
|
||||
showErrorToast(
|
||||
t("pages.proformas.delete.errorTitle"),
|
||||
proformas.length === 1
|
||||
? t("pages.proformas.delete.errorSingleMessage")
|
||||
: t("pages.proformas.delete.errorMultipleMessage", {
|
||||
count: errorCount,
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const submitDelete = React.useCallback(async () => {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
isSubmitting: true,
|
||||
}));
|
||||
|
||||
try {
|
||||
const { successCount, errorCount } = await deleteSelectedProformas(proformas);
|
||||
|
||||
notifyDeleteResult(proformas, successCount, errorCount);
|
||||
|
||||
if (errorCount === 0) {
|
||||
closeDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
setState((current) => ({
|
||||
...current,
|
||||
isSubmitting: false,
|
||||
}));
|
||||
} catch {
|
||||
showErrorToast(
|
||||
t("pages.proformas.delete.errorTitle"),
|
||||
t("pages.proformas.delete.errorUnexpectedMessage")
|
||||
);
|
||||
|
||||
setState((current) => ({
|
||||
...current,
|
||||
isSubmitting: false,
|
||||
}));
|
||||
}
|
||||
}, [closeDialog, deleteSelectedProformas, notifyDeleteResult, proformas, t]);
|
||||
|
||||
const confirmDelete = React.useCallback(async () => {
|
||||
if (!canSubmitDelete(isSubmitting, proformas)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((s) => ({ ...s, loading: true }));
|
||||
|
||||
for (const p of state.proformas) {
|
||||
await deleteProforma(p.id, {
|
||||
onSuccess: () => {
|
||||
showSuccessToast(
|
||||
"Proforma eliminada",
|
||||
`La proforma ${p.reference ?? `#${p.id}`} ha sido eliminada.`
|
||||
);
|
||||
},
|
||||
onError: (err) => {
|
||||
showErrorToast(
|
||||
"Error al eliminar",
|
||||
err instanceof Error ? err.message : "Ocurrió un error al eliminar la proforma"
|
||||
);
|
||||
},
|
||||
});
|
||||
if (shouldMoveToSecondConfirmStep(requiresSecondConfirm, confirmStep)) {
|
||||
moveToSecondConfirmStep();
|
||||
return;
|
||||
}
|
||||
|
||||
setState((s) => ({ ...s, loading: false }));
|
||||
closeDialog();
|
||||
};
|
||||
await submitDelete();
|
||||
}, [
|
||||
moveToSecondConfirmStep,
|
||||
submitDelete,
|
||||
isSubmitting,
|
||||
proformas,
|
||||
requiresSecondConfirm,
|
||||
confirmStep,
|
||||
]);
|
||||
|
||||
return {
|
||||
open: state.open,
|
||||
proformas: state.proformas,
|
||||
isSubmitting: state.loading,
|
||||
isSubmitting: state.isSubmitting,
|
||||
requiresSecondConfirm: state.requiresSecondConfirm,
|
||||
isSecondConfirmStep: state.confirmStep === "second",
|
||||
isBulkDelete: state.proformas.length > 1,
|
||||
|
||||
openDialog,
|
||||
closeDialog,
|
||||
|
||||
@ -1 +0,0 @@
|
||||
export * from "./use-delete-proforma";
|
||||
@ -1,45 +0,0 @@
|
||||
import { useDataSource } from "@erp/core/hooks";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { deleteProformaApi } from "../api";
|
||||
|
||||
interface DeleteProformaOptions {
|
||||
onSuccess?: () => void;
|
||||
onError?: (err: unknown) => void;
|
||||
onLoadingChange?: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
interface DeleteProformaPayload {
|
||||
proformaId: string;
|
||||
}
|
||||
|
||||
export function useDeleteProforma() {
|
||||
const dataSource = useDataSource();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: ({ proformaId }: DeleteProformaPayload) =>
|
||||
deleteProformaApi(dataSource, proformaId),
|
||||
|
||||
onSuccess() {
|
||||
queryClient.invalidateQueries({ queryKey: ["proformas"] });
|
||||
},
|
||||
});
|
||||
|
||||
async function deleteProforma(proformaId: string, opts?: DeleteProformaOptions) {
|
||||
try {
|
||||
opts?.onLoadingChange?.(true);
|
||||
await mutation.mutateAsync({ proformaId });
|
||||
opts?.onSuccess?.();
|
||||
} catch (err) {
|
||||
opts?.onError?.(err);
|
||||
} finally {
|
||||
opts?.onLoadingChange?.(false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
deleteProforma,
|
||||
isPending: mutation.isPending,
|
||||
};
|
||||
}
|
||||
@ -1 +1,2 @@
|
||||
export * from "./controllers";
|
||||
export * from "./ui";
|
||||
|
||||
@ -8,18 +8,18 @@ import {
|
||||
Button,
|
||||
Spinner,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useTranslation } from "../../../i18n";
|
||||
import type { ProformaSummaryData } from "../../types";
|
||||
import { useTranslation } from "../../../../i18n";
|
||||
import type { ProformaListRow } from "../../../shared";
|
||||
|
||||
interface DeleteProformaDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
proformas: ProformaSummaryData[];
|
||||
proformas: ProformaListRow[];
|
||||
isSubmitting: boolean;
|
||||
onConfirm: () => void;
|
||||
requireSecondConfirm: boolean;
|
||||
requiresSecondConfirm: boolean;
|
||||
isSecondConfirmStep: boolean;
|
||||
}
|
||||
|
||||
export function DeleteProformaDialog({
|
||||
@ -28,56 +28,53 @@ export function DeleteProformaDialog({
|
||||
proformas,
|
||||
isSubmitting,
|
||||
onConfirm,
|
||||
requireSecondConfirm,
|
||||
requiresSecondConfirm,
|
||||
isSecondConfirmStep,
|
||||
}: DeleteProformaDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const total = proformas.length;
|
||||
const isSingle = total === 1;
|
||||
const firstProforma = proformas[0];
|
||||
|
||||
const title = requireSecondConfirm
|
||||
? "Confirmación adicional"
|
||||
const title = isSecondConfirmStep
|
||||
? t("proformas.delete_proforma_dialog.second_confirm_title", { count: total })
|
||||
: isSingle
|
||||
? `Eliminar proforma ${proformas[0].reference ?? `#${proformas[0].id}`}`
|
||||
: `Eliminar ${total} proformas`;
|
||||
? t("proformas.delete_proforma_dialog.single_title", {
|
||||
reference: firstProforma?.reference ?? `#${firstProforma?.id}`,
|
||||
})
|
||||
: t("proformas.delete_proforma_dialog.multiple_title", { count: total });
|
||||
|
||||
const description = requireSecondConfirm
|
||||
? `Estás a punto de borrar ${total} proformas. Esta acción masiva no se puede deshacer. ¿Seguro que quieres continuar?`
|
||||
const description = isSecondConfirmStep
|
||||
? t("proformas.delete_proforma_dialog.second_confirm_description", { count: total })
|
||||
: isSingle
|
||||
? "¿Seguro que deseas eliminar esta proforma? Esta acción no se puede deshacer."
|
||||
: `¿Seguro que deseas eliminar las ${total} proformas seleccionadas? Esta acción no se puede deshacer.`;
|
||||
|
||||
// Usar teclado para confirmar (Enter / Escape)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onConfirm();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [open, onConfirm, onOpenChange]);
|
||||
? t("proformas.delete_proforma_dialog.single_description")
|
||||
: t("proformas.delete_proforma_dialog.multiple_description", { count: total });
|
||||
|
||||
return (
|
||||
<AlertDialog onOpenChange={onOpenChange} open={open}>
|
||||
<AlertDialog
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (isSubmitting) return;
|
||||
onOpenChange(nextOpen);
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
<AlertDialogContent className="max-w-lg">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
{!requireSecondConfirm && total > 1 && (
|
||||
{!isSecondConfirmStep && total > 1 && (
|
||||
<div className="mt-4 max-h-48 overflow-y-auto rounded-md border p-3 text-sm">
|
||||
<ul className="space-y-1">
|
||||
{proformas.map((p) => (
|
||||
<li className="flex justify-between text-muted-foreground" key={p.id}>
|
||||
<span>Proforma {p.reference ?? `#${p.id}`}</span>
|
||||
{proformas.map((proforma) => (
|
||||
<li className="flex justify-between text-muted-foreground" key={proforma.id}>
|
||||
<span>
|
||||
{t("proformas.delete_proforma_dialog.list_item", {
|
||||
reference: proforma.reference ?? `#${proforma.id}`,
|
||||
})}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@ -95,12 +92,14 @@ export function DeleteProformaDialog({
|
||||
<Spinner className="mr-2 size-4" />
|
||||
{t("proformas.delete_proforma_dialog.deleting")}
|
||||
</>
|
||||
) : requireSecondConfirm ? (
|
||||
<>{t("proformas.delete_proforma_dialog.mass_delete")}</>
|
||||
) : isSecondConfirmStep ? (
|
||||
t("proformas.delete_proforma_dialog.confirm_mass_delete")
|
||||
) : isSingle ? (
|
||||
<>{t("proformas.delete_proforma_dialog.delete")}</>
|
||||
t("proformas.delete_proforma_dialog.delete")
|
||||
) : requiresSecondConfirm ? (
|
||||
t("proformas.delete_proforma_dialog.continue")
|
||||
) : (
|
||||
<>{t("proformas.delete_proforma_dialog.delete_plural")}</>
|
||||
t("proformas.delete_proforma_dialog.delete_plural")
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
@ -0,0 +1 @@
|
||||
export * from "./delete-proforma-dialog";
|
||||
@ -1 +1 @@
|
||||
export * from "./delete-proforma-dialog";
|
||||
export * from "./components";
|
||||
|
||||
@ -1,4 +1,2 @@
|
||||
export * from "./use-issue-proforma-invoice";
|
||||
export * from "./use-proforma-query";
|
||||
export * from "./use-proforma-update-mutation";
|
||||
export * from "./use-proformas-query";
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
11111import
|
||||
{
|
||||
useDataSource;
|
||||
}
|
||||
from;
|
||||
("@erp/core/hooks");
|
||||
import {
|
||||
useDataSource
|
||||
} from ("@erp/core/hooks");
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
@ -1,49 +0,0 @@
|
||||
import { useDataSource } from "@erp/core/hooks";
|
||||
import { type DefaultError, type QueryKey, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import type { Proforma } from "../types/proforma.api.schema";
|
||||
|
||||
export const PROFORMA_QUERY_KEY = (id: string): QueryKey => ["proforma", id] as const;
|
||||
|
||||
type InvoiceQueryOptions = {
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export const useProformaQuery = (proformaId?: string, options?: InvoiceQueryOptions) => {
|
||||
const dataSource = useDataSource();
|
||||
const enabled = (options?.enabled ?? true) && !!proformaId;
|
||||
|
||||
return useQuery<Proforma, DefaultError>({
|
||||
queryKey: PROFORMA_QUERY_KEY(proformaId ?? "unknown"),
|
||||
queryFn: async (context) => {
|
||||
const { signal } = context;
|
||||
if (!proformaId) {
|
||||
if (!proformaId) throw new Error("proformaId is required");
|
||||
}
|
||||
return await dataSource.getOne<Proforma>("proformas", proformaId, {
|
||||
signal,
|
||||
});
|
||||
},
|
||||
enabled,
|
||||
});
|
||||
};
|
||||
|
||||
/*
|
||||
export function useQuery<
|
||||
TQueryFnData = unknown,
|
||||
TError = unknown,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey
|
||||
>
|
||||
|
||||
TQueryFnData: the type returned from the queryFn.
|
||||
TError: the type of Errors to expect from the queryFn.
|
||||
TData: the type our data property will eventually have.
|
||||
Only relevant if you use the select option,
|
||||
because then the data property can be different
|
||||
from what the queryFn returns.
|
||||
Otherwise, it will default to whatever the queryFn returns.
|
||||
TQueryKey: the type of our queryKey, only relevant
|
||||
if you use the queryKey that is passed to your queryFn.
|
||||
|
||||
*/
|
||||
@ -1,66 +0,0 @@
|
||||
import { useDataSource } from "@erp/core/hooks";
|
||||
import { ValidationErrorCollection } from "@repo/rdx-ddd";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
type UpdateProformaByIdRequestDTO,
|
||||
UpdateProformaByIdRequestSchema,
|
||||
} from "../../../common";
|
||||
import type { InvoiceFormData } from "../../schemas";
|
||||
|
||||
import { PROFORMA_QUERY_KEY } from "./use-proforma-query";
|
||||
import { PROFORMAS_QUERY_KEY } from "./use-proformas-query";
|
||||
|
||||
type UpdateProformaContext = unknown;
|
||||
|
||||
type UpdateProformaPayload = {
|
||||
id: string;
|
||||
data: Partial<InvoiceFormData>;
|
||||
};
|
||||
|
||||
export function useUpdateProforma() {
|
||||
const queryClient = useQueryClient();
|
||||
const dataSource = useDataSource();
|
||||
const schema = UpdateProformaByIdRequestSchema;
|
||||
|
||||
return useMutation<InvoiceFormData, Error, UpdateProformaPayload, UpdateProformaContext>({
|
||||
mutationKey: ["proforma:update"], //, customerId],
|
||||
|
||||
mutationFn: async (payload) => {
|
||||
const { id: invoiceId, data } = payload;
|
||||
|
||||
if (!invoiceId) {
|
||||
throw new Error("customerInvoiceId is required");
|
||||
}
|
||||
|
||||
const result = schema.safeParse(data);
|
||||
if (!result.success) {
|
||||
// Construye errores detallados
|
||||
const validationErrors = result.error.issues.map((err) => ({
|
||||
field: err.path.join("."),
|
||||
message: err.message,
|
||||
}));
|
||||
|
||||
throw new ValidationErrorCollection("Validation failed", validationErrors);
|
||||
}
|
||||
|
||||
const updated = await dataSource.updateOne("customer-invoices", invoiceId, data);
|
||||
return updated as InvoiceFormData;
|
||||
},
|
||||
onSuccess: (updated: InvoiceFormData, variables) => {
|
||||
const { id: invoiceId } = variables;
|
||||
|
||||
// Refresca inmediatamente el detalle
|
||||
queryClient.setQueryData<UpdateProformaByIdRequestDTO>(
|
||||
PROFORMA_QUERY_KEY(invoiceId),
|
||||
updated
|
||||
);
|
||||
|
||||
// Otra opción es invalidar el detalle para forzar refetch:
|
||||
// queryClient.invalidateQueries({ queryKey: CUSTOMER_QUERY_KEY(customerId) });
|
||||
|
||||
// Invalida el listado para refrescar desde servidor
|
||||
queryClient.invalidateQueries({ queryKey: PROFORMAS_QUERY_KEY() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -1 +1,2 @@
|
||||
export * from "./list";
|
||||
export * from "./update";
|
||||
|
||||
@ -1 +0,0 @@
|
||||
export * from "./proforma-summary-dto.adapter";
|
||||
@ -1,71 +0,0 @@
|
||||
import { MoneyDTOHelper, PercentageDTOHelper, formatCurrency } from "@erp/core";
|
||||
|
||||
import type {
|
||||
ProformaSummaryData,
|
||||
ProformaSummaryPage,
|
||||
ProformaSummaryPageData,
|
||||
} from "../../types";
|
||||
|
||||
/**
|
||||
* Convierte el DTO completo de API a datos numéricos para el formulario.
|
||||
*/
|
||||
export const ProformaSummaryDtoAdapter = {
|
||||
fromDto(pageDto: ProformaSummaryPage, context?: unknown): ProformaSummaryPageData {
|
||||
return {
|
||||
...pageDto,
|
||||
items: pageDto.items.map(
|
||||
(summaryDto) =>
|
||||
({
|
||||
...summaryDto,
|
||||
|
||||
subtotal_amount: MoneyDTOHelper.toNumber(summaryDto.subtotal_amount),
|
||||
subtotal_amount_fmt: formatCurrency(
|
||||
MoneyDTOHelper.toNumber(summaryDto.subtotal_amount),
|
||||
Number(summaryDto.total_amount.scale || 2),
|
||||
summaryDto.currency_code,
|
||||
summaryDto.language_code
|
||||
),
|
||||
|
||||
discount_percentage: PercentageDTOHelper.toNumber(summaryDto.discount_percentage),
|
||||
discount_percentage_fmt: PercentageDTOHelper.toNumericString(
|
||||
summaryDto.discount_percentage
|
||||
),
|
||||
|
||||
discount_amount: MoneyDTOHelper.toNumber(summaryDto.discount_amount),
|
||||
discount_amount_fmt: formatCurrency(
|
||||
MoneyDTOHelper.toNumber(summaryDto.discount_amount),
|
||||
Number(summaryDto.total_amount.scale || 2),
|
||||
summaryDto.currency_code,
|
||||
summaryDto.language_code
|
||||
),
|
||||
|
||||
taxable_amount: MoneyDTOHelper.toNumber(summaryDto.taxable_amount),
|
||||
taxable_amount_fmt: formatCurrency(
|
||||
MoneyDTOHelper.toNumber(summaryDto.taxable_amount),
|
||||
Number(summaryDto.total_amount.scale || 2),
|
||||
summaryDto.currency_code,
|
||||
summaryDto.language_code
|
||||
),
|
||||
|
||||
taxes_amount: MoneyDTOHelper.toNumber(summaryDto.taxes_amount),
|
||||
taxes_amount_fmt: formatCurrency(
|
||||
MoneyDTOHelper.toNumber(summaryDto.taxes_amount),
|
||||
Number(summaryDto.total_amount.scale || 2),
|
||||
summaryDto.currency_code,
|
||||
summaryDto.language_code
|
||||
),
|
||||
|
||||
total_amount: MoneyDTOHelper.toNumber(summaryDto.total_amount),
|
||||
total_amount_fmt: formatCurrency(
|
||||
MoneyDTOHelper.toNumber(summaryDto.total_amount),
|
||||
Number(summaryDto.total_amount.scale || 2),
|
||||
summaryDto.currency_code,
|
||||
summaryDto.language_code
|
||||
),
|
||||
|
||||
//taxes: dto.taxes,
|
||||
}) as unknown as ProformaSummaryData
|
||||
),
|
||||
};
|
||||
},
|
||||
};
|
||||
@ -1 +0,0 @@
|
||||
export * from "./get-proforma-list.api";
|
||||
@ -1,2 +1,2 @@
|
||||
export * from "./use-proforma-list.controller.ts";
|
||||
export * from "./use-proforma-list-page.controller.ts";
|
||||
export * from "./use-list-proformas.controller.ts";
|
||||
export * from "./use-list-proformas-page.controller.ts";
|
||||
|
||||
@ -0,0 +1,62 @@
|
||||
import React from "react";
|
||||
|
||||
import { useChangeProformaStatusDialogController } from "../../change-status";
|
||||
import { useDeleteProformaDialogController } from "../../delete";
|
||||
import { useProformaIssueDialogController } from "../../issue-proforma";
|
||||
import type { ProformaListRow } from "../../shared";
|
||||
import {
|
||||
type PROFORMA_STATUS,
|
||||
PROFORMA_STATUS_TRANSITIONS,
|
||||
} from "../../shared/entities/proforma-status.entity";
|
||||
|
||||
import { useListProformasController } from "./use-list-proformas.controller";
|
||||
|
||||
export function useListProformasPageController() {
|
||||
const listCtrl = useListProformasController();
|
||||
|
||||
// Controlador de diálogos
|
||||
const issueDialogCtrl = useProformaIssueDialogController();
|
||||
const changeStatusDialogCtrl = useChangeProformaStatusDialogController();
|
||||
const deleteDialogCtrl = useDeleteProformaDialogController();
|
||||
|
||||
const handleOpenIssueProformaDialog = React.useCallback(
|
||||
(proforma: ProformaListRow) => {
|
||||
issueDialogCtrl.openDialog(proforma);
|
||||
},
|
||||
[issueDialogCtrl]
|
||||
);
|
||||
|
||||
const handleOpenChangeProformaStatusDialog = React.useCallback(
|
||||
(proforma: ProformaListRow, nextStatus: string) => {
|
||||
const proforma_status = proforma.status as PROFORMA_STATUS;
|
||||
const transitions = PROFORMA_STATUS_TRANSITIONS[proforma_status] ?? [];
|
||||
|
||||
if (!transitions.includes(nextStatus as PROFORMA_STATUS)) {
|
||||
console.warn(`Transición inválida: ${proforma.status} → ${nextStatus}`);
|
||||
return;
|
||||
}
|
||||
|
||||
changeStatusDialogCtrl.openDialog([proforma], nextStatus as PROFORMA_STATUS);
|
||||
},
|
||||
[changeStatusDialogCtrl]
|
||||
);
|
||||
|
||||
const handleOpenDeleteProformaDialog = React.useCallback(
|
||||
(proforma: ProformaListRow) => {
|
||||
deleteDialogCtrl.openDialog([proforma]);
|
||||
},
|
||||
[deleteDialogCtrl]
|
||||
);
|
||||
|
||||
return {
|
||||
listCtrl,
|
||||
|
||||
issueDialogCtrl,
|
||||
changeStatusDialogCtrl,
|
||||
deleteDialogCtrl,
|
||||
|
||||
handleIssueProforma: handleOpenIssueProformaDialog,
|
||||
handleChangeStatusProforma: handleOpenChangeProformaStatusDialog,
|
||||
handleDeleteProforma: handleOpenDeleteProformaDialog,
|
||||
};
|
||||
}
|
||||
@ -2,10 +2,9 @@ import type { CriteriaDTO } from "@erp/core";
|
||||
import { useDebounce } from "@repo/rdx-ui/components";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { ProformaSummaryDtoAdapter } from "../adapters";
|
||||
import { useProformaListQuery } from "../hooks";
|
||||
import { useListProformasQuery } from "../../shared";
|
||||
|
||||
export const useProformaListController = () => {
|
||||
export const useListProformasController = () => {
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [search, setSearch] = useState("");
|
||||
@ -15,7 +14,7 @@ export const useProformaListController = () => {
|
||||
|
||||
const criteria = useMemo<CriteriaDTO>(() => {
|
||||
const baseFilters =
|
||||
status !== "all" ? [{ field: "status", operator: "CONTAINS", value: status }] : [];
|
||||
status === "all" ? [] : [{ field: "status", operator: "EQUALS", value: status }];
|
||||
|
||||
return {
|
||||
q: debouncedQ || "",
|
||||
@ -27,25 +26,42 @@ export const useProformaListController = () => {
|
||||
};
|
||||
}, [pageSize, pageIndex, debouncedQ, status]);
|
||||
|
||||
const query = useProformaListQuery({ criteria });
|
||||
const data = useMemo(
|
||||
() => (query.data ? ProformaSummaryDtoAdapter.fromDto(query.data) : undefined),
|
||||
[query.data]
|
||||
);
|
||||
const query = useListProformasQuery({ criteria });
|
||||
|
||||
const setSearchValue = (value: string) => setSearch(value.trim().replace(/\s+/g, " "));
|
||||
const setSearchValue = (value: string) => {
|
||||
setSearch(value.trim().replace(/\s+/g, " "));
|
||||
setPageIndex(0);
|
||||
};
|
||||
|
||||
const setStatusFilter = (newStatus: string) => setStatus(newStatus);
|
||||
const setPageSizeValue = (value: number) => {
|
||||
setPageSize(value);
|
||||
setPageIndex(0);
|
||||
};
|
||||
|
||||
const setStatusFilter = (newStatus: string) => {
|
||||
setStatus(newStatus);
|
||||
setPageIndex(0);
|
||||
};
|
||||
|
||||
return {
|
||||
...query,
|
||||
data,
|
||||
data: query.data,
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
|
||||
refetch: query.refetch,
|
||||
|
||||
pageIndex,
|
||||
pageSize,
|
||||
search,
|
||||
setPageIndex,
|
||||
setPageSize,
|
||||
setPageSize: setPageSizeValue,
|
||||
|
||||
search,
|
||||
setSearchValue,
|
||||
|
||||
status,
|
||||
setStatusFilter,
|
||||
};
|
||||
};
|
||||
@ -1,63 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
import { useChangeStatusDialogController } from "../../change-status";
|
||||
import { useDeleteProformaDialogController } from "../../delete";
|
||||
import { useProformaIssueDialogController } from "../../issue-proforma";
|
||||
import {
|
||||
type PROFORMA_STATUS,
|
||||
PROFORMA_STATUS_TRANSITIONS,
|
||||
type ProformaSummaryData,
|
||||
} from "../../types";
|
||||
|
||||
import { useProformaListController } from "./use-proforma-list.controller";
|
||||
|
||||
export function useProformaListPageController() {
|
||||
const listCtrl = useProformaListController();
|
||||
|
||||
// Controlador de diálogos
|
||||
const issueDialogCtrl = useProformaIssueDialogController();
|
||||
const changeStatusDialogCtrl = useChangeStatusDialogController();
|
||||
const deleteDialogCtrl = useDeleteProformaDialogController();
|
||||
|
||||
const handleIssueProforma = React.useCallback(
|
||||
(proforma: ProformaSummaryData) => {
|
||||
// Solo si approved → issued
|
||||
issueDialogCtrl.openDialog(proforma);
|
||||
},
|
||||
[issueDialogCtrl]
|
||||
);
|
||||
|
||||
const handleChangeStatusProforma = React.useCallback(
|
||||
(proforma: ProformaSummaryData, nextStatus: string) => {
|
||||
const proforma_status = proforma.status as PROFORMA_STATUS;
|
||||
const transitions = PROFORMA_STATUS_TRANSITIONS[proforma_status] ?? [];
|
||||
|
||||
if (!transitions.includes(nextStatus as PROFORMA_STATUS)) {
|
||||
console.warn(`Transición inválida: ${proforma.status} → ${nextStatus}`);
|
||||
return;
|
||||
}
|
||||
|
||||
changeStatusDialogCtrl.openDialog([proforma]);
|
||||
},
|
||||
[changeStatusDialogCtrl]
|
||||
);
|
||||
|
||||
const handleDeleteProforma = React.useCallback(
|
||||
(proforma: ProformaSummaryData) => {
|
||||
deleteDialogCtrl.openDialog([proforma]);
|
||||
},
|
||||
[deleteDialogCtrl]
|
||||
);
|
||||
|
||||
return {
|
||||
listCtrl,
|
||||
|
||||
issueDialogCtrl,
|
||||
changeStatusDialogCtrl,
|
||||
deleteDialogCtrl,
|
||||
|
||||
handleIssueProforma,
|
||||
handleChangeStatusProforma,
|
||||
handleDeleteProforma,
|
||||
};
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export * from "./use-proforma-list-query";
|
||||
@ -1,38 +0,0 @@
|
||||
import type { CriteriaDTO } from "@erp/core";
|
||||
import { useDataSource } from "@erp/core/hooks";
|
||||
import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "@repo/rdx-criteria";
|
||||
import { type DefaultError, type QueryKey, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import type { ProformaSummaryPage } from "../../types";
|
||||
import { getProformaListApi } from "../api";
|
||||
|
||||
export const PROFORMAS_QUERY_KEY = (criteria?: CriteriaDTO): QueryKey => [
|
||||
"proforma",
|
||||
{
|
||||
pageNumber: criteria?.pageNumber ?? INITIAL_PAGE_INDEX,
|
||||
pageSize: criteria?.pageSize ?? INITIAL_PAGE_SIZE,
|
||||
q: criteria?.q ?? "",
|
||||
filters: criteria?.filters ?? [],
|
||||
orderBy: criteria?.orderBy ?? "",
|
||||
order: criteria?.order ?? "",
|
||||
},
|
||||
];
|
||||
|
||||
type ProformasQueryOptions = {
|
||||
enabled?: boolean;
|
||||
criteria?: CriteriaDTO;
|
||||
};
|
||||
|
||||
// Obtener todas las facturas
|
||||
export const useProformaListQuery = (options?: ProformasQueryOptions) => {
|
||||
const dataSource = useDataSource();
|
||||
const enabled = options?.enabled ?? true;
|
||||
const criteria = options?.criteria ?? {};
|
||||
|
||||
return useQuery<ProformaSummaryPage, DefaultError>({
|
||||
queryKey: PROFORMAS_QUERY_KEY(criteria),
|
||||
queryFn: async ({ signal }) => getProformaListApi(dataSource, signal, criteria),
|
||||
enabled,
|
||||
placeholderData: (previousData, _previousQuery) => previousData, // Mantener datos previos mientras se carga nueva datos (antiguo `keepPreviousData`)
|
||||
});
|
||||
};
|
||||
@ -1 +1,2 @@
|
||||
export * from "./proformas-grid";
|
||||
export * from "./use-proforma-grid-columns";
|
||||
|
||||
@ -13,11 +13,10 @@ import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useTranslation } from "../../../../i18n";
|
||||
import { ChangeStatusDialog } from "../../../change-status";
|
||||
import { DeleteProformaDialog } from "../../../delete/ui";
|
||||
import { DeleteProformaDialog } from "../../../delete/ui/components";
|
||||
import { ProformaIssueDialog } from "../../../issue-proforma";
|
||||
import { useProformaListPageController } from "../../controllers";
|
||||
import { ProformasGrid } from "../blocks/proformas-grid";
|
||||
import { useProformasGridColumns } from "../blocks/proformas-grid/use-proforma-grid-columns";
|
||||
import { useListProformasPageController } from "../../controllers";
|
||||
import { ProformasGrid, useProformasGridColumns } from "../blocks";
|
||||
|
||||
export const ProformaListPage = () => {
|
||||
const { t } = useTranslation();
|
||||
@ -33,7 +32,7 @@ export const ProformaListPage = () => {
|
||||
handleChangeStatusProforma,
|
||||
handleDeleteProforma,
|
||||
handleIssueProforma,
|
||||
} = useProformaListPageController();
|
||||
} = useListProformasPageController();
|
||||
|
||||
const columns = useProformasGridColumns({
|
||||
onEditClick: (proforma) => navigate(`/proformas/${proforma.id}/edit`),
|
||||
@ -77,6 +76,7 @@ export const ProformaListPage = () => {
|
||||
<SimpleSearchInput
|
||||
loading={listCtrl.isLoading}
|
||||
onSearchChange={listCtrl.setSearchValue}
|
||||
value={listCtrl.search}
|
||||
/>
|
||||
|
||||
<Select defaultValue="all" onValueChange={listCtrl.setStatusFilter}>
|
||||
|
||||
@ -8,7 +8,7 @@ import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useTranslation } from "../../../i18n";
|
||||
import { ProformaDtoAdapter } from "../../adapters";
|
||||
import { useUpdateProforma } from "../../hooks/use-proforma-update-mutation";
|
||||
import { useUpdateProforma } from "../../shared/hooks/use-proforma-update-mutation";
|
||||
import {
|
||||
type Proforma,
|
||||
type ProformaFormData,
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export * from "./list-proformas.adapter";
|
||||
@ -0,0 +1,99 @@
|
||||
import { MoneyDTOHelper, PercentageDTOHelper, formatCurrency } from "@erp/core";
|
||||
|
||||
import type { ListProformasResponseDTO } from "../../../../common";
|
||||
import type { ProformaList, ProformaListRow } from "../entities";
|
||||
|
||||
type ListProformasItemDTO = ListProformasResponseDTO["items"][number];
|
||||
|
||||
export const ListProformasAdapter = {
|
||||
fromDTO(pageDto: ListProformasResponseDTO, context?: unknown): ProformaList {
|
||||
return {
|
||||
//...pageDto,
|
||||
page: pageDto.page,
|
||||
per_page: pageDto.per_page,
|
||||
total_pages: pageDto.total_pages,
|
||||
total_items: pageDto.total_items,
|
||||
items: pageDto.items.map((row) => ListProformasRowAdapter.fromDTO(row, context)),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const ListProformasRowAdapter = {
|
||||
fromDTO(rowDto: ListProformasItemDTO, context?: unknown): ProformaListRow {
|
||||
return {
|
||||
//...rowDto,
|
||||
id: rowDto.id,
|
||||
company_id: rowDto.company_id,
|
||||
|
||||
customer_id: rowDto.company_id,
|
||||
|
||||
invoice_number: rowDto.invoice_number,
|
||||
status: rowDto.status,
|
||||
series: rowDto.series,
|
||||
|
||||
invoice_date: rowDto.invoice_date,
|
||||
operation_date: rowDto.operation_date,
|
||||
|
||||
language_code: rowDto.language_code,
|
||||
currency_code: rowDto.currency_code,
|
||||
|
||||
reference: rowDto.reference,
|
||||
description: rowDto.description,
|
||||
|
||||
recipient: {
|
||||
tin: rowDto.recipient.tin,
|
||||
name: rowDto.recipient.name,
|
||||
|
||||
street: rowDto.recipient.street,
|
||||
street2: rowDto.recipient.street2,
|
||||
city: rowDto.recipient.city,
|
||||
province: rowDto.recipient.province,
|
||||
postal_code: rowDto.recipient.postal_code,
|
||||
country: rowDto.recipient.country,
|
||||
},
|
||||
|
||||
subtotal_amount: MoneyDTOHelper.toNumber(rowDto.subtotal_amount),
|
||||
subtotal_amount_fmt: formatCurrency(
|
||||
MoneyDTOHelper.toNumber(rowDto.subtotal_amount),
|
||||
Number(rowDto.total_amount.scale || 2),
|
||||
rowDto.currency_code,
|
||||
rowDto.language_code
|
||||
),
|
||||
|
||||
discount_percentage: PercentageDTOHelper.toNumber(rowDto.discount_percentage),
|
||||
discount_percentage_fmt: PercentageDTOHelper.toNumericString(rowDto.discount_percentage),
|
||||
|
||||
discount_amount: MoneyDTOHelper.toNumber(rowDto.discount_amount),
|
||||
discount_amount_fmt: formatCurrency(
|
||||
MoneyDTOHelper.toNumber(rowDto.discount_amount),
|
||||
Number(rowDto.total_amount.scale || 2),
|
||||
rowDto.currency_code,
|
||||
rowDto.language_code
|
||||
),
|
||||
|
||||
taxable_amount: MoneyDTOHelper.toNumber(rowDto.taxable_amount),
|
||||
taxable_amount_fmt: formatCurrency(
|
||||
MoneyDTOHelper.toNumber(rowDto.taxable_amount),
|
||||
Number(rowDto.total_amount.scale || 2),
|
||||
rowDto.currency_code,
|
||||
rowDto.language_code
|
||||
),
|
||||
|
||||
taxes_amount: MoneyDTOHelper.toNumber(rowDto.taxes_amount),
|
||||
taxes_amount_fmt: formatCurrency(
|
||||
MoneyDTOHelper.toNumber(rowDto.taxes_amount),
|
||||
Number(rowDto.total_amount.scale || 2),
|
||||
rowDto.currency_code,
|
||||
rowDto.language_code
|
||||
),
|
||||
|
||||
total_amount: MoneyDTOHelper.toNumber(rowDto.total_amount),
|
||||
total_amount_fmt: formatCurrency(
|
||||
MoneyDTOHelper.toNumber(rowDto.total_amount),
|
||||
Number(rowDto.total_amount.scale || 2),
|
||||
rowDto.currency_code,
|
||||
rowDto.language_code
|
||||
),
|
||||
};
|
||||
},
|
||||
};
|
||||
@ -4,7 +4,7 @@ export interface ChangeStatusResponse {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export async function changeProformaStatusApi(
|
||||
export async function changeProformaStatusById(
|
||||
dataSource: IDataSource,
|
||||
proformaId: string,
|
||||
newStatus: string
|
||||
@ -1,6 +1,6 @@
|
||||
import type { IDataSource } from "@erp/core/client";
|
||||
|
||||
export async function deleteProformaApi(
|
||||
export async function deleteProformaById(
|
||||
dataSource: IDataSource,
|
||||
proformaId: string
|
||||
): Promise<void> {
|
||||
@ -0,0 +1,9 @@
|
||||
import type { IDataSource } from "@erp/core/client";
|
||||
|
||||
import type { Proforma } from "../entities";
|
||||
|
||||
export async function getProformaById(dataSource: IDataSource, signal: AbortSignal, id?: string) {
|
||||
if (!id) throw new Error("proformaId is required");
|
||||
const response = dataSource.getOne<Proforma>("proformas", id, { signal });
|
||||
return response;
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
export * from "./change-proforma-status-by-id.api";
|
||||
export * from "./delete-proforma-by-ip.api";
|
||||
export * from "./get-proforma-by-ip.api";
|
||||
export * from "./list-proformas.api";
|
||||
@ -1,18 +1,17 @@
|
||||
import type { CriteriaDTO } from "@erp/core";
|
||||
import type { IDataSource } from "@erp/core/client";
|
||||
|
||||
import type { ProformaSummaryPage } from "../../types";
|
||||
import type { ListProformasResponseDTO } from "../../../../common";
|
||||
|
||||
export async function getProformaListApi(
|
||||
export async function getListProformas(
|
||||
dataSource: IDataSource,
|
||||
signal: AbortSignal,
|
||||
criteria: CriteriaDTO
|
||||
) {
|
||||
const response = dataSource.getList<ProformaSummaryPage>("proformas", {
|
||||
const response = dataSource.getList<ListProformasResponseDTO>("proformas", {
|
||||
signal,
|
||||
...criteria,
|
||||
});
|
||||
|
||||
//return mapProformaList(raw);
|
||||
return response;
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export * from "./proforma-list.entity";
|
||||
export * from "./proforma-list-row.entity";
|
||||
export * from "./proforma-status.entity";
|
||||
@ -0,0 +1,49 @@
|
||||
export interface ProformaListRow {
|
||||
id: string;
|
||||
company_id: string;
|
||||
|
||||
customer_id: string;
|
||||
|
||||
invoice_number: string;
|
||||
status: string;
|
||||
series: string;
|
||||
|
||||
invoice_date: string;
|
||||
operation_date: string;
|
||||
|
||||
language_code: string;
|
||||
currency_code: string;
|
||||
|
||||
reference: string;
|
||||
description: string;
|
||||
|
||||
recipient: {
|
||||
tin: string;
|
||||
name: string;
|
||||
|
||||
street: string;
|
||||
street2: string;
|
||||
city: string;
|
||||
province: string;
|
||||
postal_code: string;
|
||||
country: string;
|
||||
};
|
||||
|
||||
subtotal_amount: number;
|
||||
subtotal_amount_fmt: string;
|
||||
|
||||
discount_percentage: number;
|
||||
discount_percentage_fmt: string;
|
||||
|
||||
discount_amount: number;
|
||||
discount_amount_fmt: string;
|
||||
|
||||
taxable_amount: number;
|
||||
taxable_amount_fmt: string;
|
||||
|
||||
taxes_amount: number;
|
||||
taxes_amount_fmt: string;
|
||||
|
||||
total_amount: number;
|
||||
total_amount_fmt: string;
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import type { ProformaListRow } from "./proforma-list-row.entity";
|
||||
|
||||
export interface ProformaList {
|
||||
items: ProformaListRow[];
|
||||
total_pages: number;
|
||||
total_items: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
export enum PROFORMA_STATUS {
|
||||
DRAFT = "draft",
|
||||
SENT = "sent",
|
||||
APPROVED = "approved",
|
||||
REJECTED = "rejected",
|
||||
ISSUED = "issued",
|
||||
}
|
||||
|
||||
// Transiciones válidas según reglas del dominio
|
||||
export const PROFORMA_STATUS_TRANSITIONS: Record<PROFORMA_STATUS, PROFORMA_STATUS[]> = {
|
||||
[PROFORMA_STATUS.DRAFT]: [PROFORMA_STATUS.SENT],
|
||||
[PROFORMA_STATUS.SENT]: [PROFORMA_STATUS.APPROVED, PROFORMA_STATUS.REJECTED],
|
||||
[PROFORMA_STATUS.APPROVED]: [PROFORMA_STATUS.ISSUED, PROFORMA_STATUS.DRAFT],
|
||||
[PROFORMA_STATUS.REJECTED]: [PROFORMA_STATUS.DRAFT],
|
||||
[PROFORMA_STATUS.ISSUED]: [],
|
||||
};
|
||||
|
||||
export type ProformaStatus = `${PROFORMA_STATUS}`;
|
||||
@ -0,0 +1,3 @@
|
||||
export * from "./use-change-proforma-status-mutation";
|
||||
export * from "./use-delete-proforma-mutation";
|
||||
export * from "./use-list-proformas-query";
|
||||
@ -0,0 +1,33 @@
|
||||
import { useDataSource } from "@erp/core/hooks";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { changeProformaStatusById } from "../api/change-proforma-status-by-id.api";
|
||||
import type { PROFORMA_STATUS } from "../entities";
|
||||
|
||||
import { LIST_PROFORMAS_QUERY_KEY_PREFIX } from "./use-list-proformas-query";
|
||||
|
||||
interface ChangeProformaStatusPayload {
|
||||
proformaId: string;
|
||||
newStatus: PROFORMA_STATUS;
|
||||
}
|
||||
|
||||
export function useChangeProformaStatusMutation() {
|
||||
const dataSource = useDataSource();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: ({ proformaId, newStatus }: ChangeProformaStatusPayload) =>
|
||||
changeProformaStatusById(dataSource, proformaId, newStatus),
|
||||
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: LIST_PROFORMAS_QUERY_KEY_PREFIX });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
changeProformaStatus: mutation.mutateAsync,
|
||||
isPending: mutation.isPending,
|
||||
error: mutation.error,
|
||||
reset: mutation.reset,
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
import { useDataSource } from "@erp/core/hooks";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { deleteProformaById } from "../api";
|
||||
|
||||
import { LIST_PROFORMAS_QUERY_KEY_PREFIX } from "./use-list-proformas-query";
|
||||
|
||||
interface DeleteProformaPayload {
|
||||
proformaId: string;
|
||||
}
|
||||
|
||||
export function useDeleteProformaMutation() {
|
||||
const dataSource = useDataSource();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation<void, Error, DeleteProformaPayload>({
|
||||
mutationFn: ({ proformaId }: DeleteProformaPayload) =>
|
||||
deleteProformaById(dataSource, proformaId),
|
||||
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: LIST_PROFORMAS_QUERY_KEY_PREFIX });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
deleteProforma: mutation.mutateAsync,
|
||||
isPending: mutation.isPending,
|
||||
error: mutation.error,
|
||||
reset: mutation.reset,
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,114 @@
|
||||
import type { CriteriaDTO } from "@erp/core";
|
||||
import { useDataSource } from "@erp/core/hooks";
|
||||
import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "@repo/rdx-criteria";
|
||||
import {
|
||||
type DefaultError,
|
||||
type QueryClient,
|
||||
type QueryKey,
|
||||
type UseQueryResult,
|
||||
useQuery,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { ListProformasAdapter } from "../adapters";
|
||||
import { getListProformas } from "../api";
|
||||
import type { ProformaList, ProformaListRow } from "../entities";
|
||||
|
||||
export const LIST_PROFORMAS_QUERY_KEY_PREFIX = ["proformas"] as const;
|
||||
export const LIST_PROFORMAS_QUERY_KEY = (criteria?: CriteriaDTO): QueryKey => [
|
||||
LIST_PROFORMAS_QUERY_KEY_PREFIX,
|
||||
{
|
||||
pageNumber: criteria?.pageNumber ?? INITIAL_PAGE_INDEX,
|
||||
pageSize: criteria?.pageSize ?? INITIAL_PAGE_SIZE,
|
||||
q: criteria?.q ?? "",
|
||||
filters: criteria?.filters ?? [],
|
||||
orderBy: criteria?.orderBy ?? "",
|
||||
order: criteria?.order ?? "",
|
||||
},
|
||||
];
|
||||
|
||||
type useListProformasQueryOptions = {
|
||||
enabled?: boolean;
|
||||
criteria?: Partial<CriteriaDTO>;
|
||||
};
|
||||
|
||||
export const useListProformasQuery = (
|
||||
options?: useListProformasQueryOptions
|
||||
): UseQueryResult<ProformaList, DefaultError> => {
|
||||
const dataSource = useDataSource();
|
||||
const enabled = options?.enabled ?? true;
|
||||
const criteria = options?.criteria ?? {};
|
||||
|
||||
return useQuery<ProformaList, DefaultError>({
|
||||
queryKey: LIST_PROFORMAS_QUERY_KEY(criteria),
|
||||
queryFn: async ({ signal }) => {
|
||||
const dto = await getListProformas(dataSource, signal, criteria);
|
||||
return ListProformasAdapter.fromDTO(dto);
|
||||
},
|
||||
enabled,
|
||||
placeholderData: (previousData) => previousData, // Mantiene la página anterior durante refetch por cambio de criteria
|
||||
});
|
||||
};
|
||||
|
||||
export function cancelListProformasQueries(qc: QueryClient) {
|
||||
return qc.cancelQueries({ queryKey: LIST_PROFORMAS_QUERY_KEY_PREFIX });
|
||||
}
|
||||
|
||||
export function invalidateListProformasQueries(qc: QueryClient) {
|
||||
return qc.invalidateQueries({ queryKey: LIST_PROFORMAS_QUERY_KEY_PREFIX });
|
||||
}
|
||||
|
||||
export function getAllListProformasQueryKeys(qc: QueryClient): QueryKey[] {
|
||||
const entries = qc.getQueriesData<ProformaList>({
|
||||
queryKey: LIST_PROFORMAS_QUERY_KEY_PREFIX,
|
||||
});
|
||||
|
||||
return entries.map(([key]) => key);
|
||||
}
|
||||
|
||||
export function upsertProformaInListProformasCaches(
|
||||
qc: QueryClient,
|
||||
proforma: Pick<ProformaListRow, "id"> & Partial<ProformaListRow>
|
||||
) {
|
||||
const keys = getAllListProformasQueryKeys(qc);
|
||||
|
||||
for (const key of keys) {
|
||||
const page = qc.getQueryData<ProformaList>(key);
|
||||
if (!page) continue;
|
||||
|
||||
const index = page.items.findIndex((row) => row.id === proforma.id);
|
||||
if (index === -1) continue;
|
||||
|
||||
const nextItems = page.items.slice();
|
||||
nextItems[index] = {
|
||||
...page.items[index],
|
||||
...proforma,
|
||||
};
|
||||
|
||||
qc.setQueryData<ProformaList>(key, {
|
||||
...page,
|
||||
items: nextItems,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function removeProformaFromListProformasCaches(
|
||||
qc: QueryClient,
|
||||
proformaId: string
|
||||
): Array<{ key: QueryKey; page?: ProformaList }> {
|
||||
const snapshots = getAllListProformasQueryKeys(qc).map((key) => ({
|
||||
key,
|
||||
page: qc.getQueryData<ProformaList>(key),
|
||||
}));
|
||||
|
||||
for (const { key, page } of snapshots) {
|
||||
if (!page) continue;
|
||||
|
||||
qc.setQueryData<ProformaList>(key, {
|
||||
...page,
|
||||
items: page.items.filter((row) => row.id !== proformaId),
|
||||
total_items: Math.max(0, page.total_items - 1),
|
||||
});
|
||||
}
|
||||
|
||||
return snapshots;
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
import { useDataSource } from "@erp/core/hooks";
|
||||
import {
|
||||
type DefaultError,
|
||||
type QueryKey,
|
||||
type UseQueryResult,
|
||||
useQuery,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { type Proforma, getProformaById } from "../../api";
|
||||
|
||||
export const PROFORMA_QUERY_KEY = (proformaId?: string): QueryKey => [
|
||||
"proformas:detail",
|
||||
{
|
||||
proformaId,
|
||||
},
|
||||
];
|
||||
|
||||
type ProformaQueryOptions = {
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export const useProformaGetQuery = (
|
||||
proformaId?: string,
|
||||
options?: ProformaQueryOptions
|
||||
): UseQueryResult<Proforma, DefaultError> => {
|
||||
const dataSource = useDataSource();
|
||||
const enabled = options?.enabled ?? Boolean(proformaId);
|
||||
|
||||
return useQuery<Proforma, DefaultError>({
|
||||
queryKey: PROFORMA_QUERY_KEY(proformaId),
|
||||
queryFn: async ({ signal }) => getProformaById(dataSource, signal, proformaId),
|
||||
enabled,
|
||||
placeholderData: (previousData, _previousQuery) => previousData, // Mantener datos previos mientras se carga nueva datos (antiguo `keepPreviousData`)
|
||||
});
|
||||
};
|
||||
|
||||
/*export function invalidateProformaDetailCache(qc: QueryClient, id: string) {
|
||||
return qc.invalidateQueries({
|
||||
queryKey: getProformaQueryKey(id ?? "unknown"),
|
||||
exact: Boolean(id),
|
||||
});
|
||||
}
|
||||
|
||||
export function setProformaDetailCache(qc: QueryClient, id: string, data: unknown) {
|
||||
qc.setQueryData(getProformaQueryKey(id), data);
|
||||
}
|
||||
*/
|
||||
@ -0,0 +1,58 @@
|
||||
import { useDataSource } from "@erp/core/hooks";
|
||||
import { ValidationErrorCollection } from "@repo/rdx-ddd";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { UpdateProformaByIdRequestSchema } from "../../../../common";
|
||||
import type { ProformaFormData } from "../../update/types";
|
||||
|
||||
export const PROFORMA_UPDATE_KEY = ["proformas", "update"] as const;
|
||||
|
||||
type UpdateProformaContext = {};
|
||||
|
||||
type UpdateProformaPayload = {
|
||||
id: string;
|
||||
data: Partial<ProformaFormData>;
|
||||
};
|
||||
|
||||
export const useProformaUpdateMutation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const dataSource = useDataSource();
|
||||
const schema = UpdateProformaByIdRequestSchema;
|
||||
|
||||
return useMutation<Proforma, DefaultError, UpdateProformaPayload, UpdateProformaContext>({
|
||||
mutationKey: PROFORMA_UPDATE_KEY,
|
||||
|
||||
mutationFn: async (payload) => {
|
||||
const { id: proformaId, data } = payload;
|
||||
if (!proformaId) {
|
||||
throw new Error("proformaId is required");
|
||||
}
|
||||
|
||||
const result = schema.safeParse(data);
|
||||
if (!result.success) {
|
||||
throw new ValidationErrorCollection("Validation failed", toValidationErrors(result.error));
|
||||
}
|
||||
|
||||
const updated = await dataSource.updateOne("proformas", proformaId, data);
|
||||
return updated as Proforma;
|
||||
},
|
||||
|
||||
onSuccess: (updated: Proforma, variables) => {
|
||||
const { id: proformaId } = updated;
|
||||
|
||||
// Invalida el listado para refrescar desde servidor
|
||||
//invalidateProformaListCache(queryClient);
|
||||
|
||||
// Actualiza detalle
|
||||
//setProformaDetailCache(queryClient, proformaId, updated);
|
||||
|
||||
// Actualiza todas las páginas donde aparezca
|
||||
//upsertProformaIntoListCaches(queryClient, { ...updated });
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
// Refresca todos los listados
|
||||
//invalidateProformaListCache(queryClient);
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,4 @@
|
||||
export * from "./api";
|
||||
export * from "./entities";
|
||||
export * from "./hooks";
|
||||
export * from "./ui";
|
||||
@ -0,0 +1 @@
|
||||
export * from "./proforma-layout";
|
||||
@ -0,0 +1 @@
|
||||
export * from "./blocks";
|
||||
@ -1,4 +0,0 @@
|
||||
export * from "./proforma.api.schema";
|
||||
export * from "./proforma.form.schema";
|
||||
export * from "./proforma-status";
|
||||
export * from "./proforma-summary.web.schema";
|
||||
@ -7,25 +7,6 @@ import {
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export enum PROFORMA_STATUS {
|
||||
DRAFT = "draft",
|
||||
SENT = "sent",
|
||||
APPROVED = "approved",
|
||||
REJECTED = "rejected",
|
||||
ISSUED = "issued",
|
||||
}
|
||||
|
||||
// Transiciones válidas según reglas del dominio
|
||||
export const PROFORMA_STATUS_TRANSITIONS: Record<PROFORMA_STATUS, PROFORMA_STATUS[]> = {
|
||||
[PROFORMA_STATUS.DRAFT]: [PROFORMA_STATUS.SENT],
|
||||
[PROFORMA_STATUS.SENT]: [PROFORMA_STATUS.APPROVED, PROFORMA_STATUS.REJECTED],
|
||||
[PROFORMA_STATUS.APPROVED]: [PROFORMA_STATUS.ISSUED, PROFORMA_STATUS.DRAFT],
|
||||
[PROFORMA_STATUS.REJECTED]: [PROFORMA_STATUS.DRAFT],
|
||||
[PROFORMA_STATUS.ISSUED]: [],
|
||||
};
|
||||
|
||||
export type ProformaStatus = `${PROFORMA_STATUS}`;
|
||||
|
||||
export const getProformaStatusButtonVariant = (
|
||||
status: ProformaStatus
|
||||
): "default" | "secondary" | "outline" | "destructive" => {
|
||||
|
||||
@ -1,27 +0,0 @@
|
||||
import {
|
||||
CreateProformaRequestSchema,
|
||||
GetProformaByIdResponseSchema,
|
||||
type ListProformasResponseDTO,
|
||||
UpdateProformaByIdRequestSchema,
|
||||
} from "@erp/customer-invoices/common";
|
||||
import type { ArrayElement } from "@repo/rdx-utils";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
// Proformas
|
||||
export const ProformaSchema = GetProformaByIdResponseSchema.omit({
|
||||
metadata: true,
|
||||
});
|
||||
|
||||
export type Proforma = z.infer<typeof ProformaSchema>;
|
||||
export type ProformaRecipient = Proforma["recipient"];
|
||||
export type ProformaItem = ArrayElement<Proforma["items"]>;
|
||||
|
||||
export const CreateProformaSchema = CreateProformaRequestSchema;
|
||||
export const UpdateProformaSchema = UpdateProformaByIdRequestSchema;
|
||||
|
||||
export type CreateProformaInput = z.infer<typeof CreateProformaSchema>; // Cuerpo para crear
|
||||
export type UpdateProformaInput = z.infer<typeof UpdateProformaSchema>; // Cuerpo para actualizar
|
||||
|
||||
// Resultado de consulta con criteria (paginado, etc.)
|
||||
export type ProformaSummaryPage = Omit<ListProformasResponseDTO, "metadata">;
|
||||
export type ProformaSummary = Omit<ArrayElement<ProformaSummaryPage["items"]>, "metadata">;
|
||||
@ -1,2 +1 @@
|
||||
export * from "./proforma-layout";
|
||||
export * from "./proforma-tax-summary";
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export * from "./use-proforma-update-page.controller";
|
||||
@ -0,0 +1,142 @@
|
||||
import { formHasAnyDirty, pickFormDirtyValues } from "@erp/core/client";
|
||||
import { useHookForm } from "@erp/core/hooks";
|
||||
import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers";
|
||||
import { useEffect, useId, useMemo } from "react";
|
||||
import { type FieldErrors, FormProvider } from "react-hook-form";
|
||||
|
||||
import { useTranslation } from "../../../i18n";
|
||||
import type { Proforma } from "../../api";
|
||||
import { type ProformaFormData, ProformaFormSchema, defaultProformaFormData } from "../types";
|
||||
|
||||
export interface UseProformaUpdateControllerOptions {
|
||||
onUpdated?(updated: Proforma): void;
|
||||
successToasts?: boolean; // mostrar o no toast automáticcamente
|
||||
|
||||
onError?(error: Error, patchData: ReturnType<typeof pickFormDirtyValues>): void;
|
||||
errorToasts?: boolean; // mostrar o no toast automáticcamente
|
||||
}
|
||||
|
||||
export const useProformaUpdateController = (
|
||||
proformaId?: string,
|
||||
options?: UseProformaUpdateControllerOptions
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const formId = useId(); // id único por instancia
|
||||
|
||||
// 1) Estado de carga del cliente (query)
|
||||
const {
|
||||
data: proformaData,
|
||||
isLoading,
|
||||
isError: isLoadError,
|
||||
error: loadError,
|
||||
} = useProformaGetQuery(proformaId, { enabled: Boolean(proformaId) });
|
||||
|
||||
// 2) Estado de creación (mutación)
|
||||
const {
|
||||
mutateAsync,
|
||||
isPending: isUpdating,
|
||||
isError: isUpdateError,
|
||||
error: updateError,
|
||||
} = useProformaUpdateMutation();
|
||||
|
||||
const initialValues = useMemo(() => proformaData ?? defaultProformaFormData, [proformaData]);
|
||||
|
||||
// 3) Form hook
|
||||
const form = useHookForm<ProformaFormData>({
|
||||
resolverSchema: ProformaFormSchema,
|
||||
initialValues,
|
||||
disabled: isLoading || isUpdating,
|
||||
});
|
||||
|
||||
/** Reiniciar el form al recibir datos */
|
||||
useEffect(() => {
|
||||
// keepDirty = false -> deja el formulario sin cambios sin tener que esperar al siguiente render.
|
||||
if (proformaData) form.reset(proformaData, { keepDirty: false });
|
||||
}, [proformaData, form]);
|
||||
|
||||
/** Handlers */
|
||||
|
||||
const resetForm = () => form.reset(proformaData ?? defaultProformaFormData);
|
||||
|
||||
// Versión sincronizada
|
||||
const submitHandler = form.handleSubmit(
|
||||
async (formData) => {
|
||||
if (!proformaId) {
|
||||
showErrorToast(t("pages.update.error.title"), "Falta el ID de la proforma");
|
||||
return;
|
||||
}
|
||||
|
||||
const { dirtyFields } = form.formState;
|
||||
if (!formHasAnyDirty(dirtyFields)) {
|
||||
showWarningToast(t("pages.update.error.no_changes"), "No hay cambios para guardar");
|
||||
return;
|
||||
}
|
||||
|
||||
const patchData = pickFormDirtyValues(formData, dirtyFields);
|
||||
const previousData = proformaData;
|
||||
|
||||
try {
|
||||
// Enviamos cambios al servidor
|
||||
const updated = await mutateAsync({ id: proformaId, data: patchData });
|
||||
|
||||
// Ha ido bien -> actualizamos form con datos reales
|
||||
// keepDirty = false -> deja el formulario sin cambios sin tener que esperar al siguiente render.
|
||||
form.reset(updated, { keepDirty: false });
|
||||
|
||||
if (options?.successToasts !== false) {
|
||||
showSuccessToast(
|
||||
t("pages.update.success.title", "Proforma modificada"),
|
||||
t("pages.update.success.message", "Se ha modificado correctamente.")
|
||||
);
|
||||
}
|
||||
options?.onUpdated?.(updated);
|
||||
} catch (error: any) {
|
||||
// Algo ha fallado -> revertimos cambios
|
||||
form.reset(previousData ?? defaultProformaFormData);
|
||||
if (options?.errorToasts !== false) {
|
||||
showErrorToast(t("pages.update.error.title"), error.message);
|
||||
}
|
||||
options?.onError?.(error, patchData);
|
||||
}
|
||||
},
|
||||
(errors: FieldErrors<ProformaFormData>) => {
|
||||
const firstKey = Object.keys(errors)[0] as keyof ProformaFormData | undefined;
|
||||
if (firstKey) document.querySelector<HTMLElement>(`[name="${String(firstKey)}"]`)?.focus();
|
||||
|
||||
showWarningToast(
|
||||
t("forms.validation.title", "Revisa los campos"),
|
||||
t("forms.validation.message", "Hay errores de validación en el formulario.")
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// Evento onSubmit ya preparado para el <form>
|
||||
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.stopPropagation(); // <-- evita que el submit se propage por los padre en el árbol DOM
|
||||
submitHandler(event);
|
||||
};
|
||||
|
||||
return {
|
||||
// form
|
||||
form,
|
||||
formId,
|
||||
|
||||
// handlers del form
|
||||
onSubmit,
|
||||
resetForm,
|
||||
|
||||
// carga de datos
|
||||
proformaData,
|
||||
isLoading,
|
||||
isLoadError,
|
||||
loadError,
|
||||
|
||||
// mutation
|
||||
isUpdating,
|
||||
isUpdateError,
|
||||
updateError,
|
||||
|
||||
// Por comodidad
|
||||
FormProvider,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export * from "./ui";
|
||||
@ -0,0 +1 @@
|
||||
export * from "./pages";
|
||||
@ -0,0 +1 @@
|
||||
export * from "./proforma-update-page";
|
||||
@ -5,33 +5,56 @@ import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useTranslation } from "../../../i18n";
|
||||
import { useProformaQuery } from "../../hooks";
|
||||
import { useProformaUpdateController } from "../../controllers";
|
||||
|
||||
import { ProformaProvider } from "./context";
|
||||
import { ProformaUpdateComp } from "./proforma-update-comp";
|
||||
import { ProformaEditorSkeleton } from "./ui/components";
|
||||
|
||||
export const ProformaUpdatePage = () => {
|
||||
const initialProformaId = useUrlParamId();
|
||||
const { t } = useTranslation();
|
||||
const proforma_id = useUrlParamId();
|
||||
const taxCatalog = useMemo(() => SpainTaxCatalogProvider(), []);
|
||||
|
||||
const proformaQuery = useProformaQuery(proforma_id, { enabled: !!proforma_id });
|
||||
const { data: proformaData, isLoading, isError, error } = proformaQuery;
|
||||
const {
|
||||
form,
|
||||
formId,
|
||||
onSubmit,
|
||||
resetForm,
|
||||
|
||||
proformaData,
|
||||
isLoading,
|
||||
isLoadError,
|
||||
loadError,
|
||||
|
||||
isUpdating,
|
||||
isUpdateError,
|
||||
updateError,
|
||||
|
||||
FormProvider,
|
||||
} = useProformaUpdateController(initialProformaId, {});
|
||||
|
||||
if (isLoading) {
|
||||
return <ProformaEditorSkeleton />;
|
||||
}
|
||||
|
||||
if (isError || !proformaData) {
|
||||
if (isLoadError) {
|
||||
return (
|
||||
<AppContent>
|
||||
<ErrorAlert
|
||||
message={(error as Error)?.message || "Error al cargar la factura"}
|
||||
title={t("pages.update.loadErrorTitle")}
|
||||
/>
|
||||
<BackHistoryButton />
|
||||
</AppContent>
|
||||
<>
|
||||
<AppContent>
|
||||
<ErrorAlert
|
||||
message={
|
||||
(loadError as Error)?.message ??
|
||||
t("pages.update.loadErrorMsg", "Inténtalo de nuevo más tarde.")
|
||||
}
|
||||
title={t("pages.update.loadErrorTitle", "No se pudo cargar la proforma")}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<BackHistoryButton />
|
||||
</div>
|
||||
</AppContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -18,7 +18,7 @@ import { useState } from "react";
|
||||
import DataTable, { type TableColumn } from "react-data-table-component";
|
||||
import { useDebounce } from "use-debounce";
|
||||
|
||||
import type { ListCustomersResponseDTO } from "../../common";
|
||||
import type { ListCustomersResponseDTO } from "../../../common";
|
||||
import { useCustomerListQuery } from "../hooks";
|
||||
|
||||
type Customer = ListCustomersResponseDTO["items"][number];
|
||||
@ -11,7 +11,7 @@ import {
|
||||
import { Plus } from "lucide-react";
|
||||
import { useCallback, useId } from "react";
|
||||
|
||||
import { useTranslation } from "../../i18n";
|
||||
import { useTranslation } from "../../../i18n";
|
||||
import { useCustomerCreateController } from "../../pages/create/use-customer-create-controller";
|
||||
import type { CustomerFormData } from "../../schemas";
|
||||
import { CustomerEditForm } from "../editor";
|
||||
@ -1,3 +1,3 @@
|
||||
//export * from "./client-selector-modal";
|
||||
//export * from "./customer-modal-selector";
|
||||
export * from "./editor";
|
||||
//export * from "./editor";
|
||||
@ -2,8 +2,8 @@ import { useDataSource } from "@erp/core/hooks";
|
||||
import { UniqueID, ValidationErrorCollection } from "@repo/rdx-ddd";
|
||||
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { CreateCustomerRequestSchema } from "../../common";
|
||||
import { toValidationErrors } from "../common/hooks/toValidationErrors";
|
||||
import { CreateCustomerRequestSchema } from "../../../common";
|
||||
import { toValidationErrors } from "../../shared/hooks/toValidationErrors";
|
||||
import type { Customer, CustomerFormData } from "../schemas";
|
||||
|
||||
import { CUSTOMERS_LIST_KEY, invalidateCustomerListCache } from "./use-customer-list-query";
|
||||
@ -1,6 +1,12 @@
|
||||
import { useDataSource } from "@erp/core/hooks";
|
||||
import { DefaultError, QueryClient, type QueryKey, useQuery } from "@tanstack/react-query";
|
||||
import { Customer } from "../schemas";
|
||||
import {
|
||||
type DefaultError,
|
||||
type QueryClient,
|
||||
type QueryKey,
|
||||
useQuery,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import type { Customer } from "../schemas";
|
||||
|
||||
export const CUSTOMER_DETAIL_SCOPE = "customers:detail" as const;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useContext } from "react";
|
||||
import { CustomersContext, CustomersContextType } from "../context";
|
||||
|
||||
import { CustomersContext, type CustomersContextType } from "../context";
|
||||
|
||||
export const useCustomersContext = (): CustomersContextType => {
|
||||
const context = useContext(CustomersContext);
|
||||
@ -1,6 +1,13 @@
|
||||
import { useDataSource } from "@erp/core/hooks";
|
||||
import { DefaultError, QueryKey, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { CustomersPage } from "../schemas";
|
||||
import {
|
||||
type DefaultError,
|
||||
type QueryKey,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import type { CustomersPage } from "../schemas";
|
||||
|
||||
import { cancelCustomerListQueries, deleteCustomerIntoListCaches } from "./use-customer-list-query";
|
||||
import { invalidateCustomerDetailCache } from "./use-customer-query";
|
||||
|
||||
@ -2,8 +2,8 @@ import { useDataSource } from "@erp/core/hooks";
|
||||
import { ValidationErrorCollection } from "@repo/rdx-ddd";
|
||||
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { UpdateCustomerByIdRequestSchema } from "../../common";
|
||||
import { toValidationErrors } from "../common/hooks/toValidationErrors";
|
||||
import { UpdateCustomerByIdRequestSchema } from "../../../common";
|
||||
import { toValidationErrors } from "../../shared/hooks/toValidationErrors";
|
||||
import type { Customer, CustomerFormData } from "../schemas";
|
||||
|
||||
import {
|
||||
@ -3,8 +3,8 @@ import { UnsavedChangesProvider, UpdateCommitButtonGroup } from "@erp/core/hooks
|
||||
import { AppContent, AppHeader } from "@repo/rdx-ui/components";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useTranslation } from "../../../i18n";
|
||||
import { CustomerEditForm, ErrorAlert } from "../../components";
|
||||
import { useTranslation } from "../../i18n";
|
||||
|
||||
import { useCustomerCreateController } from "./use-customer-create-controller";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user