# Templates Frontend - Application Skill

> These templates generate React/TypeScript code for new applications/modules.

---

## FRONTEND ARCHITECTURE

```
web/smartstack-web/src/
├── pages/$CONTEXT/$APPLICATION/$MODULE/
│   ├── $MODULE_PASCALPage.tsx          # Main page (list)
│   ├── $MODULE_PASCALDetailPage.tsx    # Detail page
│   └── Create$MODULE_PASCALPage.tsx    # Create page
├── components/$CONTEXT/$MODULE/
│   ├── $MODULE_PASCALListView.tsx      # Reusable list component
│   ├── $MODULE_PASCALForm.tsx          # CRUD form
│   └── $MODULE_PASCALFilters.tsx       # Filters
├── components/common/
│   └── (shared components)
├── hooks/
│   ├── use$MODULE_PASCALPreferences.ts # Preferences hook
│   └── use$MODULE_PASCAL.ts            # API hook
├── services/api/
│   └── $moduleApi.ts                   # API service
└── i18n/locales/
    ├── fr/$module.json
    ├── en/$module.json
    ├── it/$module.json
    └── de/$module.json
```

---

## ROUTE PATH VARIABLES

> **All route path segments MUST use kebab-case to match the navigation seed data.**

| Variable | Description | Example |
|----------|-------------|---------|
| `$APPLICATION_KEBAB` | kebab-case of application code | `human-resources` |
| `$MODULE_KEBAB` | kebab-case of module code | `time-management` |

**Transformation rule:** `PascalCase` → `kebab-case` via regex `([a-z])([A-Z])` → `$1-$2` + `toLowerCase()`

Examples:
- `HumanResources` → `human-resources`
- `TimeManagement` → `time-management`
- `Employees` → `employees` (single word, no change needed)

> **FORBIDDEN:** Using raw `$APPLICATION` or `$MODULE` in route paths — they may produce non-kebab-case URLs (e.g., `humanresources` instead of `human-resources`). Always use `$APPLICATION_KEBAB` and `$MODULE_KEBAB` for route paths.

---

## TEMPLATE: MAIN PAGE

```tsx
// pages/$CONTEXT/$APPLICATION/$MODULE/$MODULE_PASCALPage.tsx

import { useTranslation } from 'react-i18next';
import { $MODULE_PASCALListView } from '@/components/$CONTEXT/$MODULE/$MODULE_PASCALListView';

export function $MODULE_PASCALPage() {
  const { t } = useTranslation(['$module', 'common']);

  return (
    <$MODULE_PASCALListView
      title={t('$module:title')}
      subtitle={t('$module:subtitle')}
      createPath="new"
    />
  );
}
```

---

## TEMPLATE: LIST VIEW (Reusable component)

```tsx
// components/$CONTEXT/$MODULE/$MODULE_PASCALListView.tsx

import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Plus, Search, MoreVertical, Pencil, Trash2, Check, AlertCircle } from 'lucide-react';
import { api } from '@/services/api/apiClient';
import { use$MODULE_PASCALPreferences } from '@/hooks/use$MODULE_PASCALPreferences';
import { Pagination } from '@/components/common/Pagination';
import { ColumnSelector } from '@/components/common/ColumnSelector';
import { ViewToggle } from '@/components/ui/DataView';
import { EntityCard } from '@/components/ui/EntityCard';  // ⚠️ MANDATORY for Grid view

interface $ENTITY_PASCALDto {
  id: string;
  name: string;
  description: string | null;
  isActive: boolean;
  createdAt: string;
  updatedAt: string | null;
}

interface PaginatedResult {
  items: $ENTITY_PASCALDto[];
  totalCount: number;
  page: number;
  pageSize: number;
}

interface $MODULE_PASCALListViewProps {
  title: string;
  subtitle?: string;
  createPath?: string;
}

const DEFAULT_COLUMNS = ['name', 'description', 'isActive', 'createdAt'];

export function $MODULE_PASCALListView({
  title,
  subtitle,
  createPath,
}: $MODULE_PASCALListViewProps) {
  const { t } = useTranslation(['$module', 'common']);
  const navigate = useNavigate();
  const {
    pageSize,
    sortColumn,
    sortDirection,
    visibleColumns,
    viewMode,
    setPageSize,
    setSortColumn,
    setSortDirection,
    setVisibleColumns,
    setViewMode,
  } = use$MODULE_PASCALPreferences();

  const [data, setData] = useState<PaginatedResult | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [page, setPage] = useState(1);
  const [search, setSearchTerm] = useState('');

  useEffect(() => {
    loadData();
  }, [page, pageSize, sortColumn, sortDirection, search]);

  const loadData = async () => {
    try {
      setLoading(true);
      setError(null);
      const params = new URLSearchParams({
        page: page.toString(),
        pageSize: pageSize.toString(),
        sortColumn,
        sortDirection,
        ...(search && { search }),
      });
      const result = await api.get<PaginatedResult>(`/api/$module?${params}`);
      setData(result);
    } catch (err) {
      setError(t('common:errors.loadFailed'));
      console.error('Failed to load $module:', err);
    } finally {
      setLoading(false);
    }
  };

  const handleSort = (column: string) => {
    if (sortColumn === column) {
      setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
    } else {
      setSortColumn(column);
      setSortDirection('asc');
    }
  };

  const handleDelete = async (id: string) => {
    if (!confirm(t('common:confirmDelete'))) return;

    try {
      await api.delete(`/api/$module/${id}`);
      loadData();
    } catch (err) {
      console.error('Failed to delete:', err);
    }
  };

  const columns = [
    { key: 'name', label: t('$module:columns.name'), sortable: true },
    { key: 'description', label: t('$module:columns.description'), sortable: false },
    { key: 'isActive', label: t('$module:columns.status'), sortable: true },
    { key: 'createdAt', label: t('$module:columns.createdAt'), sortable: true },
  ];

  return (
    <div className="space-y-6">
      {/* Header */}
      <div className="flex items-center justify-between">
        <div>
          <h1 className="text-2xl font-bold text-[var(--text-primary)]">{title}</h1>
          {subtitle && (
            <p className="text-[var(--text-secondary)] mt-1">{subtitle}</p>
          )}
        </div>
        {createPath && (
          <button
            onClick={() => navigate(createPath)}
            className="flex items-center gap-2 px-4 py-2 bg-[var(--color-accent-500)] hover:bg-[var(--color-accent-600)] text-white font-medium transition-colors"
            style={{ borderRadius: 'var(--radius-button)' }}
          >
            <Plus className="w-4 h-4" />
            {t('common:actions.create')}
          </button>
        )}
      </div>

      {/* Toolbar */}
      <div className="flex items-center justify-between gap-4">
        <div className="relative flex-1 max-w-md">
          <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-muted)]" />
          <input
            type="text"
            placeholder={t('common:search.placeholder')}
            value={search}
            onChange={(e) => setSearchTerm(e.target.value)}
            className="w-full pl-10 pr-4 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] text-sm focus:outline-none focus:border-[var(--color-accent-500)]"
            style={{ borderRadius: 'var(--radius-input)' }}
          />
        </div>
        <div className="flex items-center gap-2">
          <ColumnSelector
            columns={columns}
            visibleColumns={visibleColumns}
            onChange={setVisibleColumns}
          />
          <ViewToggle value={viewMode} onChange={setViewMode} />
        </div>
      </div>

      {/* Content */}
      {loading ? (
        <div className="flex items-center justify-center py-12">
          <div className="animate-spin h-8 w-8 border-4 border-[var(--color-accent-500)] border-t-transparent rounded-full" />
        </div>
      ) : error ? (
        <div className="p-4 bg-[var(--error-bg)] border border-[var(--error-border)] rounded-[var(--radius-card)] flex items-center gap-3">
          <AlertCircle className="w-5 h-5 text-[var(--error-dot)] flex-shrink-0" />
          <span className="text-[var(--error-text)]">{error}</span>
          <button onClick={() => fetchData()} className="ml-auto text-sm text-[var(--error-text)] hover:underline">
            {t('common:actions.retry')}
          </button>
        </div>
      ) : data?.items.length === 0 ? (
        <div className="text-center py-16">
          <div className="w-16 h-16 mx-auto rounded-full bg-[var(--bg-secondary)] flex items-center justify-center mb-4">
            <Search className="w-8 h-8 text-[var(--text-tertiary)]" />
          </div>
          <p className="text-[var(--text-secondary)]">{t('$module:list.empty')}</p>
        </div>
      ) : viewMode === 'list' ? (
        <div className="bg-[var(--bg-card)] border border-[var(--item-color-border)] overflow-hidden" style={{ borderRadius: 'var(--radius-card)' }}>
          <table className="w-full">
            <thead>
              <tr className="border-b border-[var(--item-color-border)]">
                {columns.filter(col => visibleColumns.includes(col.key)).map((col) => (
                  <th
                    key={col.key}
                    className={`px-4 py-3 text-left text-sm font-medium text-[var(--text-secondary)] ${
                      col.sortable ? 'cursor-pointer hover:text-[var(--text-primary)]' : ''
                    }`}
                    onClick={() => col.sortable && handleSort(col.key)}
                  >
                    {col.label}
                    {sortColumn === col.key && (
                      <span className="ml-1">{sortDirection === 'asc' ? '↑' : '↓'}</span>
                    )}
                  </th>
                ))}
                <th className="px-4 py-3 text-right text-sm font-medium text-[var(--text-secondary)]">
                  {t('common:actions.title')}
                </th>
              </tr>
            </thead>
            <tbody>
              {data?.items.map((item) => (
                <tr
                  key={item.id}
                  className="border-b border-[var(--item-color-border)] hover:bg-[var(--bg-hover)] cursor-pointer"
                  onClick={() => navigate(item.id)}
                >
                  {visibleColumns.includes('name') && (
                    <td className="px-4 py-3 text-sm text-[var(--text-primary)] font-medium">
                      {item.name}
                    </td>
                  )}
                  {visibleColumns.includes('description') && (
                    <td className="px-4 py-3 text-sm text-[var(--text-secondary)]">
                      {item.description || '-'}
                    </td>
                  )}
                  {visibleColumns.includes('isActive') && (
                    <td className="px-4 py-3">
                      <span
                        className={`inline-flex items-center px-2 py-1 text-xs font-medium ${
                          item.isActive
                            ? 'bg-[var(--success-bg)] text-[var(--success-text)]'
                            : 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
                        }`}
                        style={{ borderRadius: 'var(--radius-badge)' }}
                      >
                        {item.isActive ? t('common:status.active') : t('common:status.inactive')}
                      </span>
                    </td>
                  )}
                  {visibleColumns.includes('createdAt') && (
                    <td className="px-4 py-3 text-sm text-[var(--text-secondary)]">
                      {new Date(item.createdAt).toLocaleDateString()}
                    </td>
                  )}
                  <td className="px-4 py-3 text-right">
                    <div className="flex items-center justify-end gap-2">
                      <button
                        onClick={(e) => {
                          e.stopPropagation();
                          navigate(`${item.id}/edit`);
                        }}
                        className="p-1 hover:bg-[var(--bg-tertiary)]"
                        style={{ borderRadius: 'var(--radius-button)' }}
                      >
                        <Pencil className="w-4 h-4 text-[var(--text-secondary)]" />
                      </button>
                      <button
                        onClick={(e) => {
                          e.stopPropagation();
                          handleDelete(item.id);
                        }}
                        className="p-1 hover:bg-[var(--error-bg)]"
                        style={{ borderRadius: 'var(--radius-button)' }}
                      >
                        <Trash2 className="w-4 h-4 text-[var(--error-text)]" />
                      </button>
                    </div>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      ) : (
        /* Grid view - ⚠️ MANDATORY: use EntityCard */
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
          {data?.items.map((item) => (
            <EntityCard
              key={item.id}
              avatar={{ letter: item.name[0].toUpperCase(), color: 'var(--color-accent-500)' }}
              title={item.name}
              subtitle={item.code}
              description={item.description}
              badge={item.isActive ? {
                icon: Check,
                tooltip: t('common:status.active'),
                color: 'var(--success-text)'
              } : undefined}
              stats={`${t('common:createdAt')}: ${new Date(item.createdAt).toLocaleDateString()}`}
              onClick={() => navigate(item.id)}
              actions={[
                { label: t('common:actions.view'), onClick: () => navigate(item.id), variant: 'primary' },
              ]}
            />
          ))}
        </div>
      )}

      {/* Pagination */}
      {data && (
        <Pagination
          page={data.page}
          pageSize={data.pageSize}
          totalCount={data.totalCount}
          onPageChange={setPage}
          onPageSizeChange={setPageSize}
        />
      )}
    </div>
  );
}
```

---

## TEMPLATE: PREFERENCES HOOK

```tsx
// hooks/use$MODULE_PASCALPreferences.ts

import { useUserPreferences } from '@/contexts/UserPreferencesContext';

const DEFAULT_COLUMNS = ['name', 'description', 'isActive', 'createdAt'];

export function use$MODULE_PASCALPreferences() {
  const { preferences, updatePreference } = useUserPreferences();
  const modulePrefs = preferences.$module || {};

  return {
    // Getters with defaults
    pageSize: modulePrefs.pageSize ?? 10,
    sortColumn: modulePrefs.sortColumn ?? 'createdAt',
    sortDirection: modulePrefs.sortDirection ?? 'desc',
    filters: modulePrefs.filters ?? {},
    visibleColumns: modulePrefs.visibleColumns ?? DEFAULT_COLUMNS,
    viewMode: modulePrefs.viewMode ?? 'list',

    // Setters
    setPageSize: (size: number) =>
      updatePreference('$module.pageSize', size),
    setSortColumn: (col: string) =>
      updatePreference('$module.sortColumn', col),
    setSortDirection: (dir: 'asc' | 'desc') =>
      updatePreference('$module.sortDirection', dir),
    setFilters: (filters: Record<string, unknown>) =>
      updatePreference('$module.filters', filters),
    setVisibleColumns: (cols: string[]) =>
      updatePreference('$module.visibleColumns', cols),
    setViewMode: (mode: 'list' | 'grid') =>
      updatePreference('$module.viewMode', mode),
  };
}
```

---

## TEMPLATE: SERVICE API

```tsx
// services/api/$moduleApi.ts

import { api } from './apiClient';

export interface $ENTITY_PASCALDto {
  id: string;
  name: string;
  description: string | null;
  isActive: boolean;
  createdAt: string;
  updatedAt: string | null;
}

export interface Create$ENTITY_PASCALRequest {
  name: string;
  description?: string;
}

export interface Update$ENTITY_PASCALRequest {
  name: string;
  description?: string;
}

export interface PaginatedResult<T> {
  items: T[];
  totalCount: number;
  page: number;
  pageSize: number;
}

export interface PaginationParams {
  page?: number;
  pageSize?: number;
  search?: string;
  sortColumn?: string;
  sortDirection?: 'asc' | 'desc';
}

export const $moduleApi = {
  getAll: async (params: PaginationParams = {}): Promise<PaginatedResult<$ENTITY_PASCALDto>> => {
    const queryParams = new URLSearchParams();
    if (params.page) queryParams.set('page', params.page.toString());
    if (params.pageSize) queryParams.set('pageSize', params.pageSize.toString());
    if (params.search) queryParams.set('search', params.search);
    if (params.sortColumn) queryParams.set('sortColumn', params.sortColumn);
    if (params.sortDirection) queryParams.set('sortDirection', params.sortDirection);

    return api.get(`/api/$module?${queryParams}`);
  },

  getById: async (id: string): Promise<$ENTITY_PASCALDto> => {
    return api.get(`/api/$module/${id}`);
  },

  create: async (data: Create$ENTITY_PASCALRequest): Promise<$ENTITY_PASCALDto> => {
    return api.post('/api/$module', data);
  },

  update: async (id: string, data: Update$ENTITY_PASCALRequest): Promise<$ENTITY_PASCALDto> => {
    return api.put(`/api/$module/${id}`, data);
  },

  delete: async (id: string): Promise<void> => {
    return api.delete(`/api/$module/${id}`);
  },
};
```

---

## TEMPLATE: ROUTES (App.tsx)

### Detect App.tsx Routing Pattern FIRST

Before adding routes, **read App.tsx** and detect which pattern is used:

| Pattern | How to detect | Action |
|---------|---------------|--------|
| **Pattern A** (mergeRoutes) | `contextRoutes: ContextRouteExtensions` present | Add to `contextRoutes.{context}[]` array |
| **Pattern B** (JSX Routes) | `<Route path="/{context}" element={<{Layout} />}>` present | Insert `<Route>` children inside Layout wrapper |

### Pattern A: mergeRoutes (contextRoutes array)

> **This is the DEFAULT pattern** generated by `smartstack init`.
> Routes added to `contextRoutes` are automatically injected into BOTH standard and tenant-prefixed route trees by `mergeRoutes()`. No manual duplication needed.

```tsx
// Add to App.tsx — imports at top
import { $MODULE_PASCALPage } from '@/pages/$CONTEXT/$APPLICATION/$MODULE/$MODULE_PASCALPage';
import { $MODULE_PASCALDetailPage } from '@/pages/$CONTEXT/$APPLICATION/$MODULE/$MODULE_PASCALDetailPage';
import { Create$MODULE_PASCALPage } from '@/pages/$CONTEXT/$APPLICATION/$MODULE/Create$MODULE_PASCALPage';

// Add routes to contextRoutes.{context}[] with RELATIVE paths (no leading /)
const contextRoutes: ContextRouteExtensions = {
  $CONTEXT: [
    // ... existing routes ...
    { path: '$APPLICATION_KEBAB/$MODULE_KEBAB', element: <$MODULE_PASCALPage /> },
    { path: '$APPLICATION_KEBAB/$MODULE_KEBAB/new', element: <Create$MODULE_PASCALPage /> },
    { path: '$APPLICATION_KEBAB/$MODULE_KEBAB/:id', element: <$MODULE_PASCALDetailPage /> },
    { path: '$APPLICATION_KEBAB/$MODULE_KEBAB/:id/edit', element: <Create$MODULE_PASCALPage /> },
  ],
};
```

**mergeRoutes auto-generates redirects** for intermediate paths (e.g., `$APPLICATION_KEBAB` → `$APPLICATION_KEBAB/$DEFAULT_MODULE_KEBAB`) so you don't need to add index redirects manually.

**Context-to-Layout mapping (automatic via mergeRoutes):**

| Context key | Injected into Layout | Standard path | Tenant path |
|-------------|---------------------|---------------|-------------|
| `platform` | `AdminLayout` | `/platform/...` | `/t/:slug/platform/...` |
| `business` | `BusinessLayout` | `/business/...` | `/t/:slug/business/...` |
| `personal` | `UserLayout` | `/personal/myspace/...` | `/t/:slug/personal/myspace/...` |

### Pattern B: JSX Routes (inside Layout wrapper)

> **Legacy pattern** — only used if App.tsx was manually restructured with JSX `<Route>` elements.

SmartStack layouts (`AdminLayout`, `BusinessLayout`, `UserLayout`) provide the application shell: **header with AvatarMenu**, sidebar, navigation. They render child pages via React Router's `<Outlet />`.

**If routes are placed OUTSIDE the layout wrapper, the shell (header, sidebar, AvatarMenu) will NOT render. The page appears "naked" without any navigation.**

**Step-by-step insertion:**

1. Open `App.tsx`
2. Find the existing layout route for the target context:
   - `platform` → `<Route path="/platform" element={<AdminLayout />}>`
   - `business` → `<Route path="/business" element={<BusinessLayout />}>`
   - `personal` → `<Route path="/personal/myspace" element={<UserLayout />}>`
3. Add the new routes **INSIDE** that `<Route>` block
4. If a tenant-prefixed block exists (`/t/:slug/...`), add the routes there too

```tsx
// Add to App.tsx

import { $MODULE_PASCALPage } from '@/pages/$CONTEXT/$APPLICATION/$MODULE/$MODULE_PASCALPage';
import { $MODULE_PASCALDetailPage } from '@/pages/$CONTEXT/$APPLICATION/$MODULE/$MODULE_PASCALDetailPage';
import { Create$MODULE_PASCALPage } from '@/pages/$CONTEXT/$APPLICATION/$MODULE/Create$MODULE_PASCALPage';

// Find the EXISTING layout route and add routes INSIDE it:
<Route path="/$CONTEXT" element={<$CONTEXT_Layout />}>
  {/* ... existing routes stay here ... */}

  {/* NEW: $APPLICATION routes - added as children of the layout */}
  <Route path="$APPLICATION_KEBAB">
    <Route index element={<Navigate to="$DEFAULT_MODULE_KEBAB" replace />} />
    <Route path="$MODULE_KEBAB" element={<$MODULE_PASCALPage />} />
    <Route path="$MODULE_KEBAB/new" element={<Create$MODULE_PASCALPage />} />
    <Route path="$MODULE_KEBAB/:id" element={<$MODULE_PASCALDetailPage />} />
    <Route path="$MODULE_KEBAB/:id/edit" element={<Create$MODULE_PASCALPage />} />
  </Route>
</Route>
```

### ⚠️ CRITICAL RULES (both patterns)

**FORBIDDEN — Adding to clientRoutes[] with absolute paths:**
```tsx
// ❌ WRONG — bypasses layout entirely, shell will NOT render
const clientRoutes: RouteConfig[] = [
  { path: '/business/$APPLICATION_KEBAB/$MODULE_KEBAB', element: <$MODULE_PASCALPage /> },
];
```

`clientRoutes` is ONLY for routes **outside** SmartStack locked contexts (e.g., `/about`, `/pricing`).

**FORBIDDEN — Flat routes outside layout:**
```tsx
// ❌ WRONG (Pattern B only) — flat route bypasses layout
<Route path="/$CONTEXT/$APPLICATION_KEBAB/$MODULE_KEBAB" element={<$MODULE_PASCALPage />} />
```

### Why nested/context routes?

| Aspect | clientRoutes (wrong) | contextRoutes / nested (correct) |
|--------|---------------------|----------------------------------|
| Shell rendered | No (bypasses layout) | Yes (Outlet pattern) |
| AvatarMenu visible | No | Yes |
| Tenant prefix | Manual duplication | Automatic (mergeRoutes) |
| Auto-redirects | None | Generated for intermediate paths |
| Permission check | Bypassed (no RouteGuard) | Enforced by RouteGuard |

---

## ⛔ FORBIDDEN PATTERNS

These patterns are **strictly prohibited** in generated frontend code:

| FORBIDDEN | REQUIRED |
|-----------|----------|
| `import axios from 'axios'` | `import { api } from '@/services/api/apiClient'` |
| `bg-blue-600`, `text-gray-900`, `bg-green-100` | `bg-[var(--color-accent-600)]`, `text-[var(--text-primary)]`, `bg-[var(--success-bg)]` |
| Any hardcoded Tailwind color (`bg-{color}-{shade}`) | CSS variables from style-guide.md |
| `rounded-lg`, `rounded-md` | `rounded-[var(--radius-card)]`, `rounded-[var(--radius-button)]` |
| Custom `<div>` cards for entity lists | `<EntityCard>` component (MANDATORY) |
| Flat routes outside Layout wrapper | Nested routes inside `<BusinessLayout>` / `<AdminLayout>` |
| Route paths with PascalCase or concatenated words (e.g., `humanresources`, `timeManagement`) | Kebab-case route paths (e.g., `human-resources`, `time-management`) |
| `navigate(\`${basePath}/${item.id}\`)` in list row click | `navigate(item.id)` (relative navigation, no path duplication) |
| Only 2 languages (fr/en) | All 4 languages (fr/en/it/de) |
| `[Authorize]` without permissions | `[RequirePermission("navRoute.action")]` per endpoint |
| Missing error state | Error state with retry button (AlertCircle + underline) |
| Missing empty state | Empty state with icon + message |
| `new Date().toLocaleDateString()` without locale | Date formatting via i18n or Intl |

---

## FRONTEND CHECKLIST

### Structure
| Check | Status |
|-------|--------|
| ☐ Main page created (`$MODULE_PASCALPage.tsx`) | |
| ☐ ListView component created (`$MODULE_PASCALListView.tsx`) | |
| ☐ Preferences hook created (`use$MODULE_PASCALPreferences.ts`) | |
| ☐ API service created (uses `apiClient`, NOT raw axios) | |
| ☐ Routes added inside Layout wrapper in App.tsx | |
| ☐ Route path follows `/{context}/{application_kebab}/{module_kebab}` (kebab-case) | |

### Theme Compliance
| Check | Status |
|-------|--------|
| ☐ Zero hardcoded Tailwind colors (grep: `bg-blue-`, `text-gray-`, etc.) | |
| ☐ All colors use CSS variables (`var(--*)`) | |
| ☐ Border radius uses `var(--radius-*)` | |
| ☐ Buttons follow style-guide.md variants | |

### UI Components
| Check | Status |
|-------|--------|
| ☐ Grid view uses `EntityCard` (not custom divs) | |
| ☐ `ViewToggle` for list/grid switch | |
| ☐ Loading state (spinner) | |
| ☐ Error state (AlertCircle + retry button) | |
| ☐ Empty state (icon + message) | |
| ☐ Pagination component | |

### i18n
| Check | Status |
|-------|--------|
| ☐ French (fr) file created | |
| ☐ English (en) file created | |
| ☐ Italian (it) file created | |
| ☐ German (de) file created | |
| ☐ All 4 files have identical keys | |

### Build
| Check | Status |
|-------|--------|
| ☐ `npm run build` successful | |
| ☐ `npm run lint` successful | |
