Refactor proforma-related components and layouts

- Simplified the AppFullscreenLayout documentation.
- Removed ProformaLayout and refactored routes to use Outlet directly.
- Updated ProformaCreateCustomerField styles for better visibility.
- Adjusted ProformaCreateHeader to hide keyboard shortcuts button on small screens.
- Replaced FormSectionCard and FormSectionGrid with RdxPanel and RdxFormGrid in ProformaCreateInitialInfoCard.
- Refactored ProformaCreatePage to use RdxPageFrame for better layout control.
- Enhanced ProformaDetailContent and ProformaDetailPage for improved responsiveness.
- Updated ScopedListProformasPageView to use RdxPageFrame for consistent layout.
- Added new RdxEditorLayout and RdxPageFrame components for better structure in editor pages.
- Adjusted tests to accommodate new layout components and ensure proper rendering.
This commit is contained in:
David Arranz 2026-09-04 11:41:37 +02:00
parent 3d2ce8b981
commit 3eb7c37a7b
25 changed files with 388 additions and 304 deletions

View File

@ -12,8 +12,7 @@ export const AppFullscreenLayoutFrame = ({ children }: AppFullscreenLayoutFrameP
};
/**
* Shell de aplicación para páginas que ocupan toda el área disponible.
* Se utiliza en editores y vistas de detalle sin sidebar ni topbar global.
* Shell para páginas fullscreen sin sidebar ni topbar global.
*/
export const AppFullscreenLayout = () => {

View File

@ -2,10 +2,6 @@ import type { ModuleClientParams } from "@erp/core/client";
import { lazy } from "react";
import { Outlet, type RouteObject } from "react-router-dom";
const ProformaLayout = lazy(() =>
import("./proformas/shared/ui").then((m) => ({ default: m.ProformaLayout }))
);
const RecurringProformaLayout = lazy(() =>
import("./recurring-proformas/shared/ui").then((m) => ({ default: m.RecurringProformaLayout }))
);
@ -70,11 +66,7 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[]
layout: "app-sidebar",
protected: true,
},
element: (
<ProformaLayout>
<Outlet context={params} />
</ProformaLayout>
),
element: <Outlet context={params} />,
children: [
{
index: true,
@ -101,11 +93,7 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[]
layout: "app-fullscreen",
protected: true,
},
element: (
<ProformaLayout>
<ProformasCreatePage />
</ProformaLayout>
),
element: <ProformasCreatePage />,
},
{
@ -114,11 +102,7 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[]
layout: "app-fullscreen",
protected: true,
},
element: (
<ProformaLayout>
<ProformaUpdatePage />
</ProformaLayout>
),
element: <ProformaUpdatePage />,
},
{
@ -127,11 +111,7 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[]
layout: "app-fullscreen",
protected: true,
},
element: (
<ProformaLayout>
<ProformaDetailPage />
</ProformaLayout>
),
element: <ProformaDetailPage />,
},
{
path: "recurring-proformas",

View File

@ -38,7 +38,7 @@ export const ProformaCreateCustomerField = ({
<div className="relative">
<Button
className={cn(
"h-10 w-full justify-between border bg-muted/50 px-3 font-normal text-left text-foreground hover:bg-muted/50",
"h-10 w-full justify-between border bg-muted/20 px-3 font-normal text-left text-foreground hover:bg-muted/50",
!selectedCustomer && "text-muted-foreground"
)}
disabled={isDisabled}

View File

@ -22,7 +22,7 @@ export const ProformaCreateHeader = ({
actions={
<FormActionsBar align="end" reverseOnMobile={false}>
<PageKeyboardShortcutsButton
className="hidden sm:flex"
className="hidden"
label="Ver atajos de teclado"
shortcuts={[
{ keys: "Ctrl+S", label: "Crear y continuar" },

View File

@ -1,8 +1,8 @@
import type { CustomerSelectionOption } from "@erp/customers";
import {
DatePickerField,
FormSectionCard,
FormSectionGrid,
RdxFormGrid,
RdxPanel,
SelectField,
type SelectFieldItem,
} from "@repo/rdx-ui/components";
@ -43,13 +43,14 @@ export const ProformaCreateInitialInfoCard = ({
const { t } = useTranslation();
return (
<FormSectionCard
<RdxPanel
icon={<FileTextIcon className="size-5" />}
title={t("proformas.create.initial_info.title", "Información inicial")}
variant="card"
>
<FormSectionGrid className="md:grid-cols-6 2xl:grid-cols-12">
<RdxFormGrid className="md:grid-cols-6 2xl:grid-cols-12">
<ProformaCreateCustomerField
className="md:col-span-4"
className="md:col-span-8"
disabled={disabled}
onClearCustomer={onClearCustomer}
onSelectCustomer={onSelectCustomer}
@ -57,7 +58,7 @@ export const ProformaCreateInitialInfoCard = ({
/>
<DatePickerField<ProformaCreateForm>
className="md:col-span-2"
className="md:col-span-4"
disabled={disabled}
label={t("proformas.create.initial_info.proforma_date.label", "Fecha")}
name="proformaDate"
@ -69,7 +70,7 @@ export const ProformaCreateInitialInfoCard = ({
/>
<SelectField<ProformaCreateForm>
className="md:col-span-2"
className="md:col-span-3"
disabled={disabled || isProformaSeriesLoading}
items={proformaSeriesOptions}
label={t("proformas.create.initial_info.proforma_series.label", "Serie de esta proforma")}
@ -87,7 +88,7 @@ export const ProformaCreateInitialInfoCard = ({
/>
<SelectField<ProformaCreateForm>
className="md:col-span-2"
className="md:col-span-3"
disabled={disabled || isTargetInvoiceSeriesLoading}
items={targetInvoiceSeriesOptions}
label={t("proformas.create.initial_info.invoice_series.label", "Serie de la factura")}
@ -100,7 +101,7 @@ export const ProformaCreateInitialInfoCard = ({
/>
<SelectField<ProformaCreateForm>
className="md:col-span-4"
className="md:col-span-6"
clearable
clearLabel={t("form_fields.proformas.payment_term.clear_label", "Sin plazo de pago")}
disabled={disabled || isPaymentTermsLoading}
@ -115,7 +116,7 @@ export const ProformaCreateInitialInfoCard = ({
: t("form_fields.proformas.payment_term.placeholder", "Selecciona un plazo de pago")
}
/>
</FormSectionGrid>
</FormSectionCard>
</RdxFormGrid>
</RdxPanel>
);
};

View File

@ -1,7 +1,12 @@
import { ErrorAlert } from "@erp/core/components";
import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks";
import { QuickCreateCustomerDialog, SelectCustomerDialog } from "@erp/customers";
import { RdxEditorPageLayout, RdxPageContainer, RdxPageStack } from "@repo/rdx-ui/components";
import {
RdxEditorLayout,
RdxPageContainer,
RdxPageFrame,
RdxPageStack,
} from "@repo/rdx-ui/components";
import { FormProvider } from "react-hook-form";
import { ProformaInfoAlert } from "../../../update/ui/components";
@ -16,26 +21,25 @@ export const ProformaCreatePage = () => {
});
return (
<RdxPageStack className="fixed inset-0 min-h-0 overflow-hidden" spacing="none">
<FormProvider {...createCtrl.form}>
<UnsavedChangesProvider isDirty={createCtrl.form.formState.isDirty}>
<ProformaCreateHeader
formId={createCtrl.formId}
isSaving={createCtrl.isCreating}
onCancel={navigateBack}
>
<ProformaInfoAlert>
<span className="font-semibold">Importante: </span>Las proformas no tienen validez
fiscal. Puedes convertirlas en facturas cuando sean aceptadas por el cliente.
</ProformaInfoAlert>
</ProformaCreateHeader>
<RdxPageContainer
className="min-h-0 flex-1 overflow-y-auto bg-muted/20"
padding="none"
width="full"
>
<RdxEditorPageLayout width="simple">
<FormProvider {...createCtrl.form}>
<UnsavedChangesProvider isDirty={createCtrl.form.formState.isDirty}>
<RdxPageFrame
contentClassName="bg-muted/20"
header={
<ProformaCreateHeader
formId={createCtrl.formId}
isSaving={createCtrl.isCreating}
onCancel={navigateBack}
>
<ProformaInfoAlert>
<span className="font-semibold">Importante: </span>Las proformas no tienen validez
fiscal. Puedes convertirlas en facturas cuando sean aceptadas por el cliente.
</ProformaInfoAlert>
</ProformaCreateHeader>
}
>
<RdxPageContainer width="content">
<RdxEditorLayout>
<RdxPageStack spacing="default">
{createCtrl.isCreateError && (
<ErrorAlert
@ -75,16 +79,16 @@ export const ProformaCreatePage = () => {
targetInvoiceSeriesOptions={createCtrl.targetInvoiceSeriesOptions}
/>
</RdxPageStack>
</RdxEditorPageLayout>
</RdxEditorLayout>
</RdxPageContainer>
</RdxPageFrame>
<SelectCustomerDialog
ctrl={selectCustomerCtrl.selectCtrl}
onCreateNewCustomerClick={selectCustomerCtrl.openQuickCreateFromSelection}
/>
<QuickCreateCustomerDialog ctrl={selectCustomerCtrl.createCtrl} />
</UnsavedChangesProvider>
</FormProvider>
</RdxPageStack>
<SelectCustomerDialog
ctrl={selectCustomerCtrl.selectCtrl}
onCreateNewCustomerClick={selectCustomerCtrl.openQuickCreateFromSelection}
/>
<QuickCreateCustomerDialog ctrl={selectCustomerCtrl.createCtrl} />
</UnsavedChangesProvider>
</FormProvider>
);
};

View File

@ -7,7 +7,7 @@ type ProformaDetailContentProps = {
export const ProformaDetailContent = ({ documentPane, inspector }: ProformaDetailContentProps) => {
return (
<div className="min-h-0 flex-1 overflow-auto lg:overflow-hidden ">
<div className="min-h-full lg:h-full lg:min-h-0 lg:overflow-hidden">
<div className="grid min-h-full grid-cols-1 lg:h-full lg:min-h-0 lg:overflow-hidden lg:grid-cols-[minmax(0,1fr)_340px] xl:grid-cols-[minmax(0,1fr)_380px]">
{documentPane}
{inspector}

View File

@ -1,5 +1,5 @@
import { PageErrorState } from "@erp/core/components";
import { RdxPageStack } from "@repo/rdx-ui/components";
import { RdxPageFrame } from "@repo/rdx-ui/components";
import { ChangeProformaStatusDialog } from "../../../change-status";
import { IssueProformaDialog } from "../../../issue-proforma";
@ -41,14 +41,17 @@ export const ProformaDetailPage = () => {
}
return (
<RdxPageStack className="fixed inset-0 min-h-0 overflow-hidden" spacing="none">
<ProformaDetailHeader
actions={ctrl.actions}
header={ctrl.header}
onBack={ctrl.navigation.navigateBack}
onExecuteAction={ctrl.handlers.executeAction}
/>
<RdxPageFrame
contentClassName="lg:overflow-hidden"
header={
<ProformaDetailHeader
actions={ctrl.actions}
header={ctrl.header}
onBack={ctrl.navigation.navigateBack}
onExecuteAction={ctrl.handlers.executeAction}
/>
}
>
<ProformaDetailContent
documentPane={
<ProformaDocumentPane
@ -114,6 +117,6 @@ export const ProformaDetailPage = () => {
open={ctrl.dialogs.createRecurring.open}
unit={ctrl.dialogs.createRecurring.unit}
/>
</RdxPageStack>
</RdxPageFrame>
);
};

View File

@ -2,6 +2,7 @@ import { ErrorAlert } from "@erp/core/components";
import {
RdxPageActions,
RdxPageContainer,
RdxPageFrame,
RdxPageHeader,
RdxPageStack,
} from "@repo/rdx-ui/components";
@ -30,22 +31,24 @@ export const ScopedListProformasPageView = ({
dialogs,
}: ScopedListProformasPageViewProps) => {
return (
<RdxPageContainer width="full">
<RdxPageStack spacing="default">
<RdxPageHeader
actions={primaryAction ? <RdxPageActions primary={primaryAction} /> : undefined}
description={description}
title={title}
/>
<RdxPageFrame>
<RdxPageContainer width="full">
<RdxPageStack spacing="default">
<RdxPageHeader
actions={primaryAction ? <RdxPageActions primary={primaryAction} /> : undefined}
description={description}
title={title}
/>
{refreshError ? (
<ErrorAlert message={refreshError.message} title={refreshError.title} />
) : null}
{refreshError ? (
<ErrorAlert message={refreshError.message} title={refreshError.title} />
) : null}
{content}
</RdxPageStack>
{content}
</RdxPageStack>
{dialogs}
</RdxPageContainer>
{dialogs}
</RdxPageContainer>
</RdxPageFrame>
);
};

View File

@ -1,3 +1,2 @@
export * from "./proforma-layout";
export * from "./proforma-issuance-readiness-alert";
export * from "./proforma-page-header";

View File

@ -1,9 +0,0 @@
import type { PropsWithChildren } from "react";
export function ProformaLayout({ children }: PropsWithChildren) {
return (
<div className="flex flex-col h-full w-full" id="proformas ">
{children}
</div>
);
}

View File

@ -1,5 +1,5 @@
import type { CustomerSelectionOption } from "@erp/customers";
import { RdxEditorPageLayout, type SelectFieldItem } from "@repo/rdx-ui/components";
import { RdxEditorLayout, type SelectFieldItem } from "@repo/rdx-ui/components";
import { preventEnterKeySubmitForm } from "@repo/rdx-ui/helpers";
import type { SubmitEventHandler } from "react";
@ -53,7 +53,7 @@ export const ProformaUpdateEditorForm = ({
className,
}: ProformaUpdateEditorProps) => {
return (
<RdxEditorPageLayout
<RdxEditorLayout
aside={
<div className="flex min-w-0 flex-col gap-6">
<ProformaUpdateRecipientEditor
@ -75,7 +75,6 @@ export const ProformaUpdateEditorForm = ({
</div>
}
className={className}
width="full"
>
<div className="min-w-0">
<form id={formId} noValidate onKeyDown={preventEnterKeySubmitForm} onSubmit={onSubmit}>
@ -91,6 +90,6 @@ export const ProformaUpdateEditorForm = ({
</div>
</form>
</div>
</RdxEditorPageLayout>
</RdxEditorLayout>
);
};

View File

@ -52,6 +52,21 @@ test("conecta la alta rápida directa y la alta rápida desde selector con callb
PercentageField: () => null,
RdxEditorStickyFooter: () => null,
RdxPageContainer: ({ children }: { children: ReactNode }) => children,
RdxPageFrame: ({
children,
footer,
header,
}: {
children: ReactNode;
footer?: ReactNode;
header?: ReactNode;
}) => (
<>
{header}
{children}
{footer}
</>
),
RdxPageStack: ({ children }: { children: ReactNode }) => children,
RhfSubmitActionButton: () => null,
},

View File

@ -7,6 +7,7 @@ import {
RdxBadge,
RdxEditorActionBar,
RdxPageContainer,
RdxPageFrame,
RdxPageStack,
RhfSubmitActionButton,
} from "@repo/rdx-ui/components";
@ -94,178 +95,174 @@ export const ProformaUpdatePage = () => {
}
const hasPageAlerts = updateCtrl.isUpdateError || updateCtrl.isSeriesLoadError;
const actionBar = (
<RdxEditorActionBar
leading={
<ProformaCompactTotals
currency={updateCtrl.currencyCode}
globalDiscountField={
<PercentageField
className="md:col-span-4 md:col-start-1"
disabled={updateCtrl.isUpdating}
inputClassName="bg-background"
label={t("proformas.update.totals.globalDiscountPercentage", "Descuento global")}
name="globalDiscountPercentage"
/>
}
showRec={updateCtrl.taxCtrl.hasRecPercentage}
showRetention={updateCtrl.taxCtrl.hasRetentionPercentage}
totals={updateCtrl.totalsCtrl.totals}
variant="embedded"
/>
}
primary={
<RhfSubmitActionButton
busyLabel="Guardando..."
disabled={updateCtrl.isUpdating}
formId={updateCtrl.formId}
isBusy={updateCtrl.form.formState.isSubmitting}
label="Guardar"
/>
}
secondary={
<CancelActionButton
disabled={updateCtrl.isUpdating}
label="Cancelar"
onCancel={handleCancel}
variant="outline"
/>
}
/>
);
return (
<RdxPageStack className="fixed inset-0 min-h-0 overflow-hidden" spacing="none">
<FormProvider {...updateCtrl.form}>
<UnsavedChangesProvider isDirty={updateCtrl.form.formState.isDirty}>
<ProformaUpdateHeader
formId={updateCtrl.formId}
hasChanges={updateCtrl.form.formState.isDirty}
helpAction={
<ProformaUpdateHelpPopover
isDirty={updateCtrl.form.formState.isDirty}
isReadinessLoading={ctrl.isIssuanceReadinessLoading}
onRetryReadiness={handleRetryIssuanceReadiness}
readiness={ctrl.issuanceReadiness}
readinessError={ctrl.issuanceReadinessError}
/>
}
isSaving={updateCtrl.form.formState.isSubmitting}
labels={{
title: proforma ? (
<span className="inline-flex max-w-full items-center gap-3">
<span>{t("pages.proformas.update.title", "Editar proforma")}</span>
<RdxBadge className="border-border/80 bg-muted text-muted-foreground">
{proforma.proformaReference}
</RdxBadge>
<ProformaStatusBadge status={proforma.status} />
</span>
) : (
t("pages.proformas.update.title", "Editar proforma")
),
back: "Volver",
modified: "Modificada",
cancel: "Cancelar",
save: "Guardar",
saving: "Guardando...",
moreActions: "Más acciones",
keyboardShortcuts: "Ver atajos de teclado",
duplicate: "Duplicar proforma",
exportPdf: "Exportar PDF",
delete: "Eliminar",
}}
onCancel={handleCancel}
//onDelete={openDeleteDialog}
//onDuplicate={handleDuplicate}
//onExportPdf={handleExportPdf}
/>
<div className="flex min-h-0 flex-1 flex-col bg-muted/20">
<RdxPageContainer
className="min-h-0 flex-1 overflow-y-auto"
padding="default"
width="full"
>
{hasPageAlerts ? (
<RdxPageContainer className="pb-0" width="full">
<RdxPageStack spacing="compact">
{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.isSeriesLoadError && (
<ErrorAlert
message={
updateCtrl.seriesLoadError instanceof Error
? updateCtrl.seriesLoadError.message
: t(
"pages.proformas.update.series_load_error.message",
"Inténtalo de nuevo más tarde."
)
}
title={t(
"pages.proformas.update.series_load_error.title",
"No se pudieron cargar las series de facturación"
)}
/>
)}
</RdxPageStack>
</RdxPageContainer>
) : null}
<ProformaUpdateEditorForm
className={hasPageAlerts ? "pt-0" : undefined}
formId={updateCtrl.formId}
isSubmitting={updateCtrl.isUpdating}
itemsCtrl={updateCtrl.itemsCtrl}
onChangeCustomerClick={selectCustomerCtrl.selectCtrl.openDialog}
onCreateCustomerClick={selectCustomerCtrl.openQuickCreateDialog}
onPaymentTermChange={updateCtrl.handlePaymentTermChange}
onReset={updateCtrl.resetForm}
onSubmit={updateCtrl.onSubmit}
paymentCtrl={updateCtrl.paymentCtrl}
selectedCustomer={updateCtrl.selectedCustomer}
seriesOptions={updateCtrl.seriesOptions}
taxCtrl={updateCtrl.taxCtrl}
totalsCtrl={updateCtrl.totalsCtrl}
/>
</RdxPageContainer>
<RdxEditorActionBar
leading={
<ProformaCompactTotals
currency={updateCtrl.currencyCode}
globalDiscountField={
<PercentageField
className="md:col-span-4 md:col-start-1"
disabled={updateCtrl.isUpdating}
inputClassName="bg-background"
label={t(
"proformas.update.totals.globalDiscountPercentage",
"Descuento global"
)}
name="globalDiscountPercentage"
/>
}
showRec={updateCtrl.taxCtrl.hasRecPercentage}
showRetention={updateCtrl.taxCtrl.hasRetentionPercentage}
totals={updateCtrl.totalsCtrl.totals}
variant="embedded"
/>
}
primary={
<RhfSubmitActionButton
busyLabel="Guardando..."
disabled={updateCtrl.isUpdating}
formId={updateCtrl.formId}
isBusy={updateCtrl.form.formState.isSubmitting}
label="Guardar"
/>
}
secondary={
<CancelActionButton
disabled={updateCtrl.isUpdating}
label="Cancelar"
onCancel={handleCancel}
variant="outline"
<FormProvider {...updateCtrl.form}>
<UnsavedChangesProvider isDirty={updateCtrl.form.formState.isDirty}>
<RdxPageFrame
contentClassName="bg-muted/20"
footer={actionBar}
header={
<ProformaUpdateHeader
formId={updateCtrl.formId}
hasChanges={updateCtrl.form.formState.isDirty}
helpAction={
<ProformaUpdateHelpPopover
isDirty={updateCtrl.form.formState.isDirty}
isReadinessLoading={ctrl.isIssuanceReadinessLoading}
onRetryReadiness={handleRetryIssuanceReadiness}
readiness={ctrl.issuanceReadiness}
readinessError={ctrl.issuanceReadinessError}
/>
}
isSaving={updateCtrl.form.formState.isSubmitting}
labels={{
title: proforma ? (
<span className="inline-flex max-w-full items-center gap-3">
<span>{t("pages.proformas.update.title", "Editar proforma")}</span>
<RdxBadge className="border-border/80 bg-muted text-muted-foreground">
{proforma.proformaReference}
</RdxBadge>
<ProformaStatusBadge status={proforma.status} />
</span>
) : (
t("pages.proformas.update.title", "Editar proforma")
),
back: "Volver",
modified: "Modificada",
cancel: "Cancelar",
save: "Guardar",
saving: "Guardando...",
moreActions: "Más acciones",
keyboardShortcuts: "Ver atajos de teclado",
duplicate: "Duplicar proforma",
exportPdf: "Exportar PDF",
delete: "Eliminar",
}}
onCancel={handleCancel}
//onDelete={openDeleteDialog}
//onDuplicate={handleDuplicate}
//onExportPdf={handleExportPdf}
/>
</div>
}
>
<RdxPageContainer padding="default" width="full">
{hasPageAlerts ? (
<RdxPageContainer className="pb-0" width="full">
<RdxPageStack spacing="compact">
{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"
)}
/>
)}
<SelectCustomerDialog
ctrl={selectCustomerCtrl.selectCtrl}
onCreateNewCustomerClick={selectCustomerCtrl.openQuickCreateFromSelection}
/>
<QuickCreateCustomerDialog ctrl={selectCustomerCtrl.createCtrl} />
<ProformaUpdateCustomerChangeDialog
evaluation={customerChangeCtrl.evaluation}
isSubmitting={customerChangeCtrl.isApplying}
loadError={customerChangeCtrl.loadError as Error | null}
onConfirm={customerChangeCtrl.confirmChange}
onOpenChange={(open) => {
if (!open) {
customerChangeCtrl.closeDialog();
}
}}
open={customerChangeCtrl.open}
status={customerChangeCtrl.candidateStatus}
/>
</UnsavedChangesProvider>
</FormProvider>
</RdxPageStack>
{updateCtrl.isSeriesLoadError && (
<ErrorAlert
message={
updateCtrl.seriesLoadError instanceof Error
? updateCtrl.seriesLoadError.message
: t(
"pages.proformas.update.series_load_error.message",
"Inténtalo de nuevo más tarde."
)
}
title={t(
"pages.proformas.update.series_load_error.title",
"No se pudieron cargar las series de facturación"
)}
/>
)}
</RdxPageStack>
</RdxPageContainer>
) : null}
<ProformaUpdateEditorForm
className={hasPageAlerts ? "pt-0" : undefined}
formId={updateCtrl.formId}
isSubmitting={updateCtrl.isUpdating}
itemsCtrl={updateCtrl.itemsCtrl}
onChangeCustomerClick={selectCustomerCtrl.selectCtrl.openDialog}
onCreateCustomerClick={selectCustomerCtrl.openQuickCreateDialog}
onPaymentTermChange={updateCtrl.handlePaymentTermChange}
onReset={updateCtrl.resetForm}
onSubmit={updateCtrl.onSubmit}
paymentCtrl={updateCtrl.paymentCtrl}
selectedCustomer={updateCtrl.selectedCustomer}
seriesOptions={updateCtrl.seriesOptions}
taxCtrl={updateCtrl.taxCtrl}
totalsCtrl={updateCtrl.totalsCtrl}
/>
</RdxPageContainer>
</RdxPageFrame>
<SelectCustomerDialog
ctrl={selectCustomerCtrl.selectCtrl}
onCreateNewCustomerClick={selectCustomerCtrl.openQuickCreateFromSelection}
/>
<QuickCreateCustomerDialog ctrl={selectCustomerCtrl.createCtrl} />
<ProformaUpdateCustomerChangeDialog
evaluation={customerChangeCtrl.evaluation}
isSubmitting={customerChangeCtrl.isApplying}
loadError={customerChangeCtrl.loadError as Error | null}
onConfirm={customerChangeCtrl.confirmChange}
onOpenChange={(open) => {
if (!open) {
customerChangeCtrl.closeDialog();
}
}}
open={customerChangeCtrl.open}
status={customerChangeCtrl.candidateStatus}
/>
</UnsavedChangesProvider>
</FormProvider>
);
};

View File

@ -133,7 +133,7 @@ export const RecurringProformaDetailPage = () => {
/>
<RdxPageContainer className="min-h-0 flex-1 overflow-y-auto" padding="none" width="wide">
<RdxEditorPageLayout width="simple">
<RdxEditorPageLayout width="wide">
<RecurringProformaOperationalSummary
locale={dateLocale}
recurringProforma={recurringProforma}

View File

@ -16,6 +16,7 @@ import {
CircleIcon,
CirclePauseIcon,
CopyIcon,
PencilIcon,
PlayCircleIcon,
Trash2Icon,
} from "lucide-react";
@ -63,6 +64,7 @@ const actionIcons = {
complete: CheckCircle2Icon,
archive: ArchiveIcon,
duplicate: CopyIcon,
edit: PencilIcon,
delete: Trash2Icon,
} as const;

View File

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

View File

@ -10,7 +10,7 @@ export interface RdxEditorActionBarProps extends Omit<ComponentPropsWithoutRef<"
}
/**
* Barra inferior de acciones de una pantalla de edición.
* Barra inferior de acciones de un editor.
*
* Puede mostrar:
* - contenido auxiliar a la izquierda

View File

@ -6,7 +6,20 @@ import { renderToStaticMarkup } from "react-dom/server";
import { RdxFormGrid } from "../form/rdx-form-grid.tsx";
import { RdxEditorActionBar } from "./rdx-editor-action-bar.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", () => {
const html = renderToStaticMarkup(
<RdxEditorLayout aside={<span>Aside</span>}>Main</RdxEditorLayout>
);
assert.match(html, /grid/);
assert.match(html, /<aside/);
assert.doesNotMatch(html, /overflow/);
assert.doesNotMatch(html, /px-4/);
});
import { RdxPanel } from "./rdx-panel.tsx";
test("RdxEditorPageLayout aplica complejidad simple por defecto y renderiza children", () => {

View File

@ -0,0 +1,31 @@
import { cn } from "@repo/shadcn-ui/lib/utils";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
export interface RdxEditorLayoutProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
children: ReactNode;
aside?: ReactNode;
}
/**
* Distribuye el contenido interno de un editor entre una región principal
* y un aside opcional.
*/
export const RdxEditorLayout = ({ children, aside, className, ...props }: RdxEditorLayoutProps) => {
if (!aside) {
return (
<div className={cn("min-w-0", className)} {...props}>
{children}
</div>
);
}
return (
<div
className={cn("grid min-w-0 gap-6 xl:grid-cols-[minmax(0,1fr)_22rem]", className)}
{...props}
>
<div className="min-w-0">{children}</div>
<aside className="min-w-0">{aside}</aside>
</div>
);
};

View File

@ -43,7 +43,6 @@ export const RdxPanel = ({
...props
}: RdxPanelProps) => {
const titleId = useId();
const hasHeader = Boolean(title || description || icon || actions);
return (
<section
@ -51,34 +50,30 @@ export const RdxPanel = ({
className={cn(VARIANT_CLASSNAME[variant], className)}
{...props}
>
{hasHeader ? (
<div className={cn("flex flex-col gap-4", variant === "plain" ? "pb-4" : "pb-5")}>
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-3">
{icon ? (
<div className="flex size-6 shrink-0 items-center justify-center text-primary">
{icon}
</div>
) : null}
<div className="min-w-0 space-y-1">
<h2
className="min-w-0 text-base font-semibold tracking-tight text-foreground"
id={titleId}
>
{title}
</h2>
{description ? (
<p className="text-sm text-muted-foreground">{description}</p>
) : null}
<div className={cn("flex flex-col gap-4", variant === "plain" ? "pb-4" : "pb-5")}>
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-3">
{icon ? (
<div className="flex size-6 shrink-0 items-center justify-center text-primary">
{icon}
</div>
</div>
) : null}
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
<div className="min-w-0 space-y-1">
<h2
className="min-w-0 text-base font-semibold tracking-tight text-foreground"
id={titleId}
>
{title}
</h2>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
</div>
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
</div>
) : null}
</div>
<div className="min-w-0">{children}</div>
</section>

View File

@ -3,5 +3,6 @@ export * from "./app-header.tsx";
export * from "./app-layout.tsx";
export * from "./rdx-page-actions.tsx";
export * from "./rdx-page-container.tsx";
export * from "./rdx-page-frame.tsx";
export * from "./rdx-page-header.tsx";
export * from "./rdx-page-stack.tsx";

View File

@ -1,12 +1,27 @@
import assert from "node:assert/strict";
import test from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { RdxPageActions } from "./rdx-page-actions.tsx";
import { RdxPageContainer } from "./rdx-page-container.tsx";
import { RdxPageFrame } from "./rdx-page-frame.tsx";
import { RdxPageHeader } from "./rdx-page-header.tsx";
import { RdxPageStack } from "./rdx-page-stack.tsx";
test("RdxPageFrame separates fixed regions from the scrollable content", () => {
const html = renderToStaticMarkup(
<RdxPageFrame footer={<span>Footer</span>} header={<span>Header</span>}>
Content
</RdxPageFrame>
);
assert.match(html, /overflow-hidden/);
assert.match(html, /overflow-y-auto/);
assert.ok(html.indexOf("Header") < html.indexOf("Content"));
assert.ok(html.indexOf("Content") < html.indexOf("Footer"));
});
test("RdxPageContainer renders children with full width by default", () => {
const html = renderToStaticMarkup(<RdxPageContainer>Content</RdxPageContainer>);

View File

@ -0,0 +1,35 @@
import { cn } from "@repo/shadcn-ui/lib/utils";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
export interface RdxPageFrameProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
children: ReactNode;
header?: ReactNode;
footer?: ReactNode;
contentClassName?: string;
}
/**
* Estructura una página en cabecera, contenido scrollable y pie opcional.
* Centraliza el control del scroll principal de la página.
*/
export const RdxPageFrame = ({
children,
header,
footer,
contentClassName,
className,
...props
}: RdxPageFrameProps) => {
return (
<div
className={cn("flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden", className)}
{...props}
>
{header ? <div className="shrink-0">{header}</div> : null}
<div className={cn("min-h-0 min-w-0 flex-1 overflow-y-auto", contentClassName)}>
{children}
</div>
{footer ? <div className="shrink-0">{footer}</div> : null}
</div>
);
};

View File

@ -16,8 +16,8 @@ const SPACING_CLASSNAME: Record<RdxPageStackSpacing, string> = {
};
/**
* Contenedor para layout para apilar contenido verticalmente.
* No proporciona estructura de página ni controla scroll.
* Apila contenido verticalmente aplicando un espaciado consistente.
* No controla viewport, scroll ni estructura de página.
*/
export const RdxPageStack = ({