# /entries — current-system spec (parity bar for the rebuild)

> The /entries page is the master list of every `time_entries` row in the org: filterable,
> sortable, paginated at 100/page, with a whole-filter summary card, per-row inline edit
> (team-member-canonical identity + cascading operator→client→project entity inheritance),
> per-row re-resolve/delete, manual entry creation into an auto-created "Manual entries"
> batch, and bulk operations (apply-to-invoice, detach, recalculate, entity-aware field
> reassignment) that can run over an explicit checkbox selection or over *everything
> matching the current filter across all pages*. It is also the site of the app's worst
> historical failure (a bulk update that hit 3,274 rows instead of the selected 353), so
> its bulk-mutation-safety mechanics are load-bearing, not decorative.

Sources: `app/(app)/entries/page.tsx` (server component, 689 lines), `actions.ts`
(server actions, 1335 lines), `entries-table.tsx` (client, 1258 lines),
`new-entry-row.tsx` (client, 321 lines), `entries-summary.tsx`; helpers
`lib/filters.ts` (`readFiltersFromSearchParams`), `lib/toggl.ts` (`blockerReason`),
`lib/permissions.ts` (`canManageImports`, `canTransfer`, `canEditEntry`),
`lib/rates.ts` (`getEffectiveBilloutRate`), view-as guards. ADRs #013–#025, #027/#028,
#034/#035, #037/#038, #042.

## User workflows

- **"Show me everything that still needs to be billed"** — the default view (all dates,
  status=pending) is exactly the invoice-ready backlog. Rian filters, selects all matching,
  and applies to an invoice in one pass.
- **"What's blocked and why"** — status=blocked packs every entry missing a required field
  onto page 1; hover the ⚠ tells him which field; inline edit or bulk reassign fixes it.
- **"Last month's PlusROI"** — one-click quick chip pivots to `date=last-month`,
  `operator=PlusROI`, `status=all` (rian asked for this by name, 06-23). Bookmarkable —
  every filter lives in the URL.
- **Fix a mis-attributed entry** — open inline edit, change the Team member dropdown
  (the canonical "who is this billed for" control); cost + billout re-stamp from that
  member's rates on save. The original Clockify identity stays visible as read-only audit.
- **Rate changed retroactively** — unmark/detach the affected entries, bulk **Recalculate**
  to re-stamp cost + billout from current rates (his 05-25 ask verbatim).
- **Reassign a batch of entries to the right project/person** — bulk "Edit fields":
  searchable project + team-member pickers, or quick-create a brand-new
  operator→client→project chain inline without leaving the page (his 06-10 ask).
- **Log time manually** — "+ New entry" inline row; lands in his per-user "Manual entries"
  batch; identity/cost/billout all derived from the picked team member.
- **Audit an invoice** — invoice pages deep-link to `/entries?invoice=<id>` to list exactly
  the rows attached to that invoice.

## Complete behavior

### Page, defaults, layout

- **B1.** Route `/entries` (App Router group `(app)`), server component; all state lives in
  searchParams. `PAGE_SIZE = 100`.
- **B2.** Org resolution is single-org: `getAppOrg(supabase)`; missing org renders error
  page "Bowden Works organization not found."
- **B3.** Filter defaults for this page: `readFiltersFromSearchParams(sp, { date: 'all',
  status: 'pending' })` — **all dates + status=pending**. (The lib's own fallback would be
  `this-month`/`all`; the page overrides. Bulk actions MUST pass identical defaults —
  see B58 / ADR #034-class bug.)
- **B4.** Page renders top→bottom: page head ("+ New entry" link at right, only when
  `canEdit && !showNewEntry`, preserving all current string params + `new=1`,
  `scroll={false}`) → banners → quick chips → FilterBar → summary card → top pagination
  strip → table (or empty state "No entries match this filter.") → bottom pagination
  (only when totalPages > 1).
- **B5.** Banner searchParams: `error` (red), `info`, `marked` (transfer batch id →
  "✓ Marked {count} entries as transferred (batch `{first 8 chars}`).", `unmarked`
  ("✓ Unmarked {count} entries — they're back in the pending list."), `count` (used by
  marked/unmarked, `?` fallback), `bulk_edited` ("✓ Bulk-updated N entry/entries."),
  `recalculated` ("✓ Recalculated cost + billout for N entry/entries using current
  rates.").
- **B6.** Quick chips: when the `entries_filter_options` RPC returns any operators, render
  a "Quick:" toolbar with one chip per operator labeled `Last month's {op}` linking to
  `/entries?date=last-month&status=all&operator={op}`.

### Filters

- **B7.** Date presets (`date` param): `all` (default, no bounds) | `this-month` |
  `last-month` | `custom` (+`start`/`end`, `YYYY-MM-DD`). Bounds are naive local timestamps
  `'YYYY-MM-DD HH:MM:SS'` applied as `start_at >= startBound AND start_at < endBound`.
  `custom` end date is **inclusive** (end + 1 day, UTC date arithmetic). Month presets are
  `[first-of-month 00:00:00, first-of-next-month 00:00:00)`.
- **B8.** Status filter (`status`): `all` | `pending` | `applied` | `transferred` |
  `blocked`. **`transferred` is a back-compat alias of `applied`** (old bookmarks), treated
  identically everywhere.
- **B9.** Status semantics (`applyStatus`, mirrored in bulk `applyBulkFilter`):
  - `applied`/`transferred` → `invoice_id IS NOT NULL OR transferred_at IS NOT NULL`
    (OR is defensive: catches legacy rows that escaped the invoice-migration backfill).
  - `pending` → `invoice_id IS NULL AND transferred_at IS NULL` AND **every**
    ELIGIBILITY_COLUMN `IS NOT NULL`.
  - `blocked` → `invoice_id IS NULL AND transferred_at IS NULL` AND at least one
    ELIGIBILITY_COLUMN `IS NULL`.
  - The three non-all statuses partition all rows exactly (locked / ready / broken).
- **B10.** `ELIGIBILITY_COLUMNS = [team_member_id, operator, client, project, description,
  end_at, source_user_email]` (declared in page.tsx AND re-declared in actions.ts; also
  mirrored in the SQL of `entries_filter_totals` + `project_summary`). `start_at` is not
  listed (NOT NULL by schema). `converted_*` columns deliberately excluded (Toggl-only).
- **B11.** Other filter params, applied identically to count query, data query, and (via
  args) the two RPCs: `batch` (import UUID; `'all'` → none) → `import_id = batch`;
  free-text `q` → `.or()` of `ilike %q%` over exactly `project, description,
  source_user_email, client`; `project`/`client`/`operator`/`source_email` → `ilike %val%`
  on the same-named column; `user` → `ilike %val%` on **`converted_user`**. String filters
  trimmed; empty → no-op.
- **B12.** `imported_by=<profile uuid>` (owner-only dropdown): loads that user's
  `clockify_imports.id` list and constrains `import_id IN (...)`; **zero imports → sentinel
  list `['00000000-0000-0000-0000-000000000000']`** so nothing matches.
- **B13.** `invoice=<uuid>`: `.eq('invoice_id', id)` on the count + data queries ONLY.
  **Not passed to the totals/options RPCs — the summary card and pivot options ignore the
  invoice filter** (known wart; arrives via link from an invoice page, no FilterBar
  control).
- **B14.** Pivot dropdown options come from RPC `entries_filter_options(p_org_id, p_start,
  p_end, p_status, p_batch, p_q, p_project, p_user, p_source_email, p_client, p_operator,
  p_imported_by)` → `{operators[], clients[], projects[], users[], source_emails[]}` —
  distinct values present in the **currently filtered** result set (dependent filtering,
  rian's 05-22 ask).
- **B15.** FilterBar renders with `showStatus showBatch showQuery showPivots showClear`,
  `defaultDate="all"`, `defaultStatus="pending"`; includes a **Clear filters** button.
  Batch dropdown options from `clockify_imports` ordered `imported_at desc`, label
  precedence `name ?? filename ?? imported_at date (YYYY-MM-DD)`.

### Sorting & pagination

- **B16.** `?sort=<key>&dir=asc|desc`, default `date`/`desc`, applied server-side as
  `.order(col, { ascending, nullsFirst: false })`. SORT_COLUMNS map (key → column):
  `date→start_at, user→converted_user, source_email→source_user_email, operator→operator,
  client→client, project→project, description→description, source_hrs→duration_seconds,
  billout_hrs→converted_duration_seconds, cost→billout_cost_usd,
  billout_amount→billout_amount_usd, batch→import_id, status→transferred_at`.
  (`source_email`/`billout_hrs`/`batch` keys have no rendered header — legacy. `status`
  sorts by `transferred_at` only, so invoice-applied rows don't group — known wart.)
- **B17.** Pagination: `?page=N` (1-based, `Math.max(1, Number||1)`),
  `.range(offset, offset+99)`; `count: 'exact', head: true` sibling query;
  `totalPages = max(1, ceil(totalCount/100))`. Prev/Next links preserve every other param.
  Status filtering happens in SQL so filtered rows pack onto page 1 (ADR #018 — the
  "91 blocked rows scattered across pages" bug class).
- **B18.** Count, data, totals-RPC, and options-RPC queries run in one `Promise.all`.
  `totals.rowCount` falls back to the count query's number if the RPC returns nothing.

### Summary card

- **B19.** `EntriesSummary` shows totals for the WHOLE filter match (all pages), from RPC
  `entries_filter_totals(...)` → `{row_count, source_seconds, billout_seconds, cost_usd,
  billout_amount_usd}`: **Entries in filter** (`toLocaleString`), **Hours**
  (`fmtHoursMinutesSeconds(source_seconds)` — h:mm:ss exact, one hours stat only
  post-ADR #034), **Cost** (`fmtUsd`), **Billout** (green #0a7d3f, title "Owner-only:
  total client billout for the filter") — Billout rendered ONLY when the page passed a
  non-null value, i.e. only for `canTransfer` users. `billout_seconds` is returned but no
  longer displayed (vestige).

### Table display

- **B20.** Columns left→right: row checkbox · `St.` (status icon, sortable `status`) ·
  `Date` (sortable `date`, defaultDir desc) · `User` · `Operator` · `Client` · `Project` ·
  `Description` · `Hours` (sortable `source_hrs`) · `Cost` (sortable `cost`) · `Billout`
  (**owner-only**, sortable `billout_amount`, header title "Owner-only: what we charge the
  client for this person's hours.") · actions (no header). 12 columns with Billout, 11
  without.
- **B21.** Status cell precedence: (1) `invoice_id` set → green (#15803d) bold ✓, title
  `Applied to invoice: {invoice_name}` (fallback "Applied to an invoice"); (2) else
  `transferred_at` set → green ✓, title "Transferred (legacy — pre-invoice)" (still locked,
  still treated as applied); (3) else `blockerReason(entry)` non-null → red (#b91c1c) ⚠,
  title `Blocked: {reason}`; (4) else grey (#777) ○, title "Pending — ready to apply to an
  invoice".
- **B22.** `blockerReason` (lib/toggl.ts) returns the FIRST missing field's message in this
  order: no `team_member_id` → "Unknown person — pick a Team member."; no `operator` →
  "Missing operator (BW / PlusROI)."; no `client` → "Missing client."; no `project` →
  "Missing project."; no `description` → "Missing description."; no `start_at` → "Missing
  start time."; no `end_at` → "Missing end time."; no `source_user_email` → "Missing
  source email." (Note it checks `start_at`, which ELIGIBILITY_COLUMNS does not — a fourth
  near-copy of the eligibility predicate.)
- **B23.** Date cell: compact `yy/Mon/dd` (e.g. `26/May/22`), full `dd Mon yyyy, hh:mm[:ss]`
  on hover. User cell shows `converted_user` (em-dash if null) with
  `source: {source_user_email}` on hover. Hours = `fmtHoursMinutesSeconds
  (duration_seconds)`. Money via `fmtUsd`, `—` when null.
- **B24.** Row CSS: locked (`invoice_id || transferred_at`) → `row-transferred`; else
  blocked → `row-blocked`.
- **B25.** Per-row actions: Edit ✏️ (`?edit=<id>` preserving all params, scroll:false) and
  Delete 🗑️ (confirm "Delete this entry? Cannot be undone.") render when
  `canEdit && (!locked || canTransfer)`. When `canEdit && locked && !canTransfer` a 🔒
  renders instead, title "Entries attached to an invoice are locked. Only an owner can
  edit."

### Selection model (select-across-pages)

- **B26.** Client state: `selected: Set<id>` + `selectAllMatching: boolean`.
  `effectiveCount = selectAllMatching ? totalCount : selected.size`; the bulk bar shows
  when `effectiveCount > 0`.
- **B27.** Header checkbox toggles all rows **on the current page**; when every page row is
  selected and `entries.length < totalCount`, the bar offers **"Select all {totalCount}
  matching the filter"** → clears the Set, sets `selectAllMatching` (bar then shows "(all
  matching the current filter)").
- **B28.** Clicking any single row checkbox while in all-matching mode **collapses** the
  selection to just that row (exits all-matching).
- **B29.** Selection (both the Set and the flag) resets whenever the filter changes
  (`useEffect` keyed on `JSON.stringify(filter)`).
- **B30.** Form plumbing: explicit mode posts one hidden `id` input per selected row;
  all-matching mode posts `all_matching=1` plus one hidden `filter_{key}` input per
  non-empty key of the FilterSnapshot (the **raw URL strings** for `date, start, end,
  status, batch, q, project, user, source_email, client, operator, imported_by`). The
  server reconstructs the filter itself — the mutation must never trust a client-computed
  id list in all-matching mode.
- **B31.** The bulk bar + bulk-edit panel form sits **above and outside** the `<table>` as
  its own `<form>` — the inline EditRow has its own form and HTML forbids nesting (a nested
  form once silently broke row Save; ADR #025).

### Bulk actions bar

- **B32.** Owner-only (`canTransfer`) controls: **Apply to invoice** (inline
  `<select name="invoice_id">` of invoices with status `open` or `sent` only — paid hidden
  by design; label = name + ` (sent)` suffix when not open; placeholder "— pick an invoice
  —"; if none exist, shows "No open invoices. Create one →" linking `/invoices?new=1`),
  **Detach**, **Recalculate**. Everyone: **Edit fields ▾/▴** (toggles panel), **Clear**
  (clears selection, closes panel).
- **B33.** Every bulk action confirms with the count. Apply: alert "Pick an invoice from
  the dropdown first." if none selected, else confirm `Apply {N} entries to "{label}"?
  They'll be locked — only an owner can detach them.` Detach: **blocks all-matching mode**
  (alert 'Detach only works on an explicit selection. Uncheck "select all matching" and
  pick rows.'), else confirm `Detach {N} entries from their invoice? They'll go back to
  unlocked / Pending.` Recalculate: confirm `Recalculate cost + billout for {N} entries
  using current rates and project overrides?`

### Bulk action semantics (server)

- **B34.** `bulkApplyToInvoice` (the primary lock action; owner-only —
  "Only an organization owner can apply entries to invoices."): requires `invoice_id`
  ("Pick an invoice first."), validates it belongs to the org ("Invoice not found.").
  Payload `{invoice_id, invoice_applied_at: now ISO, invoice_applied_by: eu.real_user_id}`.
  WHERE guard in BOTH modes: `invoice_id IS NULL` (**silent skip** of already-attached
  rows) AND NOT NULL on `team_member_id, client, project, description, end_at,
  source_user_email` — **note: does NOT require `operator`**, diverging from
  ELIGIBILITY_COLUMNS (known wart: a row "blocked" only by missing operator WILL be
  applied). Counts via `.select('id')` on the UPDATE; redirects INTO
  `/invoices/{id}?info=Applied+{n}+entries.`
- **B35.** Detach = `bulkRemoveFromInvoice` (lives in `app/(app)/invoices/actions.ts`);
  explicit-selection-only is enforced client-side (B33).
- **B36.** `bulkRecalculate` (server gate `canManageImports` — "You do not have permission
  to recalculate entries."; UI button is owner-only, a UI/server mismatch): collects ids
  (all-matching → re-run filter; explicit → `guardEntryIds` view-as guard; zero →
  "No entries selected to recalculate.") then one SQL call
  `restamp_billout_for_entry_ids(p_org_id, p_entry_ids)` → count. Re-stamps
  `billout_cost_usd` + `billout_amount_usd` from **current** member rates + project
  overrides. **Deliberately not lock-restricted** (rate-fix workflow: change rate → detach
  → recalculate → re-apply). Does NOT re-resolve `team_member_id` from email. Redirect
  `/entries?recalculated={n}`.
- **B37.** `bulkUpdateFields` (bulk reassign; gate `canManageImports` — "You do not have
  permission to bulk edit."): **entity-aware reassignment only, no free-text rewrite**
  (ADR #042; renames live in `/projects/manage`). Inputs: `bulk_project_id` (uuid),
  `bulk_team_member_id` (uuid), and/or the quick-create trio `bulk_new_operator` /
  `bulk_new_client` / `bulk_new_project` (free text — if ANY is filled ALL THREE required:
  "To create a new project here, fill in all three: operator + client + project."; the
  resolved chain **overrides** `bulk_project_id`). At least one target must resolve:
  "Pick a project or a team member to reassign."
- **B38.** `bulkUpdateFields` flow: (1) project target → fetch project with client+operator
  chain, fail fast "Selected project is missing its client / operator chain." →
  payload `{project_id, operator, client, project}` (text cache written FROM the entity —
  no drift); (2) member target validated ("Selected team member not found."); (3)
  `restrictToUntransferred = !canTransfer(eu)` — non-owners only touch rows with
  `invoice_id IS NULL AND transferred_at IS NULL` in both modes; (4) build explicit id
  list (see B39/B40); (5) project reassignment = one UPDATE over ids; (6) member
  reassignment = RPC `set_team_member_for_entry_ids(p_org_id, p_team_member_id,
  p_entry_ids)` (stamps `team_member_id, converted_user, converted_duration_seconds`) then
  `restamp_billout_for_entry_ids` (ordering matters: project first so the restamp sees the
  new `project_id`); (7) `countUpdated = max(project count, restamp count)`; redirect
  `/entries?bulk_edited={n}`.
- **B39. Bulk-mutation safety stop (all-matching mode of bulkUpdateFields).** The panel
  posts hidden `expected_count` = the count the user just confirmed. The action re-runs the
  filter as a `head:true` count (`preCount`) FIRST; if `expected > 0` and
  `preCount !== expected` → abort with `"Safety stop: the filter currently matches
  {preCount} entries, but you confirmed {expected}. Nothing was changed. Refresh /entries
  to see the current state, then retry."` Only then a second query fetches the ids.
  **History:** on 05-22 a bulk update hit **all 3,274 entries instead of the selected
  353** because `applyBulkFilter` dropped the `status` and `operator` filters
  (ADR #021) — data restored from backup; a second incident changed 415 rows when 343 were
  confirmed. Outcome (ADR #024 + regression test ENT-020): filters must bind mutation
  scope identically to display scope, plus this expected-count precondition. Note the stop
  currently protects ONLY bulkUpdateFields — apply/mark/recalculate in all-matching mode
  have no equivalent (wart).
- **B40.** `readBulkFilter` + `applyBulkFilter`: reconstruct the filter from the
  `filter_{key}` hidden fields using **the same defaults as the page**
  (`{date:'all', status:'pending'}` — CRITICAL, else the bulk action acts on a different
  window than the user sees). `applyBulkFilter` applies view-as scope + every filter +
  the B9 status logic to any PostgREST builder and returns a wrapper object `{q}` — never
  the builder itself, because builders are thenable and `await`ing one fires the
  underlying UPDATE prematurely. `imported_by` with zero batches →
  `q.eq('id', '00000000-0000-0000-0000-000000000000')` sentinel.
- **B41.** Legacy pair (no /entries UI invokes them anymore; kept for the /transfer page):
  `bulkMarkTransferred` (owner-only — "Only an organization owner can mark entries as
  transferred."; stamps `{transferred_at: now, transferred_by: eu.real_user_id,
  transfer_batch_id: crypto.randomUUID()}` — the batch id is app-generated with **no
  batch table row**; WHERE = `transferred_at IS NULL` + NOT NULL on `team_member_id,
  converted_duration_seconds, converted_user, client, project, description, end_at,
  source_user_email` — the OLD Toggl eligibility set: requires converted_*, omits
  operator; redirect `/entries?marked={batchId}&count={n}`) and `bulkUnmarkTransferred`
  (owner-only; nulls `transferred_at, transferred_by, transfer_batch_id` WHERE
  `transferred_at IS NOT NULL`; does NOT touch `invoice_id`; redirect
  `/entries?unmarked=1&count={n}`).

### Bulk-edit ("Edit fields") panel

- **B42.** Darker strip under the bar: `SearchableCombobox name="bulk_project_id"` over
  project options labeled `{client} : {project}` (projects with a broken client/operator
  chain are excluded from options); `SearchableCombobox name="bulk_team_member_id"` over
  members labeled `{display_name} : {email}`; the quick-create trio inputs each with a
  datalist of ALL existing names (so typing an existing name collapses to it); hidden
  `expected_count`; **Reassign {N}** submit.
- **B43.** Client-side `confirmBulkEdit`: partial new-chain → alert "To create a new
  project, fill in all three: operator, client, and project."; nothing picked → alert
  "Pick a project or a team member to reassign to — or fill in operator + client + project
  to create a new one."; else confirm `Reassign {N} entry/entries: {summary}?` with
  suffix "Locked rows (attached to an invoice) are skipped." for non-owners.

### Per-row inline edit (EditRow)

- **B44.** `?edit=<id>` + `canEdit` swaps that row for a yellow (#fff8e1) inline
  `<form action={updateEntry}>` spanning the row (currently `colSpan={13}` — exceeds the
  real 11/12 column count; browsers clamp; wart). Hidden `id` + `_return_url` (current URL
  minus `edit`) so Save/Cancel return to the exact filtered/paged view without scrolling.
- **B45.** Fields: **Operator** (text + datalist of all operator names; hint "(change
  clears client + project)") · **Client** (datalist strictly narrowed to
  `clientsByOperator[op]` once operator matches a known one; hint "(auto-fills operator)")
  · **Project** (datalist strictly narrowed to `projectsByClientPair['op|client']` when
  both parents set; hint "(auto-fills both)") · **Team member** (`<select
  name="team_member_id">`, empty option "— unresolved —", `<optgroup>` per `team_name`
  fallback "(unnamed team)", option text `{display_name}[ → {consolidate_as}][ (inactive)]
  · {email}`; hint "(picks the cost / billout rate)") · read-only **"Logged as (audit)"**
  panel showing `source_user_name · source_user_email` (title: "Original Clockify
  identity. Read-only — change Team member above to redirect billing.") · **Start**
  (`datetime-local`, required, step 60) · **End** (optional) · **Source dur** (text,
  prefilled decimal hours 4dp, hint "(h or hh:mm:ss)") · **"Adi dur"** (dead input — see
  B49) · **Billable** checkbox · **Batch** (read-only, `batchLabel[import_id]`) ·
  **Description** (spans the grid). Buttons: **Save**, **Cancel**, **Re-resolve from
  team** (`formAction={resolveEntry}`).
- **B46.** Cascading entity inheritance fires **on blur** (not change, so mid-typing
  partials don't clobber): operator changed → clear client + project; client changed →
  clear project, and if `clientNameToOperators[client.toLowerCase()]` has exactly ONE
  operator → auto-fill it; project changed → if `projectNameToParents[project.toLowerCase()]`
  has exactly ONE (operator, client) pair → auto-fill both. Ambiguous (multi-parent)
  matches never auto-fill. Lookup maps are built server-side in page.tsx, all
  lowercase-keyed; datalist ids are per-row (`op-list-{id}` …) to avoid collisions. The
  strict narrowing exists because duplicate project names across clients ("10 Hour Support
  Block") once caused silent duplicate-project creation (05-27 bug).
- **B47.** `updateEntry` (server): requires `id`; loads existing (`transferred_at,
  invoice_id, team_member_id, import_id`) → "Entry not found."; lock rule via
  `canEditEntry` (B72) with message "This entry is attached to an invoice and locked. Only
  an owner can edit."; `guardEntry` view-as check. `start_at` required (parseTimestamp) →
  "Start date/time is required." `team_member_id` looked up org-scoped → "Selected team
  member not found (or not visible to you)."; nullable (= unresolved).
- **B48.** `updateEntry` derivations (full-overwrite payload): `converted_user =
  member.consolidate_as ?? member.display_name` (null without member);
  `converted_duration_seconds = round(duration_seconds × member.rate_proportion)`
  (server-re-derived; Toggl-export legacy); `billout_cost_usd = round(duration/3600 ×
  member.cost_rate_usd × 100)/100`; `billout_amount_usd = round(duration/3600 ×
  getEffectiveBilloutRate(supabase, newMemberId, projectId) × 100)/100` (project-override
  aware, per source hour); `description` trimmed/nullable; `billable` = checkbox;
  `end_at` nullable. `source_user_email`/`source_user_name` are **never written** —
  frozen audit history.
- **B49.** The EditRow "Adi dur" (`converted_duration_seconds`) input is **dead** — the
  server ignores it and re-derives (B48). Label hard-codes a person's name. Do not
  rebuild it.
- **B50.** Entity chain on save (same rule in createEntry): resolveOrCreate runs **only
  when ALL THREE of operator+client+project are non-empty** — `resolveOrCreateOperator(org,
  name)` → `resolveOrCreateClient(org, operatorId, name)` → `resolveOrCreateProject(org,
  clientId, name)`, each a case-insensitive `.ilike(name)` lookup scoped to its parent,
  inserting if missing, returning the DB's canonical casing (which overwrites what the
  user typed in the text columns) and setting `project_id`. **If any of the three is
  blank, `project_id` is written NULL** even if the row previously had one, while text
  columns keep whatever was typed (known wart — blanking just `operator` detaches the
  project entity). Create failure → `Could not create <entity> "<name>": <db msg>`.
- **B51.** Duration parsing (`parseSeconds`, shared): `H+:MM:SS` | `H+:MM` | decimal hours
  (→ `round(dec×3600)`); invalid/negative/empty → null. Timestamp parsing
  (`parseTimestamp`): `YYYY-MM-DD[ T]HH:MM[:SS]` → naive `'YYYY-MM-DD HH:MM[:SS|:00]'`.
- **B52.** `updateEntry` success: revalidates `/entries /transfer /`, redirects to
  `safeReturnUrl` (must start with `/entries`, else falls back to `/entries` —
  open-redirect guard).

### Per-row Re-resolve

- **B53.** `resolveEntry` re-runs the team-lookup conversion from the entry's CURRENT
  `source_user_email`. Lock rule as B72 ("…Only an owner can re-resolve it."). The lookup
  team = the team of **whoever uploaded the entry's batch** (`clockify_imports.imported_by`
  → `getTeamForUser`): Adi's CSV resolves against Adi's roster, Rian's against Rian's. No
  team → "Couldn't find the team for this entry's import. Open a comment for support."
- **B54.** No `source_user_email` on the entry → clears the conversion (`team_member_id,
  converted_user, converted_duration_seconds, billout_cost_usd` → null; **wart:
  `billout_amount_usd` is NOT cleared** — stale amount survives) → redirect with
  `info=Cleared conversion — no source email.`
- **B55.** Member lookup is exact `eq(email)` (not ilike) within the lookup team; miss →
  "No team member with email {email} in this batch's team. Add them on the Team page
  first." Hit → recompute `converted_user`, `converted_duration_seconds`,
  `billout_cost_usd`, `billout_amount_usd` (via `getEffectiveBilloutRate(member.id,
  entry.project_id)`) → redirect with `info=Re-resolved.`

### Delete

- **B56.** `deleteEntry`: requires `id`; same lock rule ("This entry is attached to an
  invoice and locked. Only an owner can delete it.") + `guardEntry`; hard DELETE;
  revalidates `/entries /transfer /`; redirects to bare `/entries` (drops filters — minor
  wart).

### Manual entry creation

- **B57.** `?new=1` + `canEdit` renders NewEntryRow as the first tbody row (light blue
  #e0f2fe, `colSpan = canTransfer ? 12 : 11`), `<form action={createEntry}>` with hidden
  `_return_url` = current URL minus `new`. Fields: **Date** (`type=date`, required,
  default today browser-local) · **Start time** (`type=time step=1`, optional) ·
  **Duration** (text, required, placeholder `1:30:00`, "(HH:MM:SS or decimal hours)") ·
  **Description** (required) · **Operator/Client/Project** (same datalists + strict
  narrowing as EditRow) · **Team member** (same grouped select, empty option "— pick one
  —", **defaults to the member whose email case-insensitively equals the current user's
  email**). Cascade difference: operator-blur is a no-op (top-down fill), client/project
  blur still auto-fill unique parents, no cascading clears. Helper text: "Lands in your
  *Manual entries* batch. Cost + billout are computed from the picked team member's
  rates." Buttons: **Create entry**, **Cancel**.
- **B58.** `createEntry` (gate `canManageImports` — "You do not have permission to add
  entries.") validation: `date` must match `YYYY-MM-DD` ("Date must be YYYY-MM-DD.");
  `start_time` optional `H{1,2}:MM[:SS]` range-checked, default/normalized `00:00:00` →
  zero-padded; `duration` required via parseSeconds ("Duration is required (HH:MM:SS or
  decimal hours)."); `description` required ("Description is required.");
  `team_member_id` **required** ("Pick a team member — entries must be billable to
  someone.") and must exist in org ("Selected team member not found (or not visible to
  you).").
- **B59.** `createEntry` derivations: `start_at = "{date} {start_time}"` naive;
  `end_at = start_at + duration` (UTC date math on naive values; rolls the date across
  midnight); identity stamped FROM the member (`source_user_email = member.email`,
  `source_user_name = member.display_name`); entity chain per B50 (all-three-or-nothing);
  `converted_user`, `converted_duration_seconds`, `billout_cost_usd`,
  `billout_amount_usd` per B48 math; `billable: true` always.
- **B60.** The row inserts with `import_id = getOrCreateManualBatch(org, effective_user)`:
  finds-or-creates ONE `clockify_imports` row per (org, user) with `{name: 'Manual
  entries', filename: null, row_count: 0, source: 'manual', notes: 'Auto-created bucket
  for manually-added entries.'}` (lookup: `imported_by = userId`, `source = 'manual'`,
  `name ilike 'Manual entries'`). Manual entries therefore appear in the imports list and
  respect importer-scoping.
- **B61.** `createEntry` success: revalidates `/entries /projects /transfer /`; redirects
  to the raw `_return_url` (wart: bypasses `safeReturnUrl`'s `/entries` prefix check,
  though only path+search of the parsed URL are used so no open redirect) with
  `info=Created entry {first 8 of id}…` — stays on /entries so the user can add another.

### Locked-row rules (consolidated)

- **B62.** A row is **locked** iff `invoice_id IS NOT NULL OR transferred_at IS NOT NULL`.
- **B63.** `canEditEntry(eu, entry)`: locked → requires `canTransfer` (owner); unlocked →
  `canManageImports` (owner/manager). Governs updateEntry, resolveEntry, deleteEntry.
- **B64.** Bulk reassign: non-owners restricted server-side to unlocked rows in both
  selection modes; owners may bulk-edit locked rows. `bulkRecalculate`: intentionally NOT
  lock-restricted. `bulkApplyToInvoice`: only touches `invoice_id IS NULL` rows (silent
  skip). Detach: explicit-selection only, owner-only.

### View-as containment

- **B65.** When a super-admin is viewing-as a non-owner, `getViewAsImportScope` returns
  the effective user's visible `clockify_imports.id` list and the page constrains EVERY
  data path (`.in('import_id', scope)`) at the app layer (RLS only scopes the real JWT
  user). `p_imported_by` in both RPCs is forced to `eu.effective_user_id` (URL can't
  escape the scope); the Imported-by dropdown is hidden. Server actions use `guardEntry` /
  `guardEntryIds` for the same containment on writes.

### Error handling

- **B66.** Every server-action failure funnels through `fail(msg, editId?)` →
  `redirect('/entries?error={msg}[&edit={id}]')` — errors render as the red banner and,
  for edit failures, re-open the same EditRow. Actions never throw to the client.

## Data model

**`time_entries`** (columns this page reads/writes):
`id, org_id, import_id (fk clockify_imports), project_id (fk projects, nullable),
source_user_email, source_user_name` (frozen audit identity), `team_member_id`
(fk team_members — the canonical billed-for identity), `operator, client, project`
(denormalized TEXT cache of the entity chain, kept in sync on every write path),
`description, billable, start_at, end_at` (naive timestamps, no TZ — ADR #003),
`duration_seconds` (source hours), `converted_user, converted_duration_seconds`
(Toggl-export-only legacy = source × `rate_proportion`), `billout_cost_usd,
billout_amount_usd` (**locked-at-write money stamps**, cents rounding
`round(x*100)/100`; never silently recomputed — ADR #014), `transferred_at,
transferred_by, transfer_batch_id` (legacy lock trio; batch id has no table),
`invoice_id (fk invoices), invoice_applied_at, invoice_applied_by` (the current lock).

Related reads: `clockify_imports` (batch options, importer scoping, manual batch,
re-resolve team lookup via `imported_by`), `team_members` (`display_name, email,
consolidate_as, is_active, team_name, rate_proportion, cost_rate_usd, billout_rate_usd,
team_id`), `operators` / `clients` / `projects` (name datalists + cascade maps + entity
resolution; chain operator→client→project), `invoices` (`id, name, status` — apply
dropdown lists `open`/`sent` only), `profiles` (importer dropdown labels).

RPCs: `entries_filter_totals(p_org_id, p_start, p_end, p_status, p_batch, p_q, p_project,
p_user, p_source_email, p_client, p_operator, p_imported_by)` → `{row_count,
source_seconds, billout_seconds, cost_usd, billout_amount_usd}`;
`entries_filter_options(same args)` → `{operators[], clients[], projects[], users[],
source_emails[]}`; `restamp_billout_for_entry_ids(p_org_id, p_entry_ids)` → count;
`set_team_member_for_entry_ids(p_org_id, p_team_member_id, p_entry_ids)`.
Rate helper: `getEffectiveBilloutRate(supabase, memberId, projectId)` — per-source-hour,
project-override precedence.

## Permissions

- `canManageImports(eu)` — org_role `owner` or `manager`, or super-admin not viewing-as.
  Gates: page edit affordances (`canEdit`), createEntry, updateEntry/deleteEntry on
  unlocked rows, bulkUpdateFields, bulkRecalculate (server side).
- `canTransfer(eu)` — org_role `owner`, or super-admin not viewing-as. Gates: Billout
  column + Billout summary stat, Imported-by dropdown, bulkApplyToInvoice,
  bulkMark/UnmarkTransferred, detach, editing/deleting/bulk-editing **locked** rows, and
  (UI-only) the Recalculate button.
- `canEditEntry(eu, entry)` — the lock-aware combination (B63).
- Members (role `member`): view only — no edit/delete/bulk affordances.
- RLS scopes entries per uploader (ADR #027): a manager sees only rows in batches he
  uploaded; owner/super-admin see all. View-as adds the app-layer containment of B65
  (guards: `guardEntry`, `guardEntryIds`, `getViewAsImportScope`).
- Known UI/server mismatches: Recalculate button owner-only vs server gate
  `canManageImports`; "Edit fields" visible to anyone who can select rows, real gate
  server-side.

## Edge cases & error behavior

- **Three divergent eligibility predicates + a fourth near-copy.** ELIGIBILITY_COLUMNS
  (filter/status; requires `operator`) vs `bulkApplyToInvoice` WHERE (no `operator`) vs
  `bulkMarkTransferred` WHERE (legacy: requires `converted_*`, no `operator`) vs
  `blockerReason` (adds `start_at`). Concrete: a row missing only `operator` shows ⚠
  Blocked yet IS swept into an invoice by bulk apply. The rebuild must define ONE
  predicate in one place (see deltas).
- **The safety stop protects only bulkUpdateFields** (B39). Apply/mark/recalculate in
  all-matching mode silently include rows that appeared between page load and submit.
- **Summary card ignores `?invoice=`** (B13) — totals ≠ visible rows when arriving from an
  invoice link. Root cause: filter logic exists twice (PostgREST query chain + SQL RPC)
  and must be kept manually in sync — the ADR #021/#034 bug class.
- **Partial entity chain nulls `project_id`** while keeping typed text (B50).
- **`resolveEntry` clear path leaves `billout_amount_usd` stale** (B54).
- **PostgREST string-injection edges**: `q` and pivot values are interpolated into
  `.or('project.ilike.%q%,…')` — commas/parentheses in the value can corrupt the filter
  expression (wrong results, not SQL injection). `resolveOrCreate*` uses `.ilike(name)`
  with the raw name, so `%`/`_` in an entity name act as wildcards and can false-match.
- **Thenable-builder hazard**: PostgREST builders fire their HTTP request when awaited;
  `applyBulkFilter` returns `{q}` (a wrapper) specifically so composing the UPDATE doesn't
  execute early. Any rebuild keeping PostgREST must preserve this shape.
- **Naive timestamps everywhere** (ADR #003): month bounds are server-local, NewEntryRow's
  default date is browser-local, `addSeconds` does UTC math on naive strings. Off-by-hours
  risk for multi-TZ teams.
- **`status` sort ≈ `transferred_at`** — invoice-applied rows (NULL transferred_at) don't
  group (B16).
- **EditRow `colSpan={13}`** exceeds the real column count (browsers clamp).
- **Dead code**: `computeCost` helper (4-dp, base-rate era), NewEntryRow's `defaultName`
  prop, `billout_seconds` display, SORT_COLUMNS keys `source_email`/`billout_hrs`/`batch`.
- **Orphaned legacy actions**: `bulkMarkTransferred`/`bulkUnmarkTransferred` have no
  /entries UI (kept for /transfer per ADR #038 Consequences + UNDOCUMENTED.md debt); the
  `marked`/`unmarked` banners remain.
- **`deleteEntry` redirects to bare `/entries`**, dropping the user's filter context.
- **Nested-form regression** (ADR #025): the bulk form must never wrap the table/EditRow —
  the browser silently drops nested forms and Save breaks with no error.
- Single-org assumption baked in (`getAppOrg`, hard-coded error string).

## UI notes

Current UI is a dense server-rendered table, full page reload per filter/sort/edit; inline
styles throughout. Specific mechanics rian shaped directly (from transcript §17 / §5):

- **Liked / demanded**: every column sortable; compact rows; icons over words (✓/○/⚠,
  ✏️/🗑️/🔒); detail on hover (full timestamp, source email folded into User-cell tooltip);
  batch column removed from the table but kept filterable; h:mm:ss exact durations
  ("I also need to see the seconds"); dropdowns constrained by context (pivot options from
  the current result set, dependent client/project narrowing); **searchable comboboxes
  "similar to a google sheet with data validation"**; quick links for recurring workflows
  ("Last Month's PlusROI" chip, quick-create chain inline in bulk panel); sensible
  defaults (all-dates+pending, member defaulting to self on new-entry); a Clear-filters
  button; **confirmation counts must match reality** — the 343-vs-415 incident made bulk
  trust a top-tier concern.
- **Disliked**: page jumping to top on inline edit (hence `scroll={false}` + `_return_url`
  threading everywhere); edits that silently don't save (nested-form bug); actions with no
  progress feedback; redundant identity fields ("source email, source user, and converted
  user… gotten messy and redundant?") — which drove the team-member-canonical edit model
  (ADR #037) with source identity demoted to read-only audit; important info in small grey
  font while noise dominates; table headers "out of whack"; slow loading.
- Known UI debt acknowledged in docs: edit grid doesn't collapse on narrow screens
  ("should become a modal"); search has no debounce.

## Rebuild deltas

Per `00-vision-and-scope.md`, `02-domain-model.md`, `04-architecture.md`:

- **Phase-outs**: `/transfer`, `lib/toggl.ts`, `bulkMarkTransferred`/`bulkUnmarkTransferred`,
  `transferred_at/transferred_by/transfer_batch_id`, `rate_proportion`/`converted_user`/
  `converted_duration_seconds` — all dropped (confirmed 2026-07-06). "Applied to invoice"
  is the only lock; B41's legacy semantics and the `marked`/`unmarked` banners do not port.
- **One eligibility predicate**: the B9/B10 status logic becomes a single implementation
  (authz/reporting service), used identically by list scope, status badge, and every write
  guard — collapsing the four divergent copies.
- **Text-cache role dropped, columns carried as `raw_*`** (02 §3): resolved rows
  display/group/join through FKs (`worker_party_id`, `project_id` chain) only; the
  as-imported text survives as immutable `raw_operator/raw_client/raw_project` and is
  what unassigned (Blocked) rows show and what the resolver reads. Blocked =
  `project_id IS NULL` etc. under the new model, same user-facing semantics.
- **`team_member_id` → `worker_party_id` + `via_engagement_id`**; cost/billout stamps come
  from `compensation_terms` (02 §3); lock becomes `invoice_line_id` (invoice lines, not a
  bag of entries).
- **Bulk-mutation safety gets architectural treatment (judgment call #11)**: mutation
  scope derived from the *same* query object as display scope (one `scope()` code path),
  expected-count precondition on EVERY all-matching mutation (not just reassign), and
  **post-action undo for bulk edits**. This is the hardened descendant of B39/B40.
- **UX** (04): SPA (TanStack Router/Query) — instant filter changes, optimistic row edits
  (kills the full-reload-per-edit slowness), URL-addressable filters carry over
  (bookmarkable chips are a real workflow); the SearchableCombobox/datalist patterns
  become ONE `EntityPicker` with create-in-place; `DataTable` with sticky header;
  structured `{error_code, summary, detail}` errors → toasts; zero inline styles; 390px
  floor (tables collapse to cards).
- **Permissions**: `canManageImports`/`canTransfer`/`canEditEntry` become named
  capabilities with identical R1 truth tables (02 §4); RLS + view-as JWT machinery
  replaced by the authz service + app-layer impersonation; audit rows for every mutation
  (the current app has none).
- Single-org assumption → `tenant_id` everywhere; quick chips generalize from operator
  labels to tenant-aware equivalents.

## Test specs

Carried IDs (existing catalog): FLT-001…FLT-008 (filter URL parsing incl. custom-range
inclusive end), ENT-001…ENT-004, ENT-008, ENT-020. ENT-005/006/007 (mark/unmark
transferred) are **retired with the Toggl phase-out — do not port**.

- T-ENT-01 (ENT-008): given no filter params / when /entries loads / then filter = all
  dates + status=pending and stays unbounded (no silent snap to this-month).
- T-ENT-02: given `status=transferred` / then results identical to `status=applied` (alias).
- T-ENT-03: given status=pending / then every row has invoice_id+transferred_at NULL and
  all 7 eligibility columns non-NULL; status=blocked → complement (≥1 NULL); the two are
  disjoint and their union = all unlocked rows (B9/B10).
- T-ENT-04: given custom date range with end=E / then rows with start_at on day E are
  included and day E+1 excluded (B7).
- T-ENT-05: given `q` containing text present in description only / then row matches; q
  searches exactly project, description, source_user_email, client (B11).
- T-ENT-06: given `imported_by` for a user with zero imports / then zero rows (sentinel)
  (B12).
- T-ENT-07: given `?invoice=<id>` / then list shows only that invoice's rows; document
  (or in rebuild: fix) that totals reflect the same scope (B13 wart → rebuild requirement).
- T-ENT-08: given sort=cost&dir=asc / then server-side order by billout_cost_usd asc,
  NULLs last; every mapped sort key works (B16).
- T-ENT-09: given 250 matching rows / when page=3 / then rows 201–250 render and
  totalPages=3; status-filtered rows pack from page 1 (B17).
- T-ENT-10: summary card equals RPC totals for the whole filter, not the page; Billout
  stat absent for non-owner (B19).
- T-ENT-11: status icon precedence invoice > legacy-transferred > blocked > pending, with
  the exact titles and blockerReason first-missing-field order (B21/B22).
- T-ENT-12: locked row for manager shows 🔒, no edit/delete; owner sees ✏️/🗑️ (B25).
- T-ENT-13: selecting all page rows with more matches offers "Select all N matching";
  entering all-matching then clicking one checkbox collapses to that single id (B27/B28).
- T-ENT-14: changing any filter clears selection and all-matching flag (B29).
- T-ENT-15: all-matching form posts all_matching=1 + filter_{key} snapshot; explicit posts
  id inputs (B30).
- T-ENT-16 (ENT-020 descendant): given bulk action in all-matching mode / then server
  reconstructs the filter with page defaults {date:all, status:pending} and applies EVERY
  posted filter — regression: dropped status/operator filter must fail this test (B40).
- T-ENT-17: given filter now matches 415 rows but expected_count=343 / when
  bulkUpdateFields all-matching / then abort with the exact "Safety stop:" message and
  zero rows changed (B39).
- T-ENT-18: bulkApplyToInvoice stamps invoice_id/applied_at/applied_by only on rows with
  invoice_id NULL + required fields; already-attached rows silently skipped; count in
  redirect matches rows actually updated; redirects into /invoices/{id} (B34).
- T-ENT-19: bulkApplyToInvoice with invoice from another org (or missing) fails "Invoice
  not found."; paid invoices never appear in the apply dropdown (B32/B34).
- T-ENT-20: detach is refused client-side in all-matching mode (B33); owner-only.
- T-ENT-21: bulkRecalculate re-stamps cost+billout from current rates incl. project
  overrides via restamp_billout_for_entry_ids; includes locked rows; does not change
  team_member_id (B36).
- T-ENT-22: bulkUpdateFields with only bulk_project_id updates project_id + all three text
  columns from the ENTITY chain (canonical casing), single UPDATE (B38).
- T-ENT-23: bulkUpdateFields with bulk_team_member_id runs set_team_member_for_entry_ids
  then restamp; with both targets, project applies first so restamp uses the new
  project_id (B38).
- T-ENT-24: bulkUpdateFields quick-create with only 2 of 3 fields fails with the
  fill-in-all-three message; with all 3, chain is resolveOrCreated and overrides
  bulk_project_id (B37).
- T-ENT-25: bulkUpdateFields as manager skips locked rows in both modes; as owner includes
  them; zero-after-guard → "No entries left to reassign after applying the lock guard."
  (B38).
- T-ENT-26 (ENT-001): inline edit save persists, revalidates, returns to the exact
  filtered/paged URL (edit param stripped), no scroll jump (B44/B52).
- T-ENT-27: updateEntry re-derives converted_duration_seconds from duration ×
  rate_proportion, ignoring the posted "Adi dur" value; source_user_email/name unchanged
  (B48/B49).
- T-ENT-28: updateEntry with member picked re-stamps cost from member.cost_rate_usd and
  billout from getEffectiveBilloutRate(member, resolved project) (B48).
- T-ENT-29: edit with all three entity fields set creates missing entities under the right
  parents and sets project_id; blanking one of the three nulls project_id (current
  behavior — rebuild replaces with FK model) (B50).
- T-ENT-30: cascade — operator blur clears client+project; client blur clears project and
  auto-fills a unique operator; project blur auto-fills a unique (operator, client);
  ambiguous parents never auto-fill (B46).
- T-ENT-31: client/project datalists strictly narrow to the picked operator / (operator,
  client) pair — a same-named project under another client must not be offered (B45/B46).
- T-ENT-32 (ENT-002): re-resolve with unknown source email fails "No team member with
  email … Add them on the Team page first." (B55).
- T-ENT-33 (ENT-003): re-resolve with empty source email clears team_member_id,
  converted_*, billout_cost_usd (and, rebuild: billout amount too — current wart B54).
- T-ENT-34: re-resolve looks up against the batch uploader's team, not the current
  viewer's (B53).
- T-ENT-35 (ENT-004): delete removes the row; locked row delete as manager fails with the
  owner-only message (B56/B63).
- T-ENT-36: createEntry requires date/duration/description/team member with the exact
  messages; start defaults 00:00:00; end_at = start + duration incl. midnight rollover
  (B58/B59).
- T-ENT-37: createEntry stamps source identity from the member, billable=true, and lands
  in the per-(org,user) "Manual entries" batch (created once, reused thereafter)
  (B59/B60).
- T-ENT-38: new-entry member select defaults to the row matching the current user's email
  case-insensitively (B57).
- T-ENT-39: duration parser accepts `1:30:00`, `1:30`, `1.5` → 5400s; rejects negatives
  (B51).
- T-ENT-40: locked-row edit/delete/re-resolve as manager fail with the invoice-locked
  message; same actions succeed as owner (B63).
- T-ENT-41: under view-as (non-owner effective user), every list/total/option/action is
  scoped to the effective user's import batches; imported_by URL param cannot widen it
  (B65).
- T-ENT-42: server action failures redirect to /entries?error=… (plus &edit=<id> for edit
  failures re-opening the row); nothing throws to the client (B66).
- T-ENT-43: every state-changing action revalidates its documented path set (B34, B36–B38,
  B52, B56, B61 lists).
- T-ENT-44 (rebuild-only, judgment call #11): every all-matching mutation carries an
  expected-count precondition and derives its scope from the same query object as the
  display; bulk edits are undoable post-action.
