# SmartStack Frontend Patterns — Mandatory Reference

> **Loaded by:** step-03 (execution) and step-04 (validation)
> **Purpose:** Defines mandatory frontend patterns extracted from SmartStack.app.
> **Enforcement:** POST-CHECKs in step-04 verify compliance.

---

## 1. Lazy Loading (React.lazy + Suspense)

> **ALL page components MUST be lazy-loaded.** Only critical entry pages (HomePage, LoginPage) may use static imports.

### Import Pattern

```tsx
// Named exports — use .then() to wrap
const EmployeesPage = lazy(() =>
  import('@/pages/HumanResources/Employees/EmployeesPage')
    .then(m => ({ default: m.EmployeesPage }))
);

// Default exports — direct lazy
const DashboardPage = lazy(() => import('@/pages/Platform/Admin/DashboardPage'));
```

### Suspense Wrapper

```tsx
import { Suspense } from 'react';
import { PageLoader } from '@/components/ui/PageLoader';

// Route element wrapping
element: (
  <Suspense fallback={<PageLoader />}>
    <PermissionGuard permissions={ROUTES['hr.employees'].permissions}>
      <EmployeesPage />
    </PermissionGuard>
  </Suspense>
)
```

### Rules

- **NEVER** static-import page components in route files
- **ALWAYS** use `<Suspense fallback={<PageLoader />}>` around lazy components
- **ALWAYS** use the `.then(m => ({ default: m.ComponentName }))` pattern for named exports
- The unified AppLayout component is ALSO lazy-loaded

**FORBIDDEN:**
```tsx
// WRONG: static import in route file
import { EmployeesPage } from '@/pages/HumanResources/Employees/EmployeesPage';

// WRONG: no Suspense wrapper
element: <EmployeesPage />

// WRONG: no fallback
<Suspense><EmployeesPage /></Suspense>
```

### Client App.tsx — Lazy Imports Mandatory

> **CRITICAL:** In the client `App.tsx` (where application routes are defined), ALL page imports MUST use `React.lazy()`.

**CORRECT — Lazy imports in client App.tsx:**
```tsx
const ClientsListPage = lazy(() =>
  import('@/pages/HumanResources/Clients/ClientsListPage')
    .then(m => ({ default: m.ClientsListPage }))
);
```

**FORBIDDEN — Static imports in client App.tsx:**
```tsx
// WRONG: Static import kills code splitting
import { ClientsListPage } from '@/pages/HumanResources/Clients/ClientsListPage';
```

> **Note:** The `smartstackRoutes.tsx` from the npm package may use static imports internally — this is acceptable for the package. But client `App.tsx` code MUST always use lazy imports for business pages.

---

## 2. I18n / Translations (react-i18next)

> **ALL user-facing text MUST use translations.** 4 languages required: fr, en, it, de.

### File Structure

```
src/i18n/
├── config.ts                    # i18n initialization
├── locales/
│   ├── fr/
│   │   ├── common.json          # Shared keys (actions, errors, validation)
│   │   ├── navigation.json      # Menu labels
│   │   └── {module}.json        # Module-specific keys
│   ├── en/
│   │   └── {module}.json
│   ├── it/
│   │   └── {module}.json
│   └── de/
│       └── {module}.json
```

### Module JSON Template

Each new module MUST generate a translation file with this structure:

```json
{
  "title": "Module display name",
  "description": "Module description",
  "actions": {
    "create": "Create {entity}",
    "edit": "Edit {entity}",
    "delete": "Delete {entity}",
    "save": "Save",
    "cancel": "Cancel",
    "search": "Search...",
    "export": "Export",
    "refresh": "Refresh"
  },
  "labels": {
    "name": "Name",
    "code": "Code",
    "description": "Description",
    "status": "Status",
    "createdAt": "Created at",
    "updatedAt": "Updated at",
    "createdBy": "Created by",
    "isActive": "Active"
  },
  "columns": {
    "name": "Name",
    "code": "Code",
    "status": "Status",
    "actions": "Actions"
  },
  "form": {
    "name": "Name",
    "namePlaceholder": "Enter name...",
    "code": "Code",
    "codePlaceholder": "Enter code...",
    "description": "Description",
    "descriptionPlaceholder": "Enter description..."
  },
  "errors": {
    "loadFailed": "Failed to load data",
    "saveFailed": "Failed to save",
    "deleteFailed": "Failed to delete",
    "notFound": "Not found",
    "permissionDenied": "Permission denied"
  },
  "validation": {
    "nameRequired": "Name is required",
    "codeRequired": "Code is required",
    "nameMaxLength": "Name must be less than {{max}} characters"
  },
  "messages": {
    "created": "{entity} created successfully",
    "updated": "{entity} updated successfully",
    "deleted": "{entity} deleted successfully",
    "confirmDelete": "Are you sure you want to delete this {entity}?"
  },
  "empty": {
    "title": "No {entity} found",
    "description": "Create your first {entity} to get started"
  }
}
```

### Usage in Components

```tsx
// Hook — specify namespace(s)
const { t } = useTranslation(['employees']);

// Simple key with MANDATORY fallback
t('employees:title', 'Employees')

// Key with interpolation
t('employees:messages.created', '{{entity}} created successfully', { entity: 'Employee' })

// Namespace prefix syntax
t('employees:actions.create', 'Create employee')
t('common:actions.save', 'Save')
t('common:errors.network', 'Network error')
```

### Namespace Registration (CRITICAL)

> **After creating i18n JSON files, you MUST register each namespace in the i18n config.**
> Root cause (test-apex-007): JSON files existed but namespaces were not registered → `useTranslation(['module'])` returned empty strings.

In the i18n config file (`src/i18n/config.ts` or `src/i18n/index.ts`), add each new namespace:

```typescript
// Example: registering new module namespaces
import employees from './locales/fr/employees.json';
import projects from './locales/fr/projects.json';
import clients from './locales/fr/clients.json';

// In resources configuration:
resources: {
  fr: { employees, projects, clients, common, navigation },
  en: { employees: employeesEn, projects: projectsEn, clients: clientsEn, ... },
  // ... it, de
}

// OR with ns array:
ns: ['common', 'navigation', 'employees', 'projects', 'clients'],
```

POST-CHECK 37 validates this. Unregistered namespaces → BLOCKING.

### Rules

- **ALWAYS** provide a fallback value as 2nd argument to `t()`
- **ALWAYS** use namespace prefix: `t('namespace:key')`
- **ALWAYS** generate 4 language files (fr, en, it, de) with identical key structures
- **ALWAYS** register new namespaces in i18n config file after creating JSON files
- **NEVER** hardcode user-facing strings in JSX
- **NEVER** use `t('key')` without namespace prefix

**FORBIDDEN:**
```tsx
// WRONG: no fallback
t('employees:title')

// WRONG: no namespace
t('title')

// WRONG: hardcoded text
<h1>Employees</h1>

// WRONG: only 2 languages generated
// Must have fr, en, it, de
```

---

## 3. Page Structure Pattern

> **ALL pages MUST follow this structure.** Extracted from SmartStack.app reference implementation.

### Standard List Page Template

```tsx
import { useState, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams } from 'react-router-dom';
import { Loader2 } from 'lucide-react';
import { DocToggleButton } from '@/components/docs/DocToggleButton';

// API hook (generated by scaffold_api_client)
import { useEntityList } from '@/hooks/useEntity';

export function EntityListPage() {
  // 1. HOOKS — always at the top
  const { t } = useTranslation(['{module}']);
  const navigate = useNavigate();

  // 2. STATE
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [data, setData] = useState<Entity[]>([]);

  // 3. DATA LOADING (useCallback + useEffect)
  const loadData = useCallback(async () => {
    try {
      setLoading(true);
      setError(null);
      const result = await entityApi.getAll();
      setData(result.items);
    } catch (err: any) {
      setError(err.message || t('{module}:errors.loadFailed', 'Failed to load data'));
    } finally {
      setLoading(false);
    }
  }, [t]);

  useEffect(() => {
    loadData();
  }, [loadData]);

  // 4. LOADING STATE
  if (loading) {
    return (
      <div className="flex items-center justify-center min-h-[400px]">
        <Loader2 className="w-8 h-8 animate-spin text-[var(--color-accent-500)]" />
      </div>
    );
  }

  // 5. ERROR STATE
  if (error) {
    return (
      <div className="flex items-center justify-center min-h-[400px]">
        <div className="text-center">
          <p className="text-[var(--text-secondary)]">{error}</p>
          <button
            onClick={loadData}
            className="mt-4 px-4 py-2 bg-[var(--color-accent-500)] text-white rounded"
          >
            {t('common:actions.retry', 'Retry')}
          </button>
        </div>
      </div>
    );
  }

  // 6. CONTENT — create button navigates to /create route
  return (
    <div className="space-y-6">
      {/* Header with DocToggleButton */}
      <div className="flex items-center justify-between">
        <h1 className="text-2xl font-bold text-[var(--text-primary)]">
          {t('{module}:title', 'Module Title')}
        </h1>
        <div className="flex items-center gap-2">
          <DocToggleButton />
          <button
            onClick={() => navigate('create')}
            className="px-4 py-2 bg-[var(--color-accent-500)] text-white rounded"
          >
            {t('{module}:actions.create', 'Create')}
          </button>
        </div>
      </div>

      {/* Content: SmartTable with row click → detail, edit action → /:id/edit */}
    </div>
  );
}
```

### Detail Page Pattern

```tsx
export function EntityDetailPage() {
  const { entityId } = useParams<{ entityId: string }>();
  const { t } = useTranslation(['{module}']);
  const navigate = useNavigate();

  const [entity, setEntity] = useState<Entity | null>(null);
  const [loading, setLoading] = useState(true);
  const [activeTab, setActiveTab] = useState('info');

  // Lazy tab loading — load data only when tab is first visited
  const visitedTabsRef = useRef<Set<string>>(new Set(['info']));

  useEffect(() => {
    if (!visitedTabsRef.current.has(activeTab)) {
      visitedTabsRef.current.add(activeTab);
      // Load tab-specific data here (e.g., fetch leaves for this employee)
    }
  }, [activeTab]);

  // Edit button navigates to /:id/edit route (NEVER opens a modal)
  const handleEdit = () => navigate(`edit`);

  // ... loading/error/content pattern
}
```

### Tab Behavior Rules (CRITICAL)

> **CRITICAL: Tabs on detail pages switch content LOCALLY — they NEVER navigate to other pages.**
> Each tab renders its content INLINE within the same page component.
> Sub-resource data (e.g., an employee's leaves) loads via API call filtered by the parent entity ID.

**Tab state management:**
- Tabs use `useState<TabKey>('info')` for the active tab — LOCAL React state only
- Tab click handler: `onClick={() => setActiveTab(tabKey)}` — NEVER `navigate()`
- Tab content: conditional rendering `{activeTab === 'tabKey' && <TabContent />}`
- Lazy loading: `visitedTabsRef` tracks which tabs have been visited to avoid redundant API calls

**Tab content for sub-resources:**
```tsx
// CORRECT — sub-resource data loaded INLINE within the tab
{activeTab === 'leaves' && (
  <div>
    <LeaveRequestsTable employeeId={entity.id} />
    {/* Optional "View all" link INSIDE the tab content area */}
    <Link to={`../leaves?employee=${entity.id}`}>
      {t('employees:tabs.viewAllLeaves', 'View all leave requests')}
    </Link>
  </div>
)}
```

**FORBIDDEN tab patterns:**
```tsx
// FORBIDDEN — tab click handler navigates to another page
const handleTabClick = (tab: TabKey) => {
  setActiveTab(tab);
  if (tab === 'leaves') navigate(`../leaves?employee=${id}`);  // ← BREAKS tab UX
};

// FORBIDDEN — tab content is empty because navigation already left the page
{activeTab === 'info' && <div>...</div>}
// Leaves tab: nothing renders here, user is already on another page
```

**Why this matters:**
- Navigating away loses the detail page context (entity data, scroll position, other tab state)
- Users expect tabs to switch content in-place, not redirect to a different page
- The browser back button should go to the list page, not toggle between tabs

**POST-CHECK 35 enforces this rule.**

---

## 3b. Form Pages Pattern (Create / Edit)

> **CRITICAL: ALL forms MUST be full pages with their own URL route.**
> **NEVER use modals, dialogs, drawers, or popups for create/edit forms.**

### Route Convention

> **CRITICAL:** Route paths MUST use **kebab-case** matching the navigation seed data (which uses `ToKebabCase()`).
> - Single word: `employees` (no change needed)
> - Multi-word: `human-resources`, `time-management` (kebab-case with hyphens)
> - **FORBIDDEN:** `humanresources`, `timemanagement` (concatenated words without hyphens)

| Action | Route pattern | Page component | File location |
|--------|--------------|----------------|---------------|
| Create | `/{module}/create` | `EntityCreatePage` | `src/pages/{App}/{Module}/EntityCreatePage.tsx` |
| Edit | `/{module}/:id/edit` | `EntityEditPage` | `src/pages/{App}/{Module}/EntityEditPage.tsx` |

### Create Page Template

```tsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';

export function EntityCreatePage() {
  const { t } = useTranslation(['{module}']);
  const navigate = useNavigate();
  const [submitting, setSubmitting] = useState(false);

  const handleSubmit = async (data: CreateEntityDto) => {
    try {
      setSubmitting(true);
      await entityApi.create(data);
      navigate(-1); // Back to list
    } catch (err: any) {
      // Handle validation errors
    } finally {
      setSubmitting(false);
    }
  };

  return (
    <div className="space-y-6">
      {/* Back button */}
      <button
        onClick={() => navigate(-1)}
        className="text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
      >
        {t('common:actions.back', 'Back')}
      </button>

      {/* Page title */}
      <h1 className="text-2xl font-bold text-[var(--text-primary)]">
        {t('{module}:actions.create', 'Create {Entity}')}
      </h1>

      {/* SmartForm — NEVER in a modal */}
      <SmartForm
        fields={formFields}
        onSubmit={handleSubmit}
        onCancel={() => navigate(-1)}
        submitting={submitting}
      />
    </div>
  );
}
```

### Edit Page Template

```tsx
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams } from 'react-router-dom';
import { Loader2 } from 'lucide-react';

export function EntityEditPage() {
  const { entityId } = useParams<{ entityId: string }>();
  const { t } = useTranslation(['{module}']);
  const navigate = useNavigate();
  const [entity, setEntity] = useState<Entity | null>(null);
  const [loading, setLoading] = useState(true);
  const [submitting, setSubmitting] = useState(false);

  const loadEntity = useCallback(async () => {
    try {
      setLoading(true);
      const result = await entityApi.getById(entityId!);
      setEntity(result);
    } catch {
      navigate(-1);
    } finally {
      setLoading(false);
    }
  }, [entityId, navigate]);

  useEffect(() => { loadEntity(); }, [loadEntity]);

  if (loading) {
    return (
      <div className="flex items-center justify-center min-h-[400px]">
        <Loader2 className="w-8 h-8 animate-spin text-[var(--color-accent-500)]" />
      </div>
    );
  }

  const handleSubmit = async (data: UpdateEntityDto) => {
    try {
      setSubmitting(true);
      await entityApi.update(entityId!, data);
      navigate(-1); // Back to detail or list
    } catch (err: any) {
      // Handle validation errors
    } finally {
      setSubmitting(false);
    }
  };

  return (
    <div className="space-y-6">
      {/* Back button */}
      <button
        onClick={() => navigate(-1)}
        className="text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
      >
        {t('common:actions.back', 'Back')}
      </button>

      {/* Page title */}
      <h1 className="text-2xl font-bold text-[var(--text-primary)]">
        {t('{module}:actions.edit', 'Edit {Entity}')}
      </h1>

      {/* SmartForm pre-filled — NEVER in a modal */}
      <SmartForm
        fields={formFields}
        initialValues={entity}
        onSubmit={handleSubmit}
        onCancel={() => navigate(-1)}
        submitting={submitting}
      />
    </div>
  );
}
```

### Lazy Loading for Form Pages

```tsx
// In route files — form pages are also lazy-loaded
const EntityCreatePage = lazy(() =>
  import('@/pages/HumanResources/Employees/EntityCreatePage')
    .then(m => ({ default: m.EntityCreatePage }))
);
const EntityEditPage = lazy(() =>
  import('@/pages/HumanResources/Employees/EntityEditPage')
    .then(m => ({ default: m.EntityEditPage }))
);

// Route registration — form pages have their own routes
{
  path: 'employees',
  children: [
    { index: true, element: <Suspense fallback={<PageLoader />}><EmployeesPage /></Suspense> },
    { path: 'create', element: <Suspense fallback={<PageLoader />}><EntityCreatePage /></Suspense> },
    { path: ':id', element: <Suspense fallback={<PageLoader />}><EntityDetailPage /></Suspense> },
    { path: ':id/edit', element: <Suspense fallback={<PageLoader />}><EntityEditPage /></Suspense> },
  ]
}

// Section-level routes — children of the module route (when module has sections)
//
// > **IMPORTANT:** The `list` and `detail` sections do NOT generate additional route entries.
// > They are already covered by the module's `index: true` (list) and `path: ':id'` (detail) routes above.
// > Only sections like `dashboard`, `approve`, `import`, etc. generate the section-kebab child routes below.
// > FORBIDDEN: `path: 'list'`, `path: 'detail'` — these would create unreachable duplicate routes.
//
{
  path: '{module-kebab}',
  children: [
    { index: true, element: <Suspense fallback={<PageLoader />}><{Module}Page /></Suspense> },
    { path: 'create', element: <Suspense fallback={<PageLoader />}><Create{Module}Page /></Suspense> },
    { path: ':id', element: <Suspense fallback={<PageLoader />}><{Module}DetailPage /></Suspense> },
    { path: ':id/edit', element: <Suspense fallback={<PageLoader />}><Edit{Module}Page /></Suspense> },
    // Section routes as children of module:
    // IMPORTANT: "list" and "detail" are NOT separate path segments.
    // - "list" section = already handled by the module's index route above (index: true)
    // - "detail" section = already handled by the module's :id route above (path: ':id')
    // - Only OTHER sections (dashboard, approve, import, etc.) add path segments:
    { path: '{section-kebab}', element: <Suspense fallback={<PageLoader />}><{Section}Page /></Suspense> },
    { path: '{section-kebab}/create', element: <Suspense fallback={<PageLoader />}><Create{Section}Page /></Suspense> },
    { path: '{section-kebab}/:id', element: <Suspense fallback={<PageLoader />}><{Section}DetailPage /></Suspense> },
    { path: '{section-kebab}/:id/edit', element: <Suspense fallback={<PageLoader />}><Edit{Section}Page /></Suspense> },
  ]
}

// PermissionGuard for section-level routes
element: (
  <Suspense fallback={<PageLoader />}>
    <PermissionGuard permissions={ROUTES['app.module.section'].permissions}>
      <SectionPage />
    </PermissionGuard>
  </Suspense>
)
```

### Rules

- **NEVER** use `<Modal>`, `<Dialog>`, `<Drawer>`, or `<Popup>` for create/edit forms
- **NEVER** use `useState(isOpen)` to toggle form visibility — forms are pages, not overlays
- **ALWAYS** create a dedicated `EntityCreatePage.tsx` and `EntityEditPage.tsx` page component
- **ALWAYS** register create/edit routes alongside list/detail routes
- **ALWAYS** use `navigate('create')` or `navigate(\`${id}/edit\`)` from list/detail pages
- **ALWAYS** include a back button that uses `navigate(-1)` to return to previous page

**FORBIDDEN:**
```tsx
// WRONG: modal for create form
const [showCreateModal, setShowCreateModal] = useState(false);
<Modal open={showCreateModal}><CreateForm /></Modal>

// WRONG: dialog for edit form
<Dialog open={editDialogOpen}><EditForm entity={selected} /></Dialog>

// WRONG: drawer for form
<Drawer open={isDrawerOpen}><SmartForm /></Drawer>

// WRONG: inline form toggle
{isEditing ? <EditForm /> : <DetailView />}
```

---

## 4. CSS Variables (Theme System)

> **NEVER use hardcoded Tailwind colors.** ALWAYS use CSS variables for theme support.

### Variable Reference

| Usage | CSS Variable | Example |
|-------|-------------|---------|
| Background | `var(--bg-primary)` | `bg-[var(--bg-primary)]` |
| Card background | `var(--bg-card)` | `bg-[var(--bg-card)]` |
| Text primary | `var(--text-primary)` | `text-[var(--text-primary)]` |
| Text secondary | `var(--text-secondary)` | `text-[var(--text-secondary)]` |
| Borders | `var(--border-color)` | `border-[var(--border-color)]` |
| Accent | `var(--color-accent-500)` | `text-[var(--color-accent-500)]` |
| Card radius | `var(--radius-card)` | `style={{ borderRadius: 'var(--radius-card)' }}` |

### Card Pattern

```tsx
<div
  className="bg-[var(--bg-card)] border border-[var(--border-color)] p-6"
  style={{ borderRadius: 'var(--radius-card)' }}
>
  <h2 className="text-lg font-semibold text-[var(--text-primary)]">Title</h2>
  <p className="text-sm text-[var(--text-secondary)]">Description</p>
</div>
```

**FORBIDDEN:**
```tsx
// WRONG: hardcoded Tailwind colors
className="bg-white border-gray-200 text-gray-900"

// WRONG: hardcoded hex/rgb
style={{ backgroundColor: '#ffffff', color: '#1a1a1a' }}
```

---

## 5. Component Rules

| Need | Component | Source |
|------|-----------|--------|
| Data table | `SmartTable` | `@/components/SmartTable` |
| Filters | `SmartFilter` | `@/components/SmartFilter` |
| Entity cards | `EntityCard` | `@/components/EntityCard` |
| Forms | `SmartForm` | `@/components/SmartForm` |
| FK field lookup | `EntityLookup` | `@/components/ui/EntityLookup` |
| Statistics | `StatCard` | `@/components/StatCard` |
| Loading spinner | `Loader2` | `lucide-react` |
| Page loader | `PageLoader` | `@/components/ui/PageLoader` |

### Rules

- **NEVER** use raw `<table>` — use SmartTable
- **NEVER** create custom spinners — use `Loader2` from lucide-react
- **NEVER** import axios directly — use `@/services/api/apiClient`
- **ALWAYS** use `PageLoader` as Suspense fallback
- **ALWAYS** use existing shared components before creating new ones

---

## 6. Foreign Key Fields & Entity Lookup (CRITICAL)

> **NEVER render a foreign key (Guid) as a plain text input.** FK fields MUST use a searchable lookup component.
> A form asking the user to type a GUID manually is a UX failure. ALL FK fields must provide entity search & selection.

### Field Type Classification

When generating form fields, determine the field type from the entity property:

| Property type | Form field type | Component |
|---------------|----------------|-----------|
| `string` | Text input | `<input type="text" />` |
| `string?` | Text input (optional) | `<input type="text" />` |
| `Guid` (FK — e.g., `EmployeeId`, `DepartmentId`) | **Entity Lookup** | `<EntityLookup />` |
| `bool` | Toggle/Checkbox | `<input type="checkbox" />` |
| `int` / `decimal` | Number input | `<input type="number" />` |
| `DateTime` | Date picker | `<input type="date" />` |
| `enum` | Select dropdown | `<select>` |

**How to detect FK fields:** Any property named `{Entity}Id` of type `Guid` that has a corresponding navigation property is a foreign key. Examples: `EmployeeId`, `DepartmentId`, `CategoryId`, `ParentId`.

### EntityLookup Component Pattern

```tsx
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Search, X, ChevronDown } from 'lucide-react';
import { apiClient } from '@/services/api/apiClient';

interface EntityLookupOption {
  id: string;
  label: string;        // Display name (e.g., employee full name)
  sublabel?: string;     // Secondary info (e.g., department, code)
}

interface EntityLookupProps {
  /** API endpoint to search entities (e.g., '/api/human-resources/employees') */
  apiEndpoint: string;
  /** Currently selected entity ID */
  value: string | null;
  /** Callback when entity is selected */
  onChange: (id: string | null) => void;
  /** Field label */
  label: string;
  /** Placeholder text */
  placeholder?: string;
  /** Map API response item to display option */
  mapOption: (item: any) => EntityLookupOption;
  /** Whether the field is required */
  required?: boolean;
  /** Whether the field is disabled */
  disabled?: boolean;
  /** Error message to display */
  error?: string;
}

export function EntityLookup({
  apiEndpoint,
  value,
  onChange,
  label,
  placeholder,
  mapOption,
  required = false,
  disabled = false,
  error,
}: EntityLookupProps) {
  const { t } = useTranslation(['common']);
  const [search, setSearch] = useState('');
  const [options, setOptions] = useState<EntityLookupOption[]>([]);
  const [selectedOption, setSelectedOption] = useState<EntityLookupOption | null>(null);
  const [isOpen, setIsOpen] = useState(false);
  const [loading, setLoading] = useState(false);
  const containerRef = useRef<HTMLDivElement>(null);
  const debounceRef = useRef<ReturnType<typeof setTimeout>>();

  // Load selected entity display on mount (when value is set but no label)
  useEffect(() => {
    if (value && !selectedOption) {
      apiClient.get(`${apiEndpoint}/${value}`)
        .then(res => setSelectedOption(mapOption(res.data)))
        .catch(() => { /* Entity not found — clear */ });
    }
  }, [value, apiEndpoint, mapOption, selectedOption]);

  // Debounced search — 300ms delay, minimum 2 characters
  const handleSearch = useCallback((term: string) => {
    setSearch(term);
    if (debounceRef.current) clearTimeout(debounceRef.current);

    if (term.length < 2) {
      setOptions([]);
      return;
    }

    debounceRef.current = setTimeout(async () => {
      setLoading(true);
      try {
        const res = await apiClient.get(apiEndpoint, {
          params: { search: term, pageSize: 20 },
        });
        setOptions((res.data.items || res.data).map(mapOption));
      } catch {
        setOptions([]);
      } finally {
        setLoading(false);
      }
    }, 300);
  }, [apiEndpoint, mapOption]);

  // Load initial options when dropdown opens (show first 20)
  const handleOpen = useCallback(async () => {
    if (disabled) return;
    setIsOpen(true);
    if (options.length === 0 && search.length < 2) {
      setLoading(true);
      try {
        const res = await apiClient.get(apiEndpoint, {
          params: { pageSize: 20 },
        });
        setOptions((res.data.items || res.data).map(mapOption));
      } catch {
        setOptions([]);
      } finally {
        setLoading(false);
      }
    }
  }, [disabled, apiEndpoint, mapOption, options.length, search.length]);

  // Select entity
  const handleSelect = useCallback((option: EntityLookupOption) => {
    setSelectedOption(option);
    onChange(option.id);
    setIsOpen(false);
    setSearch('');
  }, [onChange]);

  // Clear selection
  const handleClear = useCallback(() => {
    setSelectedOption(null);
    onChange(null);
    setSearch('');
  }, [onChange]);

  // Close on outside click
  useEffect(() => {
    const handleClickOutside = (e: MouseEvent) => {
      if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
        setIsOpen(false);
      }
    };
    document.addEventListener('mousedown', handleClickOutside);
    return () => document.removeEventListener('mousedown', handleClickOutside);
  }, []);

  return (
    <div ref={containerRef} className="relative">
      <label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
        {label} {required && <span className="text-[var(--error-text)]">*</span>}
      </label>

      {/* Selected value display OR search input */}
      {selectedOption && !isOpen ? (
        <div className="flex items-center gap-2 px-3 py-2 border border-[var(--border-color)] rounded-[var(--radius-input)] bg-[var(--bg-card)]">
          <div className="flex-1">
            <span className="text-[var(--text-primary)]">{selectedOption.label}</span>
            {selectedOption.sublabel && (
              <span className="ml-2 text-sm text-[var(--text-secondary)]">{selectedOption.sublabel}</span>
            )}
          </div>
          {!disabled && (
            <button type="button" onClick={handleClear} className="text-[var(--text-secondary)] hover:text-[var(--text-primary)]">
              <X className="w-4 h-4" />
            </button>
          )}
          <button type="button" onClick={handleOpen} className="text-[var(--text-secondary)]">
            <ChevronDown className="w-4 h-4" />
          </button>
        </div>
      ) : (
        <div className="relative">
          <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-secondary)]" />
          <input
            type="text"
            value={search}
            onChange={(e) => handleSearch(e.target.value)}
            onFocus={handleOpen}
            placeholder={placeholder || t('common:actions.search', 'Search...')}
            disabled={disabled}
            className="w-full pl-9 pr-3 py-2 border border-[var(--border-color)] rounded-[var(--radius-input)] bg-[var(--bg-card)] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)] focus:ring-2 focus:ring-[var(--color-accent-500)] focus:border-transparent"
          />
        </div>
      )}

      {/* Dropdown */}
      {isOpen && (
        <div className="absolute z-50 w-full mt-1 bg-[var(--bg-card)] border border-[var(--border-color)] rounded-[var(--radius-card)] shadow-lg max-h-60 overflow-auto">
          {loading ? (
            <div className="p-3 text-center text-[var(--text-secondary)]">
              {t('common:actions.loading', 'Loading...')}
            </div>
          ) : options.length === 0 ? (
            <div className="p-3 text-center text-[var(--text-secondary)]">
              {search.length < 2
                ? t('common:actions.typeToSearch', 'Type at least 2 characters to search...')
                : t('common:empty.noResults', 'No results found')}
            </div>
          ) : (
            options.map((option) => (
              <button
                key={option.id}
                type="button"
                onClick={() => handleSelect(option)}
                className="w-full px-3 py-2 text-left hover:bg-[var(--bg-hover)] transition-colors"
              >
                <div className="text-[var(--text-primary)]">{option.label}</div>
                {option.sublabel && (
                  <div className="text-sm text-[var(--text-secondary)]">{option.sublabel}</div>
                )}
              </button>
            ))
          )}
        </div>
      )}

      {/* Error message */}
      {error && (
        <p className="mt-1 text-sm text-[var(--error-text)]">{error}</p>
      )}
    </div>
  );
}
```

### Usage in Form Pages

```tsx
// In EntityCreatePage.tsx or EntityEditPage.tsx
import { EntityLookup } from '@/components/ui/EntityLookup';

// Inside the form:
<EntityLookup
  apiEndpoint="/api/human-resources/employees"
  value={formData.employeeId}
  onChange={(id) => handleChange('employeeId', id)}
  label={t('module:form.employee', 'Employee')}
  placeholder={t('module:form.employeePlaceholder', 'Search for an employee...')}
  mapOption={(emp) => ({
    id: emp.id,
    label: `${emp.firstName} ${emp.lastName}`,
    sublabel: emp.department || emp.code,
  })}
  required
  error={errors.employeeId}
/>

// For DepartmentId FK:
<EntityLookup
  apiEndpoint="/api/human-resources/departments"
  value={formData.departmentId}
  onChange={(id) => handleChange('departmentId', id)}
  label={t('module:form.department', 'Department')}
  placeholder={t('module:form.departmentPlaceholder', 'Search for a department...')}
  mapOption={(dept) => ({
    id: dept.id,
    label: dept.name,
    sublabel: dept.code,
  })}
  required
/>
```

### API Search Endpoint Convention (Backend)

For EntityLookup to work, each entity's API MUST support search via query parameter:

```
GET /api/{resource}?search={term}&pageSize=20
```

Response format:
```json
{
  "items": [
    { "id": "guid", "code": "EMP001", "name": "John Doe", ... }
  ],
  "totalCount": 42
}
```

The backend service's `GetAllAsync` method should accept search parameters:

```csharp
public async Task<PaginatedResult<EntityResponseDto>> GetAllAsync(
    string? search = null,
    int page = 1,
    int pageSize = 20,
    CancellationToken ct = default)
{
    var query = _db.Entities
        .Where(x => x.TenantId == _currentUser.TenantId);

    if (!string.IsNullOrWhiteSpace(search))
    {
        query = query.Where(x =>
            x.Name.Contains(search) ||
            x.Code.Contains(search));
    }

    var totalCount = await query.CountAsync(ct);
    var items = await query
        .OrderBy(x => x.Name)
        .Skip((page - 1) * pageSize)
        .Take(pageSize)
        .Select(x => new EntityResponseDto { ... })
        .ToListAsync(ct);

    return new PaginatedResult<EntityResponseDto>(items, totalCount, page, pageSize);
}
```

### Rules

- **NEVER** render a `Guid` FK field as `<input type="text">` — always use `EntityLookup`
- **NEVER** render a `Guid` FK field as `<select>` — even with API-loaded `<option>` elements, `<select>` is NOT acceptable
- **NEVER** ask the user to manually type or paste a GUID/ID
- **ALWAYS** provide a search-based selection via `<EntityLookup />` for FK fields
- **ALWAYS** show the entity's display name (Name, FullName, Code+Name) not the GUID
- **ALWAYS** include `mapOption` to define how the related entity is displayed
- **ALWAYS** load the selected entity's display name on mount (for edit forms)
- **ALWAYS** support clearing the selection (unless required + already set)

**Why `<select>` is NOT acceptable for FK fields:**
- `<select>` loads ALL options at once — fails with 100+ entities (performance + UX)
- `<select>` has no search/filter — user must scroll through all options
- `<select>` cannot show sublabels (code, department, etc.)
- `EntityLookup` provides: debounced API search, paginated results, display name resolution, sublabels

**FORBIDDEN:**
```tsx
// WRONG: Plain text input for FK field
<input
  type="text"
  value={formData.employeeId}
  onChange={(e) => handleChange('employeeId', e.target.value)}
  placeholder="Enter Employee ID..."
/>

// WRONG: <select> dropdown for FK field (even with API-loaded options)
<select
  value={formData.departmentId}
  onChange={(e) => setFormData({ ...formData, departmentId: e.target.value })}
>
  <option value="">Select Department...</option>
  {departments.map((dept) => (
    <option key={dept.id} value={dept.id}>{dept.name}</option>
  ))}
</select>

// WRONG: Raw GUID displayed to user
<span>{entity.departmentId}</span>

// WRONG: Select with hardcoded options for FK
<select onChange={(e) => handleChange('departmentId', e.target.value)}>
  <option value="guid-1">Department A</option>
</select>
```

**CORRECT — ONLY this pattern:**
```tsx
<EntityLookup
  apiEndpoint="/api/human-resources/departments"
  value={formData.departmentId}
  onChange={(id) => handleChange('departmentId', id)}
  label={t('module:form.department', 'Department')}
  mapOption={(dept) => ({ id: dept.id, label: dept.name, sublabel: dept.code })}
  required
/>
```

### I18n Keys for EntityLookup

Add these keys to the module's translation files:

```json
{
  "form": {
    "employee": "Employee",
    "employeePlaceholder": "Search for an employee...",
    "department": "Department",
    "departmentPlaceholder": "Search for a department..."
  }
}
```

---

## 7. Documentation Panel Integration (DocToggleButton)

> **EVERY list/detail page MUST include a `DocToggleButton` in its header.**
> This button opens the right-side documentation panel showing the module's user documentation.

### Component Import

```tsx
import { DocToggleButton } from '@/components/docs/DocToggleButton';
```

### Placement — Always in the page header actions area (top right)

```tsx
{/* Header with DocToggleButton */}
<div className="flex items-center justify-between">
  <h1 className="text-2xl font-bold text-[var(--text-primary)]">
    {t('{module}:title', 'Module Title')}
  </h1>
  <div className="flex items-center gap-2">
    <DocToggleButton />
    <button onClick={() => navigate('create')} className="...">
      {t('{module}:actions.create', 'Create')}
    </button>
  </div>
</div>
```

### How it Works

1. `DocToggleButton` uses `useDocPanel()` context (provided by the Layout)
2. On click → opens the `DocPanel` on the right side of the screen
3. The panel loads the module's documentation via iframe (`?embedded=true`)
4. Route → doc mapping is in `DocPanelContext.tsx` — maps current pathname to doc URL
5. Panel is resizable (20-60% width), size persists in localStorage

### Documentation Generation

After frontend pages are created, invoke the `/documentation` skill to generate:

| File | Content |
|------|---------|
| `src/pages/docs/business/{app}/{module}/doc-data.ts` | Data-driven documentation (~50-80 lines) |
| `src/pages/docs/business/{app}/{module}/index.tsx` | Page wrapper (~10 lines) using `DocRenderer` |
| `src/i18n/locales/fr/docs-{app}-{module}.json` | French doc translations (source language) |

The `DocRenderer` shared component renders all 8 documentation sections (overview, use cases, benefits, features, steps, FAQ, business rules, permissions, API endpoints) from the `doc-data.ts` file.

### Custom Doc URL (optional)

If the automatic route mapping doesn't work for your module, pass a custom URL:

```tsx
<DocToggleButton customDocUrl="/docs/human-resources/employees" />
```

### Rules

- **EVERY** list page MUST include `DocToggleButton` in its header actions
- **EVERY** detail page MUST include `DocToggleButton` in its header actions
- Create/Edit form pages do NOT need DocToggleButton (users don't read docs while filling forms)
- DocToggleButton is imported from `@/components/docs/DocToggleButton` (shared component)
- The Layout already provides `DocPanelProvider` — no additional wrapping needed
- Documentation content is generated by the `/documentation` skill AFTER frontend pages exist

---

## 7b. Checklist for /apex Frontend Execution

Before marking frontend tasks as complete, verify:

- [ ] All page imports use `React.lazy()` with named export wrapping
- [ ] `<Suspense fallback={<PageLoader />}>` wraps all lazy components in routes
- [ ] Translation files exist for **all 4 languages** (fr, en, it, de) in `src/i18n/locales/`
- [ ] All `t()` calls include namespace prefix AND fallback value
- [ ] No hardcoded strings in JSX — all text goes through `t()`
- [ ] CSS uses variables only — no hardcoded Tailwind colors (BLOCKING POST-CHECK 9)
- [ ] Pages follow loading → error → content pattern
- [ ] Pages use `src/pages/{App}/{Module}/` hierarchy
- [ ] API calls use generated hooks or `apiClient` (never raw axios)
- [ ] Components use SmartTable/SmartFilter/EntityCard (never raw HTML tables)
- [ ] **FK fields use `EntityLookup` — ZERO plain text inputs for Guid FK fields**
- [ ] **All FK fields have `mapOption` showing display name, not GUID**
- [ ] **Backend APIs support `?search=` query parameter for EntityLookup**
- [ ] **Create/Edit forms are full pages with own routes — ZERO modals/popups/drawers**
- [ ] `EntityCreatePage.tsx` exists with route `/{module}/create`
- [ ] `EntityEditPage.tsx` exists with route `/{module}/:id/edit`
- [ ] No `<Modal>`, `<Dialog>`, `<Drawer>` imports in form-related pages
- [ ] Form pages include back button with `navigate(-1)`
- [ ] Form pages are covered by frontend tests (see section 8)
- [ ] **`DocToggleButton` present in header of every list/detail page (see section 7)**
- [ ] **`/documentation` skill invoked to generate module doc-data.ts**

---

## 7c. Cross-Tenant Entity UI Patterns

> **For optional and scoped tenant entities, the frontend MUST provide UI controls to set the scope/visibility.**

### Scope Types

| Type | Behavior | Use case |
|------|----------|----------|
| **Optional** | Entity can be tenant-specific OR shared (binary choice) | Data that can belong to one org or all orgs |
| **Scoped** | Entity has explicit scope enum: Tenant / Shared / Platform | Data with multiple visibility levels |

### Scope Selector in Create Forms (Optional Entities)

For `optional` tenant entities, add a toggle in the create form allowing the user to decide:

```tsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';

export function EntityCreatePage() {
  const { t } = useTranslation(['{module}']);
  const [formData, setFormData] = useState({
    name: '',
    isShared: false,  // User decision: tenant-specific (false) or shared (true)
  });

  const handleScopeChange = (value: string) => {
    setFormData({ ...formData, isShared: value === 'shared' });
  };

  return (
    <div className="space-y-6">
      {/* ... form header ... */}

      <SmartForm fields={[
        {
          name: 'name',
          type: 'text',
          label: t('{module}:form.name', 'Name'),
          required: true,
        },
        // Scope selector — binary toggle for optional entities
        {
          name: 'scope',
          type: 'custom',
          label: t('common:scope', 'Scope'),
          render: () => (
            <div className="space-y-2">
              <label className="block text-sm font-medium text-[var(--text-primary)]">
                {t('common:scope', 'Scope')}
              </label>
              <select
                value={formData.isShared ? 'shared' : 'tenant'}
                onChange={(e) => handleScopeChange(e.target.value)}
                className="w-full px-3 py-2 border border-[var(--border-color)] rounded-[var(--radius-input)] bg-[var(--bg-card)] text-[var(--text-primary)]"
              >
                <option value="tenant">
                  {t('common:scope.tenant', 'My Organization')}
                </option>
                <option value="shared">
                  {t('common:scope.shared', 'Shared (All Organizations)')}
                </option>
              </select>
              <p className="text-xs text-[var(--text-secondary)]">
                {formData.isShared
                  ? t('common:scope.shared.hint', 'This data will be accessible to all organizations')
                  : t('common:scope.tenant.hint', 'This data will only be visible to your organization')}
              </p>
            </div>
          ),
        },
      ]} />
    </div>
  );
}
```

### Scope Selector in Create Forms (Scoped Entities)

For `scoped` entities with explicit enum values (Tenant, Shared, Platform), use a dropdown with all scope options:

```tsx
export function EntityCreatePage() {
  const { t } = useTranslation(['{module}']);
  const [formData, setFormData] = useState({
    name: '',
    scope: 'Tenant',  // Enum: 'Tenant' | 'Shared' | 'Platform'
  });

  return (
    <SmartForm fields={[
      {
        name: 'name',
        type: 'text',
        label: t('{module}:form.name', 'Name'),
        required: true,
      },
      {
        name: 'scope',
        type: 'select',
        label: t('common:scope', 'Scope'),
        options: [
          { value: 'Tenant', label: t('common:scope.tenant', 'My Organization') },
          { value: 'Shared', label: t('common:scope.shared', 'Shared') },
          { value: 'Platform', label: t('common:scope.platform', 'Platform (Admin Only)') },
        ],
        default: 'Tenant',
        required: true,
        help: t('common:scope.help', 'Select the visibility level for this data'),
      },
    ]} />
  );
}
```

### Scope Indicator in List Views

Display a visual indicator/badge on each row showing the entity scope:

```tsx
import { useTranslation } from 'react-i18next';

// ScopeBadge component for reuse
interface ScopeBadgeProps {
  tenantId?: string | null;      // For optional entities: null = shared, value = tenant-specific
  scope?: string;                 // For scoped entities: 'Tenant' | 'Shared' | 'Platform'
}

export function ScopeBadge({ tenantId, scope }: ScopeBadgeProps) {
  const { t } = useTranslation(['common']);

  // Optional entity scope
  if (tenantId !== undefined) {
    const isTenant = Boolean(tenantId);
    return (
      <span
        className={`px-2 py-1 rounded-full text-xs font-semibold ${
          isTenant
            ? 'bg-[var(--bg-accent-light)] text-[var(--color-accent-600)]'
            : 'bg-[var(--bg-secondary)] text-[var(--text-secondary)]'
        }`}
      >
        {isTenant
          ? t('common:scope.tenant', 'Tenant')
          : t('common:scope.shared', 'Shared')}
      </span>
    );
  }

  // Scoped entity scope
  if (scope) {
    const scopeStyles: Record<string, { bg: string; text: string }> = {
      Tenant: {
        bg: 'bg-[var(--bg-accent-light)]',
        text: 'text-[var(--color-accent-600)]',
      },
      Shared: {
        bg: 'bg-[var(--bg-secondary)]',
        text: 'text-[var(--text-secondary)]',
      },
      Platform: {
        bg: 'bg-[var(--bg-warning-light)]',
        text: 'text-[var(--color-warning-600)]',
      },
    };

    const style = scopeStyles[scope] || scopeStyles.Tenant;
    const scopeLabel = {
      Tenant: t('common:scope.tenant', 'Organization'),
      Shared: t('common:scope.shared', 'Shared'),
      Platform: t('common:scope.platform', 'Platform'),
    }[scope] || scope;

    return (
      <span className={`px-2 py-1 rounded-full text-xs font-semibold ${style.bg} ${style.text}`}>
        {scopeLabel}
      </span>
    );
  }

  return null;
}
```

### Using ScopeBadge in SmartTable Columns

```tsx
// In the list page, add a scope column
const columns = [
  { key: 'name', label: t('{module}:columns.name', 'Name') },
  { key: 'code', label: t('{module}:columns.code', 'Code') },
  {
    key: 'scope',
    label: t('common:scope', 'Scope'),
    render: (row) => (
      // For optional entities: show based on tenantId
      <ScopeBadge tenantId={row.tenantId} />
      // OR for scoped entities: show based on scope field
      // <ScopeBadge scope={row.scope} />
    ),
  },
  { key: 'actions', label: t('{module}:columns.actions', 'Actions') },
];

return (
  <SmartTable
    columns={columns}
    data={data}
    loading={loading}
    onRowClick={(row) => navigate(`${row.id}`)}
  />
);
```

### I18n Keys for Scope UI

Add these keys to `src/i18n/locales/*/common.json`:

```json
{
  "scope": "Scope",
  "scope.tenant": "My Organization",
  "scope.tenant.hint": "This data will only be visible to your organization",
  "scope.shared": "Shared (All Organizations)",
  "scope.shared.hint": "This data will be accessible to all organizations",
  "scope.platform": "Platform (Admin Only)",
  "scope.help": "Select the visibility level for this data"
}
```

And in the module-specific translation files (e.g., `employees.json`):

```json
{
  "form": {
    "scope": "Scope",
    "scopeHint": "Choose who can see this data"
  }
}
```

### Rules

- **ALWAYS** provide scope controls in create forms for optional/scoped entities
- **ALWAYS** show scope indicator badges in list views
- **ALWAYS** use `ScopeBadge` component for consistency across modules
- **NEVER** let users create shared entities without explicit choice
- **NEVER** hide scope controls — scope is a business-critical property
- **ALWAYS** include scope-related translation keys in i18n files (all 4 languages)
- **FORBIDDEN:** Form field for scope labeled ambiguously (e.g., "Public/Private" without context)
- **FORBIDDEN:** Scope badges with hardcoded colors — always use CSS variables

---

## 8. Frontend Form Testing

> **ALL form pages MUST have tests.** Forms are critical user interaction points and MUST be verified.

### Required Test Coverage per Form Page

| Test category | What to verify | Tool |
|---------------|---------------|------|
| Rendering | Form renders with all expected fields | Vitest + React Testing Library |
| Validation | Required fields show errors on empty submit | Vitest + React Testing Library |
| Submission | Successful submit calls API and navigates back | Vitest + MSW (mock API) |
| Pre-fill (edit) | Edit form loads entity data into fields | Vitest + React Testing Library |
| Navigation | Back button calls `navigate(-1)` | Vitest + React Testing Library |
| Error handling | API error displays error message | Vitest + MSW |

### Test File Convention

```
src/pages/{App}/{Module}/
├── EntityCreatePage.tsx
├── EntityCreatePage.test.tsx    ← MANDATORY
├── EntityEditPage.tsx
├── EntityEditPage.test.tsx      ← MANDATORY
├── EntityListPage.tsx
└── EntityDetailPage.tsx
```

### Create Page Test Template

```tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { describe, it, expect, vi } from 'vitest';
import { EntityCreatePage } from './EntityCreatePage';

// Mock API
vi.mock('@/services/api/apiClient');
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => ({
  ...(await vi.importActual('react-router-dom')),
  useNavigate: () => mockNavigate,
}));

describe('EntityCreatePage', () => {
  it('renders the create form with all fields', () => {
    render(<MemoryRouter><EntityCreatePage /></MemoryRouter>);
    expect(screen.getByRole('textbox', { name: /name/i })).toBeInTheDocument();
    // Verify all expected form fields
  });

  it('shows validation errors on empty submit', async () => {
    render(<MemoryRouter><EntityCreatePage /></MemoryRouter>);
    await userEvent.click(screen.getByRole('button', { name: /save|create/i }));
    await waitFor(() => {
      expect(screen.getByText(/required/i)).toBeInTheDocument();
    });
  });

  it('submits form and navigates back on success', async () => {
    render(<MemoryRouter><EntityCreatePage /></MemoryRouter>);
    await userEvent.type(screen.getByRole('textbox', { name: /name/i }), 'Test');
    await userEvent.click(screen.getByRole('button', { name: /save|create/i }));
    await waitFor(() => {
      expect(mockNavigate).toHaveBeenCalledWith(-1);
    });
  });

  it('navigates back on cancel/back button', async () => {
    render(<MemoryRouter><EntityCreatePage /></MemoryRouter>);
    await userEvent.click(screen.getByRole('button', { name: /back|cancel/i }));
    expect(mockNavigate).toHaveBeenCalledWith(-1);
  });
});
```

### Edit Page Test Template

```tsx
describe('EntityEditPage', () => {
  it('loads entity data and pre-fills the form', async () => {
    render(<MemoryRouter initialEntries={['/entities/123/edit']}><EntityEditPage /></MemoryRouter>);
    await waitFor(() => {
      expect(screen.getByDisplayValue('Existing Name')).toBeInTheDocument();
    });
  });

  it('submits updated data and navigates back', async () => {
    render(<MemoryRouter initialEntries={['/entities/123/edit']}><EntityEditPage /></MemoryRouter>);
    await waitFor(() => screen.getByDisplayValue('Existing Name'));
    await userEvent.clear(screen.getByRole('textbox', { name: /name/i }));
    await userEvent.type(screen.getByRole('textbox', { name: /name/i }), 'Updated');
    await userEvent.click(screen.getByRole('button', { name: /save/i }));
    await waitFor(() => {
      expect(mockNavigate).toHaveBeenCalledWith(-1);
    });
  });

  it('displays error when API call fails', async () => {
    // Mock API to reject
    render(<MemoryRouter initialEntries={['/entities/123/edit']}><EntityEditPage /></MemoryRouter>);
    // ... trigger submit with mocked failure
    await waitFor(() => {
      expect(screen.getByText(/failed/i)).toBeInTheDocument();
    });
  });
});
```

### Rules

- **EVERY** `EntityCreatePage.tsx` MUST have a companion `EntityCreatePage.test.tsx`
- **EVERY** `EntityEditPage.tsx` MUST have a companion `EntityEditPage.test.tsx`
- Tests MUST cover: rendering, validation, submit success, submit error, navigation
- Use `@testing-library/react` + `@testing-library/user-event` (NEVER enzyme)
- Mock API with `vi.mock()` or MSW — NEVER make real API calls in tests
- Test files live next to their component (co-located, NOT in a separate `__tests__/` folder)

---

## 9. Frontend Compliance Gates (5 Mandatory Checks)

> **Run these checks before any frontend commit.** All 5 gates MUST pass.
> Previously in separate files `execution-frontend-gates.md` and `execution-frontend-patterns.md` — now consolidated here.

### Gate 1: CSS Variables (Theme System)

```bash
ALL_PAGES=$(find src/pages/ src/components/ -name "*.tsx" 2>/dev/null | grep -v node_modules | grep -v "\.test\.")
if [ -n "$ALL_PAGES" ]; then
  HARDCODED=$(grep -Pn '(bg|text|border)-(?!\[)(red|blue|green|gray|white|black|slate|zinc|neutral|stone)-' $ALL_PAGES 2>/dev/null)
  if [ -n "$HARDCODED" ]; then
    echo "FAIL: Hardcoded Tailwind colors found — must use CSS variables (see section 4)"
    echo "$HARDCODED"
  else
    echo "PASS: CSS variables"
  fi
fi
```

**Fix mapping:** See section 4 (CSS Variables) for the complete variable reference table.

### Gate 2: Forms as Pages (ZERO Modals/Drawers)

```bash
PAGE_FILES=$(find src/pages/ -name "*.tsx" 2>/dev/null)
if [ -n "$PAGE_FILES" ]; then
  FAIL=false
  MODAL_IMPORTS=$(grep -Pn "import.*(?:Modal|Dialog|Drawer|Popup|Sheet|SlideOver|Overlay)" $PAGE_FILES 2>/dev/null)
  if [ -n "$MODAL_IMPORTS" ]; then
    echo "FAIL: Modal/Dialog/Drawer imports — forms MUST be full pages (see section 3b)"
    echo "$MODAL_IMPORTS"
    FAIL=true
  fi
  MODAL_STATE=$(grep -Pn "useState.*(?:isOpen|showModal|showDialog|showCreate|showEdit|showForm|isCreating|isEditing|showDrawer|showPanel|showSlideOver|selectedEntity|editingEntity)" $PAGE_FILES 2>/dev/null)
  if [ -n "$MODAL_STATE" ]; then
    echo "FAIL: Inline form state detected — forms MUST be separate pages (see section 3b)"
    echo "$MODAL_STATE"
    FAIL=true
  fi
  if [ "$FAIL" = false ]; then echo "PASS: No modals/drawers"; fi
fi
```

### Gate 3: I18n File Structure

```bash
if [ ! -d "src/i18n/locales" ]; then
  echo "FAIL: Missing src/i18n/locales/ directory"
else
  for LANG in fr en it de; do
    JSON_FILES=$(find "src/i18n/locales/$LANG" -name "*.json" 2>/dev/null | wc -l)
    if [ "$JSON_FILES" -eq 0 ]; then
      echo "FAIL: No JSON files in src/i18n/locales/$LANG/"
    else
      echo "PASS: $LANG ($JSON_FILES files)"
    fi
  done
fi
```

### Gate 4: Lazy Loading

```bash
APP_TSX=$(find src/ -name "App.tsx" -not -path "*/node_modules/*" 2>/dev/null | head -1)
ROUTE_FILES=$(find src/routes/ -name "*.tsx" -o -name "*.ts" 2>/dev/null)
if [ -n "$APP_TSX" ]; then
  STATIC_IMPORTS=$(grep -Pn "^import .+ from '@/pages/" "$APP_TSX" $ROUTE_FILES 2>/dev/null)
  if [ -n "$STATIC_IMPORTS" ]; then
    echo "FAIL: Static page imports — MUST use React.lazy() (see section 1)"
    echo "$STATIC_IMPORTS"
  else
    echo "PASS: Lazy loading"
  fi
fi
```

### Gate 5: useTranslation in Pages

```bash
PAGE_FILES=$(find src/pages/ -name "*.tsx" 2>/dev/null | grep -v "\.test\." | grep -v node_modules)
if [ -n "$PAGE_FILES" ]; then
  TOTAL=$(echo "$PAGE_FILES" | wc -l)
  WITH_I18N=$(grep -l "useTranslation" $PAGE_FILES 2>/dev/null | wc -l)
  if [ "$WITH_I18N" -eq 0 ]; then
    echo "FAIL: No pages use useTranslation — all text must be translated (see section 2)"
  else
    echo "PASS: $WITH_I18N/$TOTAL pages use useTranslation"
  fi
fi
```

> **ALL 5 gates MUST pass before frontend commit.** When delegating to `/ui-components` skill, include explicit instructions: CSS variables only, forms as full pages, i18n with namespace + fallback.
