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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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