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