Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
David Arranz 2026-05-02 23:20:16 +02:00
parent 6adb538aa4
commit 6db80fbc07
10 changed files with 283 additions and 246 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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