Compare commits
2 Commits
9c7dfc8645
...
a13ababdf6
| Author | SHA1 | Date | |
|---|---|---|---|
| a13ababdf6 | |||
| e7e82d95ad |
@ -2,12 +2,11 @@ import {
|
||||
RdxBadge,
|
||||
RdxContextPanel,
|
||||
RdxEditorActionBar,
|
||||
RdxEditorLayout,
|
||||
RdxFormGrid,
|
||||
RdxPageActions,
|
||||
RdxPageContainer,
|
||||
RdxPageContent,
|
||||
RdxPageHeader,
|
||||
RdxPageStack,
|
||||
RdxPanel,
|
||||
RdxRowActions,
|
||||
RightPanel,
|
||||
@ -87,7 +86,7 @@ export default function UiKitPage() {
|
||||
|
||||
return (
|
||||
<RdxPageContainer width="wide">
|
||||
<RdxPageStack spacing="relaxed">
|
||||
<RdxPageContent spacing="relaxed">
|
||||
<RdxPageHeader
|
||||
actions={
|
||||
<RdxPageActions
|
||||
@ -244,7 +243,18 @@ export default function UiKitPage() {
|
||||
id="page-containers"
|
||||
title="Page containers"
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
<div className="grid gap-4 xl:grid-cols-4">
|
||||
<ExampleCard notes="`spacing="none"`" title="None">
|
||||
<RdxPageContent
|
||||
className="rounded-lg border border-border bg-background p-4"
|
||||
spacing="none"
|
||||
>
|
||||
<SurfaceExample>Block 1</SurfaceExample>
|
||||
<SurfaceExample>Block 2</SurfaceExample>
|
||||
<SurfaceExample>Block 3</SurfaceExample>
|
||||
</RdxPageContent>
|
||||
</ExampleCard>
|
||||
|
||||
<ExampleCard notes="`width="full"`" title="Full">
|
||||
<RdxPageContainer
|
||||
className="rounded-lg border border-dashed border-border bg-muted/40"
|
||||
@ -277,42 +287,42 @@ export default function UiKitPage() {
|
||||
<Separator />
|
||||
|
||||
<UiKitSection
|
||||
description="`RdxPageStack` usa `gap` para componer contenido condicional sin depender de `space-y-*`."
|
||||
description="`RdxPageContent` usa `gap` para componer contenido condicional sin depender de `space-y-*`."
|
||||
id="vertical-spacing"
|
||||
title="Vertical spacing"
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
<ExampleCard notes="`spacing="compact"`" title="Compact">
|
||||
<RdxPageStack
|
||||
<RdxPageContent
|
||||
className="rounded-lg border border-border bg-background p-4"
|
||||
spacing="compact"
|
||||
>
|
||||
<SurfaceExample>Block 1</SurfaceExample>
|
||||
<SurfaceExample>Block 2</SurfaceExample>
|
||||
<SurfaceExample>Block 3</SurfaceExample>
|
||||
</RdxPageStack>
|
||||
</RdxPageContent>
|
||||
</ExampleCard>
|
||||
|
||||
<ExampleCard notes="`spacing="default"`" title="Default">
|
||||
<RdxPageStack
|
||||
<RdxPageContent
|
||||
className="rounded-lg border border-border bg-background p-4"
|
||||
spacing="default"
|
||||
>
|
||||
<SurfaceExample>Block 1</SurfaceExample>
|
||||
<SurfaceExample>Block 2</SurfaceExample>
|
||||
<SurfaceExample>Block 3</SurfaceExample>
|
||||
</RdxPageStack>
|
||||
</RdxPageContent>
|
||||
</ExampleCard>
|
||||
|
||||
<ExampleCard notes="`spacing="relaxed"`" title="Relaxed">
|
||||
<RdxPageStack
|
||||
<RdxPageContent
|
||||
className="rounded-lg border border-border bg-background p-4"
|
||||
spacing="relaxed"
|
||||
>
|
||||
<SurfaceExample>Block 1</SurfaceExample>
|
||||
<SurfaceExample>Block 2</SurfaceExample>
|
||||
<SurfaceExample>Block 3</SurfaceExample>
|
||||
</RdxPageStack>
|
||||
</RdxPageContent>
|
||||
</ExampleCard>
|
||||
</div>
|
||||
</UiKitSection>
|
||||
@ -454,12 +464,12 @@ export default function UiKitPage() {
|
||||
title="Real list-page composition"
|
||||
>
|
||||
<ExampleCard
|
||||
notes="Representa `RdxPageContainer width="full"`, `RdxPageStack spacing="default"`, `RdxPageHeader`, acción primaria, aviso opcional y superficie de listado."
|
||||
notes="Representa `RdxPageContainer width="full"`, `RdxPageContent spacing="default"`, `RdxPageHeader`, acción primaria, aviso opcional y superficie de listado."
|
||||
title="Pilot integration"
|
||||
>
|
||||
<div className="rounded-lg border border-border bg-muted/30">
|
||||
<RdxPageContainer width="full">
|
||||
<RdxPageStack spacing="default">
|
||||
<RdxPageContent spacing="default">
|
||||
<RdxPageHeader
|
||||
actions={
|
||||
<RdxPageActions
|
||||
@ -487,7 +497,7 @@ export default function UiKitPage() {
|
||||
<div className="h-72 rounded-md border border-dashed border-border bg-muted/30" />
|
||||
</div>
|
||||
</div>
|
||||
</RdxPageStack>
|
||||
</RdxPageContent>
|
||||
</RdxPageContainer>
|
||||
</div>
|
||||
</ExampleCard>
|
||||
@ -506,7 +516,7 @@ export default function UiKitPage() {
|
||||
>
|
||||
<div className="rounded-lg border border-border bg-muted/30">
|
||||
<RdxPageContainer width="full">
|
||||
<RdxPageStack spacing="default">
|
||||
<RdxPageContent spacing="default">
|
||||
<RdxPageHeader
|
||||
actions={
|
||||
<RdxPageActions
|
||||
@ -557,7 +567,7 @@ export default function UiKitPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RdxPageStack>
|
||||
</RdxPageContent>
|
||||
</RdxPageContainer>
|
||||
</div>
|
||||
</ExampleCard>
|
||||
@ -746,19 +756,19 @@ export default function UiKitPage() {
|
||||
<Separator />
|
||||
|
||||
<UiKitSection
|
||||
description="Foundations iniciales del editor simple validadas con `customers/update` y `customers/create`, manteniendo el header legacy fuera de `rdx-ui`."
|
||||
description="Composición interior de página con spacing y aside opcional, separada del ancho y padding exterior."
|
||||
id="editor-foundations"
|
||||
title="Editor foundations"
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<ExampleCard title="RdxPageContainer + RdxEditorLayout">
|
||||
<ExampleCard title="RdxPageContainer + RdxPageContent">
|
||||
<div className="rounded-lg border border-border bg-muted/30">
|
||||
<RdxPageContainer width="content">
|
||||
<RdxEditorLayout>
|
||||
<RdxPageContent>
|
||||
<div className="rounded-lg border border-dashed border-border bg-background p-4 text-sm text-foreground">
|
||||
Centered editor content
|
||||
</div>
|
||||
</RdxEditorLayout>
|
||||
</RdxPageContent>
|
||||
</RdxPageContainer>
|
||||
</div>
|
||||
</ExampleCard>
|
||||
@ -809,7 +819,7 @@ export default function UiKitPage() {
|
||||
>
|
||||
<div className="rounded-lg border border-border bg-muted/30">
|
||||
<RdxPageContainer width="content">
|
||||
<RdxEditorLayout>
|
||||
<RdxPageContent>
|
||||
<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="rounded-lg border border-border bg-background px-4 py-3 text-sm text-muted-foreground">
|
||||
@ -849,7 +859,7 @@ export default function UiKitPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RdxEditorLayout>
|
||||
</RdxPageContent>
|
||||
</RdxPageContainer>
|
||||
</div>
|
||||
</ExampleCard>
|
||||
@ -875,7 +885,7 @@ export default function UiKitPage() {
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-muted/20">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<RdxPageContainer width="full">
|
||||
<RdxEditorLayout
|
||||
<RdxPageContent
|
||||
aside={
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
<RdxPanel
|
||||
@ -931,7 +941,7 @@ export default function UiKitPage() {
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</RdxEditorLayout>
|
||||
</RdxPageContent>
|
||||
</RdxPageContainer>
|
||||
</div>
|
||||
|
||||
@ -972,14 +982,14 @@ export default function UiKitPage() {
|
||||
<ExampleCard title="List composition">
|
||||
<div className="rounded-lg border border-border bg-muted/30">
|
||||
<RdxPageContainer width="full">
|
||||
<RdxPageStack>
|
||||
<RdxPageContent>
|
||||
<RdxPageHeader
|
||||
actions={<RdxPageActions primary={<Button type="button">New item</Button>} />}
|
||||
description="Static list example"
|
||||
title="List page"
|
||||
/>
|
||||
<SurfaceExample>List content</SurfaceExample>
|
||||
</RdxPageStack>
|
||||
</RdxPageContent>
|
||||
</RdxPageContainer>
|
||||
</div>
|
||||
</ExampleCard>
|
||||
@ -988,26 +998,26 @@ export default function UiKitPage() {
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-border bg-muted/30">
|
||||
<RdxPageContainer width="content">
|
||||
<RdxPageStack>
|
||||
<RdxPageContent>
|
||||
<RdxPageHeader description="Simple centered editor" title="Editor page" />
|
||||
<SurfaceExample>Form content</SurfaceExample>
|
||||
</RdxPageStack>
|
||||
</RdxPageContent>
|
||||
</RdxPageContainer>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-muted/30">
|
||||
<RdxPageContainer width="wide">
|
||||
<RdxPageStack>
|
||||
<RdxPageContent>
|
||||
<RdxPageHeader description="Wide detail composition" title="Detail page" />
|
||||
<SurfaceExample>Detail content</SurfaceExample>
|
||||
</RdxPageStack>
|
||||
</RdxPageContent>
|
||||
</RdxPageContainer>
|
||||
</div>
|
||||
</div>
|
||||
</ExampleCard>
|
||||
</div>
|
||||
</UiKitSection>
|
||||
</RdxPageStack>
|
||||
</RdxPageContent>
|
||||
</RdxPageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { PageErrorState } from "@erp/core/components";
|
||||
import {
|
||||
RdxPageContainer,
|
||||
RdxPageContent,
|
||||
RdxPageFrame,
|
||||
RdxPageHeader,
|
||||
RdxPageStack,
|
||||
} from "@repo/rdx-ui/components";
|
||||
import { Skeleton } from "@repo/shadcn-ui/components";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
@ -64,7 +64,7 @@ export const HomePage = () => {
|
||||
return (
|
||||
<RdxPageFrame>
|
||||
<RdxPageContainer width="full">
|
||||
<RdxPageStack spacing="default">
|
||||
<RdxPageContent spacing="default">
|
||||
<RdxPageHeader
|
||||
description={t("web.home.header.description")}
|
||||
title={t("web.home.header.title")}
|
||||
@ -113,7 +113,7 @@ export const HomePage = () => {
|
||||
{t("web.home.footer.link")}
|
||||
</Link>
|
||||
</p>
|
||||
</RdxPageStack>
|
||||
</RdxPageContent>
|
||||
</RdxPageContainer>
|
||||
</RdxPageFrame>
|
||||
);
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import {
|
||||
RdxPageContainer,
|
||||
RdxPageContent,
|
||||
RdxPageFrame,
|
||||
RdxPageHeader,
|
||||
RdxPageStack,
|
||||
} from "@repo/rdx-ui/components";
|
||||
import { Outlet } from "react-router-dom";
|
||||
|
||||
@ -30,7 +30,7 @@ export const SettingsLayout = ({ sections }: SettingsLayoutProps) => {
|
||||
title={t("web.settings.title")}
|
||||
/>
|
||||
|
||||
<RdxPageStack spacing="default">
|
||||
<RdxPageContent spacing="default">
|
||||
<div className="grid gap-6 lg:grid-cols-[18rem_minmax(0,1fr)]">
|
||||
<aside className="min-w-0 lg:sticky lg:top-6 lg:self-start">
|
||||
<SettingsNavigation sections={sections} />
|
||||
@ -40,7 +40,7 @@ export const SettingsLayout = ({ sections }: SettingsLayoutProps) => {
|
||||
<Outlet />
|
||||
</section>
|
||||
</div>
|
||||
</RdxPageStack>
|
||||
</RdxPageContent>
|
||||
</RdxPageContainer>
|
||||
</RdxPageFrame>
|
||||
);
|
||||
|
||||
@ -61,6 +61,16 @@ Application shell
|
||||
|
||||
Dentro de `AppMain`, una pantalla usa `RdxPageFrame`; el sistema visual y sus reglas se documentan en [UI system](./ui-system.md).
|
||||
|
||||
```text
|
||||
AppMain
|
||||
└── RdxPageFrame
|
||||
├── header
|
||||
├── content
|
||||
│ └── RdxPageContainer
|
||||
│ └── RdxPageContent
|
||||
└── footer opcional
|
||||
```
|
||||
|
||||
## 6. Identity y sesión
|
||||
|
||||
El runtime actual se compone en `apps/web/src/app.tsx`:
|
||||
@ -130,4 +140,3 @@ El estado remoto permanece en TanStack Query. No se duplica en estado local salv
|
||||
- No introducir acceso a red en UI ni reglas de negocio en Pages.
|
||||
- Mantener routing, sesión, tenant y sistema visual como responsabilidades separadas.
|
||||
- Cambios de API visual pública deben sincronizar implementación, [UI system](./ui-system.md), UI Kit y tests.
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@ Los componentes aquí citados se definen en `ui-system.md`; este documento expli
|
||||
```tsx
|
||||
<RdxPageFrame>
|
||||
<RdxPageContainer width="full">
|
||||
<RdxPageStack>
|
||||
<RdxPageContent>
|
||||
<RdxPageHeader
|
||||
title={title}
|
||||
description={description}
|
||||
@ -17,7 +17,7 @@ Los componentes aquí citados se definen en `ui-system.md`; este documento expli
|
||||
/>
|
||||
{refreshError}
|
||||
<DataTable topContent={toolbarAndFilters} {...tableProps} />
|
||||
</RdxPageStack>
|
||||
</RdxPageContent>
|
||||
</RdxPageContainer>
|
||||
</RdxPageFrame>
|
||||
```
|
||||
@ -40,12 +40,10 @@ El controller de página concentra URL, filtros, consulta, estados y navegación
|
||||
footer={<RdxEditorActionBar secondary={cancel} primary={submit} />}
|
||||
>
|
||||
<RdxPageContainer width="content">
|
||||
<RdxEditorLayout aside={aside}>
|
||||
<RdxPageStack>
|
||||
{notice}
|
||||
<RdxPanel title={sectionTitle}>{fields}</RdxPanel>
|
||||
</RdxPageStack>
|
||||
</RdxEditorLayout>
|
||||
<RdxPageContent aside={aside}>
|
||||
{notice}
|
||||
<RdxPanel title={sectionTitle}>{fields}</RdxPanel>
|
||||
</RdxPageContent>
|
||||
</RdxPageContainer>
|
||||
</RdxPageFrame>
|
||||
```
|
||||
@ -185,4 +183,3 @@ Las traducciones productivas deben probarse sin sustituirlas por un diccionario
|
||||
- [ ] Acciones, permisos y navegación permanecen en el consumidor.
|
||||
- [ ] Responsive, teclado, foco, i18n y light/dark están validados.
|
||||
- [ ] Implementación, documentación, UI Kit y tests siguen sincronizados.
|
||||
|
||||
|
||||
@ -28,8 +28,7 @@ AppMain
|
||||
│ └── RdxEditorHeader
|
||||
├── content (scroll principal)
|
||||
│ └── RdxPageContainer
|
||||
│ ├── RdxPageStack
|
||||
│ └── RdxEditorLayout
|
||||
│ └── RdxPageContent
|
||||
└── footer opcional
|
||||
└── RdxEditorActionBar
|
||||
```
|
||||
@ -52,7 +51,7 @@ Cada nivel tiene una sola responsabilidad. No se reconstruye viewport, anchura y
|
||||
|
||||
`padding="none"` elimina únicamente su padding. El container no controla altura ni scroll.
|
||||
|
||||
`RdxPageStack` establece el ritmo vertical:
|
||||
`RdxPageContent` establece el ritmo vertical del main:
|
||||
|
||||
| Spacing | Gap |
|
||||
| --- | --- |
|
||||
@ -93,9 +92,9 @@ Ninguno usa `sticky` para seleccionar una variante. El frame coloca el header fu
|
||||
|
||||
Ordena regiones `secondary`, `primary` y `overflow`. No decide qué acciones existen ni sus permisos.
|
||||
|
||||
### `RdxEditorLayout`
|
||||
### `RdxPageContent`
|
||||
|
||||
Distribuye main y un `aside` opcional. No controla width, padding ni scroll. Sin aside no reserva una columna. En desktop, el aside usa `22rem`; en anchos menores se apila.
|
||||
Apila verticalmente el main y distribuye un `aside` opcional. No controla width, padding ni scroll. Sin aside no reserva una columna. En desktop, el aside usa `22rem`; en anchos menores se apila. `spacing` afecta únicamente al main.
|
||||
|
||||
El aside aloja contexto secundario, metadata o configuración auxiliar. No se mueven campos allí solo por estética.
|
||||
|
||||
@ -182,4 +181,3 @@ Cada cambio de API pública debe actualizar conjuntamente:
|
||||
- Colores físicos o color como única señal.
|
||||
|
||||
Las recetas para construir páginas concretas están en [UI patterns](./ui-patterns.md).
|
||||
|
||||
|
||||
@ -776,7 +776,7 @@ Ejemplos:
|
||||
```txt
|
||||
RdxPageContainer
|
||||
RdxPageHeader
|
||||
RdxPageStack
|
||||
RdxPageContent
|
||||
```
|
||||
|
||||
No mover a `packages/rdx-ui`:
|
||||
|
||||
@ -118,7 +118,7 @@ Todos los textos visibles pertenecen al namespace `Suppliers`. Se mantienen recu
|
||||
|
||||
## 17. Componentes reutilizados
|
||||
|
||||
- `RdxPageContainer`, `RdxPageStack`, `RdxPageHeader`, `RdxPageActions`;
|
||||
- `RdxPageContainer`, `RdxPageContent`, `RdxPageHeader`, `RdxPageActions`;
|
||||
- `RdxEditorPageLayout`, `RdxPanel`, `RdxFormGrid`;
|
||||
- `DataTable`, `SkeletonDataTable`, `DataTableColumnHeader`, `RdxRowActions`;
|
||||
- `TextField`, `SelectField`, `PageFormHeader`, `FormActionsBar`;
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { ErrorAlert, PageErrorState, SimpleSearchInput } from "@erp/core/components";
|
||||
import {
|
||||
RdxPageContainer,
|
||||
RdxPageContent,
|
||||
RdxPageFrame,
|
||||
RdxPageHeader,
|
||||
RdxPageStack,
|
||||
} from "@repo/rdx-ui/components";
|
||||
import {
|
||||
Button,
|
||||
@ -54,7 +54,7 @@ export const AuditPage = () => {
|
||||
return (
|
||||
<RdxPageFrame>
|
||||
<RdxPageContainer width="full">
|
||||
<RdxPageStack spacing="default">
|
||||
<RdxPageContent spacing="default">
|
||||
<RdxPageHeader description={t("audit.description")} title={t("audit.title")} />
|
||||
|
||||
{controller.isError && controller.hasUsableData ? (
|
||||
@ -97,7 +97,7 @@ export const AuditPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
<AuditEntrySheet entryId={controller.entry} onClose={controller.closeEntry} />
|
||||
</RdxPageStack>
|
||||
</RdxPageContent>
|
||||
</RdxPageContainer>
|
||||
</RdxPageFrame>
|
||||
);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { RdxEditorLayout, RdxPageContainer, RdxPageFrame } from "@repo/rdx-ui/components";
|
||||
import { RdxPageContainer, RdxPageContent, RdxPageFrame } from "@repo/rdx-ui/components";
|
||||
import { Skeleton } from "@repo/shadcn-ui/components";
|
||||
|
||||
export const ProformaCreateSkeleton = () => {
|
||||
@ -12,13 +12,13 @@ export const ProformaCreateSkeleton = () => {
|
||||
}
|
||||
>
|
||||
<RdxPageContainer width="content">
|
||||
<RdxEditorLayout>
|
||||
<RdxPageContent>
|
||||
<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>
|
||||
</RdxPageContent>
|
||||
</RdxPageContainer>
|
||||
</RdxPageFrame>
|
||||
);
|
||||
|
||||
@ -1,12 +1,7 @@
|
||||
import { ErrorAlert } from "@erp/core/components";
|
||||
import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks";
|
||||
import { QuickCreateCustomerDialog, SelectCustomerDialog } from "@erp/customers";
|
||||
import {
|
||||
RdxEditorLayout,
|
||||
RdxPageContainer,
|
||||
RdxPageFrame,
|
||||
RdxPageStack,
|
||||
} from "@repo/rdx-ui/components";
|
||||
import { RdxPageContainer, RdxPageContent, RdxPageFrame } from "@repo/rdx-ui/components";
|
||||
import { FormProvider } from "react-hook-form";
|
||||
|
||||
import { ProformaInfoAlert } from "../../../update/ui/components";
|
||||
@ -39,47 +34,45 @@ export const ProformaCreatePage = () => {
|
||||
}
|
||||
>
|
||||
<RdxPageContainer width="content">
|
||||
<RdxEditorLayout>
|
||||
<RdxPageStack spacing="default">
|
||||
{createCtrl.isCreateError && (
|
||||
<ErrorAlert
|
||||
message={
|
||||
createCtrl.createError instanceof Error
|
||||
? createCtrl.createError.message
|
||||
: "Revisa los datos e inténtalo de nuevo."
|
||||
}
|
||||
title="No se pudo crear la proforma"
|
||||
/>
|
||||
)}
|
||||
|
||||
{createCtrl.isSeriesLoadError && (
|
||||
<ErrorAlert
|
||||
message={
|
||||
createCtrl.seriesLoadError instanceof Error
|
||||
? createCtrl.seriesLoadError.message
|
||||
: "Inténtalo de nuevo más tarde."
|
||||
}
|
||||
title="No se pudieron cargar las series de facturación"
|
||||
/>
|
||||
)}
|
||||
|
||||
<ProformaCreateForm
|
||||
formId={createCtrl.formId}
|
||||
isPaymentTermsLoading={createCtrl.isPaymentTermsLoading}
|
||||
isProformaSeriesLoading={createCtrl.isProformaSeriesLoading}
|
||||
isSubmitting={createCtrl.isCreating}
|
||||
isTargetInvoiceSeriesLoading={createCtrl.isTargetInvoiceSeriesLoading}
|
||||
onClearCustomer={createCtrl.clearCustomer}
|
||||
onPaymentTermChange={createCtrl.handlePaymentTermChange}
|
||||
onSelectCustomer={selectCustomerCtrl.selectCtrl.openDialog}
|
||||
onSubmit={createCtrl.onSubmit}
|
||||
paymentTermOptions={createCtrl.paymentTermOptions}
|
||||
proformaSeriesOptions={createCtrl.proformaSeriesOptions}
|
||||
selectedCustomer={createCtrl.selectedCustomer}
|
||||
targetInvoiceSeriesOptions={createCtrl.targetInvoiceSeriesOptions}
|
||||
<RdxPageContent spacing="default">
|
||||
{createCtrl.isCreateError && (
|
||||
<ErrorAlert
|
||||
message={
|
||||
createCtrl.createError instanceof Error
|
||||
? createCtrl.createError.message
|
||||
: "Revisa los datos e inténtalo de nuevo."
|
||||
}
|
||||
title="No se pudo crear la proforma"
|
||||
/>
|
||||
</RdxPageStack>
|
||||
</RdxEditorLayout>
|
||||
)}
|
||||
|
||||
{createCtrl.isSeriesLoadError && (
|
||||
<ErrorAlert
|
||||
message={
|
||||
createCtrl.seriesLoadError instanceof Error
|
||||
? createCtrl.seriesLoadError.message
|
||||
: "Inténtalo de nuevo más tarde."
|
||||
}
|
||||
title="No se pudieron cargar las series de facturación"
|
||||
/>
|
||||
)}
|
||||
|
||||
<ProformaCreateForm
|
||||
formId={createCtrl.formId}
|
||||
isPaymentTermsLoading={createCtrl.isPaymentTermsLoading}
|
||||
isProformaSeriesLoading={createCtrl.isProformaSeriesLoading}
|
||||
isSubmitting={createCtrl.isCreating}
|
||||
isTargetInvoiceSeriesLoading={createCtrl.isTargetInvoiceSeriesLoading}
|
||||
onClearCustomer={createCtrl.clearCustomer}
|
||||
onPaymentTermChange={createCtrl.handlePaymentTermChange}
|
||||
onSelectCustomer={selectCustomerCtrl.selectCtrl.openDialog}
|
||||
onSubmit={createCtrl.onSubmit}
|
||||
paymentTermOptions={createCtrl.paymentTermOptions}
|
||||
proformaSeriesOptions={createCtrl.proformaSeriesOptions}
|
||||
selectedCustomer={createCtrl.selectedCustomer}
|
||||
targetInvoiceSeriesOptions={createCtrl.targetInvoiceSeriesOptions}
|
||||
/>
|
||||
</RdxPageContent>
|
||||
</RdxPageContainer>
|
||||
</RdxPageFrame>
|
||||
|
||||
|
||||
@ -2,9 +2,9 @@ import { ErrorAlert } from "@erp/core/components";
|
||||
import {
|
||||
RdxPageActions,
|
||||
RdxPageContainer,
|
||||
RdxPageContent,
|
||||
RdxPageFrame,
|
||||
RdxPageHeader,
|
||||
RdxPageStack,
|
||||
} from "@repo/rdx-ui/components";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
@ -33,7 +33,7 @@ export const ScopedListProformasPageView = ({
|
||||
return (
|
||||
<RdxPageFrame>
|
||||
<RdxPageContainer width="full">
|
||||
<RdxPageStack spacing="default">
|
||||
<RdxPageContent spacing="default">
|
||||
<RdxPageHeader
|
||||
actions={primaryAction ? <RdxPageActions primary={primaryAction} /> : undefined}
|
||||
description={description}
|
||||
@ -45,7 +45,7 @@ export const ScopedListProformasPageView = ({
|
||||
) : null}
|
||||
|
||||
{content}
|
||||
</RdxPageStack>
|
||||
</RdxPageContent>
|
||||
|
||||
{dialogs}
|
||||
</RdxPageContainer>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { CustomerSelectionOption } from "@erp/customers";
|
||||
import { RdxEditorLayout, type SelectFieldItem } from "@repo/rdx-ui/components";
|
||||
import { RdxPageContent, type SelectFieldItem } from "@repo/rdx-ui/components";
|
||||
import { preventEnterKeySubmitForm } from "@repo/rdx-ui/helpers";
|
||||
import type { SubmitEventHandler } from "react";
|
||||
|
||||
@ -53,43 +53,42 @@ export const ProformaUpdateEditorForm = ({
|
||||
className,
|
||||
}: ProformaUpdateEditorProps) => {
|
||||
return (
|
||||
<RdxEditorLayout
|
||||
aside={
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
<ProformaUpdateRecipientEditor
|
||||
disabled={isSubmitting}
|
||||
onChangeCustomerClick={onChangeCustomerClick}
|
||||
onCreateCustomerClick={onCreateCustomerClick}
|
||||
selectedCustomer={selectedCustomer}
|
||||
/>
|
||||
|
||||
<ProformaUpdateTaxEditor taxCtrl={taxCtrl} taxRegimeOptions={taxCtrl.taxRegimeOptions} />
|
||||
|
||||
<ProformaUpdatePaymentEditor
|
||||
className="w-full"
|
||||
disabled={isSubmitting || paymentCtrl.isLoading}
|
||||
onPaymentTermChange={onPaymentTermChange}
|
||||
paymentMethodOptions={paymentCtrl.paymentMethodOptions}
|
||||
paymentTermOptions={paymentCtrl.paymentTermOptions}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
className={className}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<form id={formId} noValidate onKeyDown={preventEnterKeySubmitForm} onSubmit={onSubmit}>
|
||||
<form id={formId} noValidate onKeyDown={preventEnterKeySubmitForm} onSubmit={onSubmit}>
|
||||
<RdxPageContent
|
||||
aside={
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
<ProformaUpdateHeaderEditor disabled={isSubmitting} seriesOptions={seriesOptions} />
|
||||
|
||||
<ProformaUpdateItemsEditor
|
||||
<ProformaUpdateRecipientEditor
|
||||
disabled={isSubmitting}
|
||||
itemsCtrl={itemsCtrl}
|
||||
onChangeCustomerClick={onChangeCustomerClick}
|
||||
onCreateCustomerClick={onCreateCustomerClick}
|
||||
selectedCustomer={selectedCustomer}
|
||||
/>
|
||||
|
||||
<ProformaUpdateTaxEditor
|
||||
taxCtrl={taxCtrl}
|
||||
totalsCtrl={totalsCtrl}
|
||||
taxRegimeOptions={taxCtrl.taxRegimeOptions}
|
||||
/>
|
||||
|
||||
<ProformaUpdatePaymentEditor
|
||||
className="w-full"
|
||||
disabled={isSubmitting || paymentCtrl.isLoading}
|
||||
onPaymentTermChange={onPaymentTermChange}
|
||||
paymentMethodOptions={paymentCtrl.paymentMethodOptions}
|
||||
paymentTermOptions={paymentCtrl.paymentTermOptions}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</RdxEditorLayout>
|
||||
}
|
||||
className={className}
|
||||
>
|
||||
<ProformaUpdateHeaderEditor disabled={isSubmitting} seriesOptions={seriesOptions} />
|
||||
|
||||
<ProformaUpdateItemsEditor
|
||||
disabled={isSubmitting}
|
||||
itemsCtrl={itemsCtrl}
|
||||
taxCtrl={taxCtrl}
|
||||
totalsCtrl={totalsCtrl}
|
||||
/>
|
||||
</RdxPageContent>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@ -67,7 +67,7 @@ test("conecta la alta rápida directa y la alta rápida desde selector con callb
|
||||
{footer}
|
||||
</>
|
||||
),
|
||||
RdxPageStack: ({ children }: { children: ReactNode }) => children,
|
||||
RdxPageContent: ({ children }: { children: ReactNode }) => children,
|
||||
RhfSubmitActionButton: () => null,
|
||||
},
|
||||
});
|
||||
|
||||
@ -7,8 +7,8 @@ import {
|
||||
RdxBadge,
|
||||
RdxEditorActionBar,
|
||||
RdxPageContainer,
|
||||
RdxPageContent,
|
||||
RdxPageFrame,
|
||||
RdxPageStack,
|
||||
RhfSubmitActionButton,
|
||||
} from "@repo/rdx-ui/components";
|
||||
import { showErrorToast } from "@repo/rdx-ui/helpers";
|
||||
@ -54,43 +54,45 @@ export const ProformaUpdatePage = () => {
|
||||
|
||||
if (ctrl.pageError) {
|
||||
return (
|
||||
<RdxPageContainer width="full">
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<PageErrorState
|
||||
description={ctrl.pageError.description}
|
||||
primaryAction={
|
||||
ctrl.pageError.canRetry
|
||||
? {
|
||||
label: ctrl.isRetrying
|
||||
? t("pages.proformas.update.page_error.actions.retrying")
|
||||
: t("pages.proformas.update.page_error.actions.retry"),
|
||||
onClick: ctrl.retryUpdate,
|
||||
isLoading: ctrl.isRetrying,
|
||||
}
|
||||
: ctrl.pageError.variant === "forbidden"
|
||||
<RdxPageFrame>
|
||||
<RdxPageContainer width="full">
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<PageErrorState
|
||||
description={ctrl.pageError.description}
|
||||
primaryAction={
|
||||
ctrl.pageError.canRetry
|
||||
? {
|
||||
label: t("pages.proformas.update.page_error.actions.go_home"),
|
||||
onClick: ctrl.goHome,
|
||||
label: ctrl.isRetrying
|
||||
? t("pages.proformas.update.page_error.actions.retrying")
|
||||
: t("pages.proformas.update.page_error.actions.retry"),
|
||||
onClick: ctrl.retryUpdate,
|
||||
isLoading: ctrl.isRetrying,
|
||||
}
|
||||
: {
|
||||
: ctrl.pageError.variant === "forbidden"
|
||||
? {
|
||||
label: t("pages.proformas.update.page_error.actions.go_home"),
|
||||
onClick: ctrl.goHome,
|
||||
}
|
||||
: {
|
||||
label: t("pages.proformas.update.page_error.actions.back_to_list"),
|
||||
onClick: ctrl.goBackToList,
|
||||
}
|
||||
}
|
||||
reference={ctrl.pageError.reference}
|
||||
secondaryAction={
|
||||
ctrl.pageError.canRetry
|
||||
? {
|
||||
label: t("pages.proformas.update.page_error.actions.back_to_list"),
|
||||
onClick: ctrl.goBackToList,
|
||||
}
|
||||
}
|
||||
reference={ctrl.pageError.reference}
|
||||
secondaryAction={
|
||||
ctrl.pageError.canRetry
|
||||
? {
|
||||
label: t("pages.proformas.update.page_error.actions.back_to_list"),
|
||||
onClick: ctrl.goBackToList,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
title={ctrl.pageError.title}
|
||||
variant={ctrl.pageError.variant}
|
||||
/>
|
||||
</div>
|
||||
</RdxPageContainer>
|
||||
: undefined
|
||||
}
|
||||
title={ctrl.pageError.title}
|
||||
variant={ctrl.pageError.variant}
|
||||
/>
|
||||
</div>
|
||||
</RdxPageContainer>
|
||||
</RdxPageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@ -188,7 +190,7 @@ export const ProformaUpdatePage = () => {
|
||||
<RdxPageContainer width="full">
|
||||
{hasPageAlerts ? (
|
||||
<RdxPageContainer className="pb-0" width="full">
|
||||
<RdxPageStack spacing="compact">
|
||||
<RdxPageContent spacing="compact">
|
||||
{updateCtrl.isUpdateError && (
|
||||
<ErrorAlert
|
||||
message={
|
||||
@ -221,7 +223,7 @@ export const ProformaUpdatePage = () => {
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</RdxPageStack>
|
||||
</RdxPageContent>
|
||||
</RdxPageContainer>
|
||||
) : null}
|
||||
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { useHookForm } from "@erp/core/hooks";
|
||||
import {
|
||||
RdxEditorHeader,
|
||||
RdxEditorLayout,
|
||||
RdxPageContainer,
|
||||
RdxPageContent,
|
||||
RdxPageFrame,
|
||||
} from "@repo/rdx-ui/components";
|
||||
import {
|
||||
@ -88,7 +88,7 @@ export const RecurringProformaCreatePage = () => {
|
||||
}
|
||||
>
|
||||
<RdxPageContainer width="content">
|
||||
<RdxEditorLayout>
|
||||
<RdxPageContent>
|
||||
<form
|
||||
className="space-y-6"
|
||||
id={RECURRING_PROFORMA_CREATE_FORM_ID}
|
||||
@ -139,7 +139,7 @@ export const RecurringProformaCreatePage = () => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</form>
|
||||
</RdxEditorLayout>
|
||||
</RdxPageContent>
|
||||
</RdxPageContainer>
|
||||
</RdxPageFrame>
|
||||
);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { PageErrorState } from "@erp/core/components";
|
||||
import { RdxBadge, RdxPageContainer, RdxPageFrame, RdxPageStack } from "@repo/rdx-ui/components";
|
||||
import { RdxBadge, RdxPageContainer, RdxPageContent, RdxPageFrame } from "@repo/rdx-ui/components";
|
||||
import { Button, Card, CardContent, CardHeader, CardTitle } from "@repo/shadcn-ui/components";
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
@ -130,7 +130,7 @@ export const RecurringProformaDetailPage = () => {
|
||||
}
|
||||
>
|
||||
<RdxPageContainer width="wide">
|
||||
<RdxPageStack spacing="default">
|
||||
<RdxPageContent spacing="default">
|
||||
<RecurringProformaOperationalSummary
|
||||
locale={dateLocale}
|
||||
recurringProforma={recurringProforma}
|
||||
@ -264,7 +264,7 @@ export const RecurringProformaDetailPage = () => {
|
||||
<ArrowLeftIcon aria-hidden className="size-4" />
|
||||
{t("recurring_proformas.detail.back")}
|
||||
</Button>
|
||||
</RdxPageStack>
|
||||
</RdxPageContent>
|
||||
</RdxPageContainer>
|
||||
</RdxPageFrame>
|
||||
);
|
||||
|
||||
@ -125,7 +125,7 @@ mock.module("@repo/rdx-ui/components", {
|
||||
namedExports: {
|
||||
RdxPageContainer: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
RdxPageFrame: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
RdxPageStack: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
RdxPageContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
RdxPageHeader: ({
|
||||
title,
|
||||
description,
|
||||
|
||||
@ -2,9 +2,9 @@ import { ErrorAlert } from "@erp/core/components";
|
||||
import {
|
||||
RdxPageActions,
|
||||
RdxPageContainer,
|
||||
RdxPageContent,
|
||||
RdxPageFrame,
|
||||
RdxPageHeader,
|
||||
RdxPageStack,
|
||||
} from "@repo/rdx-ui/components";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
@ -31,7 +31,7 @@ export const ScopedListRecurringProformasPageView = ({
|
||||
return (
|
||||
<RdxPageFrame>
|
||||
<RdxPageContainer width="full">
|
||||
<RdxPageStack spacing="default">
|
||||
<RdxPageContent spacing="default">
|
||||
<RdxPageHeader
|
||||
actions={primaryAction ? <RdxPageActions primary={primaryAction} /> : undefined}
|
||||
description={description}
|
||||
@ -43,7 +43,7 @@ export const ScopedListRecurringProformasPageView = ({
|
||||
) : null}
|
||||
|
||||
{content}
|
||||
</RdxPageStack>
|
||||
</RdxPageContent>
|
||||
</RdxPageContainer>
|
||||
</RdxPageFrame>
|
||||
);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { ErrorAlert } from "@erp/core/components";
|
||||
import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks";
|
||||
import { RdxEditorLayout, RdxPageContainer, RdxPageFrame } from "@repo/rdx-ui/components";
|
||||
import { RdxPageContainer, RdxPageContent, RdxPageFrame } from "@repo/rdx-ui/components";
|
||||
import { FormProvider } from "react-hook-form";
|
||||
|
||||
import { useTranslation } from "../../../i18n";
|
||||
@ -47,28 +47,22 @@ export const CustomerCreatePage = () => {
|
||||
}
|
||||
>
|
||||
<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="flex min-w-0 flex-col gap-6">
|
||||
{createCtrl.isCreateError ? (
|
||||
<ErrorAlert
|
||||
message={
|
||||
(createCtrl.createError as Error)?.message ?? t("create.errors.message")
|
||||
}
|
||||
title={t("create.errors.title")}
|
||||
/>
|
||||
) : null}
|
||||
<RdxPageContent spacing="default">
|
||||
{createCtrl.isCreateError ? (
|
||||
<ErrorAlert
|
||||
message={(createCtrl.createError as Error)?.message ?? t("create.errors.message")}
|
||||
title={t("create.errors.title")}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<CustomerCreateEditorForm
|
||||
fiscalCtrl={createCtrl.fiscalCtrl}
|
||||
formId={createCtrl.formId}
|
||||
isSubmitting={createCtrl.isCreating}
|
||||
onReset={createCtrl.resetForm}
|
||||
onSubmit={createCtrl.onSubmit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</RdxEditorLayout>
|
||||
<CustomerCreateEditorForm
|
||||
fiscalCtrl={createCtrl.fiscalCtrl}
|
||||
formId={createCtrl.formId}
|
||||
isSubmitting={createCtrl.isCreating}
|
||||
onReset={createCtrl.resetForm}
|
||||
onSubmit={createCtrl.onSubmit}
|
||||
/>
|
||||
</RdxPageContent>
|
||||
</RdxPageContainer>
|
||||
</RdxPageFrame>
|
||||
</UnsavedChangesProvider>
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
CUSTOMER_LIST_SORT_FIELDS,
|
||||
DEFAULT_SORT,
|
||||
isCustomerListSortField,
|
||||
normalizeCustomerListSort,
|
||||
toCustomerListTableSort,
|
||||
} from "./customer-list-sorting";
|
||||
|
||||
test("acepta customer emitido por el grid y conserva ASC/DESC en el criterio API y la URL", () => {
|
||||
for (const direction of ["asc", "desc"] as const) {
|
||||
const sort = normalizeCustomerListSort({ field: "customer", direction });
|
||||
assert.ok(sort);
|
||||
assert.deepEqual(sort, { field: "name", direction });
|
||||
assert.deepEqual(
|
||||
{ orderBy: CUSTOMER_LIST_SORT_FIELDS[sort.field], order: sort.direction },
|
||||
{ orderBy: "name", order: direction }
|
||||
);
|
||||
assert.equal(isCustomerListSortField(sort.field), true);
|
||||
assert.deepEqual(toCustomerListTableSort(sort), { field: "customer", direction });
|
||||
}
|
||||
});
|
||||
|
||||
test("mantiene URLs name y preferencias antiguas NAME con el ID de columna estable", () => {
|
||||
for (const field of ["name", "NAME"]) {
|
||||
const sort = normalizeCustomerListSort({ field, direction: "asc" });
|
||||
assert.ok(sort);
|
||||
assert.deepEqual(sort, { field: "name", direction: "asc" });
|
||||
assert.deepEqual(toCustomerListTableSort(sort), { field: "customer", direction: "asc" });
|
||||
}
|
||||
assert.deepEqual(toCustomerListTableSort(DEFAULT_SORT), { field: "customer", direction: "desc" });
|
||||
});
|
||||
|
||||
test("preserva el mapping de los demás campos semánticos y rechaza campos ajenos", () => {
|
||||
const sort = normalizeCustomerListSort({ field: "tradeName", direction: "desc" });
|
||||
assert.ok(sort);
|
||||
assert.equal(CUSTOMER_LIST_SORT_FIELDS[sort.field], "trade_name");
|
||||
assert.deepEqual(toCustomerListTableSort(sort), sort);
|
||||
for (const field of ["contact", "address", "trade_name", "toString"]) {
|
||||
assert.equal(normalizeCustomerListSort({ field, direction: "asc" }), undefined);
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,45 @@
|
||||
import type { DataTableSort, DataTableSortDirection } from "@repo/rdx-ui/components";
|
||||
|
||||
// Contrato semántico existente de URL y preferencias → columnas de API.
|
||||
export const CUSTOMER_LIST_SORT_FIELDS = {
|
||||
name: "name",
|
||||
tradeName: "trade_name",
|
||||
reference: "reference",
|
||||
tin: "tin",
|
||||
emailPrimary: "email_primary",
|
||||
mobilePrimary: "mobile_primary",
|
||||
} as const;
|
||||
|
||||
export type CustomerListSortField = keyof typeof CUSTOMER_LIST_SORT_FIELDS;
|
||||
|
||||
export const DEFAULT_SORT = {
|
||||
field: "name",
|
||||
direction: "desc",
|
||||
} as const satisfies DataTableSort;
|
||||
|
||||
export const isCustomerListSortField = (value: string | null): value is CustomerListSortField => {
|
||||
return value !== null && Object.hasOwn(CUSTOMER_LIST_SORT_FIELDS, value);
|
||||
};
|
||||
|
||||
export const isSortDirection = (value: string | null): value is DataTableSortDirection => {
|
||||
return value === "asc" || value === "desc";
|
||||
};
|
||||
|
||||
// customer es el ID estable de la columna compuesta; NAME procede de preferencias antiguas.
|
||||
const SORT_FIELD_ALIASES: Readonly<Record<string, string>> = {
|
||||
customer: "name",
|
||||
NAME: "name",
|
||||
};
|
||||
const SORT_COLUMN_IDS: Readonly<Record<string, string>> = { name: "customer" };
|
||||
|
||||
export function normalizeCustomerListSort(sort: DataTableSort) {
|
||||
const field = SORT_FIELD_ALIASES[sort.field] ?? sort.field;
|
||||
if (!(isCustomerListSortField(field) && isSortDirection(sort.direction))) {
|
||||
return undefined;
|
||||
}
|
||||
return { field, direction: sort.direction };
|
||||
}
|
||||
|
||||
export function toCustomerListTableSort(sort: DataTableSort): DataTableSort {
|
||||
return { ...sort, field: SORT_COLUMN_IDS[sort.field] ?? sort.field };
|
||||
}
|
||||
@ -1,10 +1,7 @@
|
||||
import { useDebounce } from "@erp/core/hooks";
|
||||
import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "@repo/rdx-criteria";
|
||||
import {
|
||||
type DataTableSort,
|
||||
type DataTableSortDirection,
|
||||
useDataTablePreferences,
|
||||
} from "@repo/rdx-ui/components";
|
||||
import type { DataTableSort } from "@repo/rdx-ui/components";
|
||||
import { useDataTablePreferences } from "@repo/rdx-ui/components";
|
||||
import { NumberHelper } from "@repo/rdx-utils";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
@ -16,6 +13,15 @@ import {
|
||||
useCustomersListQuery,
|
||||
} from "../../shared";
|
||||
|
||||
import {
|
||||
CUSTOMER_LIST_SORT_FIELDS,
|
||||
DEFAULT_SORT,
|
||||
isCustomerListSortField,
|
||||
isSortDirection,
|
||||
normalizeCustomerListSort,
|
||||
toCustomerListTableSort,
|
||||
} from "./customer-list-sorting";
|
||||
|
||||
type CustomerListStatusFilter = "all" | CustomerStatus;
|
||||
|
||||
// Datos por defecto mientras se carga la consulta o en caso de error.
|
||||
@ -27,33 +33,6 @@ const EMPTY_CUSTOMERS_LIST: CustomerList = {
|
||||
totalItems: 0,
|
||||
};
|
||||
|
||||
// Campos que se permiten ordenar para la lista de proformas (consulta).
|
||||
const CUSTOMER_LIST_SORT_FIELDS = {
|
||||
name: "name",
|
||||
tradeName: "trade_name",
|
||||
reference: "reference",
|
||||
tin: "tin",
|
||||
emailPrimary: "email_primary",
|
||||
mobilePrimary: "mobile_primary",
|
||||
} as const;
|
||||
|
||||
type CustomerListSortField = keyof typeof CUSTOMER_LIST_SORT_FIELDS;
|
||||
|
||||
const DEFAULT_SORT = {
|
||||
field: "NAME",
|
||||
direction: "desc",
|
||||
} satisfies DataTableSort;
|
||||
|
||||
const DEFAULT_API_SORT_FIELD = CUSTOMER_LIST_SORT_FIELDS.name;
|
||||
|
||||
const isCustomerListSortField = (value: string | null): value is CustomerListSortField => {
|
||||
return value !== null && value in CUSTOMER_LIST_SORT_FIELDS;
|
||||
};
|
||||
|
||||
const isSortDirection = (value: string | null): value is DataTableSortDirection => {
|
||||
return value === "asc" || value === "desc";
|
||||
};
|
||||
|
||||
export const useListCustomersController = () => {
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<CustomerListStatusFilter>("all");
|
||||
@ -102,10 +81,12 @@ export const useListCustomersController = () => {
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const sort = urlSort ?? preferencesSorting ?? DEFAULT_SORT;
|
||||
const sort =
|
||||
urlSort ??
|
||||
(preferencesSorting ? normalizeCustomerListSort(preferencesSorting) : undefined) ??
|
||||
DEFAULT_SORT;
|
||||
|
||||
const orderBy =
|
||||
CUSTOMER_LIST_SORT_FIELDS[sort.field as CustomerListSortField] ?? DEFAULT_API_SORT_FIELD;
|
||||
const orderBy = CUSTOMER_LIST_SORT_FIELDS[sort.field];
|
||||
|
||||
const order = sort.direction;
|
||||
|
||||
@ -200,8 +181,9 @@ export const useListCustomersController = () => {
|
||||
);
|
||||
|
||||
const setSortValue = useCallback(
|
||||
(nextSort: DataTableSort) => {
|
||||
if (!isCustomerListSortField(nextSort.field)) {
|
||||
(tableSort: DataTableSort) => {
|
||||
const nextSort = normalizeCustomerListSort(tableSort);
|
||||
if (!nextSort) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -246,7 +228,7 @@ export const useListCustomersController = () => {
|
||||
search,
|
||||
setSearchValue,
|
||||
|
||||
sort,
|
||||
sort: toCustomerListTableSort(sort),
|
||||
setSort: setSortValue,
|
||||
|
||||
statusFilter,
|
||||
|
||||
@ -2,9 +2,9 @@ import { ErrorAlert } from "@erp/core/components";
|
||||
import {
|
||||
RdxPageActions,
|
||||
RdxPageContainer,
|
||||
RdxPageContent,
|
||||
RdxPageFrame,
|
||||
RdxPageHeader,
|
||||
RdxPageStack,
|
||||
} from "@repo/rdx-ui/components";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
@ -33,7 +33,7 @@ export const ListCustomersPageView = ({
|
||||
return (
|
||||
<RdxPageFrame>
|
||||
<RdxPageContainer width="full">
|
||||
<RdxPageStack spacing="default">
|
||||
<RdxPageContent spacing="default">
|
||||
<RdxPageHeader
|
||||
actions={primaryAction ? <RdxPageActions primary={primaryAction} /> : undefined}
|
||||
description={description}
|
||||
@ -45,7 +45,7 @@ export const ListCustomersPageView = ({
|
||||
) : null}
|
||||
|
||||
{content}
|
||||
</RdxPageStack>
|
||||
</RdxPageContent>
|
||||
|
||||
{dialogs}
|
||||
</RdxPageContainer>
|
||||
|
||||
@ -1,11 +1,6 @@
|
||||
import { ErrorAlert, PageErrorState } from "@erp/core/components";
|
||||
import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks";
|
||||
import {
|
||||
RdxEditorLayout,
|
||||
RdxPageContainer,
|
||||
RdxPageFrame,
|
||||
RdxPageStack,
|
||||
} from "@repo/rdx-ui/components";
|
||||
import { RdxPageContainer, RdxPageContent, RdxPageFrame } from "@repo/rdx-ui/components";
|
||||
import { FormProvider } from "react-hook-form";
|
||||
|
||||
import { useTranslation } from "../../../i18n";
|
||||
@ -16,6 +11,7 @@ import { CustomerUpdateEditorForm } from "../editor";
|
||||
|
||||
export const CustomerUpdatePage = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const ctrl = useCustomerUpdatePageController();
|
||||
const { updateCtrl, returnTo } = ctrl;
|
||||
|
||||
@ -78,6 +74,7 @@ export const CustomerUpdatePage = () => {
|
||||
<UnsavedChangesProvider isDirty={updateCtrl.form.formState.isDirty}>
|
||||
<RdxPageFrame
|
||||
contentClassName="bg-muted/20"
|
||||
footer={<h1>hola</h1>}
|
||||
header={
|
||||
<CustomerUpdateHeader
|
||||
formId={updateCtrl.formId}
|
||||
@ -102,30 +99,38 @@ export const CustomerUpdatePage = () => {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<RdxPageContainer width="content">
|
||||
{hasPageAlerts ? (
|
||||
<RdxPageContainer className="pb-0" width="full">
|
||||
<RdxPageStack spacing="compact">
|
||||
{updateCtrl.isUpdateError && (
|
||||
<ErrorAlert
|
||||
message={
|
||||
(updateCtrl.updateError as Error)?.message ??
|
||||
t(
|
||||
"pages.customers.update.error_msg",
|
||||
"Revisa los datos e inténtalo de nuevo."
|
||||
)
|
||||
}
|
||||
title={t(
|
||||
"pages.customers.update.error_title",
|
||||
"No se pudo guardar los cambios"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</RdxPageStack>
|
||||
</RdxPageContainer>
|
||||
) : null}
|
||||
<RdxPageContainer className="bg-white" width="content">
|
||||
<RdxPageContent>
|
||||
{hasPageAlerts ? (
|
||||
<RdxPageContainer className="pb-0" width="full">
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
{updateCtrl.isUpdateError && (
|
||||
<ErrorAlert
|
||||
message={
|
||||
(updateCtrl.updateError as Error)?.message ??
|
||||
t(
|
||||
"pages.customers.update.error_msg",
|
||||
"Revisa los datos e inténtalo de nuevo."
|
||||
)
|
||||
}
|
||||
title={t(
|
||||
"pages.customers.update.error_title",
|
||||
"No se pudo guardar los cambios"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</RdxPageContainer>
|
||||
) : null}
|
||||
|
||||
<CustomerUpdateEditorForm
|
||||
fiscalCtrl={updateCtrl.fiscalCtrl}
|
||||
formId={updateCtrl.formId}
|
||||
isSubmitting={updateCtrl.isUpdating}
|
||||
onReset={updateCtrl.resetForm}
|
||||
onSubmit={updateCtrl.onSubmit}
|
||||
/>
|
||||
|
||||
<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="flex min-w-0 flex-col gap-6">
|
||||
{updateCtrl.isUpdateError ? (
|
||||
@ -143,17 +148,9 @@ export const CustomerUpdatePage = () => {
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<CustomerUpdateEditorForm
|
||||
fiscalCtrl={updateCtrl.fiscalCtrl}
|
||||
formId={updateCtrl.formId}
|
||||
isSubmitting={updateCtrl.isUpdating}
|
||||
onReset={updateCtrl.resetForm}
|
||||
onSubmit={updateCtrl.onSubmit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</RdxEditorLayout>
|
||||
</RdxPageContent>
|
||||
</RdxPageContainer>
|
||||
</RdxPageFrame>
|
||||
</UnsavedChangesProvider>
|
||||
|
||||
@ -88,7 +88,7 @@ Contenido visible actual:
|
||||
|
||||
- información personal: `name`, `email`, `language_code`
|
||||
- modo `app-fullscreen`, sin sidebar principal
|
||||
- contenido centrado mediante `RdxPageContainer` y compuesto con `RdxEditorLayout`
|
||||
- contenido centrado mediante `RdxPageContainer` y compuesto con `RdxPageContent`
|
||||
- seguridad mediante dialog para cambio de contraseña
|
||||
- cierre de sesión reutilizando el flujo de logout existente
|
||||
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
import { PageErrorState } from "@erp/core/components";
|
||||
import { RdxEditorLayout, RdxPageContainer, RdxPageFrame } from "@repo/rdx-ui/components";
|
||||
import { RdxPageContainer, RdxPageContent, 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>
|
||||
<RdxPageContent>
|
||||
<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>
|
||||
</RdxPageContent>
|
||||
</RdxPageContainer>
|
||||
</RdxPageFrame>
|
||||
);
|
||||
@ -41,7 +41,7 @@ export const AccountErrorState = ({
|
||||
}: AccountErrorStateProps) => (
|
||||
<RdxPageFrame contentClassName="bg-muted/20">
|
||||
<RdxPageContainer width="content">
|
||||
<RdxEditorLayout>
|
||||
<RdxPageContent>
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<PageErrorState
|
||||
description={description}
|
||||
@ -59,7 +59,7 @@ export const AccountErrorState = ({
|
||||
variant={variant}
|
||||
/>
|
||||
</div>
|
||||
</RdxEditorLayout>
|
||||
</RdxPageContent>
|
||||
</RdxPageContainer>
|
||||
</RdxPageFrame>
|
||||
);
|
||||
|
||||
@ -4,8 +4,8 @@ import {
|
||||
CancelActionButton,
|
||||
RdxEditorActionBar,
|
||||
RdxEditorHeader,
|
||||
RdxEditorLayout,
|
||||
RdxPageContainer,
|
||||
RdxPageContent,
|
||||
RdxPageFrame,
|
||||
RdxPanel,
|
||||
RhfSubmitActionButton,
|
||||
@ -207,7 +207,7 @@ export const AccountPage = () => {
|
||||
}
|
||||
>
|
||||
<RdxPageContainer width="content">
|
||||
<RdxEditorLayout>
|
||||
<RdxPageContent>
|
||||
<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">
|
||||
<FormProvider {...ctrl.profileForm}>
|
||||
@ -352,7 +352,7 @@ export const AccountPage = () => {
|
||||
</RdxPanel>
|
||||
</div>
|
||||
</div>
|
||||
</RdxEditorLayout>
|
||||
</RdxPageContent>
|
||||
</RdxPageContainer>
|
||||
|
||||
<AccountPasswordDialog
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { ErrorAlert } from "@erp/core/components";
|
||||
import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks";
|
||||
import { RdxEditorLayout, RdxPageContainer, RdxPageFrame } from "@repo/rdx-ui/components";
|
||||
import { RdxPageContainer, RdxPageContent, RdxPageFrame } from "@repo/rdx-ui/components";
|
||||
import { FormProvider } from "react-hook-form";
|
||||
import { createSearchParams, useNavigate, useSearchParams } from "react-router-dom";
|
||||
|
||||
@ -39,7 +39,7 @@ export const SupplierCreatePage = () => {
|
||||
}
|
||||
>
|
||||
<RdxPageContainer width="content">
|
||||
<RdxEditorLayout>
|
||||
<RdxPageContent>
|
||||
<div className="rounded-2xl border border-border/60 bg-background p-4 shadow-xs md:p-6">
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
{controller.mutation.isError ? (
|
||||
@ -55,7 +55,7 @@ export const SupplierCreatePage = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</RdxEditorLayout>
|
||||
</RdxPageContent>
|
||||
</RdxPageContainer>
|
||||
</RdxPageFrame>
|
||||
</UnsavedChangesProvider>
|
||||
|
||||
@ -2,9 +2,9 @@ import { PageErrorState, SimpleSearchInput } from "@erp/core/components";
|
||||
import {
|
||||
RdxPageActions,
|
||||
RdxPageContainer,
|
||||
RdxPageContent,
|
||||
RdxPageFrame,
|
||||
RdxPageHeader,
|
||||
RdxPageStack,
|
||||
} from "@repo/rdx-ui/components";
|
||||
import { Button } from "@repo/shadcn-ui/components";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
@ -58,7 +58,7 @@ export const SuppliersListPage = () => {
|
||||
return (
|
||||
<RdxPageFrame>
|
||||
<RdxPageContainer width="full">
|
||||
<RdxPageStack spacing="default">
|
||||
<RdxPageContent spacing="default">
|
||||
<RdxPageHeader
|
||||
actions={
|
||||
<RdxPageActions
|
||||
@ -96,7 +96,7 @@ export const SuppliersListPage = () => {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</RdxPageStack>
|
||||
</RdxPageContent>
|
||||
</RdxPageContainer>
|
||||
</RdxPageFrame>
|
||||
);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { ErrorAlert, PageErrorState } from "@erp/core/components";
|
||||
import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks";
|
||||
import { RdxEditorLayout, RdxPageContainer, RdxPageFrame } from "@repo/rdx-ui/components";
|
||||
import { RdxPageContainer, RdxPageContent, RdxPageFrame } from "@repo/rdx-ui/components";
|
||||
import { FormProvider } from "react-hook-form";
|
||||
import { createSearchParams, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
|
||||
@ -65,7 +65,7 @@ export const SupplierUpdatePage = () => {
|
||||
}
|
||||
>
|
||||
<RdxPageContainer width="content">
|
||||
<RdxEditorLayout>
|
||||
<RdxPageContent>
|
||||
<div className="rounded-2xl border border-border/60 bg-background p-4 shadow-xs md:p-6">
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
{controller.mutation.isError ? (
|
||||
@ -81,7 +81,7 @@ export const SupplierUpdatePage = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</RdxEditorLayout>
|
||||
</RdxPageContent>
|
||||
</RdxPageContainer>
|
||||
</RdxPageFrame>
|
||||
</UnsavedChangesProvider>
|
||||
|
||||
@ -3,9 +3,9 @@ import { useReturnToNavigation } from "@erp/core/hooks";
|
||||
import {
|
||||
RdxPageActions,
|
||||
RdxPageContainer,
|
||||
RdxPageContent,
|
||||
RdxPageFrame,
|
||||
RdxPageHeader,
|
||||
RdxPageStack,
|
||||
} from "@repo/rdx-ui/components";
|
||||
import { Button, Card, CardContent, CardHeader, CardTitle } from "@repo/shadcn-ui/components";
|
||||
import { FileTextIcon, LanguagesIcon, MailIcon, MapPinIcon, PencilIcon } from "lucide-react";
|
||||
@ -99,7 +99,7 @@ export const SupplierDetailPage = () => {
|
||||
}
|
||||
>
|
||||
<RdxPageContainer width="full">
|
||||
<RdxPageStack spacing="default">
|
||||
<RdxPageContent spacing="default">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@ -186,7 +186,7 @@ export const SupplierDetailPage = () => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</RdxPageStack>
|
||||
</RdxPageContent>
|
||||
</RdxPageContainer>
|
||||
</RdxPageFrame>
|
||||
);
|
||||
|
||||
@ -37,6 +37,7 @@ export function DataTableColumnHeader<TData, TValue>({
|
||||
<Button
|
||||
//className="data-[state=open]:bg-accent -ml-4 h-8 text-xs text-muted-foreground font-semibold text-nowrap cursor-pointer"
|
||||
className="-ml-3 h-8 data-[state=open]:bg-accent cursor-pointer text-foreground"
|
||||
data-testid={`data-table-sort-${column.id}`}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@ -54,11 +55,17 @@ export function DataTableColumnHeader<TData, TValue>({
|
||||
/>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
|
||||
<DropdownMenuItem
|
||||
data-testid={`data-table-sort-${column.id}-asc`}
|
||||
onClick={() => column.toggleSorting(false)}
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
{t("components.datatable.asc")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
|
||||
<DropdownMenuItem
|
||||
data-testid={`data-table-sort-${column.id}-desc`}
|
||||
onClick={() => column.toggleSorting(true)}
|
||||
>
|
||||
<ArrowDownIcon />
|
||||
{t("components.datatable.desc")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
export * from "./rdx-editor-action-bar.tsx";
|
||||
export * from "./rdx-editor-header.tsx";
|
||||
export * from "./rdx-editor-layout.tsx";
|
||||
export * from "./rdx-panel.tsx";
|
||||
|
||||
@ -7,18 +7,6 @@ import { RdxFormGrid } from "../form/rdx-form-grid.tsx";
|
||||
|
||||
import { RdxEditorActionBar } from "./rdx-editor-action-bar.tsx";
|
||||
import { RdxEditorHeader } from "./rdx-editor-header.tsx";
|
||||
import { RdxEditorLayout } from "./rdx-editor-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/);
|
||||
});
|
||||
|
||||
test("RdxEditorHeader renderiza navegación, título, estado, acciones y contenido contextual", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
|
||||
@ -1,31 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@ -3,6 +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-content.tsx";
|
||||
export * from "./rdx-page-frame.tsx";
|
||||
export * from "./rdx-page-header.tsx";
|
||||
export * from "./rdx-page-stack.tsx";
|
||||
|
||||
52
packages/rdx-ui/src/components/layout/rdx-page-content.tsx
Normal file
52
packages/rdx-ui/src/components/layout/rdx-page-content.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
|
||||
export type RdxPageContentSpacing = "none" | "compact" | "default" | "relaxed";
|
||||
|
||||
export interface RdxPageContentProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
|
||||
children: ReactNode;
|
||||
aside?: ReactNode;
|
||||
spacing?: RdxPageContentSpacing;
|
||||
}
|
||||
|
||||
const SPACING_CLASSNAME: Record<RdxPageContentSpacing, string> = {
|
||||
none: "gap-0",
|
||||
compact: "gap-4",
|
||||
default: "gap-6",
|
||||
relaxed: "gap-8",
|
||||
};
|
||||
|
||||
/**
|
||||
* Compone el contenido interior de una página.
|
||||
*
|
||||
* Apila verticalmente el contenido principal y, cuando existe,
|
||||
* distribuye un aside responsive junto al main.
|
||||
*/
|
||||
export const RdxPageContent = ({
|
||||
children,
|
||||
aside,
|
||||
spacing = "default",
|
||||
className,
|
||||
...props
|
||||
}: RdxPageContentProps) => {
|
||||
if (!aside) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex min-w-0 flex-col", SPACING_CLASSNAME[spacing], 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={cn("flex min-w-0 flex-col", SPACING_CLASSNAME[spacing])}>{children}</div>
|
||||
<aside className="min-w-0">{aside}</aside>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -5,9 +5,9 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { RdxPageActions } from "./rdx-page-actions.tsx";
|
||||
import { RdxPageContainer } from "./rdx-page-container.tsx";
|
||||
import { RdxPageContent } from "./rdx-page-content.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(
|
||||
@ -57,11 +57,11 @@ test("RdxPageContainer permite eliminar el padding exterior", () => {
|
||||
assert.doesNotMatch(html, /py-4/);
|
||||
});
|
||||
|
||||
test("RdxPageStack applies default spacing and className", () => {
|
||||
test("RdxPageContent applies default spacing and className", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<RdxPageStack className="custom-stack">
|
||||
<RdxPageContent className="custom-stack">
|
||||
<div>One</div>
|
||||
</RdxPageStack>
|
||||
</RdxPageContent>
|
||||
);
|
||||
|
||||
assert.match(html, /flex/);
|
||||
@ -69,16 +69,38 @@ test("RdxPageStack applies default spacing and className", () => {
|
||||
assert.match(html, /custom-stack/);
|
||||
});
|
||||
|
||||
test("RdxPageStack applies spacing variants", () => {
|
||||
const noneHtml = renderToStaticMarkup(<RdxPageStack spacing="none">None</RdxPageStack>);
|
||||
const compactHtml = renderToStaticMarkup(<RdxPageStack spacing="compact">Compact</RdxPageStack>);
|
||||
const relaxedHtml = renderToStaticMarkup(<RdxPageStack spacing="relaxed">Relaxed</RdxPageStack>);
|
||||
test("RdxPageContent applies spacing variants", () => {
|
||||
const noneHtml = renderToStaticMarkup(<RdxPageContent spacing="none">None</RdxPageContent>);
|
||||
const compactHtml = renderToStaticMarkup(
|
||||
<RdxPageContent spacing="compact">Compact</RdxPageContent>
|
||||
);
|
||||
const relaxedHtml = renderToStaticMarkup(
|
||||
<RdxPageContent spacing="relaxed">Relaxed</RdxPageContent>
|
||||
);
|
||||
|
||||
assert.match(noneHtml, /gap-0/);
|
||||
assert.match(compactHtml, /gap-4/);
|
||||
assert.match(relaxedHtml, /gap-8/);
|
||||
});
|
||||
|
||||
test("RdxPageContent renders an optional responsive aside and forwards HTML attributes", () => {
|
||||
const withAsideHtml = renderToStaticMarkup(
|
||||
<RdxPageContent aside={<span>Aside</span>} data-layout="content">
|
||||
Main
|
||||
</RdxPageContent>
|
||||
);
|
||||
const withoutAsideHtml = renderToStaticMarkup(<RdxPageContent>Main</RdxPageContent>);
|
||||
const falseAsideHtml = renderToStaticMarkup(<RdxPageContent aside={false}>Main</RdxPageContent>);
|
||||
|
||||
assert.match(withAsideHtml, /data-layout="content"/);
|
||||
assert.match(withAsideHtml, /grid/);
|
||||
assert.match(withAsideHtml, /xl:grid-cols-\[minmax\(0,1fr\)_22rem\]/);
|
||||
assert.match(withAsideHtml, /<aside/);
|
||||
assert.doesNotMatch(withoutAsideHtml, /<aside/);
|
||||
assert.doesNotMatch(withoutAsideHtml, /grid-cols/);
|
||||
assert.doesNotMatch(falseAsideHtml, /<aside/);
|
||||
});
|
||||
|
||||
test("RdxPageHeader renders semantic title, description and optional regions", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<RdxPageHeader
|
||||
|
||||
@ -22,14 +22,22 @@ export const RdxPageFrame = ({
|
||||
}: RdxPageFrameProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden", className)}
|
||||
className={cn(
|
||||
"rdx-page-frame 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)}>
|
||||
{header ? <div className="shrink-0 rdx-page-frame-header">{header}</div> : null}
|
||||
<div
|
||||
className={cn(
|
||||
"rdx-page-frame-wrapper min-h-0 min-w-0 flex-1 overflow-y-auto",
|
||||
contentClassName
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{footer ? <div className="shrink-0">{footer}</div> : null}
|
||||
{footer ? <div className="shrink-0 rdx-page-frame-footer">{footer}</div> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
|
||||
export type RdxPageStackSpacing = "none" | "compact" | "default" | "relaxed";
|
||||
|
||||
export interface RdxPageStackProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
|
||||
children: ReactNode;
|
||||
spacing?: RdxPageStackSpacing;
|
||||
}
|
||||
|
||||
const SPACING_CLASSNAME: Record<RdxPageStackSpacing, string> = {
|
||||
none: "gap-0",
|
||||
compact: "gap-4",
|
||||
default: "gap-6",
|
||||
relaxed: "gap-8",
|
||||
};
|
||||
|
||||
/**
|
||||
* Apila contenido verticalmente aplicando un espaciado consistente.
|
||||
* No controla viewport, scroll ni estructura de página.
|
||||
*/
|
||||
|
||||
export const RdxPageStack = ({
|
||||
children,
|
||||
className,
|
||||
spacing = "default",
|
||||
...props
|
||||
}: RdxPageStackProps) => {
|
||||
return (
|
||||
<div className={cn("flex min-w-0 flex-col", SPACING_CLASSNAME[spacing], className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
29
tests/e2e/customers/customer-list.spec.ts
Normal file
29
tests/e2e/customers/customer-list.spec.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { loginViaUi } from "../support/login-via-ui";
|
||||
|
||||
import { createCustomerViaUi } from "./support/create-customer-via-ui";
|
||||
import { searchCustomersViaUi } from "./support/search-customers-via-ui";
|
||||
|
||||
test("CUSTOMERS-LIST-001: muestra un cliente persistido y permite acceder a su edición", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await loginViaUi(page);
|
||||
const token = `e2elist${randomUUID().replaceAll("-", "")}`;
|
||||
const customer = await createCustomerViaUi(page, testInfo, `E2E LIST ${token}`);
|
||||
|
||||
await page.goto("/customers");
|
||||
// Aísla el cliente propio sin depender de su posición entre datos de otras ejecuciones.
|
||||
await searchCustomersViaUi(page, token);
|
||||
const editAction = page.getByTestId(`customer-edit-${customer.id}`);
|
||||
const row = page.getByRole("row").filter({ has: editAction });
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row).toContainText(customer.name);
|
||||
await expect(row).toContainText(customer.email);
|
||||
await editAction.click();
|
||||
|
||||
await expect(page).toHaveURL((url) => url.pathname === `/customers/${customer.id}/edit`);
|
||||
await expect(page.locator('input[name="name"]')).toHaveValue(customer.name);
|
||||
});
|
||||
27
tests/e2e/customers/customer-search.spec.ts
Normal file
27
tests/e2e/customers/customer-search.spec.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { loginViaUi } from "../support/login-via-ui";
|
||||
|
||||
import { createCustomerViaUi } from "./support/create-customer-via-ui";
|
||||
import { searchCustomersViaUi } from "./support/search-customers-via-ui";
|
||||
|
||||
test("CUSTOMERS-SEARCH-001: buscar por nombre incluye el cliente correcto y excluye el otro", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await loginViaUi(page);
|
||||
const group = `e2esearch${randomUUID().replaceAll("-", "")}`;
|
||||
const alpha = await createCustomerViaUi(page, testInfo, `E2E Alpha ${group}`);
|
||||
const beta = await createCustomerViaUi(page, testInfo, `E2E Beta ${group}`);
|
||||
await page.goto("/customers");
|
||||
await searchCustomersViaUi(page, group);
|
||||
await expect(page.getByTestId(`customer-edit-${alpha.id}`)).toBeVisible();
|
||||
await expect(page.getByTestId(`customer-edit-${beta.id}`)).toBeVisible();
|
||||
|
||||
await searchCustomersViaUi(page, `Alpha ${group}`);
|
||||
|
||||
await expect(page.getByTestId(`customer-edit-${alpha.id}`)).toBeVisible();
|
||||
await expect(page.getByTestId(`customer-edit-${beta.id}`)).toHaveCount(0);
|
||||
await expect(page.locator('[data-testid^="customer-edit-"]')).toHaveCount(1);
|
||||
});
|
||||
43
tests/e2e/customers/customer-sort.spec.ts
Normal file
43
tests/e2e/customers/customer-sort.spec.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { loginViaUi } from "../support/login-via-ui";
|
||||
|
||||
import { createCustomerViaUi } from "./support/create-customer-via-ui";
|
||||
import { searchCustomersViaUi } from "./support/search-customers-via-ui";
|
||||
|
||||
test("CUSTOMERS-SORT-001: ordenar por nombre desde UI devuelve ASC y DESC desde el servidor", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await loginViaUi(page);
|
||||
const group = `e2esort${randomUUID().replaceAll("-", "")}`;
|
||||
const middle = await createCustomerViaUi(page, testInfo, `E2E SORT MMM ${group}`);
|
||||
const last = await createCustomerViaUi(page, testInfo, `E2E SORT ZZZ ${group}`);
|
||||
const first = await createCustomerViaUi(page, testInfo, `E2E SORT AAA ${group}`);
|
||||
await page.goto("/customers");
|
||||
await searchCustomersViaUi(page, group);
|
||||
const rows = page.locator('[data-testid^="customer-edit-"]');
|
||||
await expect(rows).toHaveCount(3);
|
||||
|
||||
for (const [direction, customers] of [
|
||||
["asc", [first, middle, last]],
|
||||
["desc", [last, middle, first]],
|
||||
] as const) {
|
||||
await page.getByTestId("data-table-sort-customer").click();
|
||||
await page.getByTestId(`data-table-sort-customer-${direction}`).click();
|
||||
await expect(page).toHaveURL(
|
||||
(url) =>
|
||||
url.searchParams.get("sortField") === "name" &&
|
||||
url.searchParams.get("sortDirection") === direction
|
||||
);
|
||||
// Orden de filas visibles, con paginación y ordenación manuales en la tabla real.
|
||||
await expect
|
||||
.poll(() =>
|
||||
rows.evaluateAll((elements) =>
|
||||
elements.map((element) => element.getAttribute("data-testid"))
|
||||
)
|
||||
)
|
||||
.toEqual(customers.map((customer) => `customer-edit-${customer.id}`));
|
||||
}
|
||||
});
|
||||
@ -3,9 +3,13 @@ import { randomUUID } from "node:crypto";
|
||||
import type { Page, TestInfo } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
export async function createCustomerViaUi(page: Page, testInfo: TestInfo) {
|
||||
export async function createCustomerViaUi(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
namePrefix = "zzzz E2E customer"
|
||||
) {
|
||||
const suffix = `${Date.now()}-${testInfo.workerIndex}-${testInfo.retry}-${randomUUID().slice(0, 8)}`;
|
||||
const name = `zzzz E2E customer ${suffix}`;
|
||||
const name = `${namePrefix} ${suffix}`;
|
||||
const email = `e2e-${suffix}@example.invalid`;
|
||||
|
||||
await page.goto("/customers/create");
|
||||
|
||||
16
tests/e2e/customers/support/search-customers-via-ui.ts
Normal file
16
tests/e2e/customers/support/search-customers-via-ui.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
export async function searchCustomersViaUi(page: Page, search: string) {
|
||||
const responsePromise = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return (
|
||||
response.request().method() === "GET" &&
|
||||
/\/customers\/?$/.test(url.pathname) &&
|
||||
url.searchParams.get("q") === search
|
||||
);
|
||||
});
|
||||
await page.locator('input[inputmode="search"]').fill(search);
|
||||
const response = await responsePromise;
|
||||
expect(response.status()).toBe(200);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user