Skip to content

Global search — design

Audit row 2.2; Figma header search pill 77:4078 in frame 123:7183: a white pill, radius 50, h34, w389, 8px inset, 20px search icon, 12px placeholder “Search for anything”, centred in the top bar between the breadcrumb (left) and the user chip (right).

  1. One endpoint, GET /search?q=&limit=, returning four groups: claims, spends, topups (petty-cash requests), advances. Each hit is {id, reference, text, amount, currency, supplier} — supplier is set only on a claim found by a line’s supplier rather than its reference or title, and the UI shows it as the subline — text is the claim title, spend description, top-up purpose or advance purpose, matched as stored (record language). Money is a string. q is trimmed, 2–100 characters (422 otherwise); limit is per group, 1–20, default 5.
  2. A search never shows what the list would not. Each group is the union of the scopes its own list endpoint would serve the caller, using that list’s own queries:
    • claims: expenses.claim.read, then scope=all if read_all, else mine ∪ to_act (ListClaims / ToActFiltered). Claims also match a line’s supplier_name — an opt-in flag on the filter, so the claims list’s own q is unchanged.
    • spends / top-ups: pettycash.read, then with read_all or float.manage the scope=all answer (CanReadSpend / CanReadTopUp per row), else own ∪ the to_act queue.
    • advances: advances.advance.read, then scope=all if read_all, else mine ∪ the unfiltered to_act queue filtered in memory (the queue takes no q, and searching it in SQL could reach past the window the list shows). A group whose read permission is missing is empty, not an error. Each module exports its group from a contract.go (AGENTS.md: cross-module access is contract.go); a new modules/search composes them.
  3. Permission search.use, so the route is httpx.Protected like every other, held by everyone (controller ruling): 00268 granted it to Owners, 00269 to every other role; new organizations’ seeded roles get it from provisioning (EveryRolePermissions, on creation only, so an administrator’s removal stands), and the Employee role preset includes it. It widens no one’s sight: each group is still its own list’s visibility.
  4. Match rule: reference by prefix, text (and claim supplier) anywhere, case-insensitive ILIKE, LIKE metacharacters escaped. No trigram index in v1: every query is already bounded by org_id and the groups are small. Add pg_trgm GIN indexes when a large tenant is slow.
  5. People and pages are client-side. People come from the member directory the app already loads (/org/members, open to every member), shown only when the caller can open Team → Members, which is where a person result goes. Pages are the sidebar and user-menu links the caller’s permissions show. Neither needs the backend, and a member without search.use still gets them.
  6. UI. Typing debounces 250 ms; results appear in a listbox grouped Pages, Claims, Petty cash, Advances, People. ↑/↓ move, Enter opens, Esc closes, ⌘K / Ctrl+K focuses. A claim opens /claims/$id, a spend /petty-cash/spends/$id, a top-up /petty-cash/topups/$id, an advance /advances/$id. Below 768 px the pill is a search icon that opens a full-width sheet with the same box. Strings in en / ms / zh.