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. * Shell para páginas fullscreen sin sidebar ni topbar global.
* Se utiliza en editores y vistas de detalle sin sidebar ni topbar global.
*/ */
export const AppFullscreenLayout = () => { export const AppFullscreenLayout = () => {

View File

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

View File

@ -38,7 +38,7 @@ export const ProformaCreateCustomerField = ({
<div className="relative"> <div className="relative">
<Button <Button
className={cn( 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" !selectedCustomer && "text-muted-foreground"
)} )}
disabled={isDisabled} disabled={isDisabled}

View File

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

View File

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

View File

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

View File

@ -7,7 +7,7 @@ type ProformaDetailContentProps = {
export const ProformaDetailContent = ({ documentPane, inspector }: ProformaDetailContentProps) => { export const ProformaDetailContent = ({ documentPane, inspector }: ProformaDetailContentProps) => {
return ( 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]"> <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} {documentPane}
{inspector} {inspector}

View File

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

View File

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

View File

@ -1,3 +1,2 @@
export * from "./proforma-layout";
export * from "./proforma-issuance-readiness-alert"; export * from "./proforma-issuance-readiness-alert";
export * from "./proforma-page-header"; 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 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 { preventEnterKeySubmitForm } from "@repo/rdx-ui/helpers";
import type { SubmitEventHandler } from "react"; import type { SubmitEventHandler } from "react";
@ -53,7 +53,7 @@ export const ProformaUpdateEditorForm = ({
className, className,
}: ProformaUpdateEditorProps) => { }: ProformaUpdateEditorProps) => {
return ( return (
<RdxEditorPageLayout <RdxEditorLayout
aside={ aside={
<div className="flex min-w-0 flex-col gap-6"> <div className="flex min-w-0 flex-col gap-6">
<ProformaUpdateRecipientEditor <ProformaUpdateRecipientEditor
@ -75,7 +75,6 @@ export const ProformaUpdateEditorForm = ({
</div> </div>
} }
className={className} className={className}
width="full"
> >
<div className="min-w-0"> <div className="min-w-0">
<form id={formId} noValidate onKeyDown={preventEnterKeySubmitForm} onSubmit={onSubmit}> <form id={formId} noValidate onKeyDown={preventEnterKeySubmitForm} onSubmit={onSubmit}>
@ -91,6 +90,6 @@ export const ProformaUpdateEditorForm = ({
</div> </div>
</form> </form>
</div> </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, PercentageField: () => null,
RdxEditorStickyFooter: () => null, RdxEditorStickyFooter: () => null,
RdxPageContainer: ({ children }: { children: ReactNode }) => children, RdxPageContainer: ({ children }: { children: ReactNode }) => children,
RdxPageFrame: ({
children,
footer,
header,
}: {
children: ReactNode;
footer?: ReactNode;
header?: ReactNode;
}) => (
<>
{header}
{children}
{footer}
</>
),
RdxPageStack: ({ children }: { children: ReactNode }) => children, RdxPageStack: ({ children }: { children: ReactNode }) => children,
RhfSubmitActionButton: () => null, RhfSubmitActionButton: () => null,
}, },

View File

@ -7,6 +7,7 @@ import {
RdxBadge, RdxBadge,
RdxEditorActionBar, RdxEditorActionBar,
RdxPageContainer, RdxPageContainer,
RdxPageFrame,
RdxPageStack, RdxPageStack,
RhfSubmitActionButton, RhfSubmitActionButton,
} from "@repo/rdx-ui/components"; } from "@repo/rdx-ui/components";
@ -94,178 +95,174 @@ export const ProformaUpdatePage = () => {
} }
const hasPageAlerts = updateCtrl.isUpdateError || updateCtrl.isSeriesLoadError; 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 ( return (
<RdxPageStack className="fixed inset-0 min-h-0 overflow-hidden" spacing="none"> <FormProvider {...updateCtrl.form}>
<FormProvider {...updateCtrl.form}> <UnsavedChangesProvider isDirty={updateCtrl.form.formState.isDirty}>
<UnsavedChangesProvider isDirty={updateCtrl.form.formState.isDirty}> <RdxPageFrame
<ProformaUpdateHeader contentClassName="bg-muted/20"
formId={updateCtrl.formId} footer={actionBar}
hasChanges={updateCtrl.form.formState.isDirty} header={
helpAction={ <ProformaUpdateHeader
<ProformaUpdateHelpPopover formId={updateCtrl.formId}
isDirty={updateCtrl.form.formState.isDirty} hasChanges={updateCtrl.form.formState.isDirty}
isReadinessLoading={ctrl.isIssuanceReadinessLoading} helpAction={
onRetryReadiness={handleRetryIssuanceReadiness} <ProformaUpdateHelpPopover
readiness={ctrl.issuanceReadiness} isDirty={updateCtrl.form.formState.isDirty}
readinessError={ctrl.issuanceReadinessError} isReadinessLoading={ctrl.isIssuanceReadinessLoading}
/> onRetryReadiness={handleRetryIssuanceReadiness}
} readiness={ctrl.issuanceReadiness}
isSaving={updateCtrl.form.formState.isSubmitting} readinessError={ctrl.issuanceReadinessError}
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"
/> />
} }
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 {updateCtrl.isSeriesLoadError && (
ctrl={selectCustomerCtrl.selectCtrl} <ErrorAlert
onCreateNewCustomerClick={selectCustomerCtrl.openQuickCreateFromSelection} message={
/> updateCtrl.seriesLoadError instanceof Error
<QuickCreateCustomerDialog ctrl={selectCustomerCtrl.createCtrl} /> ? updateCtrl.seriesLoadError.message
<ProformaUpdateCustomerChangeDialog : t(
evaluation={customerChangeCtrl.evaluation} "pages.proformas.update.series_load_error.message",
isSubmitting={customerChangeCtrl.isApplying} "Inténtalo de nuevo más tarde."
loadError={customerChangeCtrl.loadError as Error | null} )
onConfirm={customerChangeCtrl.confirmChange} }
onOpenChange={(open) => { title={t(
if (!open) { "pages.proformas.update.series_load_error.title",
customerChangeCtrl.closeDialog(); "No se pudieron cargar las series de facturación"
} )}
}} />
open={customerChangeCtrl.open} )}
status={customerChangeCtrl.candidateStatus} </RdxPageStack>
/> </RdxPageContainer>
</UnsavedChangesProvider> ) : null}
</FormProvider>
</RdxPageStack> <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"> <RdxPageContainer className="min-h-0 flex-1 overflow-y-auto" padding="none" width="wide">
<RdxEditorPageLayout width="simple"> <RdxEditorPageLayout width="wide">
<RecurringProformaOperationalSummary <RecurringProformaOperationalSummary
locale={dateLocale} locale={dateLocale}
recurringProforma={recurringProforma} recurringProforma={recurringProforma}

View File

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

View File

@ -1,3 +1,4 @@
export * from "./rdx-editor-action-bar.tsx"; export * from "./rdx-editor-action-bar.tsx";
export * from "./rdx-editor-layout.tsx";
export * from "./rdx-editor-page-layout.tsx"; export * from "./rdx-editor-page-layout.tsx";
export * from "./rdx-panel.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: * Puede mostrar:
* - contenido auxiliar a la izquierda * - 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 { RdxFormGrid } from "../form/rdx-form-grid.tsx";
import { RdxEditorActionBar } from "./rdx-editor-action-bar.tsx"; import { RdxEditorActionBar } from "./rdx-editor-action-bar.tsx";
import { RdxEditorLayout } from "./rdx-editor-layout.tsx";
import { RdxEditorPageLayout } from "./rdx-editor-page-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"; import { RdxPanel } from "./rdx-panel.tsx";
test("RdxEditorPageLayout aplica complejidad simple por defecto y renderiza children", () => { 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 ...props
}: RdxPanelProps) => { }: RdxPanelProps) => {
const titleId = useId(); const titleId = useId();
const hasHeader = Boolean(title || description || icon || actions);
return ( return (
<section <section
@ -51,34 +50,30 @@ export const RdxPanel = ({
className={cn(VARIANT_CLASSNAME[variant], className)} className={cn(VARIANT_CLASSNAME[variant], className)}
{...props} {...props}
> >
{hasHeader ? ( <div className={cn("flex flex-col gap-4", variant === "plain" ? "pb-4" : "pb-5")}>
<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 items-start justify-between gap-4"> <div className="flex min-w-0 items-start gap-3">
<div className="flex min-w-0 items-start gap-3"> {icon ? (
{icon ? ( <div className="flex size-6 shrink-0 items-center justify-center text-primary">
<div className="flex size-6 shrink-0 items-center justify-center text-primary"> {icon}
{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> </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> </div>
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
</div> </div>
) : null} </div>
<div className="min-w-0">{children}</div> <div className="min-w-0">{children}</div>
</section> </section>

View File

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

View File

@ -1,12 +1,27 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { RdxPageActions } from "./rdx-page-actions.tsx"; import { RdxPageActions } from "./rdx-page-actions.tsx";
import { RdxPageContainer } from "./rdx-page-container.tsx"; import { RdxPageContainer } from "./rdx-page-container.tsx";
import { RdxPageFrame } from "./rdx-page-frame.tsx";
import { RdxPageHeader } from "./rdx-page-header.tsx"; import { RdxPageHeader } from "./rdx-page-header.tsx";
import { RdxPageStack } from "./rdx-page-stack.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", () => { test("RdxPageContainer renders children with full width by default", () => {
const html = renderToStaticMarkup(<RdxPageContainer>Content</RdxPageContainer>); 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. * Apila contenido verticalmente aplicando un espaciado consistente.
* No proporciona estructura de página ni controla scroll. * No controla viewport, scroll ni estructura de página.
*/ */
export const RdxPageStack = ({ export const RdxPageStack = ({