.
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
parent
6adb538aa4
commit
6db80fbc07
@ -4,49 +4,66 @@ import { ChevronLeftIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface PageHeaderProps {
|
||||
backIcon?: ReactNode;
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
status?: string;
|
||||
rightSlot?: ReactNode;
|
||||
|
||||
showBackButton?: boolean;
|
||||
onBackClick?: () => void;
|
||||
backButtonLabel?: string;
|
||||
|
||||
statusSlot?: ReactNode;
|
||||
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
backIcon,
|
||||
export const PageHeader = ({
|
||||
title,
|
||||
description,
|
||||
rightSlot,
|
||||
showBackButton = false,
|
||||
onBackClick,
|
||||
backButtonLabel = "Volver",
|
||||
statusSlot,
|
||||
className,
|
||||
}: PageHeaderProps) {
|
||||
}: PageHeaderProps) => {
|
||||
return (
|
||||
<div className={cn("flex flex-row items-center justify-between mb-6", className)}>
|
||||
{/* Lado izquierdo */}
|
||||
<header
|
||||
className={cn(
|
||||
"mb-6 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start gap-4">
|
||||
{backIcon && (
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
{showBackButton && (
|
||||
<Button
|
||||
className="cursor-pointer"
|
||||
onClick={() => window.history.back()}
|
||||
aria-label={backButtonLabel}
|
||||
className="shrink-0 cursor-pointer"
|
||||
onClick={onBackClick}
|
||||
size="default"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ChevronLeftIcon className="size-5" />
|
||||
<ChevronLeftIcon aria-hidden="true" className="size-5" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-xl font-semibold tracking-tight lg:text-2xl h-8 text-foreground sm:truncate sm:tracking-tight">
|
||||
{title}
|
||||
</h1>
|
||||
{description && <div className="text-sm text-muted-foreground">{description}</div>}
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h1 className="min-w-0 truncate text-xl font-semibold tracking-tight text-foreground lg:text-2xl">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
{statusSlot}
|
||||
</div>
|
||||
|
||||
{description && <p className="text-sm text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lado derecho parametrizable */}
|
||||
<div className="mt-4 flex lg:mt-0 lg:ml-4">{rightSlot}</div>
|
||||
</div>
|
||||
{rightSlot && <div className="flex shrink-0 items-center lg:pt-0">{rightSlot}</div>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
import { Button } from "@repo/shadcn-ui/components";
|
||||
import { cn } from '@repo/shadcn-ui/lib/utils';
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import { XIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useCallback } from "react";
|
||||
import type * as React from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useTranslation } from "../../../i18n.ts";
|
||||
import { useUnsavedChangesContext } from "../use-unsaved-changes-notifier";
|
||||
|
||||
export type CancelFormButtonProps = {
|
||||
formId?: string;
|
||||
to?: string; /// Ruta a la que navegar si no se pasa onCancel
|
||||
onCancel?: () => void | Promise<void>; // Prioritaria sobre "to"
|
||||
to?: string;
|
||||
onCancel?: () => void | Promise<void>;
|
||||
label?: string;
|
||||
variant?: React.ComponentProps<typeof Button>["variant"];
|
||||
size?: React.ComponentProps<typeof Button>["size"];
|
||||
@ -20,50 +20,57 @@ export type CancelFormButtonProps = {
|
||||
};
|
||||
|
||||
export const CancelFormButton = ({
|
||||
formId,
|
||||
to,
|
||||
onCancel,
|
||||
label,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
className,
|
||||
disabled,
|
||||
disabled = false,
|
||||
"data-testid": dataTestId = "cancel-button",
|
||||
}: CancelFormButtonProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const defaultLabel = t ? t("common.cancel") : "Cancel";
|
||||
const { requestConfirm } = useUnsavedChangesContext();
|
||||
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
|
||||
const defaultLabel = t("common.cancel");
|
||||
const computedDisabled = disabled || isRunning;
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
if (computedDisabled) return;
|
||||
|
||||
const ok = requestConfirm ? await requestConfirm() : true;
|
||||
if (!ok) return;
|
||||
|
||||
if (onCancel) {
|
||||
await onCancel();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsRunning(true);
|
||||
|
||||
if (to) {
|
||||
navigate(to);
|
||||
if (onCancel) {
|
||||
await onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (to) {
|
||||
navigate(to);
|
||||
}
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
// si no hay ni onCancel ni to → no hace nada
|
||||
}, [requestConfirm, onCancel, to, navigate]);
|
||||
}, [computedDisabled, requestConfirm, onCancel, to, navigate]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
form={formId}
|
||||
type='button'
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn("cursor-pointer", className)}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
aria-disabled={disabled}
|
||||
data-testid={dataTestId}
|
||||
disabled={computedDisabled}
|
||||
onClick={handleClick}
|
||||
size={size}
|
||||
type="button"
|
||||
variant={variant}
|
||||
>
|
||||
<XIcon className='mr-2 h-3 w-3' />
|
||||
<XIcon aria-hidden="true" className="mr-2 h-3 w-3" />
|
||||
<span>{label ?? defaultLabel}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@ -17,6 +17,7 @@ import {
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { CancelFormButton, type CancelFormButtonProps } from "./cancel-form-button";
|
||||
import { type SubmitButtonProps, SubmitFormButton } from "./submit-form-button";
|
||||
@ -25,7 +26,7 @@ type Align = "start" | "center" | "end" | "between";
|
||||
|
||||
type GroupSubmitButtonProps = Omit<SubmitButtonProps, "isLoading" | "preventDoubleSubmit">;
|
||||
|
||||
export type UpdateCommitButtonGroupProps = {
|
||||
export type FormCommitButtonGroupProps = {
|
||||
className?: string;
|
||||
align?: Align; // default "end"
|
||||
gap?: string; // default "gap-2"
|
||||
@ -52,7 +53,7 @@ const alignToJustify: Record<Align, string> = {
|
||||
between: "justify-between",
|
||||
};
|
||||
|
||||
export const UpdateCommitButtonGroup = ({
|
||||
export const FormCommitButtonGroup = ({
|
||||
className,
|
||||
align = "end",
|
||||
gap = "gap-2",
|
||||
@ -70,82 +71,97 @@ export const UpdateCommitButtonGroup = ({
|
||||
onPreview,
|
||||
onDuplicate,
|
||||
onBack,
|
||||
}: UpdateCommitButtonGroupProps) => {
|
||||
const showCancel = cancel?.show ?? true;
|
||||
const hasSecondaryActions = onReset || onPreview || onDuplicate || onBack || onDelete;
|
||||
|
||||
// ⛳️ RHF opcional: auto-detectar isSubmitting si no se pasó isLoading
|
||||
let rhfIsSubmitting = false;
|
||||
}: FormCommitButtonGroupProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const ctx = useFormContext();
|
||||
rhfIsSubmitting = !!ctx?.formState?.isSubmitting;
|
||||
|
||||
const rhfIsSubmitting = !!ctx.formState.isSubmitting;
|
||||
const busy = isLoading ?? rhfIsSubmitting;
|
||||
|
||||
const showCancel = cancel?.show ?? true;
|
||||
const computedDisabled = !!(disabled || (preventDoubleSubmit && busy));
|
||||
const hasSecondaryActions = !!(onReset || onPreview || onDuplicate || onBack || onDelete);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex",
|
||||
"flex sm:items-center",
|
||||
reverseOrderOnMobile ? "flex-col-reverse sm:flex-row" : "flex-row",
|
||||
alignToJustify[align],
|
||||
gap,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{showCancel && <CancelFormButton {...cancel} />}
|
||||
{submit && <SubmitFormButton {...submit} />}
|
||||
{showCancel && (
|
||||
<CancelFormButton {...cancel} disabled={cancel?.disabled ?? computedDisabled} />
|
||||
)}
|
||||
|
||||
{submit && (
|
||||
<SubmitFormButton
|
||||
{...submit}
|
||||
disabled={submit.disabled ?? computedDisabled}
|
||||
isLoading={busy}
|
||||
preventDoubleSubmit={preventDoubleSubmit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Menú de acciones adicionales */}
|
||||
{hasSecondaryActions && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button className="px-2" disabled={computedDisabled} size="sm" variant="ghost">
|
||||
<Button
|
||||
className="px-2"
|
||||
disabled={computedDisabled}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<MoreHorizontalIcon className="h-4 w-4" />
|
||||
<span className="sr-only">Más acciones</span>
|
||||
<span className="sr-only">{t("common.moreActions")}</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuGroup>
|
||||
{onReset && (
|
||||
<DropdownMenuItem
|
||||
className="text-muted-foreground"
|
||||
disabled={computedDisabled}
|
||||
onClick={onReset}
|
||||
>
|
||||
<DropdownMenuItem disabled={computedDisabled} onClick={onReset}>
|
||||
<RotateCcwIcon className="mr-2 h-4 w-4" />
|
||||
Deshacer cambios
|
||||
{t("common.resetChanges")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{onPreview && (
|
||||
<DropdownMenuItem className="text-muted-foreground" onClick={onPreview}>
|
||||
<DropdownMenuItem disabled={computedDisabled} onClick={onPreview}>
|
||||
<EyeIcon className="mr-2 h-4 w-4" />
|
||||
Vista previa
|
||||
{t("common.preview")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{onDuplicate && (
|
||||
<DropdownMenuItem className="text-muted-foreground" onClick={onDuplicate}>
|
||||
<DropdownMenuItem disabled={computedDisabled} onClick={onDuplicate}>
|
||||
<CopyIcon className="mr-2 h-4 w-4" />
|
||||
Duplicar
|
||||
{t("common.duplicate")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{onBack && (
|
||||
<DropdownMenuItem className="text-muted-foreground" onClick={onBack}>
|
||||
<DropdownMenuItem disabled={computedDisabled} onClick={onBack}>
|
||||
<ArrowLeftIcon className="mr-2 h-4 w-4" />
|
||||
Volver
|
||||
{t("common.back")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{onDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
disabled={computedDisabled}
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2Icon className="mr-2 h-4 w-4" />
|
||||
Eliminar
|
||||
{t("common.delete")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -16,7 +16,7 @@ export type SubmitButtonProps = {
|
||||
size?: React.ComponentProps<typeof Button>["size"];
|
||||
className?: string;
|
||||
|
||||
preventDoubleSubmit?: boolean; // Evita múltiples submits mientras loading
|
||||
preventDoubleSubmit?: boolean;
|
||||
hasChanges?: boolean;
|
||||
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
@ -36,47 +36,35 @@ export const SubmitFormButton = ({
|
||||
preventDoubleSubmit = true,
|
||||
hasChanges = false,
|
||||
onClick,
|
||||
disabled,
|
||||
disabled = false,
|
||||
children,
|
||||
"data-testid": dataTestId = "submit-button",
|
||||
}: SubmitButtonProps) => {
|
||||
const { t } = useTranslation();
|
||||
const defaultLabel = t ? t("common.save") : "Save";
|
||||
const defaultLabelIsLoading = t ? t("common.saving") : "Saving...";
|
||||
const { formState } = useFormContext();
|
||||
|
||||
// ⛳️ 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 busy = isLoading ?? formState.isSubmitting;
|
||||
const computedDisabled = disabled || (preventDoubleSubmit && busy);
|
||||
|
||||
const computedDisabled = !!(disabled || (preventDoubleSubmit && busy));
|
||||
const dataState = busy ? "loading" : "idle";
|
||||
|
||||
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (event) => {
|
||||
if (preventDoubleSubmit && busy) {
|
||||
// Evitar submits duplicados durante loading
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
onClick?.(e);
|
||||
|
||||
onClick?.(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-busy={busy}
|
||||
aria-disabled={computedDisabled}
|
||||
className={cn(
|
||||
"min-w-[100px] cursor-pointer font-medium",
|
||||
hasChanges && "ring-2 ring-primary/20",
|
||||
className
|
||||
)}
|
||||
data-state={dataState}
|
||||
data-state={busy ? "loading" : "idle"}
|
||||
data-testid={dataTestId}
|
||||
disabled={computedDisabled}
|
||||
form={formId}
|
||||
@ -85,20 +73,17 @@ export const SubmitFormButton = ({
|
||||
type="submit"
|
||||
variant={variant}
|
||||
>
|
||||
{children ? (
|
||||
children
|
||||
) : (
|
||||
{children ?? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{busy && (
|
||||
{busy ? (
|
||||
<>
|
||||
<LoaderCircleIcon aria-hidden="true" className="mr-2 h-3 w-3 animate-spin" />
|
||||
<span>{labelIsLoading ?? defaultLabelIsLoading}</span>
|
||||
<LoaderCircleIcon aria-hidden="true" className="h-3 w-3 animate-spin" />
|
||||
<span>{labelIsLoading ?? t("common.saving")}</span>
|
||||
</>
|
||||
)}
|
||||
{!busy && (
|
||||
) : (
|
||||
<>
|
||||
<SaveIcon className="mr-2 h-3 w-3" />
|
||||
<span>{label ?? defaultLabel}</span>
|
||||
<SaveIcon aria-hidden="true" className="h-3 w-3" />
|
||||
<span>{label ?? t("common.save")}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@ -2,12 +2,12 @@ import { CustomDialog } from "@repo/rdx-ui/components";
|
||||
|
||||
import { useTranslation } from "../../../i18n";
|
||||
|
||||
type UnsavedChangesDialogProps = {
|
||||
interface UnsavedChangesDialogProps {
|
||||
open: boolean;
|
||||
onConfirm: (ok: boolean) => void;
|
||||
};
|
||||
onConfirm: (discardChanges: boolean) => void;
|
||||
}
|
||||
|
||||
export function UnsavedChangesDialog({ open, onConfirm }: UnsavedChangesDialogProps) {
|
||||
export const UnsavedChangesDialog = ({ open, onConfirm }: UnsavedChangesDialogProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@ -23,4 +23,4 @@ export function UnsavedChangesDialog({ open, onConfirm }: UnsavedChangesDialogPr
|
||||
title={t("hooks.unsaved_changes_dialog.title", "¿Descartar cambios?")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,88 +1,85 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
import { UNSAFE_NavigationContext, useBeforeUnload, useBlocker } from "react-router-dom";
|
||||
import {
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useBlocker } from "react-router-dom";
|
||||
|
||||
import { UnsavedChangesDialog } from "./components";
|
||||
|
||||
type ContextValue = {
|
||||
interface UnsavedChangesContextValue {
|
||||
requestConfirm: () => Promise<boolean>;
|
||||
};
|
||||
}
|
||||
|
||||
export const UnsavedChangesContext = createContext<ContextValue | null>(null);
|
||||
export const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
|
||||
|
||||
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,
|
||||
}: {
|
||||
interface UnsavedChangesProviderProps {
|
||||
isDirty: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [resolver, setResolver] = useState<((ok: boolean) => void) | null>(null);
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const UnsavedChangesProvider = ({ isDirty, children }: UnsavedChangesProviderProps) => {
|
||||
const resolverRef = useRef<((ok: boolean) => void) | null>(null);
|
||||
const allowNavigationRef = useRef(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// requestConfirm() devuelve una promesa que resuelve true/false
|
||||
// según lo que haya pulsado el usuario
|
||||
const requestConfirm = useCallback(() => {
|
||||
if (!isDirty) return Promise.resolve(true);
|
||||
|
||||
if (resolverRef.current) return Promise.resolve(false);
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
setResolver(() => resolve);
|
||||
resolverRef.current = resolve;
|
||||
setOpen(true);
|
||||
});
|
||||
}, []);
|
||||
}, [isDirty]);
|
||||
|
||||
const handleConfirm = (ok: boolean) => {
|
||||
console.log("handleConfirm");
|
||||
resolver?.(ok);
|
||||
setResolver(null);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
// bloquea F5/cierre de pestaña
|
||||
useBeforeUnload(
|
||||
(event) => {
|
||||
if (isDirty) {
|
||||
event.preventDefault();
|
||||
event.returnValue = ""; // requerido por Chrome/Edge
|
||||
}
|
||||
},
|
||||
{ capture: true }
|
||||
);
|
||||
|
||||
// 🔹 bloquea navegación interna (react-router)
|
||||
const navigator = useContext(UNSAFE_NavigationContext).navigator as any;
|
||||
|
||||
useEffect(() => {
|
||||
const push = navigator.push;
|
||||
|
||||
if (isDirty) {
|
||||
navigator.push = async (...args: any[]) => {
|
||||
const ok = await requestConfirm();
|
||||
if (ok) push.apply(navigator, args);
|
||||
};
|
||||
const handleConfirm = useCallback((discardChanges: boolean) => {
|
||||
if (discardChanges) {
|
||||
allowNavigationRef.current = true;
|
||||
}
|
||||
|
||||
return () => {
|
||||
navigator.push = push;
|
||||
};
|
||||
}, [isDirty, requestConfirm, navigator]);
|
||||
resolverRef.current?.(discardChanges);
|
||||
resolverRef.current = null;
|
||||
setOpen(false);
|
||||
}, []);
|
||||
|
||||
// 🔹 Bloquea navegación entre rutas
|
||||
const blocker = useBlocker(() => isDirty);
|
||||
const blocker = useBlocker(() => {
|
||||
if (allowNavigationRef.current) return false;
|
||||
return isDirty;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (blocker.state !== "blocked") return;
|
||||
|
||||
let active = true;
|
||||
|
||||
(async () => {
|
||||
const ok = await requestConfirm();
|
||||
const _ = (async () => {
|
||||
const discardChanges = await requestConfirm();
|
||||
|
||||
if (!active) return;
|
||||
ok ? blocker.proceed() : blocker.reset();
|
||||
|
||||
if (discardChanges) {
|
||||
allowNavigationRef.current = true;
|
||||
blocker.proceed();
|
||||
} else {
|
||||
blocker.reset();
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
@ -90,10 +87,24 @@ export function UnsavedChangesProvider({
|
||||
};
|
||||
}, [blocker, requestConfirm]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
resolverRef.current?.(false);
|
||||
resolverRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo<UnsavedChangesContextValue>(
|
||||
() => ({
|
||||
requestConfirm,
|
||||
}),
|
||||
[requestConfirm]
|
||||
);
|
||||
|
||||
return (
|
||||
<UnsavedChangesContext.Provider value={{ requestConfirm }}>
|
||||
<UnsavedChangesContext.Provider value={contextValue}>
|
||||
{children}
|
||||
<UnsavedChangesDialog onConfirm={handleConfirm} open={open} />
|
||||
</UnsavedChangesContext.Provider>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { SpainTaxCatalogProvider } from "@erp/core";
|
||||
import { ErrorAlert, NotFoundCard, PageHeader } from "@erp/core/components";
|
||||
import { UnsavedChangesProvider, UpdateCommitButtonGroup } from "@erp/core/hooks";
|
||||
import { FormCommitButtonGroup, UnsavedChangesProvider } from "@erp/core/hooks";
|
||||
import { SelectCustomerDialog } from "@erp/customers";
|
||||
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
|
||||
import { Spinner } from "@repo/shadcn-ui/components";
|
||||
import { useMemo } from "react";
|
||||
import { FormProvider } from "react-hook-form";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useTranslation } from "../../../../i18n";
|
||||
import { useUpdateProformaPageController } from "../../controllers/use-update-proforma-page-controller";
|
||||
@ -14,6 +14,7 @@ import { ProformaUpdateEditorForm } from "../editors";
|
||||
|
||||
export const ProformaUpdatePage = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const taxCatalog = useMemo(() => SpainTaxCatalogProvider(), []);
|
||||
|
||||
const { updateCtrl, selectCustomerCtrl } = useUpdateProformaPageController();
|
||||
@ -55,70 +56,59 @@ export const ProformaUpdatePage = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<UnsavedChangesProvider isDirty={updateCtrl.form.formState.isDirty}>
|
||||
<AppHeader className="space-y-4 max-w-5xl mx-auto">
|
||||
<PageHeader
|
||||
backIcon
|
||||
description={t("pages.proformas.update.description")}
|
||||
rightSlot={
|
||||
<UpdateCommitButtonGroup
|
||||
cancel={{
|
||||
formId: updateCtrl.formId,
|
||||
to: "/proformas/list",
|
||||
disabled: updateCtrl.isUpdating,
|
||||
}}
|
||||
disabled={updateCtrl.isUpdating}
|
||||
isLoading={updateCtrl.isUpdating}
|
||||
onReset={updateCtrl.resetForm}
|
||||
submit={{
|
||||
formId: updateCtrl.formId,
|
||||
disabled: updateCtrl.isUpdating,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
title={<>{t("pages.proformas.update.title")}</>}
|
||||
/>
|
||||
</AppHeader>
|
||||
<AppContent className="space-y-4 max-w-5xl mx-auto">
|
||||
{/* Alerta de error de actualización (si ha fallado el último intento) */}
|
||||
{updateCtrl.isUpdateError && (
|
||||
<ErrorAlert
|
||||
message={
|
||||
(updateCtrl.updateError as Error)?.message ??
|
||||
t("pages.proformas.update.error_msg", "Revisa los datos e inténtalo de nuevo.")
|
||||
}
|
||||
title={t("pages.proformas.update.error_title", "No se pudo guardar los cambios")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{updateCtrl.isLoading && <Spinner />}
|
||||
|
||||
{!updateCtrl.isLoading && (
|
||||
<>
|
||||
<FormProvider {...updateCtrl.form}>
|
||||
<ProformaUpdateEditorForm
|
||||
currencyCode={updateCtrl.currencyCode}
|
||||
formId={updateCtrl.formId}
|
||||
isSubmitting={updateCtrl.isUpdating}
|
||||
itemsCtrl={updateCtrl.itemsCtrl}
|
||||
languageCode={updateCtrl.languageCode}
|
||||
onChangeCustomerClick={selectCustomerCtrl.selectCtrl.openDialog}
|
||||
//onCreateCustomerClick={selectCustomerCtrl.createCtrl.openDialog}
|
||||
onCreateCustomerClick={() => null}
|
||||
onReset={updateCtrl.resetForm}
|
||||
onSubmit={updateCtrl.onSubmit}
|
||||
selectedCustomer={updateCtrl.selectedCustomer}
|
||||
taxCtrl={updateCtrl.taxCtrl}
|
||||
totalsCtrl={updateCtrl.totalsCtrl}
|
||||
<FormProvider {...updateCtrl.form}>
|
||||
<UnsavedChangesProvider isDirty={updateCtrl.form.formState.isDirty}>
|
||||
<AppHeader className="mx-auto max-w-5xl space-y-4">
|
||||
<PageHeader
|
||||
description={t("pages.proformas.update.description")}
|
||||
onBackClick={() => navigate("/proformas/list")}
|
||||
rightSlot={
|
||||
<FormCommitButtonGroup
|
||||
cancel={{
|
||||
to: "/proformas/list",
|
||||
}}
|
||||
disabled={updateCtrl.isUpdating}
|
||||
isLoading={updateCtrl.isUpdating}
|
||||
onReset={updateCtrl.form.formState.isDirty ? updateCtrl.resetForm : undefined}
|
||||
submit={{
|
||||
formId: updateCtrl.formId,
|
||||
}}
|
||||
/>
|
||||
</FormProvider>
|
||||
<SelectCustomerDialog
|
||||
ctrl={selectCustomerCtrl.selectCtrl}
|
||||
//onCreateNewCustomerClick={selectCustomerCtrl.createCtrl.openDialog}
|
||||
}
|
||||
showBackButton
|
||||
title={t("pages.proformas.update.title")}
|
||||
/>
|
||||
</AppHeader>
|
||||
|
||||
<AppContent className="mx-auto max-w-5xl space-y-4">
|
||||
{updateCtrl.isUpdateError && (
|
||||
<ErrorAlert
|
||||
message={
|
||||
(updateCtrl.updateError as Error)?.message ??
|
||||
t("pages.proformas.update.error_msg", "Revisa los datos e inténtalo de nuevo.")
|
||||
}
|
||||
title={t("pages.proformas.update.error_title", "No se pudo guardar los cambios")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</AppContent>
|
||||
</UnsavedChangesProvider>
|
||||
)}
|
||||
|
||||
<ProformaUpdateEditorForm
|
||||
currencyCode={updateCtrl.currencyCode}
|
||||
formId={updateCtrl.formId}
|
||||
isSubmitting={updateCtrl.isUpdating}
|
||||
itemsCtrl={updateCtrl.itemsCtrl}
|
||||
languageCode={updateCtrl.languageCode}
|
||||
onChangeCustomerClick={selectCustomerCtrl.selectCtrl.openDialog}
|
||||
onCreateCustomerClick={() => null}
|
||||
onReset={updateCtrl.resetForm}
|
||||
onSubmit={updateCtrl.onSubmit}
|
||||
selectedCustomer={updateCtrl.selectedCustomer}
|
||||
taxCtrl={updateCtrl.taxCtrl}
|
||||
totalsCtrl={updateCtrl.totalsCtrl}
|
||||
/>
|
||||
|
||||
<SelectCustomerDialog ctrl={selectCustomerCtrl.selectCtrl} />
|
||||
</AppContent>
|
||||
</UnsavedChangesProvider>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { ErrorAlert, PageHeader } from "@erp/core/components";
|
||||
import { UnsavedChangesProvider, UpdateCommitButtonGroup } from "@erp/core/hooks";
|
||||
import { FormCommitButtonGroup, UnsavedChangesProvider } from "@erp/core/hooks";
|
||||
import { AppContent, AppHeader } from "@repo/rdx-ui/components";
|
||||
import { FormProvider } from "react-hook-form";
|
||||
|
||||
@ -20,7 +20,7 @@ export const CustomerCreatePage = () => {
|
||||
backIcon
|
||||
description={t("pages.create.description")}
|
||||
rightSlot={
|
||||
<UpdateCommitButtonGroup
|
||||
<FormCommitButtonGroup
|
||||
cancel={{ formId, to: "/customers/list", disabled: isCreating }}
|
||||
disabled={isCreating}
|
||||
isLoading={isCreating}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { ErrorAlert, NotFoundCard, PageHeader } from "@erp/core/components";
|
||||
import { UnsavedChangesProvider, UpdateCommitButtonGroup } from "@erp/core/hooks";
|
||||
import { FormCommitButtonGroup, UnsavedChangesProvider } from "@erp/core/hooks";
|
||||
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
|
||||
import { Spinner } from "@repo/shadcn-ui/components";
|
||||
import { FormProvider } from "react-hook-form";
|
||||
@ -54,7 +54,7 @@ export const CustomerUpdatePage = () => {
|
||||
backIcon
|
||||
description={t("pages.update.description")}
|
||||
rightSlot={
|
||||
<UpdateCommitButtonGroup
|
||||
<FormCommitButtonGroup
|
||||
cancel={{
|
||||
formId: updateCtrl.formId,
|
||||
to: "/customers/list",
|
||||
|
||||
@ -8,7 +8,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { useState } from "react";
|
||||
import { useRef } from "react";
|
||||
|
||||
interface CustomDialogProps {
|
||||
open: boolean;
|
||||
@ -27,18 +27,24 @@ export const CustomDialog = ({
|
||||
cancelLabel,
|
||||
confirmLabel,
|
||||
}: CustomDialogProps) => {
|
||||
const [closedByAction, setClosedByAction] = useState(false);
|
||||
const closedByActionRef = useRef(false);
|
||||
|
||||
const handleClose = (ok: boolean) => {
|
||||
setClosedByAction(true);
|
||||
closedByActionRef.current = true;
|
||||
onConfirm(ok);
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!(nextOpen || closedByAction)) onConfirm(false);
|
||||
if (nextOpen) setClosedByAction(false);
|
||||
if (nextOpen) {
|
||||
closedByActionRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!closedByActionRef.current) {
|
||||
onConfirm(false);
|
||||
}
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
@ -49,11 +55,16 @@ export const CustomDialog = ({
|
||||
{description}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogFooter className="mt-12">
|
||||
<AlertDialogCancel autoFocus className="min-w-[120px]">
|
||||
<AlertDialogCancel autoFocus className="min-w-[120px]" onClick={() => handleClose(false)}>
|
||||
{cancelLabel}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction className="min-w-[120px] bg-destructive text-white hover:bg-destructive/90">
|
||||
|
||||
<AlertDialogAction
|
||||
className="min-w-[120px] bg-destructive text-white hover:bg-destructive/90"
|
||||
onClick={() => handleClose(true)}
|
||||
>
|
||||
{confirmLabel}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user