Compare commits
No commits in common. "322df28591819cf0bac2ac818b3caa166f88f804" and "99a3f702267196608e8c023025402f5a18523f89" have entirely different histories.
322df28591
...
99a3f70226
15
.ai/backend/agent.md
Normal file
15
.ai/backend/agent.md
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
Especialización backend.
|
||||||
|
|
||||||
|
Contiene:
|
||||||
|
|
||||||
|
- Clean Architecture
|
||||||
|
- DDD
|
||||||
|
- módulos
|
||||||
|
- Sequelize
|
||||||
|
- DTOs
|
||||||
|
- repositorios
|
||||||
|
- fiscalidad
|
||||||
|
- use cases
|
||||||
|
- Express
|
||||||
|
- snapshot builders
|
||||||
|
|
||||||
32
.ai/frontend/agent.md
Normal file
32
.ai/frontend/agent.md
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# Frontend ERP Agent
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Construir frontend ERP modular, tipado, mantenible y desacoplado.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Stack
|
||||||
|
|
||||||
|
- React
|
||||||
|
- TypeScript
|
||||||
|
- Vite
|
||||||
|
- TanStack Query
|
||||||
|
- React Hook Form
|
||||||
|
- Zod
|
||||||
|
- Zustand (si aplica)
|
||||||
|
- Tailwind/Shadcn/etc
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Principios
|
||||||
|
|
||||||
|
- UI desacoplada de transporte
|
||||||
|
- SSR/CSR explícito
|
||||||
|
- Estado mínimo global
|
||||||
|
- Formularios tipados
|
||||||
|
- Server state != UI state
|
||||||
|
- Componentes pequeños
|
||||||
|
- Evitar smart components gigantes
|
||||||
|
- Accesibilidad obligatoria
|
||||||
|
- Diseño orientado a features
|
||||||
10
.ai/index.md
Normal file
10
.ai/index.md
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
# ERP AI Context
|
||||||
|
|
||||||
|
## Shared => filosofía + convenciones
|
||||||
|
- ./shared/agent.md
|
||||||
|
|
||||||
|
## Backend => dominio + arquitectura + persistencia
|
||||||
|
- ./backend/agent.md
|
||||||
|
|
||||||
|
## Frontend => interacción + UX + estado + rendering
|
||||||
|
- ./frontend/agent.md
|
||||||
5
.ai/shared/convenciones.md
Normal file
5
.ai/shared/convenciones.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
- Mantener código en inglés.
|
||||||
|
- Comentarios solo si aportan, en español.
|
||||||
|
- No usar any.
|
||||||
|
- No crear abstracciones genéricas prematuras.
|
||||||
|
|
||||||
@ -78,7 +78,7 @@ david
|
|||||||
|
|
||||||
Node.js 24.x
|
Node.js 24.x
|
||||||
|
|
||||||
pnpm 11.25.0
|
pnpm 11.22.0
|
||||||
|
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -179,7 +179,7 @@ export function useCustomersGridColumns(
|
|||||||
primaryActions.push({
|
primaryActions.push({
|
||||||
id: "edit",
|
id: "edit",
|
||||||
label: t("list.actions.edit"),
|
label: t("list.actions.edit"),
|
||||||
icon: <PencilIcon className="size-4" data-testid={`customer-edit-${customer.id}`} />,
|
icon: <PencilIcon className="size-4" />,
|
||||||
onSelect: () => actionHandlers.onEditClick?.(customer),
|
onSelect: () => actionHandlers.onEditClick?.(customer),
|
||||||
disabled: !canEdit,
|
disabled: !canEdit,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -110,9 +110,7 @@ export const CustomerViewPage = () => {
|
|||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-muted-foreground">{t("view.fields.name")}</dt>
|
<dt className="text-sm font-medium text-muted-foreground">{t("view.fields.name")}</dt>
|
||||||
<dd className="mt-1 text-base text-foreground" id="customer-view-name">
|
<dd className="mt-1 text-base text-foreground">{viewCtrl.customerData?.name}</dd>
|
||||||
{viewCtrl.customerData?.name}
|
|
||||||
</dd>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-muted-foreground">
|
<dt className="text-sm font-medium text-muted-foreground">
|
||||||
|
|||||||
@ -1,44 +0,0 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
|
||||||
|
|
||||||
import { loginViaUi } from "../support/login-via-ui";
|
|
||||||
|
|
||||||
import { createCustomerViaUi } from "./support/create-customer-via-ui";
|
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await loginViaUi(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("CUSTOMERS-CREATE-001: crea un cliente y conserva sus datos tras recargar", async ({
|
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
const customer = await createCustomerViaUi(page, testInfo);
|
|
||||||
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
await expect(page.locator('input[name="name"]')).toHaveValue(customer.name);
|
|
||||||
await expect(page.locator('input[name="primaryEmail"]')).toHaveValue(customer.email);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("CUSTOMERS-CREATE-002: valida el nombre obligatorio sin enviar el alta", async ({ page }) => {
|
|
||||||
await page.goto("/customers/create");
|
|
||||||
const name = page.locator('input[name="name"]');
|
|
||||||
await expect(name).toBeVisible();
|
|
||||||
await expect(name).toHaveValue("");
|
|
||||||
|
|
||||||
let creationRequests = 0;
|
|
||||||
page.on("request", (request) => {
|
|
||||||
if (request.method() === "POST" && /\/customers\/?$/.test(new URL(request.url()).pathname)) {
|
|
||||||
creationRequests += 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.getByTestId("submit-button").click();
|
|
||||||
|
|
||||||
await expect(name).toHaveAttribute("aria-invalid", "true");
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-slot="field"]').filter({ has: name }).locator('[data-slot="field-error"]')
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(page).toHaveURL(/\/customers\/create$/);
|
|
||||||
// El error visible marca el fin de la validación; sin POST no puede existir un alta.
|
|
||||||
expect(creationRequests).toBe(0);
|
|
||||||
});
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
|
||||||
|
|
||||||
import { loginViaUi } from "../support/login-via-ui";
|
|
||||||
|
|
||||||
import { createCustomerViaUi } from "./support/create-customer-via-ui";
|
|
||||||
|
|
||||||
test("CUSTOMERS-NAVIGATION-001: cancelar edición conserva el contexto del listado", async ({
|
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
await loginViaUi(page);
|
|
||||||
const customer = await createCustomerViaUi(page, testInfo);
|
|
||||||
const listPath = "/customers?page=1&pageSize=25&sortField=name&sortDirection=desc";
|
|
||||||
|
|
||||||
await page.goto(listPath);
|
|
||||||
const editAction = page.getByTestId(`customer-edit-${customer.id}`);
|
|
||||||
await expect(editAction).toBeVisible();
|
|
||||||
await editAction.click();
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(
|
|
||||||
(url) =>
|
|
||||||
url.pathname === `/customers/${customer.id}/edit` &&
|
|
||||||
url.searchParams.get("returnTo") === listPath
|
|
||||||
);
|
|
||||||
await expect(page.locator('input[name="name"]')).toHaveValue(customer.name);
|
|
||||||
|
|
||||||
// Recargar demuestra que returnTo no depende de estado de navegación en memoria.
|
|
||||||
await page.reload();
|
|
||||||
await expect(page.locator('input[name="name"]')).toHaveValue(customer.name);
|
|
||||||
await page.getByTestId("cancel-button").click();
|
|
||||||
|
|
||||||
await expect(page).toHaveURL((url) => `${url.pathname}${url.search}` === listPath);
|
|
||||||
await expect(editAction).toBeVisible();
|
|
||||||
});
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
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) {
|
|
||||||
const suffix = `${Date.now()}-${testInfo.workerIndex}-${testInfo.retry}-${randomUUID().slice(0, 8)}`;
|
|
||||||
const name = `zzzz E2E customer ${suffix}`;
|
|
||||||
const email = `e2e-${suffix}@example.invalid`;
|
|
||||||
|
|
||||||
await page.goto("/customers/create");
|
|
||||||
await page.locator('input[name="name"]').fill(name);
|
|
||||||
await page.locator('input[name="primaryEmail"]').fill(email);
|
|
||||||
await page.getByTestId("submit-button").click();
|
|
||||||
|
|
||||||
// La navegación solo ocurre tras la respuesta real de creación.
|
|
||||||
await expect(page).toHaveURL(/\/customers\/[^/]+\/edit\?returnTo=/);
|
|
||||||
await expect(page.locator('input[name="name"]')).toHaveValue(name);
|
|
||||||
await expect(page.locator('input[name="primaryEmail"]')).toHaveValue(email);
|
|
||||||
|
|
||||||
const editUrl = new URL(page.url());
|
|
||||||
const id = editUrl.pathname.split("/")[2];
|
|
||||||
if (!id) {
|
|
||||||
throw new Error("Missing customer ID in edit URL");
|
|
||||||
}
|
|
||||||
return { id, name, email };
|
|
||||||
}
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
|
||||||
|
|
||||||
import { loginViaUi } from "../support/login-via-ui";
|
|
||||||
|
|
||||||
import { createCustomerViaUi } from "./support/create-customer-via-ui";
|
|
||||||
|
|
||||||
test("CUSTOMERS-UPDATE-001: modifica un cliente propio y conserva el cambio tras recargar", async ({
|
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
await loginViaUi(page);
|
|
||||||
const customer = await createCustomerViaUi(page, testInfo);
|
|
||||||
const updatedName = `${customer.name} updated`;
|
|
||||||
|
|
||||||
await page.locator('input[name="name"]').fill(updatedName);
|
|
||||||
const responsePromise = page.waitForResponse(
|
|
||||||
(response) =>
|
|
||||||
response.request().method() === "PUT" &&
|
|
||||||
new URL(response.url()).pathname.endsWith(`/customers/${customer.id}`)
|
|
||||||
);
|
|
||||||
await page.getByTestId("submit-button").click();
|
|
||||||
const response = await responsePromise;
|
|
||||||
expect(response.status()).toBe(200);
|
|
||||||
|
|
||||||
await expect(page).toHaveURL((url) => url.pathname === `/customers/${customer.id}/`);
|
|
||||||
await expect(page.locator("#customer-view-name")).toHaveText(updatedName);
|
|
||||||
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
await expect(page.locator("#customer-view-name")).toHaveText(updatedName);
|
|
||||||
});
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
import { loginViaUi } from "../support/login-via-ui";
|
import { loginViaUi } from "./support/login-via-ui";
|
||||||
|
|
||||||
test("IDENTITY-LOGIN-001: mantiene la sesión después de iniciar sesión y recargar", async ({
|
test("IDENTITY-LOGIN-001: mantiene la sesión después de iniciar sesión y recargar", async ({
|
||||||
page,
|
page,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
import { loginViaUi } from "../support/login-via-ui";
|
import { loginViaUi } from "./support/login-via-ui";
|
||||||
|
|
||||||
test("IDENTITY-LOGOUT-001: cierra la sesión y bloquea el acceso posterior a rutas privadas", async ({
|
test("IDENTITY-LOGOUT-001: cierra la sesión y bloquea el acceso posterior a rutas privadas", async ({
|
||||||
page,
|
page,
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
import type { Page } from "@playwright/test";
|
import { type Page, expect } from "@playwright/test";
|
||||||
import { expect } from "@playwright/test";
|
|
||||||
|
|
||||||
export async function loginViaUi(page: Page): Promise<void> {
|
export async function loginViaUi(page: Page): Promise<void> {
|
||||||
const email = process.env.E2E_USER_EMAIL;
|
const email = process.env.E2E_USER_EMAIL;
|
||||||
const password = process.env.E2E_USER_PASSWORD;
|
const password = process.env.E2E_USER_PASSWORD;
|
||||||
|
|
||||||
if (!(email && password)) {
|
if (!(email && password)) {
|
||||||
throw new Error("E2E requires E2E_USER_EMAIL and E2E_USER_PASSWORD in apps/server/.env.e2e.");
|
throw new Error(
|
||||||
|
"Identity E2E requires E2E_USER_EMAIL and E2E_USER_PASSWORD in apps/server/.env.e2e."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
Loading…
Reference in New Issue
Block a user