This commit is contained in:
David Arranz 2025-09-23 10:59:15 +02:00
parent 633f7cf6bd
commit 01ead56fff
17 changed files with 393 additions and 246 deletions

View File

@ -7,7 +7,7 @@ import { i18n } from "@/locales";
import { AuthProvider, createAuthService } from "@erp/auth/client";
import { createAxiosDataSource, createAxiosInstance } from "@erp/core/client";
import { DataSourceProvider, UnsavedWarnProvider } from "@erp/core/hooks";
import { DataSourceProvider } from "@erp/core/hooks";
import DineroFactory from "dinero.js";
import { RouterProvider } from "react-router-dom";
import { clearAccessToken, getAccessToken, setAccessToken } from "./lib";
@ -55,12 +55,10 @@ export const App = () => {
}}
>
<TooltipProvider delayDuration={0}>
<UnsavedWarnProvider>
{/* Fallback Route */}
<Suspense fallback={<LoadingOverlay />}>
<RouterProvider router={appRouter} />
</Suspense>
</UnsavedWarnProvider>
{/* Fallback Route */}
<Suspense fallback={<LoadingOverlay />}>
<RouterProvider router={appRouter} />
</Suspense>
</TooltipProvider>
<Toaster
toastOptions={

View File

@ -30,6 +30,7 @@
"express": "^4.18.2",
"http-status": "^2.1.0",
"i18next": "^25.1.1",
"lucide-react": "^0.503.0",
"react-hook-form": "^7.58.1",
"react-i18next": "^15.5.1",
"react-router-dom": "^6.26.0",

View File

@ -1,6 +1,7 @@
{
"common": {
"cancel": "Cancel",
"save": "Save",
"required": "•"
},
"components": {
@ -12,11 +13,11 @@
}
},
"hooks": {
"use_unsaved_changes_notifier": {
"unsaved_changes": "You have unsaved changes",
"unsaved_changes_explanation": "If you leave, your changes will be lost.",
"discard_changes": "Discard changes",
"stay_on_page": "Stay on page"
"unsaved_changes_dialog": {
"title": "You have unsaved changes",
"description": "If you leave, your changes will be lost.",
"confirm": "Discard changes",
"cancel": "Stay on page"
}
}
}

View File

@ -1,6 +1,7 @@
{
"common": {
"cancel": "Cancelar",
"save": "Guardar",
"required": "•"
},
"components": {
@ -12,11 +13,11 @@
}
},
"hooks": {
"use_unsaved_changes_notifier": {
"unsaved_changes": "Tienes cambios no guardados",
"unsaved_changes_explanation": "Si sales, tus cambios se perderán.",
"discard_changes": "Descartar cambios",
"stay_on_page": "Permanecer en la página"
"unsaved_changes_dialog": {
"title": "Tienes cambios no guardados",
"description": "Si sales, tus cambios se perderán.",
"confirm": "Descartar cambios",
"cancel": "Permanecer en esta página"
}
}
}

View File

@ -0,0 +1,66 @@
import { Button } from "@repo/shadcn-ui/components";
import * as React from "react";
import { useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../i18n.ts";
import { useUnsavedChangesContext } from "../use-unsaved-changes-notifier";
export type CancelFormButtonProps = {
to?: string; /// Ruta a la que navegar si no se pasa onCancel
onCancel?: () => void | Promise<void>; // Prioritaria sobre "to"
label?: string;
variant?: React.ComponentProps<typeof Button>["variant"];
size?: React.ComponentProps<typeof Button>["size"];
className?: string;
disabled?: boolean;
"data-testid"?: string;
};
export const CancelFormButton = ({
to,
onCancel,
label,
variant = "outline",
size = "default",
className,
disabled,
"data-testid": dataTestId = "cancel-button",
}: CancelFormButtonProps) => {
const navigate = useNavigate();
const { t } = useTranslation();
const defaultLabel = t ? t("common.cancel") : "Cancel";
const { requestConfirm } = useUnsavedChangesContext();
const handleClick = useCallback(async () => {
const ok = requestConfirm ? await requestConfirm() : true;
console.log("ok => ", ok);
if (!ok) return;
if (onCancel) {
await onCancel();
return;
}
if (to) {
console.log("navego => ", to);
navigate(to);
}
// si no hay ni onCancel ni to → no hace nada
}, [requestConfirm, onCancel, to, navigate]);
return (
<Button
type='button'
variant={variant}
size={size}
className={className}
onClick={handleClick}
disabled={disabled}
aria-disabled={disabled}
data-testid={dataTestId}
>
<span>{label ?? defaultLabel}</span>
</Button>
);
};

View File

@ -0,0 +1,47 @@
import { cn } from "@repo/shadcn-ui/lib/utils";
import { CancelFormButton, CancelFormButtonProps } from "./cancel-form-button";
import { SubmitButtonProps, SubmitFormButton } from "./submit-form-button";
type Align = "start" | "center" | "end" | "between";
export type FormCommitButtonGroupProps = {
className?: string;
align?: Align; // default "end"
gap?: string; // default "gap-2"
reverseOrderOnMobile?: boolean; // default true (Cancel debajo en móvil)
cancel?: CancelFormButtonProps & { show?: boolean };
submit?: SubmitButtonProps; // props directas a SubmitButton
};
const alignToJustify: Record<Align, string> = {
start: "justify-start",
center: "justify-center",
end: "justify-end",
between: "justify-between",
};
export const FormCommitButtonGroup = ({
className,
align = "end",
gap = "gap-2",
reverseOrderOnMobile = true,
cancel,
submit,
}: FormCommitButtonGroupProps) => {
const showCancel = cancel?.show ?? true;
return (
<div
className={cn(
"flex",
reverseOrderOnMobile ? "flex-col-reverse sm:flex-row" : "flex-row",
alignToJustify[align],
gap,
className
)}
>
{showCancel && <CancelFormButton {...cancel} />}
{submit && <SubmitFormButton {...submit} />}
</div>
);
};

View File

@ -0,0 +1,4 @@
export * from "./cancel-form-button";
export * from "./form-commit-button-group";
export * from "./submit-form-button";
export * from "./unsaved-changes-dialog";

View File

@ -0,0 +1,86 @@
import { Button } from "@repo/shadcn-ui/components";
import { LoaderCircleIcon } from "lucide-react";
import * as React from "react";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "../../../i18n.ts";
export type SubmitButtonProps = {
formId?: string;
isLoading?: boolean;
label?: string;
variant?: React.ComponentProps<typeof Button>["variant"];
size?: React.ComponentProps<typeof Button>["size"];
className?: string;
preventDoubleSubmit?: boolean; // Evita múltiples submits mientras loading
onClick?: React.MouseEventHandler<HTMLButtonElement>;
disabled?: boolean;
children?: React.ReactNode;
"data-testid"?: string;
};
export const SubmitFormButton = ({
formId,
isLoading,
label,
variant = "default",
size = "default",
className,
preventDoubleSubmit = true,
onClick,
disabled,
children,
"data-testid": dataTestId = "submit-button",
}: SubmitButtonProps) => {
const { t } = useTranslation();
const defaultLabel = t ? t("common.save") : "Save";
// ⛳️ RHF opcional: auto-detectar isSubmitting si no se pasó isLoading
let rhfIsSubmitting = false;
try {
const ctx = useFormContext();
rhfIsSubmitting = !!ctx?.formState?.isSubmitting;
} catch {
// No hay provider de RHF; ignorar
}
const busy = isLoading ?? rhfIsSubmitting;
const computedDisabled = !!(disabled || (preventDoubleSubmit && busy));
const dataState = busy ? "loading" : "idle";
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
if (preventDoubleSubmit && busy) {
// Evitar submits duplicados durante loading
e.preventDefault();
e.stopPropagation();
return;
}
onClick?.(e);
};
return (
<Button
type='submit'
form={formId}
variant={variant}
size={size}
className={className}
disabled={computedDisabled}
aria-busy={busy}
aria-disabled={computedDisabled}
data-state={dataState}
onClick={handleClick}
data-testid={dataTestId}
>
{children ? (
children
) : (
<span className='inline-flex items-center gap-2'>
{busy && <LoaderCircleIcon className='h-4 w-4 animate-spin' aria-hidden='true' />}
<span>{label ?? defaultLabel}</span>
</span>
)}
</Button>
);
};

View File

@ -0,0 +1,29 @@
import { CustomDialog } from "@repo/rdx-ui/components";
import { useTranslation } from "../../../i18n";
type UnsavedChangesDialogProps = {
open: boolean;
onConfirm: (ok: boolean) => void;
};
export function UnsavedChangesDialog({ open, onConfirm }: UnsavedChangesDialogProps) {
const { t } = useTranslation();
const defaultTitle = t ? t("hooks.unsaved_changes_dialog.title") : "¿Descartar cambios?";
const defaultDescription = t
? t("hooks.unsaved_changes_dialog.description")
: "Tienes cambios sin guardar. Si sales de esta página, se perderán.";
const defaultCancelLabel = t ? t("hooks.unsaved_changes_dialog.cancel") : "Seguir en esta página";
const defaultConfirmLabel = t ? t("hooks.unsaved_changes_dialog.confirm") : "Descartar cambios";
return (
<CustomDialog
open={open}
onConfirm={onConfirm}
title={defaultTitle}
description={defaultDescription}
confirmLabel={defaultConfirmLabel}
cancelLabel={defaultCancelLabel}
/>
);
}

View File

@ -1,4 +1,2 @@
export * from "./components";
export * from "./use-unsaved-changes-notifier";
export * from "./use-warn-about-change";
export * from "./warn-about-change-context";
export * from "./warn-about-change-provider";

View File

@ -1,89 +1,94 @@
import { useCallback, useEffect, useMemo } from "react";
import { useBlocker } from "react-router-dom";
import { useTranslation } from "../../i18n";
import { useWarnAboutChange } from "./use-warn-about-change";
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { UNSAFE_NavigationContext, useBeforeUnload, useBlocker } from "react-router-dom";
import { UnsavedChangesDialog } from "./components";
export type UnsavedChangesNotifierProps = {
isDirty?: boolean;
title?: string;
subtitle?: string;
confirmText?: string;
cancelText?: string;
onConfirm?: () => void;
onCancel?: () => void;
type?: "success" | "error" | "warning" | "info";
type ContextValue = {
requestConfirm: () => Promise<boolean>;
};
export const useUnsavedChangesNotifier = ({
isDirty = false,
title,
subtitle,
confirmText,
cancelText,
onConfirm,
onCancel,
type = "warning",
}: UnsavedChangesNotifierProps = {}) => {
const { t } = useTranslation();
const blocker = useBlocker(isDirty);
const { show } = useWarnAboutChange();
export const UnsavedChangesContext = createContext<ContextValue | null>(null);
const texts = useMemo(
() => ({
title: title ?? t("hooks.use_unsaved_changes_notifier.unsaved_changes"),
subtitle: subtitle ?? t("hooks.use_unsaved_changes_notifier.unsaved_changes_explanation"),
confirmText: confirmText ?? t("hooks.use_unsaved_changes_notifier.discard_changes"),
cancelText: cancelText ?? t("hooks.use_unsaved_changes_notifier.stay_on_page"),
}),
[t, title, subtitle, confirmText, cancelText]
export const useUnsavedChangesContext = () => {
const context = useContext(UnsavedChangesContext);
if (context === null) {
throw new Error("useUnsavedChangesContext must be used within an UnsavedChangesProvider");
}
return context;
};
export function UnsavedChangesProvider({
isDirty,
children,
}: {
isDirty: boolean;
children: React.ReactNode;
}) {
const [resolver, setResolver] = useState<(ok: boolean) => void>();
const [open, setOpen] = useState(false);
// requestConfirm() devuelve una promesa que resuelve true/false
// según lo que haya pulsado el usuario
const requestConfirm = useCallback(() => {
return new Promise<boolean>((resolve) => {
setResolver(() => resolve);
setOpen(true);
});
}, []);
const handleConfirm = (ok: boolean) => {
resolver?.(ok);
setResolver(undefined);
setOpen(false);
};
// 🔹 bloquea F5/cierre de pestaña
useBeforeUnload(
(event) => {
if (isDirty) {
event.preventDefault();
}
},
{ capture: true }
);
const confirm = useCallback(() => {
if (!isDirty) return Promise.resolve(true);
// 🔹 bloquea navegación interna (react-router)
const navigator = useContext(UNSAFE_NavigationContext).navigator as any;
return new Promise<boolean>((resolve) => {
show({
title: texts.title,
subtitle: texts.subtitle,
confirmText: texts.confirmText,
cancelText: texts.cancelText,
type,
onConfirm: () => {
resolve(true);
onConfirm?.();
window.onbeforeunload = null; // limpirar de cambios
},
onCancel: () => {
resolve(false);
onCancel?.();
},
});
});
}, [isDirty, show, texts, type, onConfirm, onCancel]);
useEffect(() => {
if (!isDirty) return;
const push = navigator.push;
navigator.push = async (...args: any[]) => {
const ok = await requestConfirm();
if (ok) push.apply(navigator, args);
};
return () => {
navigator.push = push;
};
}, [isDirty, requestConfirm, navigator]);
// 🔹 Bloquea navegación entre rutas
const blocker = useBlocker(() => isDirty);
useEffect(() => {
if (blocker.state === "blocked") {
confirm().then((result) => {
if (result) blocker.proceed();
else blocker.reset();
});
(async () => {
const ok = await requestConfirm();
if (ok) {
blocker.proceed();
} else {
blocker.reset();
}
})();
}
}, [blocker, confirm]);
}, [blocker, requestConfirm]);
useEffect(() => {
if (isDirty) {
window.onbeforeunload = (e) => {
e.preventDefault();
return texts.subtitle;
};
} else {
window.onbeforeunload = null;
}
return () => {
window.onbeforeunload = null;
};
}, [isDirty, texts.subtitle]);
return { confirm };
};
return (
<UnsavedChangesContext.Provider value={{ requestConfirm }}>
<h1>hola</h1>
{children}
<UnsavedChangesDialog open={open} onConfirm={handleConfirm} />
</UnsavedChangesContext.Provider>
);
}

View File

@ -1,10 +0,0 @@
import { useContext } from "react";
import { UnsavedWarnContext } from "./warn-about-change-context";
export const useWarnAboutChange = () => {
const context = useContext(UnsavedWarnContext);
if (context === null) {
throw new Error("useWarnAboutChange must be used within an UnsavedWarnProvider");
}
return context;
};

View File

@ -1,10 +0,0 @@
import { NullOr } from "@repo/rdx-utils";
import { createContext } from "react";
import type { UnsavedChangesNotifierProps } from "./use-unsaved-changes-notifier";
export interface IUnsavedWarnContextState {
show: (options: NullOr<UnsavedChangesNotifierProps>) => void;
hide: () => void;
}
export const UnsavedWarnContext = createContext<NullOr<IUnsavedWarnContextState>>(null);

View File

@ -1,52 +0,0 @@
import { CustomDialog } from "@repo/rdx-ui/components";
import { NullOr } from "@repo/rdx-utils";
import { type PropsWithChildren, useCallback, useMemo, useState } from "react";
import type { UnsavedChangesNotifierProps } from "./use-unsaved-changes-notifier";
import { UnsavedWarnContext } from "./warn-about-change-context";
// Aseguramos que las props mínimas para el diálogo siempre estén presentes
type ConfirmOptions = Required<
Pick<UnsavedChangesNotifierProps, "title" | "subtitle" | "confirmText" | "cancelText">
> &
Omit<UnsavedChangesNotifierProps, "title" | "subtitle" | "confirmText" | "cancelText">;
export const UnsavedWarnProvider = ({ children }: PropsWithChildren) => {
const [confirm, setConfirm] = useState<NullOr<ConfirmOptions>>(null);
const [open, setOpen] = useState(false);
const show = useCallback((confirmOptions: NullOr<UnsavedChangesNotifierProps>) => {
if (confirmOptions) {
setConfirm(confirmOptions as ConfirmOptions);
setOpen(true);
}
}, []);
const hide = useCallback(() => setOpen(false), []);
const onConfirm = useCallback(() => {
confirm?.onConfirm?.();
hide();
}, [confirm, hide]);
const onCancel = useCallback(() => {
confirm?.onCancel?.();
hide();
}, [confirm, hide]);
const value = useMemo(() => ({ show, hide }), [show, hide]);
return (
<UnsavedWarnContext.Provider value={value}>
{children}
<CustomDialog
onCancel={onCancel}
onConfirm={onConfirm}
title={confirm?.title}
description={confirm?.subtitle}
confirmLabel={confirm?.confirmText}
cancelLabel={confirm?.cancelText}
isOpen={open}
/>
</UnsavedWarnContext.Provider>
);
};

View File

@ -1,8 +1,7 @@
import { AppBreadcrumb, AppContent, ButtonGroup } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { AppBreadcrumb, AppContent } from "@repo/rdx-ui/components";
import { useNavigate } from "react-router-dom";
import { useUnsavedChangesNotifier } from "@erp/core/hooks";
import { FormCommitButtonGroup, UnsavedChangesProvider } from "@erp/core/hooks";
import { showErrorToast, showSuccessToast } from "@repo/shadcn-ui/lib/utils";
import { useState } from "react";
import { FieldErrors } from "react-hook-form";
@ -24,10 +23,6 @@ export const CustomerCreate = () => {
error: createError,
} = useCreateCustomerMutation();
const { confirm: confirmCancel } = useUnsavedChangesNotifier({
isDirty,
});
// 3) Submit con navegación condicionada por éxito
const handleSubmit = async (formData: CustomerFormData) => {
/*const changedData: Record<string, string> = {};
@ -56,68 +51,54 @@ export const CustomerCreate = () => {
// Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
};
console.log("render");
return (
<>
<AppBreadcrumb />
<AppContent>
<div className='flex items-center justify-between space-y-4 px-6'>
<div className='space-y-2'>
<h2 className='text-2xl font-bold tracking-tight text-balance scroll-m-2'>
{t("pages.create.title")}
</h2>
<p className='text-muted-foreground scroll-m-20 tracking-tight text-balance'>
{t("pages.create.description")}
</p>
</div>
<ButtonGroup>
<Button
variant={"outline"}
className='cursor-pointer'
onClick={async (e) => {
e.preventDefault();
const ok = await confirmCancel();
if (ok) {
console.log("Cambios descartados");
navigate("/customers/list");
}
<UnsavedChangesProvider isDirty={isDirty}>
<div className='flex items-center justify-between space-y-4 px-6'>
<div className='space-y-2'>
<h2 className='text-2xl font-bold tracking-tight text-balance scroll-m-2'>
{t("pages.create.title")}
</h2>
<p className='text-muted-foreground scroll-m-20 tracking-tight text-balance'>
{t("pages.create.description")}
</p>
</div>
<FormCommitButtonGroup
cancel={{
to: "/customers/list",
}}
>
{t("common.cancel")}
</Button>
submit={{
formId: "customer-create-form",
disabled: isCreating,
isLoading: isCreating,
}}
/>
</div>
{/* Alerta de error de actualización (si ha fallado el último intento) */}
{isCreateError && (
<ErrorAlert
title={t("pages.create.errorTitle", "No se pudo guardar los cambios")}
message={
(createError as Error)?.message ??
t("pages.create.errorMsg", "Revisa los datos e inténtalo de nuevo.")
}
/>
)}
<Button
type='submit'
form='customer-create-form'
className='cursor-pointer'
disabled={isCreating}
aria-busy={isCreating}
aria-disabled={isCreating}
data-state={isCreating ? "loading" : "idle"}
>
{t("common.save")}
</Button>
</ButtonGroup>
</div>
{/* Alerta de error de actualización (si ha fallado el último intento) */}
{isCreateError && (
<ErrorAlert
title={t("pages.create.errorTitle", "No se pudo guardar los cambios")}
message={
(createError as Error)?.message ??
t("pages.create.errorMsg", "Revisa los datos e inténtalo de nuevo.")
}
/>
)}
<div className='flex flex-1 flex-col gap-4 p-4'>
<CustomerEditForm
formId={"customer-create-form"} // para que el botón del header pueda hacer submit
initialValues={defaultCustomerFormData}
onSubmit={handleSubmit}
onError={handleError}
onDirtyChange={setIsDirty}
/>
</div>
<div className='flex flex-1 flex-col gap-4 p-4'>
<CustomerEditForm
formId={"customer-create-form"} // para que el botón del header pueda hacer submit
initialValues={defaultCustomerFormData}
onSubmit={handleSubmit}
onError={handleError}
onDirtyChange={setIsDirty}
/>
</div>
</UnsavedChangesProvider>
</AppContent>
</>
);

View File

@ -10,18 +10,16 @@ import {
} from "@repo/shadcn-ui/components";
interface CustomDialogProps {
isOpen: boolean;
onCancel: () => void;
onConfirm: () => void;
title: React.ReactNode;
description: React.ReactNode;
cancelLabel: React.ReactNode;
confirmLabel: React.ReactNode;
open: boolean;
onConfirm: (ok: boolean) => void;
title?: string;
description?: string;
cancelLabel?: string;
confirmLabel?: string;
}
export const CustomDialog = ({
isOpen,
onCancel,
open,
onConfirm,
title,
description,
@ -29,15 +27,15 @@ export const CustomDialog = ({
confirmLabel,
}: CustomDialogProps) => {
return (
<AlertDialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
<AlertDialog open={open} onOpenChange={(open) => !open && onConfirm(false)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>{cancelLabel}</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>{confirmLabel}</AlertDialogAction>
<AlertDialogCancel onClick={() => onConfirm(false)}>{cancelLabel}</AlertDialogCancel>
<AlertDialogAction onClick={() => onConfirm(true)}>{confirmLabel}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

View File

@ -179,7 +179,7 @@ importers:
version: 29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3))
ts-jest:
specifier: ^29.2.5
version: 29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3)
version: 29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(esbuild@0.25.5)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3)
tsconfig-paths:
specifier: ^4.2.0
version: 4.2.0
@ -395,6 +395,9 @@ importers:
i18next:
specifier: ^25.1.1
version: 25.2.1(typescript@5.8.3)
lucide-react:
specifier: ^0.503.0
version: 0.503.0(react@19.1.0)
react:
specifier: ^19.1.0
version: 19.1.0
@ -12295,7 +12298,7 @@ snapshots:
ts-interface-checker@0.1.13: {}
ts-jest@29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3):
ts-jest@29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(esbuild@0.25.5)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3):
dependencies:
bs-logger: 0.2.6
ejs: 3.1.10
@ -12313,6 +12316,7 @@ snapshots:
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
babel-jest: 29.7.0(@babel/core@7.27.4)
esbuild: 0.25.5
jest-util: 29.7.0
ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3):