Clientes
This commit is contained in:
parent
633f7cf6bd
commit
01ead56fff
@ -7,7 +7,7 @@ import { i18n } from "@/locales";
|
|||||||
|
|
||||||
import { AuthProvider, createAuthService } from "@erp/auth/client";
|
import { AuthProvider, createAuthService } from "@erp/auth/client";
|
||||||
import { createAxiosDataSource, createAxiosInstance } from "@erp/core/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 DineroFactory from "dinero.js";
|
||||||
import { RouterProvider } from "react-router-dom";
|
import { RouterProvider } from "react-router-dom";
|
||||||
import { clearAccessToken, getAccessToken, setAccessToken } from "./lib";
|
import { clearAccessToken, getAccessToken, setAccessToken } from "./lib";
|
||||||
@ -55,12 +55,10 @@ export const App = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<UnsavedWarnProvider>
|
{/* Fallback Route */}
|
||||||
{/* Fallback Route */}
|
<Suspense fallback={<LoadingOverlay />}>
|
||||||
<Suspense fallback={<LoadingOverlay />}>
|
<RouterProvider router={appRouter} />
|
||||||
<RouterProvider router={appRouter} />
|
</Suspense>
|
||||||
</Suspense>
|
|
||||||
</UnsavedWarnProvider>
|
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
<Toaster
|
<Toaster
|
||||||
toastOptions={
|
toastOptions={
|
||||||
|
|||||||
@ -30,6 +30,7 @@
|
|||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"http-status": "^2.1.0",
|
"http-status": "^2.1.0",
|
||||||
"i18next": "^25.1.1",
|
"i18next": "^25.1.1",
|
||||||
|
"lucide-react": "^0.503.0",
|
||||||
"react-hook-form": "^7.58.1",
|
"react-hook-form": "^7.58.1",
|
||||||
"react-i18next": "^15.5.1",
|
"react-i18next": "^15.5.1",
|
||||||
"react-router-dom": "^6.26.0",
|
"react-router-dom": "^6.26.0",
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
"save": "Save",
|
||||||
"required": "•"
|
"required": "•"
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
@ -12,11 +13,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"use_unsaved_changes_notifier": {
|
"unsaved_changes_dialog": {
|
||||||
"unsaved_changes": "You have unsaved changes",
|
"title": "You have unsaved changes",
|
||||||
"unsaved_changes_explanation": "If you leave, your changes will be lost.",
|
"description": "If you leave, your changes will be lost.",
|
||||||
"discard_changes": "Discard changes",
|
"confirm": "Discard changes",
|
||||||
"stay_on_page": "Stay on page"
|
"cancel": "Stay on page"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
|
"save": "Guardar",
|
||||||
"required": "•"
|
"required": "•"
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
@ -12,11 +13,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"use_unsaved_changes_notifier": {
|
"unsaved_changes_dialog": {
|
||||||
"unsaved_changes": "Tienes cambios no guardados",
|
"title": "Tienes cambios no guardados",
|
||||||
"unsaved_changes_explanation": "Si sales, tus cambios se perderán.",
|
"description": "Si sales, tus cambios se perderán.",
|
||||||
"discard_changes": "Descartar cambios",
|
"confirm": "Descartar cambios",
|
||||||
"stay_on_page": "Permanecer en la página"
|
"cancel": "Permanecer en esta página"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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";
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,2 @@
|
|||||||
|
export * from "./components";
|
||||||
export * from "./use-unsaved-changes-notifier";
|
export * from "./use-unsaved-changes-notifier";
|
||||||
export * from "./use-warn-about-change";
|
|
||||||
export * from "./warn-about-change-context";
|
|
||||||
export * from "./warn-about-change-provider";
|
|
||||||
|
|||||||
@ -1,89 +1,94 @@
|
|||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||||
import { useBlocker } from "react-router-dom";
|
import { UNSAFE_NavigationContext, useBeforeUnload, useBlocker } from "react-router-dom";
|
||||||
import { useTranslation } from "../../i18n";
|
import { UnsavedChangesDialog } from "./components";
|
||||||
import { useWarnAboutChange } from "./use-warn-about-change";
|
|
||||||
|
|
||||||
export type UnsavedChangesNotifierProps = {
|
type ContextValue = {
|
||||||
isDirty?: boolean;
|
requestConfirm: () => Promise<boolean>;
|
||||||
title?: string;
|
|
||||||
subtitle?: string;
|
|
||||||
confirmText?: string;
|
|
||||||
cancelText?: string;
|
|
||||||
onConfirm?: () => void;
|
|
||||||
onCancel?: () => void;
|
|
||||||
type?: "success" | "error" | "warning" | "info";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useUnsavedChangesNotifier = ({
|
export const UnsavedChangesContext = createContext<ContextValue | null>(null);
|
||||||
isDirty = false,
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
confirmText,
|
|
||||||
cancelText,
|
|
||||||
onConfirm,
|
|
||||||
onCancel,
|
|
||||||
type = "warning",
|
|
||||||
}: UnsavedChangesNotifierProps = {}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const blocker = useBlocker(isDirty);
|
|
||||||
const { show } = useWarnAboutChange();
|
|
||||||
|
|
||||||
const texts = useMemo(
|
export const useUnsavedChangesContext = () => {
|
||||||
() => ({
|
const context = useContext(UnsavedChangesContext);
|
||||||
title: title ?? t("hooks.use_unsaved_changes_notifier.unsaved_changes"),
|
if (context === null) {
|
||||||
subtitle: subtitle ?? t("hooks.use_unsaved_changes_notifier.unsaved_changes_explanation"),
|
throw new Error("useUnsavedChangesContext must be used within an UnsavedChangesProvider");
|
||||||
confirmText: confirmText ?? t("hooks.use_unsaved_changes_notifier.discard_changes"),
|
}
|
||||||
cancelText: cancelText ?? t("hooks.use_unsaved_changes_notifier.stay_on_page"),
|
return context;
|
||||||
}),
|
};
|
||||||
[t, title, subtitle, confirmText, cancelText]
|
|
||||||
|
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(() => {
|
// 🔹 bloquea navegación interna (react-router)
|
||||||
if (!isDirty) return Promise.resolve(true);
|
const navigator = useContext(UNSAFE_NavigationContext).navigator as any;
|
||||||
|
|
||||||
return new Promise<boolean>((resolve) => {
|
useEffect(() => {
|
||||||
show({
|
if (!isDirty) return;
|
||||||
title: texts.title,
|
|
||||||
subtitle: texts.subtitle,
|
const push = navigator.push;
|
||||||
confirmText: texts.confirmText,
|
navigator.push = async (...args: any[]) => {
|
||||||
cancelText: texts.cancelText,
|
const ok = await requestConfirm();
|
||||||
type,
|
if (ok) push.apply(navigator, args);
|
||||||
onConfirm: () => {
|
};
|
||||||
resolve(true);
|
|
||||||
onConfirm?.();
|
return () => {
|
||||||
window.onbeforeunload = null; // limpirar de cambios
|
navigator.push = push;
|
||||||
},
|
};
|
||||||
onCancel: () => {
|
}, [isDirty, requestConfirm, navigator]);
|
||||||
resolve(false);
|
|
||||||
onCancel?.();
|
// 🔹 Bloquea navegación entre rutas
|
||||||
},
|
const blocker = useBlocker(() => isDirty);
|
||||||
});
|
|
||||||
});
|
|
||||||
}, [isDirty, show, texts, type, onConfirm, onCancel]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (blocker.state === "blocked") {
|
if (blocker.state === "blocked") {
|
||||||
confirm().then((result) => {
|
(async () => {
|
||||||
if (result) blocker.proceed();
|
const ok = await requestConfirm();
|
||||||
else blocker.reset();
|
if (ok) {
|
||||||
});
|
blocker.proceed();
|
||||||
|
} else {
|
||||||
|
blocker.reset();
|
||||||
|
}
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
}, [blocker, confirm]);
|
}, [blocker, requestConfirm]);
|
||||||
|
|
||||||
useEffect(() => {
|
return (
|
||||||
if (isDirty) {
|
<UnsavedChangesContext.Provider value={{ requestConfirm }}>
|
||||||
window.onbeforeunload = (e) => {
|
<h1>hola</h1>
|
||||||
e.preventDefault();
|
{children}
|
||||||
return texts.subtitle;
|
<UnsavedChangesDialog open={open} onConfirm={handleConfirm} />
|
||||||
};
|
</UnsavedChangesContext.Provider>
|
||||||
} else {
|
);
|
||||||
window.onbeforeunload = null;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.onbeforeunload = null;
|
|
||||||
};
|
|
||||||
}, [isDirty, texts.subtitle]);
|
|
||||||
|
|
||||||
return { confirm };
|
|
||||||
};
|
|
||||||
|
|||||||
@ -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;
|
|
||||||
};
|
|
||||||
@ -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);
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,8 +1,7 @@
|
|||||||
import { AppBreadcrumb, AppContent, ButtonGroup } from "@repo/rdx-ui/components";
|
import { AppBreadcrumb, AppContent } from "@repo/rdx-ui/components";
|
||||||
import { Button } from "@repo/shadcn-ui/components";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
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 { showErrorToast, showSuccessToast } from "@repo/shadcn-ui/lib/utils";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { FieldErrors } from "react-hook-form";
|
import { FieldErrors } from "react-hook-form";
|
||||||
@ -24,10 +23,6 @@ export const CustomerCreate = () => {
|
|||||||
error: createError,
|
error: createError,
|
||||||
} = useCreateCustomerMutation();
|
} = useCreateCustomerMutation();
|
||||||
|
|
||||||
const { confirm: confirmCancel } = useUnsavedChangesNotifier({
|
|
||||||
isDirty,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3) Submit con navegación condicionada por éxito
|
// 3) Submit con navegación condicionada por éxito
|
||||||
const handleSubmit = async (formData: CustomerFormData) => {
|
const handleSubmit = async (formData: CustomerFormData) => {
|
||||||
/*const changedData: Record<string, string> = {};
|
/*const changedData: Record<string, string> = {};
|
||||||
@ -56,68 +51,54 @@ export const CustomerCreate = () => {
|
|||||||
// Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
|
// Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log("render");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppBreadcrumb />
|
<AppBreadcrumb />
|
||||||
<AppContent>
|
<AppContent>
|
||||||
<div className='flex items-center justify-between space-y-4 px-6'>
|
<UnsavedChangesProvider isDirty={isDirty}>
|
||||||
<div className='space-y-2'>
|
<div className='flex items-center justify-between space-y-4 px-6'>
|
||||||
<h2 className='text-2xl font-bold tracking-tight text-balance scroll-m-2'>
|
<div className='space-y-2'>
|
||||||
{t("pages.create.title")}
|
<h2 className='text-2xl font-bold tracking-tight text-balance scroll-m-2'>
|
||||||
</h2>
|
{t("pages.create.title")}
|
||||||
<p className='text-muted-foreground scroll-m-20 tracking-tight text-balance'>
|
</h2>
|
||||||
{t("pages.create.description")}
|
<p className='text-muted-foreground scroll-m-20 tracking-tight text-balance'>
|
||||||
</p>
|
{t("pages.create.description")}
|
||||||
</div>
|
</p>
|
||||||
<ButtonGroup>
|
</div>
|
||||||
<Button
|
<FormCommitButtonGroup
|
||||||
variant={"outline"}
|
cancel={{
|
||||||
className='cursor-pointer'
|
to: "/customers/list",
|
||||||
onClick={async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const ok = await confirmCancel();
|
|
||||||
if (ok) {
|
|
||||||
console.log("Cambios descartados");
|
|
||||||
navigate("/customers/list");
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
submit={{
|
||||||
{t("common.cancel")}
|
formId: "customer-create-form",
|
||||||
</Button>
|
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
|
<div className='flex flex-1 flex-col gap-4 p-4'>
|
||||||
type='submit'
|
<CustomerEditForm
|
||||||
form='customer-create-form'
|
formId={"customer-create-form"} // para que el botón del header pueda hacer submit
|
||||||
className='cursor-pointer'
|
initialValues={defaultCustomerFormData}
|
||||||
disabled={isCreating}
|
onSubmit={handleSubmit}
|
||||||
aria-busy={isCreating}
|
onError={handleError}
|
||||||
aria-disabled={isCreating}
|
onDirtyChange={setIsDirty}
|
||||||
data-state={isCreating ? "loading" : "idle"}
|
/>
|
||||||
>
|
</div>
|
||||||
{t("common.save")}
|
</UnsavedChangesProvider>
|
||||||
</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>
|
|
||||||
</AppContent>
|
</AppContent>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -10,18 +10,16 @@ import {
|
|||||||
} from "@repo/shadcn-ui/components";
|
} from "@repo/shadcn-ui/components";
|
||||||
|
|
||||||
interface CustomDialogProps {
|
interface CustomDialogProps {
|
||||||
isOpen: boolean;
|
open: boolean;
|
||||||
onCancel: () => void;
|
onConfirm: (ok: boolean) => void;
|
||||||
onConfirm: () => void;
|
title?: string;
|
||||||
title: React.ReactNode;
|
description?: string;
|
||||||
description: React.ReactNode;
|
cancelLabel?: string;
|
||||||
cancelLabel: React.ReactNode;
|
confirmLabel?: string;
|
||||||
confirmLabel: React.ReactNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CustomDialog = ({
|
export const CustomDialog = ({
|
||||||
isOpen,
|
open,
|
||||||
onCancel,
|
|
||||||
onConfirm,
|
onConfirm,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@ -29,15 +27,15 @@ export const CustomDialog = ({
|
|||||||
confirmLabel,
|
confirmLabel,
|
||||||
}: CustomDialogProps) => {
|
}: CustomDialogProps) => {
|
||||||
return (
|
return (
|
||||||
<AlertDialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
|
<AlertDialog open={open} onOpenChange={(open) => !open && onConfirm(false)}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel onClick={onCancel}>{cancelLabel}</AlertDialogCancel>
|
<AlertDialogCancel onClick={() => onConfirm(false)}>{cancelLabel}</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={onConfirm}>{confirmLabel}</AlertDialogAction>
|
<AlertDialogAction onClick={() => onConfirm(true)}>{confirmLabel}</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|||||||
@ -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))
|
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:
|
ts-jest:
|
||||||
specifier: ^29.2.5
|
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:
|
tsconfig-paths:
|
||||||
specifier: ^4.2.0
|
specifier: ^4.2.0
|
||||||
version: 4.2.0
|
version: 4.2.0
|
||||||
@ -395,6 +395,9 @@ importers:
|
|||||||
i18next:
|
i18next:
|
||||||
specifier: ^25.1.1
|
specifier: ^25.1.1
|
||||||
version: 25.2.1(typescript@5.8.3)
|
version: 25.2.1(typescript@5.8.3)
|
||||||
|
lucide-react:
|
||||||
|
specifier: ^0.503.0
|
||||||
|
version: 0.503.0(react@19.1.0)
|
||||||
react:
|
react:
|
||||||
specifier: ^19.1.0
|
specifier: ^19.1.0
|
||||||
version: 19.1.0
|
version: 19.1.0
|
||||||
@ -12295,7 +12298,7 @@ snapshots:
|
|||||||
|
|
||||||
ts-interface-checker@0.1.13: {}
|
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:
|
dependencies:
|
||||||
bs-logger: 0.2.6
|
bs-logger: 0.2.6
|
||||||
ejs: 3.1.10
|
ejs: 3.1.10
|
||||||
@ -12313,6 +12316,7 @@ snapshots:
|
|||||||
'@jest/transform': 29.7.0
|
'@jest/transform': 29.7.0
|
||||||
'@jest/types': 29.6.3
|
'@jest/types': 29.6.3
|
||||||
babel-jest: 29.7.0(@babel/core@7.27.4)
|
babel-jest: 29.7.0(@babel/core@7.27.4)
|
||||||
|
esbuild: 0.25.5
|
||||||
jest-util: 29.7.0
|
jest-util: 29.7.0
|
||||||
|
|
||||||
ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3):
|
ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3):
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user