refactor: reemplazar RdxEditorPageLayout con RdxPageContainer y RdxEditorLayout en múltiples componentes

This commit is contained in:
David Arranz 2026-09-04 13:48:16 +02:00
parent 2f8122ca77
commit 3aa3f4018b
14 changed files with 343 additions and 295 deletions

View File

@ -2,7 +2,7 @@ import {
RdxBadge, RdxBadge,
RdxContextPanel, RdxContextPanel,
RdxEditorActionBar, RdxEditorActionBar,
RdxEditorPageLayout, RdxEditorLayout,
RdxFormGrid, RdxFormGrid,
RdxPageActions, RdxPageActions,
RdxPageContainer, RdxPageContainer,
@ -751,13 +751,15 @@ export default function UiKitPage() {
title="Editor foundations" title="Editor foundations"
> >
<div className="grid gap-4"> <div className="grid gap-4">
<ExampleCard title="RdxEditorPageLayout simple"> <ExampleCard title="RdxPageContainer + RdxEditorLayout">
<div className="rounded-lg border border-border bg-muted/30"> <div className="rounded-lg border border-border bg-muted/30">
<RdxEditorPageLayout complexity="simple"> <RdxPageContainer width="content">
<div className="rounded-lg border border-dashed border-border bg-background p-4 text-sm text-foreground"> <RdxEditorLayout>
Centered editor content <div className="rounded-lg border border-dashed border-border bg-background p-4 text-sm text-foreground">
</div> Centered editor content
</RdxEditorPageLayout> </div>
</RdxEditorLayout>
</RdxPageContainer>
</div> </div>
</ExampleCard> </ExampleCard>
@ -806,47 +808,49 @@ export default function UiKitPage() {
title="Simple editor composition" title="Simple editor composition"
> >
<div className="rounded-lg border border-border bg-muted/30"> <div className="rounded-lg border border-border bg-muted/30">
<RdxEditorPageLayout complexity="simple"> <RdxPageContainer width="content">
<div className="rounded-2xl border border-border/60 bg-background px-4 py-4 shadow-xs md:px-6 md:py-6"> <RdxEditorLayout>
<div className="flex min-w-0 flex-col gap-6"> <div className="rounded-2xl border border-border/60 bg-background px-4 py-4 shadow-xs md:px-6 md:py-6">
<div className="rounded-lg border border-border bg-background px-4 py-3 text-sm text-muted-foreground">
Save error aligned with editor width
</div>
<div className="flex min-w-0 flex-col gap-6"> <div className="flex min-w-0 flex-col gap-6">
<RdxPanel <div className="rounded-lg border border-border bg-background px-4 py-3 text-sm text-muted-foreground">
description="Primary customer information" Save error aligned with editor width
icon={<PlusIcon className="size-4" />} </div>
title="Basic information"
>
<RdxFormGrid className="md:grid-cols-2">
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm">
Name field
</div>
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm">
Tax ID field
</div>
</RdxFormGrid>
</RdxPanel>
<RdxPanel <div className="flex min-w-0 flex-col gap-6">
description="Secondary preferences" <RdxPanel
icon={<PlusIcon className="size-4" />} description="Primary customer information"
title="Preferences" icon={<PlusIcon className="size-4" />}
> title="Basic information"
<RdxFormGrid className="md:grid-cols-2"> >
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm"> <RdxFormGrid className="md:grid-cols-2">
Language field <div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm">
</div> Name field
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm"> </div>
Currency field <div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm">
</div> Tax ID field
</RdxFormGrid> </div>
</RdxPanel> </RdxFormGrid>
</RdxPanel>
<RdxPanel
description="Secondary preferences"
icon={<PlusIcon className="size-4" />}
title="Preferences"
>
<RdxFormGrid className="md:grid-cols-2">
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm">
Language field
</div>
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm">
Currency field
</div>
</RdxFormGrid>
</RdxPanel>
</div>
</div> </div>
</div> </div>
</div> </RdxEditorLayout>
</RdxEditorPageLayout> </RdxPageContainer>
</div> </div>
</ExampleCard> </ExampleCard>
@ -870,61 +874,65 @@ export default function UiKitPage() {
<div className="flex min-h-0 flex-1 flex-col bg-muted/20"> <div className="flex min-h-0 flex-1 flex-col bg-muted/20">
<div className="min-h-0 flex-1 overflow-y-auto"> <div className="min-h-0 flex-1 overflow-y-auto">
<RdxEditorPageLayout <RdxPageContainer width="full">
aside={ <RdxEditorLayout
<div className="flex min-w-0 flex-col gap-6"> aside={
<RdxPanel description="Secondary recipient controls." title="Recipient"> <div className="flex min-w-0 flex-col gap-6">
<div className="rounded-md border border-dashed border-border bg-muted/30 p-4 text-sm text-muted-foreground"> <RdxPanel
Customer selector and helper actions description="Secondary recipient controls."
</div> title="Recipient"
</RdxPanel> >
<div className="rounded-md border border-dashed border-border bg-muted/30 p-4 text-sm text-muted-foreground">
Customer selector and helper actions
</div>
</RdxPanel>
<RdxPanel <RdxPanel
description="Fiscal defaults and toggles." description="Fiscal defaults and toggles."
title="Tax settings" title="Tax settings"
> >
<RdxFormGrid> <RdxFormGrid>
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm md:col-span-12"> <div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm md:col-span-12">
Tax regime field Tax regime field
</div> </div>
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm md:col-span-12"> <div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm md:col-span-12">
Default tax selector Default tax selector
</div> </div>
</RdxFormGrid> </RdxFormGrid>
</RdxPanel> </RdxPanel>
</div>
}
complexity="complex"
>
<div className="rounded-2xl border border-border bg-background px-4 py-4 shadow-xs md:px-6 md:py-6">
<div className="flex min-w-0 flex-col gap-6">
<div className="rounded-lg border border-border bg-background px-4 py-3 text-sm text-muted-foreground">
Inline feedback aligned with the editor content
</div> </div>
}
<RdxPanel title="Document information"> >
<RdxFormGrid> <div className="rounded-2xl border border-border bg-background px-4 py-4 shadow-xs md:px-6 md:py-6">
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm md:col-span-4"> <div className="flex min-w-0 flex-col gap-6">
Reference field <div className="rounded-lg border border-border bg-background px-4 py-3 text-sm text-muted-foreground">
</div> Inline feedback aligned with the editor content
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm md:col-span-4">
Date field
</div>
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm md:col-span-4">
Series field
</div>
</RdxFormGrid>
</RdxPanel>
<section className="min-w-0 border-b border-border pb-6">
<div className="rounded-md border border-dashed border-border bg-muted/30 p-4 text-sm text-muted-foreground">
Line editor keeps its own internal table layout and stays in the
main surface.
</div> </div>
</section>
<RdxPanel title="Document information">
<RdxFormGrid>
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm md:col-span-4">
Reference field
</div>
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm md:col-span-4">
Date field
</div>
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm md:col-span-4">
Series field
</div>
</RdxFormGrid>
</RdxPanel>
<section className="min-w-0 border-b border-border pb-6">
<div className="rounded-md border border-dashed border-border bg-muted/30 p-4 text-sm text-muted-foreground">
Line editor keeps its own internal table layout and stays in the
main surface.
</div>
</section>
</div>
</div> </div>
</div> </RdxEditorLayout>
</RdxEditorPageLayout> </RdxPageContainer>
</div> </div>
<RdxEditorActionBar <RdxEditorActionBar

View File

@ -0,0 +1,15 @@
import assert from "node:assert/strict";
import test from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { ProformaCreateSkeleton } from "./proforma-create-skeleton";
test("Proforma Create skeleton comparte el frame del editor productivo", () => {
const html = renderToStaticMarkup(<ProformaCreateSkeleton />);
assert.match(html, /flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden/);
assert.match(html, /rdx-page-container/);
assert.doesNotMatch(html, /fixed inset-0/);
assert.equal(html.match(/overflow-y-auto/g)?.length, 1);
});

View File

@ -1,16 +1,25 @@
import { RdxEditorLayout, RdxPageContainer, RdxPageFrame } from "@repo/rdx-ui/components";
import { Skeleton } from "@repo/shadcn-ui/components"; import { Skeleton } from "@repo/shadcn-ui/components";
export const ProformaCreateSkeleton = () => { export const ProformaCreateSkeleton = () => {
return ( return (
<div className="fixed inset-0 flex flex-col overflow-hidden"> <RdxPageFrame
<div className="border-b px-6 py-4"> contentClassName="bg-muted/20"
<Skeleton className="h-10 w-full max-w-md" /> header={
</div> <div className="border-b px-6 py-4">
<div className="flex-1 space-y-6 overflow-y-auto p-6"> <Skeleton className="h-10 w-full max-w-md" />
<Skeleton className="h-10 w-full" /> </div>
<Skeleton className="h-64 w-full" /> }
<Skeleton className="h-48 w-full" /> >
</div> <RdxPageContainer width="content">
</div> <RdxEditorLayout>
<div className="space-y-6">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-64 w-full" />
<Skeleton className="h-48 w-full" />
</div>
</RdxEditorLayout>
</RdxPageContainer>
</RdxPageFrame>
); );
}; };

View File

@ -18,10 +18,10 @@ import { useCreateRecurringProformaFromProforma } from "../../../recurring-profo
import { buildRecurringProformaDetailPath } from "../../../recurring-proformas/shared/recurring-proforma-navigation"; import { buildRecurringProformaDetailPath } from "../../../recurring-proformas/shared/recurring-proforma-navigation";
import { import {
buildCreateRecurringProformaFromProformaDefaultValues,
buildCreateRecurringProformaFromProformaInput,
type CreateRecurringProformaFromProformaForm, type CreateRecurringProformaFromProformaForm,
CreateRecurringProformaFromProformaFormSchema, CreateRecurringProformaFromProformaFormSchema,
buildCreateRecurringProformaFromProformaDefaultValues,
buildCreateRecurringProformaFromProformaInput,
} from "./use-create-recurring-proforma-from-proforma-dialog-controller.utils"; } from "./use-create-recurring-proforma-from-proforma-dialog-controller.utils";
const normalizeSubmitError = (error: unknown): Error | ValidationErrorCollection => { const normalizeSubmitError = (error: unknown): Error | ValidationErrorCollection => {
@ -41,6 +41,9 @@ export interface CreateRecurringProformaFromProformaDialogControllerParams {
proformaReference: string | null; proformaReference: string | null;
} }
type CreateRecurringProformaFromProformaTarget =
CreateRecurringProformaFromProformaDialogControllerParams;
export const useCreateRecurringProformaFromProformaDialogController = ({ export const useCreateRecurringProformaFromProformaDialogController = ({
proformaId, proformaId,
proformaReference, proformaReference,
@ -51,6 +54,10 @@ export const useCreateRecurringProformaFromProformaDialogController = ({
const mutation = useCreateRecurringProformaFromProforma(); const mutation = useCreateRecurringProformaFromProforma();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const submitInProgressRef = useRef(false); const submitInProgressRef = useRef(false);
const targetRef = useRef<CreateRecurringProformaFromProformaTarget>({
proformaId,
proformaReference,
});
const form = useHookForm<CreateRecurringProformaFromProformaForm>({ const form = useHookForm<CreateRecurringProformaFromProformaForm>({
resolverSchema: CreateRecurringProformaFromProformaFormSchema, resolverSchema: CreateRecurringProformaFromProformaFormSchema,
initialValues: buildCreateRecurringProformaFromProformaDefaultValues(proformaReference), initialValues: buildCreateRecurringProformaFromProformaDefaultValues(proformaReference),
@ -77,8 +84,10 @@ export const useCreateRecurringProformaFromProformaDialogController = ({
); );
}; };
const openDialog = () => { const openDialog = (target?: CreateRecurringProformaFromProformaTarget) => {
form.reset(buildCreateRecurringProformaFromProformaDefaultValues(proformaReference)); const nextTarget = target ?? { proformaId, proformaReference };
targetRef.current = nextTarget;
form.reset(buildCreateRecurringProformaFromProformaDefaultValues(nextTarget.proformaReference));
setOpen(true); setOpen(true);
}; };
@ -100,7 +109,7 @@ export const useCreateRecurringProformaFromProformaDialogController = ({
try { try {
const created = await mutation.mutateAsync({ const created = await mutation.mutateAsync({
proformaId, proformaId: targetRef.current.proformaId,
data: buildCreateRecurringProformaFromProformaInput(values), data: buildCreateRecurringProformaFromProformaInput(values),
}); });

View File

@ -1,13 +1,14 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { isValidElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import type { CellContext } from "@tanstack/react-table";
import { RdxRowActions, type RdxRowActionsProps } from "@repo/rdx-ui/components"; import { RdxRowActions, type RdxRowActionsProps } from "@repo/rdx-ui/components";
import type { CellContext } from "@tanstack/react-table";
import { isValidElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import type { ProformaListRow } from "../../../../shared"; import type { ProformaListRow } from "../../../../shared";
import { PROFORMA_STATUS } from "../../../../shared"; import { PROFORMA_STATUS } from "../../../../shared";
import { useProformasGridColumns } from "./use-proforma-grid-columns"; import { useProformasGridColumns } from "./use-proforma-grid-columns";
const proformaFixture: ProformaListRow = { const proformaFixture: ProformaListRow = {
@ -104,12 +105,16 @@ test("mantiene las destructivas en overflow y conserva disabled", () => {
assert.equal(viewAction?.disabled, true); assert.equal(viewAction?.disabled, true);
}); });
test("preserva callbacks de view, edit y delete", () => { test("preserva callbacks de view, edit, crear recurrencia y delete", () => {
let didView = false; let didView = false;
let didEdit = false; let didEdit = false;
let didCreateRecurring = false;
let didDelete = false; let didDelete = false;
const props = getRowActionsProps("normal", { const props = getRowActionsProps("normal", {
onCreateRecurringClick: () => {
didCreateRecurring = true;
},
onDeleteClick: () => { onDeleteClick: () => {
didDelete = true; didDelete = true;
}, },
@ -123,9 +128,29 @@ test("preserva callbacks de view, edit y delete", () => {
props.primaryActions?.find((action) => action.id === "view")?.onSelect(); props.primaryActions?.find((action) => action.id === "view")?.onSelect();
props.primaryActions?.find((action) => action.id === "edit")?.onSelect(); props.primaryActions?.find((action) => action.id === "edit")?.onSelect();
props.overflowActions?.find((action) => action.id === "create-recurring")?.onSelect();
props.overflowActions?.find((action) => action.id === "delete")?.onSelect(); props.overflowActions?.find((action) => action.id === "delete")?.onSelect();
assert.equal(didView, true); assert.equal(didView, true);
assert.equal(didEdit, true); assert.equal(didEdit, true);
assert.equal(didCreateRecurring, true);
assert.equal(didDelete, true); assert.equal(didDelete, true);
}); });
test("solo permite crear recurrencia desde el listado normal", () => {
const normalProps = getRowActionsProps("normal", {
onCreateRecurringClick: () => undefined,
});
const archivedProps = getRowActionsProps("archived", {
onCreateRecurringClick: () => undefined,
});
assert.equal(
normalProps.overflowActions?.find((action) => action.id === "create-recurring")?.disabled,
false
);
assert.equal(
archivedProps.overflowActions?.find((action) => action.id === "create-recurring")?.disabled,
true
);
});

View File

@ -1,12 +1,10 @@
import { EmptyCellValue } from "@erp/core/components"; import { EmptyCellValue } from "@erp/core/components";
import { DataTableColumnHeader, RdxRowActions, type RdxRowAction } from "@repo/rdx-ui/components"; import { DataTableColumnHeader, type RdxRowAction, RdxRowActions } from "@repo/rdx-ui/components";
import { DateHelper } from "@repo/rdx-utils"; import { DateHelper } from "@repo/rdx-utils";
import { import { Checkbox, Spinner } from "@repo/shadcn-ui/components";
Checkbox,
Spinner,
} from "@repo/shadcn-ui/components";
import type { ColumnDef } from "@tanstack/react-table"; import type { ColumnDef } from "@tanstack/react-table";
import { import {
CalendarSyncIcon,
CopyIcon, CopyIcon,
ExternalLinkIcon, ExternalLinkIcon,
EyeIcon, EyeIcon,
@ -37,6 +35,7 @@ type GridActionHandlers = {
onIssueClick?: (proforma: ProformaListRow) => void; onIssueClick?: (proforma: ProformaListRow) => void;
onArchiveClick?: (proforma: ProformaListRow) => void; onArchiveClick?: (proforma: ProformaListRow) => void;
onDuplicateClick?: (proforma: ProformaListRow) => void; onDuplicateClick?: (proforma: ProformaListRow) => void;
onCreateRecurringClick?: (proforma: ProformaListRow) => void;
onUnarchiveClick?: (proforma: ProformaListRow) => void; onUnarchiveClick?: (proforma: ProformaListRow) => void;
onDeleteClick?: (proforma: ProformaListRow) => void; onDeleteClick?: (proforma: ProformaListRow) => void;
onChangeStatusClick?: (proforma: ProformaListRow, nextStatus: ProformaStatus) => void; onChangeStatusClick?: (proforma: ProformaListRow, nextStatus: ProformaStatus) => void;
@ -52,10 +51,8 @@ type GridActionHandlers = {
isPdfDownloading?: boolean; isPdfDownloading?: boolean;
}; };
const renderActionIcon = ( const renderActionIcon = (Icon: ComponentType<{ className?: string }>, loading?: boolean) =>
Icon: ComponentType<{ className?: string }>, loading ? <Spinner className="size-4" /> : <Icon className="size-4" />;
loading?: boolean
) => (loading ? <Spinner className="size-4" /> : <Icon className="size-4" />);
export function useProformasGridColumns( export function useProformasGridColumns(
scope: ProformaListScope, scope: ProformaListScope,
@ -312,6 +309,7 @@ export function useProformasGridColumns(
const handleView = () => actionHandlers.onViewClick?.(proforma); const handleView = () => actionHandlers.onViewClick?.(proforma);
const handleEdit = () => actionHandlers.onEditClick?.(proforma); const handleEdit = () => actionHandlers.onEditClick?.(proforma);
const handleDuplicate = () => actionHandlers.onDuplicateClick?.(proforma); const handleDuplicate = () => actionHandlers.onDuplicateClick?.(proforma);
const handleCreateRecurring = () => actionHandlers.onCreateRecurringClick?.(proforma);
const handleArchive = () => actionHandlers.onArchiveClick?.(proforma); const handleArchive = () => actionHandlers.onArchiveClick?.(proforma);
const handleUnarchive = () => actionHandlers.onUnarchiveClick?.(proforma); const handleUnarchive = () => actionHandlers.onUnarchiveClick?.(proforma);
const handleIssue = () => actionHandlers.onIssueClick?.(proforma); const handleIssue = () => actionHandlers.onIssueClick?.(proforma);
@ -416,6 +414,14 @@ export function useProformasGridColumns(
: undefined, : undefined,
onSelect: handleDuplicate, onSelect: handleDuplicate,
}, },
{
id: "create-recurring",
label: t("pages.proformas.list.actions.create_recurring"),
icon: renderActionIcon(CalendarSyncIcon),
disabled: !(actionHandlers.onCreateRecurringClick && isNormalScope),
disabledReason: isNormalScope ? undefined : getScopeReason(),
onSelect: handleCreateRecurring,
},
{ {
id: "archive", id: "archive",
label: t("pages.proformas.list.actions.archive"), label: t("pages.proformas.list.actions.archive"),

View File

@ -32,6 +32,8 @@ import { ChangeProformaStatusDialog } from "../../../change-status";
import { prepareChangeProformaStatusTargets } from "../../../change-status/utils"; import { prepareChangeProformaStatusTargets } from "../../../change-status/utils";
import { DeleteProformaDialog } from "../../../delete"; import { DeleteProformaDialog } from "../../../delete";
import { prepareDeleteProformaTargets } from "../../../delete/utils"; import { prepareDeleteProformaTargets } from "../../../delete/utils";
import { useCreateRecurringProformaFromProformaDialogController } from "../../../detail/controllers";
import { CreateRecurringProformaFromProformaDialog } from "../../../detail/ui/components";
import { downloadProformaPDFApi } from "../../../download-pdf/api"; import { downloadProformaPDFApi } from "../../../download-pdf/api";
import { IssueProformaDialog } from "../../../issue-proforma"; import { IssueProformaDialog } from "../../../issue-proforma";
import { prepareIssueProformaTarget } from "../../../issue-proforma/utils"; import { prepareIssueProformaTarget } from "../../../issue-proforma/utils";
@ -238,6 +240,10 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => {
goHome, goHome,
isRetrying, isRetrying,
} = useListProformasPageController(scope); } = useListProformasPageController(scope);
const createRecurringDialogCtrl = useCreateRecurringProformaFromProformaDialogController({
proformaId: "",
proformaReference: null,
});
const handleEditClick = (proformaId: string) => { const handleEditClick = (proformaId: string) => {
navigate({ navigate({
@ -293,6 +299,11 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => {
onViewClick: (proforma) => handleOpenDetailClick(proforma.id), onViewClick: (proforma) => handleOpenDetailClick(proforma.id),
onEditClick: (proforma) => handleEditClick(proforma.id), onEditClick: (proforma) => handleEditClick(proforma.id),
onDuplicateClick: handleDuplicateClick, onDuplicateClick: handleDuplicateClick,
onCreateRecurringClick: (proforma) =>
createRecurringDialogCtrl.openDialog({
proformaId: proforma.id,
proformaReference: proforma.proformaReference,
}),
onIssueClick: (proformaRow) => onIssueClick: (proformaRow) =>
issueDialogCtrl.openDialog(prepareIssueProformaTarget(proformaRow)), issueDialogCtrl.openDialog(prepareIssueProformaTarget(proformaRow)),
onArchiveClick: archiveDialogCtrl.openDialog, onArchiveClick: archiveDialogCtrl.openDialog,
@ -550,6 +561,21 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => {
targets={changeStatusDialogCtrl.targets} targets={changeStatusDialogCtrl.targets}
/> />
<CreateRecurringProformaFromProformaDialog
form={createRecurringDialogCtrl.form}
formId={createRecurringDialogCtrl.formId}
isSubmitting={createRecurringDialogCtrl.isSubmitting}
onOpenChange={(open) => {
if (!open) {
createRecurringDialogCtrl.closeDialog();
}
}}
onSubmit={createRecurringDialogCtrl.onSubmit}
onUnitChange={createRecurringDialogCtrl.changeUnit}
open={createRecurringDialogCtrl.open}
unit={createRecurringDialogCtrl.unit}
/>
<DeleteProformaDialog <DeleteProformaDialog
isSubmitting={deleteDialogCtrl.isSubmitting} isSubmitting={deleteDialogCtrl.isSubmitting}
onConfirm={deleteDialogCtrl.confirmDelete} onConfirm={deleteDialogCtrl.confirmDelete}

View File

@ -88,7 +88,7 @@ Contenido visible actual:
- información personal: `name`, `email`, `language_code` - información personal: `name`, `email`, `language_code`
- modo `app-fullscreen`, sin sidebar principal - modo `app-fullscreen`, sin sidebar principal
- contenido centrado con ancho controlado mediante `RdxEditorPageLayout` - contenido centrado mediante `RdxPageContainer` y compuesto con `RdxEditorLayout`
- seguridad mediante dialog para cambio de contraseña - seguridad mediante dialog para cambio de contraseña
- cierre de sesión reutilizando el flujo de logout existente - cierre de sesión reutilizando el flujo de logout existente

View File

@ -0,0 +1,65 @@
import { PageErrorState } from "@erp/core/components";
import { RdxEditorLayout, RdxPageContainer, RdxPageFrame } from "@repo/rdx-ui/components";
import type { ComponentProps } from "react";
export const AccountLoadingState = ({ label }: { label: string }) => (
<RdxPageFrame contentClassName="bg-muted/20">
<RdxPageContainer className="min-h-full" width="content">
<RdxEditorLayout>
<div className="flex min-h-[16rem] items-center justify-center text-sm text-muted-foreground">
<div aria-busy="true" role="status">
{label}
</div>
</div>
</RdxEditorLayout>
</RdxPageContainer>
</RdxPageFrame>
);
interface AccountErrorStateProps {
canRetry: boolean;
description: string;
isRetrying: boolean;
onRetry: () => void;
reference?: string;
retryLabel: string;
retryingLabel: string;
title: string;
variant: ComponentProps<typeof PageErrorState>["variant"];
}
export const AccountErrorState = ({
canRetry,
description,
isRetrying,
onRetry,
reference,
retryLabel,
retryingLabel,
title,
variant,
}: AccountErrorStateProps) => (
<RdxPageFrame contentClassName="bg-muted/20">
<RdxPageContainer width="content">
<RdxEditorLayout>
<div className="mx-auto w-full max-w-3xl">
<PageErrorState
description={description}
primaryAction={
canRetry
? {
label: isRetrying ? retryingLabel : retryLabel,
onClick: onRetry,
isLoading: isRetrying,
}
: undefined
}
reference={reference}
title={title}
variant={variant}
/>
</div>
</RdxEditorLayout>
</RdxPageContainer>
</RdxPageFrame>
);

View File

@ -0,0 +1,36 @@
import assert from "node:assert/strict";
import test from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { AccountErrorState, AccountLoadingState } from "./account-page-states";
test("Account loading usa el frame y contenedor definitivos", () => {
const html = renderToStaticMarkup(<AccountLoadingState label="Cargando cuenta" />);
assert.match(html, /flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden/);
assert.match(html, /rdx-page-container/);
assert.match(html, /Cargando cuenta/);
assert.doesNotMatch(html, /fixed inset-0/);
assert.doesNotMatch(html, /rdx-editor-page-layout/);
});
test("Account error permanece dentro del frame actual", () => {
const html = renderToStaticMarkup(
<AccountErrorState
canRetry
description="No se pudo cargar la cuenta"
isRetrying={false}
onRetry={() => undefined}
retryingLabel="Reintentando"
retryLabel="Reintentar"
title="Error de cuenta"
variant="server"
/>
);
assert.match(html, /flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden/);
assert.match(html, /Error de cuenta/);
assert.match(html, /Reintentar/);
assert.doesNotMatch(html, /fixed inset-0/);
});

View File

@ -1,10 +1,12 @@
import { ErrorAlert, PageErrorState } from "@erp/core/components"; import { ErrorAlert } from "@erp/core/components";
import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks"; import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks";
import { import {
CancelActionButton, CancelActionButton,
RdxEditorActionBar, RdxEditorActionBar,
RdxEditorHeader, RdxEditorHeader,
RdxEditorPageLayout, RdxEditorLayout,
RdxPageContainer,
RdxPageFrame,
RdxPanel, RdxPanel,
RhfSubmitActionButton, RhfSubmitActionButton,
SelectField, SelectField,
@ -27,65 +29,7 @@ import { useTranslation } from "../../../i18n";
import { useAccountPageController } from "../../controllers"; import { useAccountPageController } from "../../controllers";
import type { AccountPasswordForm, AccountProfileForm } from "../../entities"; import type { AccountPasswordForm, AccountProfileForm } from "../../entities";
const AccountLoadingState = ({ label }: { label: string }) => { import { AccountErrorState, AccountLoadingState } from "./account-page-states";
return (
<div className="flex-1 overflow-y-auto bg-muted/20">
<RdxEditorPageLayout className="min-h-full" width="simple">
<div className="flex min-h-[16rem] items-center justify-center text-sm text-muted-foreground">
<div aria-busy="true" role="status">
{label}
</div>
</div>
</RdxEditorPageLayout>
</div>
);
};
const AccountErrorState = ({
canRetry,
description,
isRetrying,
onRetry,
reference,
retryLabel,
retryingLabel,
title,
variant,
}: {
canRetry: boolean;
description: string;
isRetrying: boolean;
onRetry: () => void;
reference?: string;
retryLabel: string;
retryingLabel: string;
title: string;
variant: React.ComponentProps<typeof PageErrorState>["variant"];
}) => {
return (
<div className="flex-1 overflow-y-auto bg-muted/20">
<RdxEditorPageLayout width="simple">
<div className="mx-auto w-full max-w-3xl">
<PageErrorState
description={description}
primaryAction={
canRetry
? {
label: isRetrying ? retryingLabel : retryLabel,
onClick: onRetry,
isLoading: isRetrying,
}
: undefined
}
reference={reference}
title={title}
variant={variant}
/>
</div>
</RdxEditorPageLayout>
</div>
);
};
const AccountPasswordDialog = ({ const AccountPasswordDialog = ({
ctrl, ctrl,
@ -252,15 +196,18 @@ export const AccountPage = () => {
}; };
return ( return (
<div className="fixed inset-0 flex flex-col overflow-hidden"> <RdxPageFrame
<RdxEditorHeader contentClassName="bg-muted/20"
backLabel={t("account.header.back")} header={
onBack={navigateBack} <RdxEditorHeader
title={t("account.title")} backLabel={t("account.header.back")}
/> onBack={navigateBack}
title={t("account.title")}
<div className="flex-1 overflow-y-auto bg-muted/20"> />
<RdxEditorPageLayout width="simple"> }
>
<RdxPageContainer width="content">
<RdxEditorLayout>
<div className="rounded-2xl border border-border/60 bg-background px-4 py-4 shadow-xs md:px-6 md:py-6"> <div className="rounded-2xl border border-border/60 bg-background px-4 py-4 shadow-xs md:px-6 md:py-6">
<div className="flex min-w-0 flex-col gap-6"> <div className="flex min-w-0 flex-col gap-6">
<FormProvider {...ctrl.profileForm}> <FormProvider {...ctrl.profileForm}>
@ -405,8 +352,8 @@ export const AccountPage = () => {
</RdxPanel> </RdxPanel>
</div> </div>
</div> </div>
</RdxEditorPageLayout> </RdxEditorLayout>
</div> </RdxPageContainer>
<AccountPasswordDialog <AccountPasswordDialog
ctrl={ctrl} ctrl={ctrl}
@ -415,6 +362,6 @@ export const AccountPage = () => {
onOpenChange={handlePasswordDialogOpenChange} onOpenChange={handlePasswordDialogOpenChange}
t={t} t={t}
/> />
</div> </RdxPageFrame>
); );
}; };

View File

@ -1,5 +1,4 @@
export * from "./rdx-editor-action-bar.tsx"; export * from "./rdx-editor-action-bar.tsx";
export * from "./rdx-editor-header.tsx"; export * from "./rdx-editor-header.tsx";
export * from "./rdx-editor-layout.tsx"; export * from "./rdx-editor-layout.tsx";
export * from "./rdx-editor-page-layout.tsx";
export * from "./rdx-panel.tsx"; export * from "./rdx-panel.tsx";

View File

@ -8,7 +8,6 @@ import { RdxFormGrid } from "../form/rdx-form-grid.tsx";
import { RdxEditorActionBar } from "./rdx-editor-action-bar.tsx"; import { RdxEditorActionBar } from "./rdx-editor-action-bar.tsx";
import { RdxEditorHeader } from "./rdx-editor-header.tsx"; import { RdxEditorHeader } from "./rdx-editor-header.tsx";
import { RdxEditorLayout } from "./rdx-editor-layout.tsx"; import { RdxEditorLayout } from "./rdx-editor-layout.tsx";
import { RdxEditorPageLayout } from "./rdx-editor-page-layout.tsx";
test("RdxEditorLayout only distributes main content and an optional aside", () => { test("RdxEditorLayout only distributes main content and an optional aside", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
@ -50,44 +49,6 @@ test("RdxEditorHeader no renderiza back sin callback", () => {
import { RdxPanel } from "./rdx-panel.tsx"; import { RdxPanel } from "./rdx-panel.tsx";
test("RdxEditorPageLayout aplica complejidad simple por defecto y renderiza children", () => {
const html = renderToStaticMarkup(
<RdxEditorPageLayout>
<div>Editor content</div>
</RdxEditorPageLayout>
);
assert.match(html, /Editor content/);
assert.match(html, /max-w-5xl/);
assert.match(html, /px-4/);
assert.match(html, /md:px-6/);
});
test("RdxEditorPageLayout soporta aside opcional y complejidad complex", () => {
const html = renderToStaticMarkup(
<RdxEditorPageLayout aside={<div>Aside content</div>} width="full">
<div>Main content</div>
</RdxEditorPageLayout>
);
assert.match(html, /Main content/);
assert.match(html, /Aside content/);
assert.match(html, /xl:grid-cols-\[minmax\(0,1fr\)_22rem\]/);
assert.doesNotMatch(html, /max-w-5xl/);
});
test("RdxEditorPageLayout no reserva aside cuando no existe incluso en complex", () => {
const html = renderToStaticMarkup(
<RdxEditorPageLayout width="full">
<div>Main content</div>
</RdxEditorPageLayout>
);
assert.match(html, /Main content/);
assert.doesNotMatch(html, /<aside/);
assert.doesNotMatch(html, /grid-cols-\[/);
});
test("RdxPanel plain renderiza header semántico y contenido sin card", () => { test("RdxPanel plain renderiza header semántico y contenido sin card", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<RdxPanel <RdxPanel

View File

@ -1,58 +0,0 @@
import { cn } from "@repo/shadcn-ui/lib/utils";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
export type RdxEditorWidth = "wide" | "full";
export interface RdxEditorPageLayoutProps
extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
children: ReactNode;
aside?: ReactNode;
width?: RdxEditorWidth;
}
const CONTENT_WIDTH_CLASSNAME: Record<RdxEditorWidth, string> = {
wide: "mx-auto w-full max-w-5xl",
full: "w-full",
};
const ASIDE_GRID_CLASSNAME: Record<RdxEditorWidth, string> = {
wide: "xl:grid-cols-[minmax(0,1fr)_280px]",
full: "xl:grid-cols-[minmax(0,1fr)_22rem]",
};
/**
* @deprecated
* Distribuye el contenido interno de un contenedor en:
*
* - una región principal
* - un aside opcional.
*
* No controla padding, scroll ni estructura general de página.
* Para nuevos consumidores usar RdxPageContainer + RdxEditorLayout.
*/
export const RdxEditorPageLayout = ({
children,
aside,
width = "wide",
className,
...props
}: RdxEditorPageLayoutProps) => {
const contentClassName = CONTENT_WIDTH_CLASSNAME[width];
return (
<div
className={cn("rdx-editor-page-layout min-w-0 w-full px-4 py-4 md:px-6 md:py-6", className)}
{...props}
>
{aside ? (
<div className={cn(contentClassName, "grid min-w-0 gap-6", ASIDE_GRID_CLASSNAME[width])}>
<div className="min-w-0">{children}</div>
<aside className="min-w-0">{aside}</aside>
</div>
) : (
<div className={cn("min-w-0", contentClassName)}>{children}</div>
)}
</div>
);
};