This commit is contained in:
David Arranz 2026-03-23 12:13:33 +01:00
parent 700445499b
commit 132408ac23
170 changed files with 1650 additions and 1061 deletions

View File

@ -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);

View File

@ -0,0 +1,5 @@
export function safeHTTPUrl(url: string) {
if (!url) return "#";
if (/^https?:\/\//i.test(url)) return url;
return `https://${url}`;
}

View File

@ -1,2 +1,3 @@
export * from "./date-func";
export * from "./form-utils";
export * from "./http-url-utils";

View File

@ -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": {

View File

@ -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 /> },
],
},

View File

@ -1 +0,0 @@
export * from "./use-download-invoice-pdf-query";

View File

@ -1 +0,0 @@
export * from "./use-issued-invoice-list-query";

View File

@ -0,0 +1,2 @@
export * from "./use-download-invoice-pdf-query";
export * from "./use-issued-invoice-list-query";

View File

@ -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;

View File

@ -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",

View File

@ -0,0 +1 @@
export * from "./hooks";

View File

@ -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";

View File

@ -1 +0,0 @@
export * from "./change-proforma-status.api";

View File

@ -1 +1 @@
export * from "./use-change-status-dialog-controller";
export * from "./use-change-proforma-status-dialog-controller";

View File

@ -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,
};
}

View File

@ -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,
};
}

View File

@ -1 +0,0 @@
export * from "./use-change-status";

View File

@ -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,
};
}

View File

@ -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>

View File

@ -1 +0,0 @@
export * from "./delete-proforma.api";

View File

@ -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,

View File

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

View File

@ -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,
};
}

View File

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

View File

@ -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>

View File

@ -0,0 +1 @@
export * from "./delete-proforma-dialog";

View File

@ -1 +1 @@
export * from "./delete-proforma-dialog";
export * from "./components";

View File

@ -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";

View File

@ -1,9 +1,6 @@
11111import
{
useDataSource;
}
from;
("@erp/core/hooks");
import {
useDataSource
} from ("@erp/core/hooks");
import { useMutation, useQueryClient } from "@tanstack/react-query";

View File

@ -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.
*/

View File

@ -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() });
},
});
}

View File

@ -1 +1,2 @@
export * from "./list";
export * from "./update";

View File

@ -1 +0,0 @@
export * from "./proforma-summary-dto.adapter";

View File

@ -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
),
};
},
};

View File

@ -1 +0,0 @@
export * from "./get-proforma-list.api";

View File

@ -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";

View File

@ -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,
};
}

View File

@ -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,
};
};

View File

@ -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,
};
}

View File

@ -1 +0,0 @@
export * from "./use-proforma-list-query";

View File

@ -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`)
});
};

View File

@ -1 +1,2 @@
export * from "./proformas-grid";
export * from "./use-proforma-grid-columns";

View File

@ -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}>

View File

@ -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,

View File

@ -0,0 +1 @@
export * from "./list-proformas.adapter";

View File

@ -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
),
};
},
};

View File

@ -4,7 +4,7 @@ export interface ChangeStatusResponse {
success: boolean;
}
export async function changeProformaStatusApi(
export async function changeProformaStatusById(
dataSource: IDataSource,
proformaId: string,
newStatus: string

View File

@ -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> {

View File

@ -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;
}

View File

@ -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";

View File

@ -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;
}

View File

@ -0,0 +1,3 @@
export * from "./proforma-list.entity";
export * from "./proforma-list-row.entity";
export * from "./proforma-status.entity";

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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}`;

View File

@ -0,0 +1,3 @@
export * from "./use-change-proforma-status-mutation";
export * from "./use-delete-proforma-mutation";
export * from "./use-list-proformas-query";

View File

@ -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,
};
}

View File

@ -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,
};
}

View File

@ -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;
}

View File

@ -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);
}
*/

View File

@ -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);
},
});
};

View File

@ -0,0 +1,4 @@
export * from "./api";
export * from "./entities";
export * from "./hooks";
export * from "./ui";

View File

@ -0,0 +1 @@
export * from "./proforma-layout";

View File

@ -0,0 +1 @@
export * from "./blocks";

View File

@ -1,4 +0,0 @@
export * from "./proforma.api.schema";
export * from "./proforma.form.schema";
export * from "./proforma-status";
export * from "./proforma-summary.web.schema";

View File

@ -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" => {

View File

@ -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">;

View File

@ -1,2 +1 @@
export * from "./proforma-layout";
export * from "./proforma-tax-summary";

View File

@ -0,0 +1 @@
export * from "./use-proforma-update-page.controller";

View File

@ -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,
};
};

View File

@ -0,0 +1 @@
export * from "./ui";

View File

@ -0,0 +1 @@
export * from "./pages";

View File

@ -0,0 +1 @@
export * from "./proforma-update-page";

View File

@ -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>
</>
);
}

View File

@ -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];

View File

@ -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";

View File

@ -1,3 +1,3 @@
//export * from "./client-selector-modal";
//export * from "./customer-modal-selector";
export * from "./editor";
//export * from "./editor";

View File

@ -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";

View File

@ -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;

View File

@ -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);

View File

@ -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";

View File

@ -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 {

View File

@ -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