Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -888,6 +888,7 @@ const [filters, setFilters] = useUrlState<ListUOMsParams>({
- Uses `router.replace()` (no history per keystroke)
- Browser back/forward auto-restores filter state
- Always reset `page: 1` when any non-pagination filter changes
- **Only keys present in `defaultValues` are tracked** — pages with server-side sorting MUST include `sortBy`/`sortOrder` in `defaultValues` or sort changes are silently dropped (see RULES.md Mistake 12)

### 10.4 Cache Settings

Expand Down
107 changes: 93 additions & 14 deletions docs/design-system/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,19 @@ When `CardHeader` has a right slot (badge, button), always add `space-y-0` to pr
| Icon + text inside button | `mr-2 h-4 w-4` on icon |
| Icon + text inside badge | `mr-1 h-3 w-3` on icon |

### Control Height Parity (filter / toolbar rows)

Every control in a filter/toolbar row is **`h-9` (36px)** — search inputs, comboboxes, selects, the column-visibility trigger, and any adjacent buttons. Never mix `h-8`/`h-10` controls in the same row.

```tsx
<DebouncedSearchInput className="h-9" ... />
<ProductTypeMultiCombobox className="h-9" ... />
<SelectTrigger className="h-9">...</SelectTrigger>
<ColumnVisibilityMenu className="h-9" ... /> {/* trigger defaults to h-8 — pass h-9 in filter rows */}
```

Reference implementation: the filter toolbar in `src/app/(dashboard)/finance/product-master/product-master-page-client.tsx`.

---

## 5. Components A–Z
Expand Down Expand Up @@ -398,6 +411,21 @@ Used for contextual notices that appear inline on a page — not toasts. Two var
</Button>
```

#### Header Action Buttons (PageHeader actions slot)

All buttons in a `PageHeader` actions slot use the **same size (default, 36px)**. Below `sm` they collapse to **icon-only**: wrap the label in `hidden sm:inline`, keep the icon always visible, and add `aria-label` so the icon-only button stays accessible. Dropdown chevrons may stay visible on mobile.

```tsx
<Button onClick={openCreate} aria-label="New product">
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">New product</span>
</Button>
```

- No `mr-2` on the icon here — rely on the Button's built-in `gap-2`, so the hidden label leaves no stray margin on mobile
- The PageHeader actions slot is already `flex flex-wrap items-center gap-2` — never remove the wrap
- Reference: `src/app/(dashboard)/finance/product-master/product-master-page-client.tsx` (Import / Export / New product)

#### Action Bar Pattern (Detail Pages)

```tsx
Expand Down Expand Up @@ -697,6 +725,15 @@ export function ExampleCombobox({ value, onSelect, placeholder = "Select…", di
- Always `CommandEmpty` — "No results." message when search returns nothing
- Never use `<Select>` for data that requires async fetch or search

#### Multi-Select Combobox

For multi-value filters (e.g. product types), use the same Popover + Command shell with these additions — reference `src/components/finance/comboboxes/product-type-multi-combobox.tsx`:

- The popover **stays open** when an item is toggled — do not `setOpen(false)` in `onSelect`
- Selected state via the Check-icon opacity convention (`opacity-100` selected / `opacity-0` not)
- The trigger shows a **count badge** ("N selected") when multiple values are selected
- A "Clear selection" `CommandItem` lives **inside the popover** — **never** nest an interactive element (inline ✕, `<span role="button">`) inside the trigger `<button>`: it's an invalid HTML content model and breaks accessibility

---

### 5.7 Date Picker
Expand Down Expand Up @@ -906,11 +943,18 @@ Used for complex secondary UI that stays in context without full navigation.
**Rules:**
- `showCloseButton={false}` — always suppress default X, add your own
- `flex flex-col p-0 gap-0` on `SheetContent` — removes shadcn defaults
- Header and footer `shrink-0` + `bg-background` — prevent content bleeding through
- Header and footer `shrink-0` + `bg-background` + `border-b`/`border-t` + `px-6 py-4` — prevent content bleeding through
- `flex-1 overflow-y-auto` on body — only body scrolls
- `SheetTitle` and `SheetDescription` always present (accessibility — can be `sr-only`)
- `w-full sm:max-w-2xl` — full width on mobile, capped on desktop

**Read-only detail drawers** (quick-view from a list row) — reference `src/components/finance/cost-product-master/product-detail-drawer.tsx`:
- Body: `flex-1 overflow-y-auto px-6 py-5 space-y-4`; each content section wrapped in its own `rounded-lg border bg-card p-4` card
- Sections load **independently**: per-section loading skeleton while fetching, inline muted error text on failure — a failed section never blanks the whole drawer
- Detail queries pass `enabled` only while the drawer is open — nothing fetches while closed
- Footer always includes a link to the full detail page
- Grouped parameter display: group by `displayGroup` ordered by min `displayOrder` within each group — named groups first, ungrouped last (the algorithm lives in `src/components/finance/cost-results/cost-breakdown-modal.tsx` — reuse it, don't reinvent)

---

### 5.12 DropdownMenu
Expand Down Expand Up @@ -1156,6 +1200,8 @@ See §5.19.
- Place `<KpiGrid>` directly under `<PageHeader>`, before the main list card
- Use `href` to make KPI cards link to a filtered list view when possible
- `cols` prop: 2 for simple pages, 3–4 for dashboards
- **Count widgets use the neutral `default` variant** — reserve `success`/`warning`/`destructive` for semantic alerts (failed jobs, pending reviews), never for plain category counts like Active/Inactive (reference: `/finance/product-master` KPI row)
- `KpiGrid` handles odd card counts on mobile automatically (the last card spans the full 2-col row) — do not add manual `col-span` overrides on children

---

Expand Down Expand Up @@ -1507,25 +1553,50 @@ Pass `tableId` to enable the column visibility menu (⚙ button top-right):
/>
```

For **custom tables** (not `DataTable`), any table with **more than 6 columns** gets `useColumnVisibility(tableId, columns)` + `<ColumnVisibilityMenu>` (both in `src/components/shared/data-table/`):

- `tableId` convention: `<module>-<entity>` (e.g. `finance-product-master`)
- The identity/key column is `canHide: false` — users can never hide it
- Hoist the hook to the page component (see RULES.md Mistake 9) and pass `visibility` down to the table
- When the menu trigger sits in an h-9 filter row, pass `className="h-9"` (trigger defaults to h-8)
- Reference: `useProductMasterTableColumns()` in `src/components/finance/cost-product-master/product-master-table.tsx`

#### Sortable Columns

Column sorting is handled at the **backend** level via filter state. In the filter component:
Column sorting is always **server-side**, flowing through `filters.sortBy` / `filters.sortOrder` into the list hook:

- Sort keys must match the proto `sort_by` `buf.validate` `in`-list values — never invent frontend-only keys
- Every sort change (like every filter change) resets `page: 1`
- **`useUrlState` only tracks keys present in `defaultValues`** — `sortBy`/`sortOrder` MUST be listed in the page's `defaultFilters`, or header clicks are silently dropped (documented bug pattern; see RULES.md Mistake 12)

For **column-header click sort**, use the shared **`SortableHeader`** (`src/components/shared/data-table/sortable-header.tsx`) — never build ad-hoc sort buttons inside headers:

- fa-sort-style stacked-triangle SVG indicator: both triangles muted when inactive; the active direction's triangle filled
- The **whole `TableHead`** is the click target — `cursor-pointer select-none`, **no hover background**
- Accessibility built in: `aria-sort`, `tabIndex={0}`, Enter/Space keyboard activation

```tsx
// In filter component
<Select value={currentSort} onValueChange={handleSortChange}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="name-asc">Name (A-Z)</SelectItem>
<SelectItem value="name-desc">Name (Z-A)</SelectItem>
<SelectItem value="created_at-desc">Newest First</SelectItem>
</SelectContent>
</Select>
<SortableHeader
label="Product Code"
sortKey="product_code" // must be a proto-validated sort_by value
currentSortBy={sortBy}
currentSortOrder={sortOrder}
onSort={onSort}
/>
```

For **column-header click sort**, add `sortKey` to column definition and handle in parent:
Direction cycling is owned by the page client — asc ↔ desc on the active column, a new column always starts asc:

```tsx
function handleSort(sortKey: string) {
const nextOrder = filters.sortBy === sortKey && filters.sortOrder === "asc" ? "desc" : "asc"
setFilters({ ...filters, sortBy: sortKey, sortOrder: nextOrder, page: 1 })
}
```

Reference implementation: `product-master-table.tsx` + `product-master-page-client.tsx` (`/finance/product-master`).

In `DataTable` columns, add `sortKey` to the column definition and handle in the parent:
```tsx
{
id: "name",
Expand All @@ -1535,6 +1606,14 @@ For **column-header click sort**, add `sortKey` to column definition and handle
}
```

#### Row-Click Navigation

When list rows navigate to a detail page:

- `onClick` on the `TableRow` + `cursor-pointer`
- The actions cell must call `stopPropagation` so action buttons don't trigger navigation
- Do **not** also render a `<Link>` on the identity column — it stays plain text (no link-inside-clickable-row)

#### Row Actions

```tsx
Expand Down
19 changes: 19 additions & 0 deletions docs/design-system/LAYOUT.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,13 @@ The `DataTable` component already applies this internally. Do NOT add `overflow-
3. DataTable
4. DataTablePagination

**Vertical rhythm**: the single root `space-y-6` is the ONLY spacing between header → KPI row → filters → table → pagination — no extra ad-hoc margins between these blocks. If `PageHeader`'s built-in bottom padding doubles the gap, pass `className="pb-0"` to it rather than adjusting neighbors.

**Flat list variant** (no wrapping Card — reference `/finance/product-master`): PageHeader → KpiGrid → filter toolbar → table → DataTablePagination sit directly under the root `space-y-6`. Rules for the flat table:
- Table wrapper: `rounded-md border` with an **inner** `overflow-x-auto` div — the table scrolls inside the border, not the page
- First column gets `pl-4` and last column `pr-4` — on **both** head and body cells — so content aligns with the rounded border
- Every control in the filter toolbar row is `h-9` (see DESIGN.md §4 — Control Height Parity)

---

### 4.2 Detail Page (Bento Grid)
Expand Down Expand Up @@ -504,6 +511,18 @@ Action bars always use `flex-wrap`:

On mobile (`<640px`), when the container is narrower, buttons wrap to the next line. The `flex-1` spacer collapses and the "Cancel" button appears below the primary actions — this is correct behavior.

### Header Actions Collapse to Icon-Only

Below `sm` (640px), buttons in the `PageHeader` actions slot collapse to **icon-only**: wrap the label in `<span className="hidden sm:inline">`, keep the icon always visible, and add `aria-label` on the button so icon-only remains accessible. Dropdown chevrons may stay visible. Full pattern in DESIGN.md §5.3 (Header Action Buttons); reference: `/finance/product-master` (Import / Export / New product).

### 375px Responsive Floor

Every list page must be verified at **375px** viewport width:

- Zero horizontal page overflow (header and sidebar never scroll sideways)
- Filter controls stack (`grid-cols-1` / `flex-col` at base)
- Tables scroll **inside their own wrapper only** — never the page

### PageHeader on Mobile

The `PageHeader` component handles this automatically:
Expand Down
47 changes: 46 additions & 1 deletion docs/design-system/RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,23 @@ I need a side panel that stays in context
I need to pick from a long/async list in a form
→ Build a Combobox (DESIGN.md §5.6) — NEVER a Select with 50+ items

I need a multi-value filter (e.g. several product types at once)
→ Multi-Select Combobox (DESIGN.md §5.6) — popover stays open on toggle,
count badge in trigger, "Clear selection" CommandItem INSIDE the popover
(never a nested interactive element inside the trigger button)

I need sortable column headers on a custom table
→ <SortableHeader> from src/components/shared/data-table/sortable-header.tsx (DESIGN.md §5.24)
→ NEVER ad-hoc sort buttons inside TableHead

I need show/hide columns on a custom table (>6 columns)
→ useColumnVisibility("<module>-<entity>", columns) + <ColumnVisibilityMenu> (DESIGN.md §5.24)
→ Identity/key column gets canHide: false

I need list rows that navigate to a detail page
→ onClick on TableRow + cursor-pointer; stopPropagation on the actions cell;
identity column stays plain text — no <Link> inside a clickable row (DESIGN.md §5.24)

I need to show multi-line read-only text
→ <p className="text-sm whitespace-pre-wrap"> (NOT a Textarea)

Expand Down Expand Up @@ -185,6 +202,10 @@ For every component/page generated, verify:
- [ ] Dialog max-width uses `sm:max-w-[Npx]` (full-width on mobile, capped on desktop)
- [ ] Drawer uses `w-full sm:max-w-2xl`
- [ ] No hardcoded px widths on main content containers (they should flex/fill)
- [ ] **Control height parity**: every control in a filter/toolbar row is `h-9` (36px) — search input, comboboxes, selects, column-visibility trigger, adjacent buttons; never mix h-8/h-10 in one row (DESIGN.md §4)
- [ ] **PageHeader action buttons**: all same size (default, 36px); below `sm` they collapse to icon-only — `hidden sm:inline` on the label + `aria-label` on the button (DESIGN.md §5.3)
- [ ] **375px floor**: page verified at 375px — zero horizontal page overflow, filters stack, tables scroll inside their wrapper only (LAYOUT.md §8)
- [ ] KpiGrid with an odd card count needs no manual fix — the last card auto-spans the 2-col mobile row

---

Expand Down Expand Up @@ -221,6 +242,10 @@ For every component/page generated, verify:
| `<ScrollableDialogContent>` for 6+ fields | Regular `<DialogContent>` that overflows |
| `<DataTablePagination totalItems={Number(...)} />` | `totalItems={response.totalItems}` (string) |
| Extend `status-colors.ts` for new statuses | Inline `<Badge className="bg-green-...">` |
| `<SortableHeader>` for header-click sort | Ad-hoc `<Button>` sort toggles inside `TableHead` |
| Row nav: `onClick` on `TableRow` + `stopPropagation` on actions cell | `<Link>` on the identity column inside a clickable row |
| `KpiCard` neutral (`default`) variant for count widgets | `success`/`warning` variants for plain category counts |
| "Clear selection" `CommandItem` inside the multi-combobox popover | `<span role="button">` (inline ✕) nested in the trigger button |

### Forms

Expand Down Expand Up @@ -581,7 +606,27 @@ Every page directory **must** have a `loading.tsx`. If you create a new page, cr

---

### Mistake 12: Typography.ts cardTitle — stale value
### Mistake 12: Sort state not in useUrlState defaultValues

`useUrlState` only tracks keys that are present in `defaultValues`. If a page adds server-side sorting but `sortBy`/`sortOrder` are missing from `defaultFilters`, header clicks are **silently dropped** — the URL never updates and the list never re-sorts.

```tsx
// ✗ Wrong — sortBy/sortOrder missing → sort clicks silently ignored
const defaultFilters: ListParams = { page: 1, pageSize: 20, search: "" }

// ✓ Correct — sortBy/sortOrder must be listed even when empty
const defaultFilters: ListParams = {
page: 1, pageSize: 20, search: "",
sortBy: "",
sortOrder: undefined,
}
```

Also: sort keys must match the proto `sort_by` validated values, and every sort change resets `page: 1`. See DESIGN.md §5.24 (Sortable Columns).

---

### Mistake 13: Typography.ts cardTitle — stale value

The file `src/lib/ui/typography.ts` has a conflicting `cardTitle` definition. The **correct value** is:
```ts
Expand Down
Loading
Loading