Compare commits

...

4 Commits

17 changed files with 263 additions and 141 deletions

View File

@ -8,7 +8,7 @@ interface AppFullscreenLayoutFrameProps {
} }
export const AppFullscreenLayoutFrame = ({ children }: AppFullscreenLayoutFrameProps) => { export const AppFullscreenLayoutFrame = ({ children }: AppFullscreenLayoutFrameProps) => {
return <AppMain className="h-dvh w-dvw">{children}</AppMain>; return <AppMain className="h-dvh w-dvw rdx-app-fullscreem-layout">{children}</AppMain>;
}; };
/** /**

View File

@ -21,7 +21,7 @@ export const AppSidebarLayoutFrame = ({ children }: AppSidebarLayoutFrameProps)
} }
> >
<AppSidebar /> <AppSidebar />
<SidebarInset className="flex h-dvh min-w-0 flex-col overflow-hidden bg-background"> <SidebarInset className="flex h-dvh min-w-0 flex-col overflow-hidden bg-background rdx-app-sidebar-layout">
<AppTopbar /> <AppTopbar />
<AppMain>{children}</AppMain> <AppMain>{children}</AppMain>
</SidebarInset> </SidebarInset>

View File

@ -68,9 +68,22 @@ AppMain
├── content ├── content
│ └── RdxPageContainer │ └── RdxPageContainer
│ └── RdxPageContent │ └── RdxPageContent
└── footer opcional └── footer opcional
``` ```
```text
RdxPageFrame
→ estructura y scroll
RdxPageContainer
→ ancho y padding
RdxPageContent
→ composición de los hijos principales
```
Regla: Las Pages no deben reconstruir estas responsabilidades con wrappers HTML locales.
## 6. Identity y sesión ## 6. Identity y sesión
El runtime actual se compone en `apps/web/src/app.tsx`: El runtime actual se compone en `apps/web/src/app.tsx`:

View File

@ -36,18 +36,20 @@ El controller de página concentra URL, filtros, consulta, estados y navegación
```tsx ```tsx
<RdxPageFrame <RdxPageFrame
header={<RdxEditorHeader title={title} onBack={onBack} />} header={<RdxEditorHeader />}
footer={<RdxEditorActionBar secondary={cancel} primary={submit} />} footer={<RdxEditorActionBar />}
> >
<RdxPageContainer width="content"> <RdxPageContainer width="content">
<RdxPageContent aside={aside}> <RdxPageContent>
{notice} {pageError ? <ErrorAlert /> : null}
<RdxPanel title={sectionTitle}>{fields}</RdxPanel> <Editor />
</RdxPageContent> </RdxPageContent>
</RdxPageContainer> </RdxPageContainer>
</RdxPageFrame> </RdxPageFrame>
``` ```
Los errores inline no definen su propio spacing estructural. Participan del spacing estándar de RdxPageContent igual que el resto de hijos principales.
Footer y aside son opcionales. `content` es adecuado para formularios focalizados; `full` para editores documentales amplios. Create y Update deben compartir geometría cuando solo cambian defaults, submit o navegación. Footer y aside son opcionales. `content` es adecuado para formularios focalizados; `full` para editores documentales amplios. Create y Update deben compartir geometría cuando solo cambian defaults, submit o navegación.
Loading y error bloqueante usan también `RdxPageFrame` y `RdxPageContainer`; no reconstruyen el viewport. Loading y error bloqueante usan también `RdxPageFrame` y `RdxPageContainer`; no reconstruyen el viewport.

View File

@ -144,6 +144,21 @@ Excepción: un detalle documental puede configurar el contenido del frame con `o
- Tablas y editores de líneas conservan su layout funcional. - Tablas y editores de líneas conservan su layout funcional.
- Evitar card dentro de card y sombras acumuladas. - Evitar card dentro de card y sombras acumuladas.
### Composición del contenido principal
`RdxPageContent` es el compositor estructural de los hijos principales de una página.
Los avisos, errores inline, secciones y contenido funcional deben ser hijos directos cuando pertenezcan al mismo nivel visual.
`RdxPanel` no es un wrapper genérico. Se usa únicamente cuando existe una sección con identidad visual o semántica propia.
```tsx
<RdxPageContent>
{error ? <ErrorAlert /> : null}
<Editor />
</RdxPageContent>
```
No deben introducirse wrappers adicionales únicamente para controlar gap, ancho, padding o centrado.
## 8. Responsive y accesibilidad ## 8. Responsive y accesibilidad
- Las columnas se apilan antes de producir overflow horizontal. - Las columnas se apilan antes de producir overflow horizontal.

View File

@ -12,7 +12,10 @@ export const ProformaDetailHeaderMeta = ({
const items = useMemo(() => [customerName, documentReference], [customerName, documentReference]); const items = useMemo(() => [customerName, documentReference], [customerName, documentReference]);
return ( return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 text-sm font-medium text-muted-foreground"> <div
className="flex flex-wrap items-center gap-x-3 gap-y-2 text-sm font-medium text-muted-foreground"
id="proforma-detail-identity"
>
{items.map((item, index) => ( {items.map((item, index) => (
<div className="flex items-center gap-3" key={`${item}-${index}`}> <div className="flex items-center gap-3" key={`${item}-${index}`}>
{index > 0 ? <span className="hidden text-border sm:inline">|</span> : null} {index > 0 ? <span className="hidden text-border sm:inline">|</span> : null}

View File

@ -246,6 +246,7 @@ export function ProformaRowActions({
return ( return (
<RdxRowActions <RdxRowActions
data-testid={`proforma-row-${proforma.id}`}
overflowActions={menuActions} overflowActions={menuActions}
overflowLabel={t("common.more_actions")} overflowLabel={t("common.more_actions")}
primaryActions={visibleActions} primaryActions={visibleActions}

View File

@ -72,7 +72,12 @@ export const SelectedRecipientSummary = ({
<div className="min-w-0 space-y-3"> <div className="min-w-0 space-y-3">
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-1">
<div className="flex min-w-0 flex-wrap items-center gap-2"> <div className="flex min-w-0 flex-wrap items-center gap-2">
<p className="min-w-0 wrap-break-word text-base font-semibold">{recipient.name}</p> <p
className="min-w-0 wrap-break-word text-base font-semibold"
id="proforma-recipient-name"
>
{recipient.name}
</p>
{recipient.status ? ( {recipient.status ? (
<RdxBadge <RdxBadge

View File

@ -49,14 +49,22 @@ export const ProformaUpdatePage = () => {
}; };
if (ctrl.isInitialLoading) { if (ctrl.isInitialLoading) {
return <ProformaUpdateSkeleton />; return (
<RdxPageFrame>
<RdxPageContainer width="content">
<RdxPageContent>
<ProformaUpdateSkeleton />
</RdxPageContent>
</RdxPageContainer>
</RdxPageFrame>
);
} }
if (ctrl.pageError) { if (ctrl.pageError) {
return ( return (
<RdxPageFrame> <RdxPageFrame>
<RdxPageContainer width="full"> <RdxPageContainer width="content">
<div className="mx-auto w-full max-w-3xl"> <RdxPageContent>
<PageErrorState <PageErrorState
description={ctrl.pageError.description} description={ctrl.pageError.description}
primaryAction={ primaryAction={
@ -90,13 +98,12 @@ export const ProformaUpdatePage = () => {
title={ctrl.pageError.title} title={ctrl.pageError.title}
variant={ctrl.pageError.variant} variant={ctrl.pageError.variant}
/> />
</div> </RdxPageContent>
</RdxPageContainer> </RdxPageContainer>
</RdxPageFrame> </RdxPageFrame>
); );
} }
const hasPageAlerts = updateCtrl.isUpdateError || updateCtrl.isSeriesLoadError;
const actionBar = ( const actionBar = (
<RdxEditorActionBar <RdxEditorActionBar
leading={ leading={
@ -120,6 +127,7 @@ export const ProformaUpdatePage = () => {
primary={ primary={
<RhfSubmitActionButton <RhfSubmitActionButton
busyLabel="Guardando..." busyLabel="Guardando..."
data-testid="proforma-update-submit"
disabled={updateCtrl.isUpdating} disabled={updateCtrl.isUpdating}
formId={updateCtrl.formId} formId={updateCtrl.formId}
isBusy={updateCtrl.form.formState.isSubmitting} isBusy={updateCtrl.form.formState.isSubmitting}
@ -188,61 +196,50 @@ export const ProformaUpdatePage = () => {
} }
> >
<RdxPageContainer width="full"> <RdxPageContainer width="full">
{hasPageAlerts ? ( <RdxPageContent>
<RdxPageContainer className="pb-0" width="full"> {updateCtrl.isUpdateError ? (
<RdxPageContent spacing="compact"> <ErrorAlert
{updateCtrl.isUpdateError && ( message={
<ErrorAlert (updateCtrl.updateError as Error)?.message ??
message={ t("pages.proformas.update.error_msg", "Revisa los datos e inténtalo de nuevo.")
(updateCtrl.updateError as Error)?.message ?? }
t( title={t("pages.proformas.update.error_title", "No se pudo guardar los cambios")}
"pages.proformas.update.error_msg", />
"Revisa los datos e inténtalo de nuevo." ) : null}
{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( title={t(
"pages.proformas.update.error_title", "pages.proformas.update.series_load_error.title",
"No se pudo guardar los cambios" "No se pudieron cargar las series de facturación"
)}
/>
)} )}
/>
) : null}
{updateCtrl.isSeriesLoadError && ( <ProformaUpdateEditorForm
<ErrorAlert formId={updateCtrl.formId}
message={ isSubmitting={updateCtrl.isUpdating}
updateCtrl.seriesLoadError instanceof Error itemsCtrl={updateCtrl.itemsCtrl}
? updateCtrl.seriesLoadError.message onChangeCustomerClick={selectCustomerCtrl.selectCtrl.openDialog}
: t( onCreateCustomerClick={selectCustomerCtrl.openQuickCreateDialog}
"pages.proformas.update.series_load_error.message", onPaymentTermChange={updateCtrl.handlePaymentTermChange}
"Inténtalo de nuevo más tarde." onReset={updateCtrl.resetForm}
) onSubmit={updateCtrl.onSubmit}
} paymentCtrl={updateCtrl.paymentCtrl}
title={t( selectedCustomer={updateCtrl.selectedCustomer}
"pages.proformas.update.series_load_error.title", seriesOptions={updateCtrl.seriesOptions}
"No se pudieron cargar las series de facturación" taxCtrl={updateCtrl.taxCtrl}
)} totalsCtrl={updateCtrl.totalsCtrl}
/> />
)} </RdxPageContent>
</RdxPageContent>
</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> </RdxPageContainer>
</RdxPageFrame> </RdxPageFrame>

View File

@ -88,7 +88,11 @@ export const SelectCustomerDialog = ({
<Button onClick={ctrl.closeDialog} variant="outline"> <Button onClick={ctrl.closeDialog} variant="outline">
Cancelar Cancelar
</Button> </Button>
<Button disabled={ctrl.isLoading || !selectedCustomer} onClick={handleConfirm}> <Button
disabled={ctrl.isLoading || !selectedCustomer}
id="customer-selection-confirm"
onClick={handleConfirm}
>
{selectedCustomer ? "Confirmar selección" : "Seleccionar cliente"} {selectedCustomer ? "Confirmar selección" : "Seleccionar cliente"}
</Button> </Button>
</div> </div>

View File

@ -50,6 +50,7 @@ export const SelectCustomerSelectionList = ({
"cursor-pointer", "cursor-pointer",
customer.id === selectedCustomer?.id && "border-primary bg-accent" customer.id === selectedCustomer?.id && "border-primary bg-accent"
)} )}
data-testid={`customer-selection-${customer.id}`}
key={customer.id} key={customer.id}
onClick={() => onSelect(customer)} onClick={() => onSelect(customer)}
role="listitem" role="listitem"

View File

@ -29,52 +29,51 @@ export const CustomerUpdatePage = () => {
return ( return (
<RdxPageFrame> <RdxPageFrame>
<RdxPageContainer width="content"> <RdxPageContainer width="content">
<PageErrorState <RdxPageContent>
description={ctrl.pageError.description} <PageErrorState
primaryAction={ description={ctrl.pageError.description}
ctrl.pageError.canRetry primaryAction={
? { ctrl.pageError.canRetry
label: ctrl.isRetrying
? t("update.page_error.actions.retrying")
: t("update.page_error.actions.retry"),
onClick: ctrl.retryUpdate,
isLoading: ctrl.isRetrying,
}
: ctrl.pageError.variant === "forbidden"
? { ? {
label: t("update.page_error.actions.go_home"), label: ctrl.isRetrying
onClick: ctrl.goHome, ? t("update.page_error.actions.retrying")
: t("update.page_error.actions.retry"),
onClick: ctrl.retryUpdate,
isLoading: ctrl.isRetrying,
} }
: { : ctrl.pageError.variant === "forbidden"
? {
label: t("update.page_error.actions.go_home"),
onClick: ctrl.goHome,
}
: {
label: t("update.page_error.actions.back_to_list"),
onClick: ctrl.goBackToList,
}
}
reference={ctrl.pageError.reference}
secondaryAction={
ctrl.pageError.canRetry
? {
label: t("update.page_error.actions.back_to_list"), label: t("update.page_error.actions.back_to_list"),
onClick: ctrl.goBackToList, onClick: ctrl.goBackToList,
} }
} : undefined
reference={ctrl.pageError.reference} }
secondaryAction={ title={ctrl.pageError.title}
ctrl.pageError.canRetry variant={ctrl.pageError.variant}
? { />
label: t("update.page_error.actions.back_to_list"), </RdxPageContent>
onClick: ctrl.goBackToList,
}
: undefined
}
title={ctrl.pageError.title}
variant={ctrl.pageError.variant}
/>
</RdxPageContainer> </RdxPageContainer>
</RdxPageFrame> </RdxPageFrame>
); );
} }
const hasPageAlerts = updateCtrl.isUpdateError;
return ( return (
<FormProvider {...updateCtrl.form}> <FormProvider {...updateCtrl.form}>
<UnsavedChangesProvider isDirty={updateCtrl.form.formState.isDirty}> <UnsavedChangesProvider isDirty={updateCtrl.form.formState.isDirty}>
<RdxPageFrame <RdxPageFrame
contentClassName="bg-muted/20" contentClassName="bg-red-500"
footer={<h1>hola</h1>}
header={ header={
<CustomerUpdateHeader <CustomerUpdateHeader
formId={updateCtrl.formId} formId={updateCtrl.formId}
@ -99,28 +98,16 @@ export const CustomerUpdatePage = () => {
/> />
} }
> >
<RdxPageContainer className="bg-white" width="content"> <RdxPageContainer width="content">
<RdxPageContent> <RdxPageContent>
{hasPageAlerts ? ( {updateCtrl.isUpdateError ? (
<RdxPageContainer className="pb-0" width="full"> <ErrorAlert
<div className="flex min-w-0 flex-col gap-4"> message={
{updateCtrl.isUpdateError && ( (updateCtrl.updateError as Error)?.message ??
<ErrorAlert t("pages.customers.update.error_msg", "Revisa los datos e inténtalo de nuevo.")
message={ }
(updateCtrl.updateError as Error)?.message ?? title={t("pages.customers.update.error_title", "No se pudo guardar los cambios")}
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} ) : null}
<CustomerUpdateEditorForm <CustomerUpdateEditorForm
@ -130,26 +117,6 @@ export const CustomerUpdatePage = () => {
onReset={updateCtrl.resetForm} onReset={updateCtrl.resetForm}
onSubmit={updateCtrl.onSubmit} onSubmit={updateCtrl.onSubmit}
/> />
<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 ? (
<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"
)}
/>
) : null}
</div>
</div>
</RdxPageContent> </RdxPageContent>
</RdxPageContainer> </RdxPageContainer>
</RdxPageFrame> </RdxPageFrame>

View File

@ -0,0 +1,16 @@
import { expect, test } from "@playwright/test";
import { loginViaUi } from "../../support/login-via-ui";
import { createProformaViaUi } from "./support/create-proforma-via-ui";
test("CUSTOMER-INVOICES-PROFORMA-CREATE-001: crea un borrador mínimo sin líneas y comprueba persistencia", async ({
page,
}, testInfo) => {
await loginViaUi(page);
const proforma = await createProformaViaUi(page, testInfo);
await page.reload();
await expect(page).toHaveURL((url) => url.pathname === `/proformas/${proforma.id}/edit`);
await expect(page.locator('input[name="proformaReference"]')).toHaveValue(proforma.reference);
await expect(page.locator("#proforma-recipient-name")).toHaveText(proforma.customer.name);
});

View File

@ -0,0 +1,21 @@
import { expect, test } from "@playwright/test";
import { loginViaUi } from "../../support/login-via-ui";
import { createProformaViaUi } from "./support/create-proforma-via-ui";
test("CUSTOMER-INVOICES-PROFORMA-LIST-001: muestra la proforma persistida propia del escenario", async ({
page,
}, testInfo) => {
await loginViaUi(page);
const proforma = await createProformaViaUi(page, testInfo);
await page.goto("/proformas");
// La referencia asignada por el servidor permite aislar el registro sin depender de su página.
await page.locator('input[inputmode="search"]').fill(proforma.reference);
const row = page
.getByRole("row")
.filter({ has: page.getByTestId(`proforma-row-${proforma.id}`) });
await expect(row).toBeVisible();
await expect(row).toContainText(proforma.reference);
await expect(row).toContainText(proforma.customer.name);
});

View File

@ -0,0 +1,19 @@
import { expect, test } from "@playwright/test";
import { loginViaUi } from "../../support/login-via-ui";
import { createProformaViaUi } from "./support/create-proforma-via-ui";
test("CUSTOMER-INVOICES-PROFORMA-VIEW-001: muestra referencia y cliente persistidos al abrir y recargar detalle", async ({
page,
}, testInfo) => {
await loginViaUi(page);
const proforma = await createProformaViaUi(page, testInfo);
await page.goto(`/proformas/${proforma.id}`);
const identity = page.locator("#proforma-detail-identity");
await expect(identity).toContainText(proforma.reference);
await expect(identity).toContainText(proforma.customer.name);
await page.reload();
await expect(identity).toContainText(proforma.reference);
await expect(identity).toContainText(proforma.customer.name);
});

View File

@ -0,0 +1,29 @@
import { randomUUID } from "node:crypto";
import type { Page, TestInfo } from "@playwright/test";
import { expect } from "@playwright/test";
import { createCustomerViaUi } from "../../../customers/support/create-customer-via-ui";
export async function createProformaViaUi(page: Page, testInfo: TestInfo) {
const token = `e2eproforma${randomUUID().replaceAll("-", "")}`;
const customer = await createCustomerViaUi(page, testInfo, `E2E Proforma ${token}`);
await page.goto("/proformas/create");
await page.locator("#proforma-create-customer").click();
await page.getByRole("dialog").locator('input[inputmode="search"]').fill(token);
await page.getByTestId(`customer-selection-${customer.id}`).click();
await page.locator("#customer-selection-confirm").click();
await expect(page.locator("#proforma-create-customer")).toContainText(customer.name);
await page.getByTestId("submit-button").click();
await expect(page).toHaveURL(/\/proformas\/[^/]+\/edit\?returnTo=/);
const referenceInput = page.locator('input[name="proformaReference"]');
await expect(referenceInput).toHaveValue(/\S+/);
await expect(page.locator("#proforma-recipient-name")).toHaveText(customer.name);
const reference = await referenceInput.inputValue();
const id = new URL(page.url()).pathname.split("/")[2];
if (!id) {
throw new Error("Missing proforma ID in edit URL");
}
return { id, reference, customer, token };
}

View File

@ -0,0 +1,29 @@
import { expect, test } from "@playwright/test";
import { loginViaUi } from "../../support/login-via-ui";
import { createProformaViaUi } from "./support/create-proforma-via-ui";
test("CUSTOMER-INVOICES-PROFORMA-UPDATE-001: modifica referencia y descripción del borrador y comprueba persistencia", async ({
page,
}, testInfo) => {
await loginViaUi(page);
const proforma = await createProformaViaUi(page, testInfo);
const reference = `E2E ${proforma.token}`;
const description = `E2E updated draft ${proforma.token}`;
await page.locator('input[name="reference"]').fill(reference);
await page.locator('input[name="description"]').fill(description);
const responsePromise = page.waitForResponse(
(response) =>
response.request().method() === "PUT" &&
new URL(response.url()).pathname.endsWith(`/proformas/${proforma.id}`)
);
await page.getByTestId("proforma-update-submit").click();
expect((await responsePromise).status()).toBe(200);
await expect(page).toHaveURL((url) => url.pathname === `/proformas/${proforma.id}/edit`);
await expect(page.locator('input[name="reference"]')).toHaveValue(reference);
await expect(page.locator('input[name="description"]')).toHaveValue(description);
await page.reload();
await expect(page.locator('input[name="reference"]')).toHaveValue(reference);
await expect(page.locator('input[name="description"]')).toHaveValue(description);
});