# Projects & Entities — current-system spec (parity bar for the rebuild)

> This spec covers the `/projects` summary page (one aggregated row per
> (operator, client, project) group, lazily expandable into a per-description
> breakdown, with owner-only income/billout/margin columns and inline editors
> for income, billout adjustments, and rate overrides) and the `/projects/manage`
> entity-CRUD page (rename / create / move / delete-with-merge for the
> operator → client → project hierarchy, including the hand-maintained
> propagation of the denormalized `time_entries.operator/client/project` text
> cache). Source of truth: `app/(app)/projects/{page.tsx, actions.ts,
> projects-table.tsx, quick-filters.tsx, expand/route.ts, manage/page.tsx,
> manage/actions.ts}` as of 2026-07-06. Every behavior below is a parity
> requirement for the rebuild unless listed under Rebuild deltas.

## User workflows

- **"What did we work on and what's it worth?"** — rian opens `/projects`,
  filters by month/operator/client, and reads per-project totals: entries,
  billout hours, cost, and (owner-only) billout dollars, income, margin. The
  headline card totals follow the filter, so this is his per-period P&L glance.
- **Set what a project earns** — rian records `income_usd` (what he charges the
  end-client) plus optional billout adjustments (a signed % and/or a signed $
  discount/markup) inline on a row; margin appears immediately.
- **Special-case billing rates** — rian adds per-project rate overrides
  ("Prompt Victoria bills at -20%", "all hours on this project at $30/hr"),
  which re-stamp affected entries on save.
- **Drill into a project** — click a row to see its entries grouped by
  description (which tasks consumed the hours) without leaving the page.
- **Fix messy data** — Clockify imports produce typo'd/missing/misattributed
  operators, clients, projects. rian (or Adi) renames entities (fixes every
  entry at once), creates missing entities, moves a project to the right
  client, bulk-overwrites text fields on selected groups, or merges a
  duplicate entity into its canonical twin via delete-with-reassign. rian said
  wrong-operator assignments "will happen often" — this cleanup loop is a core
  workflow, not an edge case.
- **Recurring views one click away** — quick-filter chips (Hourly / Missing
  info / Last month admin) jump to saved views; `?expand=` links share a
  pre-expanded state. The Hourly chip in particular is rian's prepaid
  support-block check: "how many hours are left on each client's block?"
  (see B17/B48).

## Complete behavior

### /projects summary page

- **B1.** Route `/projects` renders one row per distinct **text tuple**
  `(operator, client, project)` aggregated over `time_entries`, via the
  Postgres RPC `project_summary` (args exactly: `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, p_missing_info`). Grouping is by the
  denormalized text columns, NOT by `project_id`.
- **B2.** RPC row shape: `operator | client | project` (each `string|null`),
  `project_entity_id` (`projects.id` when the tuple matches an entity, else
  NULL), `row_count`, `billout_seconds`, `cost_usd`, `billout_amount_usd`,
  `billout_adjusted_usd` (= `raw × (1 + pct/100) + amount`),
  `billout_adjustment_pct`, `billout_adjustment_amount`, `income_usd`.
  `income_usd` is NULL for non-owners (RLS on `projects` hides it); the RPC
  runs `stable security invoker` so RLS applies.
- **B3.** Filter params are read via `readFiltersFromSearchParams(sp, { date:
  'all', status: 'all' })` — **defaults on this page are date=`all` and
  status=`all`** (unlike /entries). Supported params: `date, start, end,
  status, batch, q, project, user, source_email, client, operator,
  imported_by`, plus page-specific `missing_info=1` (boolean → RPC
  `p_missing_info`; matches groups with NULL operator, client, or project).
- **B4.** View-as override: when a super admin is viewing-as another user
  (`getViewAsImportScope` returns a non-null import-id list),
  `effectiveImportedBy = eu.effective_user_id` — any URL `imported_by` is
  discarded so the impersonated session can't use a Rian-only filter. The
  batches dropdown is also restricted with `.in('id', viewAsScope)`.
- **B5.** Sorting is done in JS (RPC has no guaranteed order). `sort` param is
  validated against `SORT_KEYS = ['operator','client','project','row_count',
  'billout_hrs','cost','billout_amount','income','margin']`; invalid →
  `billout_hrs`. `dir` defaults `desc` and is NOT validated (cast). Text keys
  use `localeCompare` on `?? ''`; `billout_hrs` compares `billout_seconds`;
  `billout_amount` treats NULL as 0; `income`/`margin` (margin =
  `income − cost`, NULL when income NULL) sort **NULLs to the bottom
  regardless of direction**.
- **B6.** Clicking a sort header preserves all other query params; clicking
  the active key toggles asc↔desc; clicking a new key sets `sort=key&dir=desc`.
- **B7.** Headline card (reduced over all visible rows): `$X.XX cost`,
  `Hh MMm` (floor hours, zero-padded minutes), `N project(s) · N entr(y|ies)`.
  Owner-only, when ≥1 row has income: green `$X.XX income` and a margin span
  where **total margin = Σincome − Σ(cost of rows with income set)** — cost of
  income-less rows is excluded to keep the math meaningful — rendered with `+`
  prefix when ≥0, class `over-target`/`under-target`, suffixed
  `(incomeRows/projectCount with income)`.
- **B8.** Columns in order: [checkbox, canEdit only] · [expand toggle ▶/▼] ·
  Operator · Client · Project · Entries · Billout hrs (`H:MM` via
  `fmtHM`) · Cost · then owner-only: Billout · Income · Margin. NULL
  operator/client/project cells render muted "(missing)".
- **B9.** Billout cell (owner): `—` when NULL or 0. When an adjustment exists
  (pct non-null and ≠0, or amount non-null and ≠0): bold adjusted total plus a
  small muted second line `raw · <signed pct>% <signed $>` and a tooltip
  `raw $X + adj`. Otherwise plain adjusted value (falls back to raw).
- **B10.** Income cell (owner): `—` or `fmtUsd(income)`; pencil ✏️ link toggles
  `?edit_income=<projectKey>`; gear ⚙️ link (only when `project_entity_id`
  non-null) toggles `?edit_rates=<projectKey>` with a green superscript count
  of existing override rules. The two editors are mutually exclusive (each
  link deletes the other param); links use `scroll={false}`. Margin cell:
  `—` when income NULL, else `income − cost` with `+` prefix when ≥0 and
  `over-target`/`under-target` class. **Margin ignores billout and billout
  adjustments entirely** (display-only arithmetic, ADR #040).
- **B11.** Project cell (canEdit + entity match): pencil ✏️ deep-link to
  `/projects/manage?edit=pr:<project_entity_id>` (title "Rename project").
- **B12.** Row identity everywhere (selection, `?expand=`, `?edit_income=`,
  `?edit_rates=`, bulk-edit `project_key` fields) is
  `projectKey = "${operator ?? ''}|${client ?? ''}|${project ?? ''}"` — NULL
  encoded as `''`, pipe separator, no escaping (assumes no `|` in names).
- **B13.** Expand: clicking a row's toggle lazily fetches
  `GET /projects/expand?...` with `cache: 'no-store'`, showing "Loading
  breakdown…" then a sub-table of Description ("(no description)" muted for
  NULL) · Entries · Billout hrs (`fmtHM`) · Cost (`fmtUsd`); empty array →
  "No entries." Fetch failure → "Couldn't load breakdown. Reload and try
  again." The fetch query = all non-empty filter-snapshot params with
  `operator/client/project` **overwritten** by the row's group values,
  sentinel `__null__` for NULLs.
- **B14.** `?expand=` (comma-separated projectKeys) seeds the expanded set so
  shared links render rows pre-expanded — but the breakdown fetch only fires
  inside the toggle handler, so pre-expanded rows render open-but-empty until
  clicked closed then open (known bug). Expanding/collapsing after load never
  writes back to the URL.
- **B15.** Empty result set renders "No projects match this filter." Flash
  params: `error` → `.error`, `info` → `.info`, `bulk_edited=N` → "✓
  Bulk-updated N entry/entries across selected projects." Missing org →
  "Bowden Works organization not found."
- **B16.** Filter pivot options reuse the entries RPC `entries_filter_options`
  (same filter args, no `p_missing_info`) → distinct `operators, clients,
  projects, users, source_emails` within the current scope for `FilterBar`
  (rendered with `showStatus showBatch showQuery showPivots showClear
  defaultDate="all" defaultStatus="all"`). Batch labels: `name ?? filename ??
  imported_at` ISO date. Importer dropdown is owner-only and hidden during
  view-as: distinct `clockify_imports.imported_by` → `profiles` → label
  `full_name || email`, sorted.

### Quick filters

- **B17.** Three hard-coded chips above the FilterBar; clicking one **replaces
  the entire query string** (built from empty `URLSearchParams` — a one-click
  reset, other filters intentionally not preserved):
  `hourly` → `date=all&project=hour&sort=billout_hrs&dir=desc`;
  `missing-info` → `date=all&missing_info=1`;
  `last-month-admin` → `date=last-month&project=admin`.
  A chip renders active (primary style vs `secondary`) when every param it
  sets is present with the same value. A code comment promises
  click-again-to-clear, but it is NOT implemented — clicking an active chip
  re-applies it.
  **Purpose of `hourly` (not just mechanics):** it is the prepaid
  support-block / hour-bank drawdown view — clients pre-purchase 5/10-hour
  blocks ("10 Hour General Support Block" projects, matched by the `hour`
  substring) that entries draw down over months. `date=all` is load-bearing:
  the lifetime totals ARE the drawdown balance (a dated window would show one
  period's usage, not what's left on the block). See `02-domain-model.md` §2
  (`prepaid_block`).
- **B48.** **Sentinel baseline entries (LOAD-BEARING DATA).** The hourly view
  is only correct because of 20 manual `time_entries` rows dated
  **2025-12-31**, start `00:00:00`, description **"Transfer baseline from
  Toggl, totals as of March 31, 2026"**, source email `rian@rian.ca` — one
  per prepaid block, carrying that block's opening balance so the chip's
  lifetime totals (B17) start from the right number. They are **not Toggl
  residue**: any cleanup or phase-out that looks like "delete old Toggl rows"
  must explicitly exclude them, and the migration carries them as ordinary
  entries (`05-migration-plan.md` step 2.6; `02-domain-model.md` §2
  `prepaid_block`).

### GET /projects/expand (route handler)

- **B18.** Auth chain: `createClient` → `getAppOrg` (404 `{error:'org not
  found'}`) → `getEffectiveUser` → `getViewAsImportScope` (same
  `imported_by` override as B4). Group key params `operator/client/project`:
  literal `__null__` → SQL NULL; a missing param is the empty string `''`
  (matched literally, not as NULL).
- **B19.** Calls RPC `project_entries_by_description` with args exactly:
  `p_org_id, p_operator, p_client, p_project` (group key) + `p_start, p_end,
  p_status, p_batch, p_q, p_project_q, p_user, p_source_email, p_client_q,
  p_operator_q, p_imported_by, p_missing_info` (the `_q` args are the
  substring *filters*, distinct from the group key). Response: JSON array
  `[{description, rowCount, billoutSeconds, cost}]` camelCased from
  `description, row_count, billout_seconds, cost_usd`. Errors → 500
  `{error: msg}`, logged `[projects/expand] …`.
- **B20.** Known collision: the client overwrites the `operator/client/
  project` URL params with group values, and the route reads those SAME
  params both as group keys and (via `readFiltersFromSearchParams`) as the
  `_q` filters — so for a NULL-group row, `p_operator_q` etc. receive the
  literal string `'__null__'` as a substring filter. Whether "(missing)"
  groups return rows depends on how the RPC combines them. The rebuild must
  use distinct param names.

### Inline income / adjustment editor (owner-only)

- **B21.** `updateProjectIncome(formData)` — gate `canTransfer(eu)`, error
  "Only an owner can edit project income." Inputs: `operator/client/project`
  (group key, `''`→NULL), `income_usd` (strips `$ ,`; empty = clear; must be
  finite and ≥0 else "Income must be a non-negative number, or empty to
  clear."; rounded to cents), `billout_adjustment_pct` and
  `billout_adjustment_amount` (each optional; strips `$ , %`; "<label> must
  be a number, or empty to clear."; **rounded to 2 decimals** — note the
  override pct in B26 keeps 4), `_return_url` (must start with `/projects`
  else falls back to `/projects` — open-redirect guard).
- **B22.** Any NULL in the tuple → fail "Cannot set income on a project group
  with missing operator, client, or project. Fix the entries first."
  Resolution chain uses case-insensitive **exact** `ilike` (no wildcards
  added — but `%_\` in names are live wildcards, known wart):
  `operators` by org+name → `Operator "X" not found.`; `clients` by
  operator_id+name → `Client "X" not found under operator "Y".`; `projects`
  by client_id+name (maybeSingle).
- **B23.** Writes all three columns every time (`income_usd,
  billout_adjustment_pct, billout_adjustment_amount` — clearing = writing
  NULLs). Existing project row → UPDATE by id; no row and any value non-null
  → defensive INSERT `{org_id, client_id, name, …patch}` (post-migration
  leftover; shouldn't fire). Then `revalidatePath('/projects')` + redirect to
  `_return_url`. Adjustments are **display-time only** — they never re-stamp
  entries (ADR #040).

### Rate overrides (owner-only, per project entity)

- **B24.** Inline panel per row (only when `project_entity_id` non-null).
  Precedence rule (must survive rebuild): **user-specific rule > project-wide
  "all hours" rule (NULL `team_member_id`) > team member's default billout
  rate.** Exactly one of `override_rate_usd` / `override_pct` is set per rule
  (DB CHECK). Rule list sorts project-wide rule first, then by member name.
- **B25.** `upsertProjectRateOverride(formData)` — gate `canTransfer`, "Only
  an owner can edit rate overrides." Inputs: `project_id` (required, "Missing
  project id."), `team_member_id` (`''`→NULL = project-wide), `override_kind`
  ∈ `rate` (default) | `pct`, `override_value` (required — "Override value is
  required. To remove a rule, use Delete instead."; strips `$ , %`; non-finite
  → "Override value must be a number.").
- **B26.** `pct` kind: signed % of the user's normal rate, must be ≥ −100
  ("Percentage discount cannot be less than -100% (which is free)."), stored
  rounded to **4 decimals** in `override_pct`. `rate` kind: absolute $/hr,
  ≥ 0 ("Absolute rate must be non-negative."), rounded to cents in
  `override_rate_usd`. Upsert is manual find-or-insert on
  `(project_id, team_member_id)` NULL-safe (PostgREST can't upsert on a
  nullable column pair).
- **B27.** After save (and after delete): re-stamp via RPC
  `restamp_billout_for_project(p_org_id, p_project_id, p_team_member_id)` —
  single SQL UPDATE using the `effective_billout_rate` function; NULL
  team-member scope re-stamps every entry on the project, a user-specific
  rule re-stamps only that member's rows. Save-then-restamp-failure → "Saved
  the override but failed to re-stamp some entries: …" (delete variant:
  "Deleted the override but…"). Success info: `Saved override · re-stamped N
  entr(y|ies).` / `Deleted override · …`. Revalidates `/projects`,
  `/entries`, `/`.
- **B28.** `deleteProjectRateOverride(formData)` — owner-only ("Only an owner
  can delete rate overrides."), input `id` ("Missing override id."); loads the
  row first for the re-stamp scope ("Override not found."), deletes, re-stamps.

### Bulk edit of group text fields

- **B29.** `bulkUpdateProjectFields(formData)` — gate `canManageImports`
  (manager+), "You do not have permission to bulk edit." Inputs: repeated
  `project_key` (`op|cl|pr`, `''`=NULL; zero keys → "No project groups
  selected."), `bulk_operator/bulk_client/bulk_project` (trimmed; all empty →
  "No fields provided for bulk edit."). Hidden `filter_*` fields are parsed
  but **deliberately not used for scoping** — the user selected explicit
  groups, so every entry in them is updated regardless of the active filter.
- **B30.** Per selected tuple, one `time_entries` UPDATE writing only the
  provided **text columns** (never `project_id`), org-scoped, NULL-safe match
  (`.is()` for NULL vs `.eq()`). Non-owners additionally get
  `.is('transferred_at', null)` (can't touch transferred rows); view-as
  sessions get `.in('import_id', viewAsScope)`. Counts via `.select('id')`.
  Redirect `/projects?bulk_edited=<total>`; revalidates `/projects`,
  `/entries`, `/transfer`, `/`. **Known trap:** because `project_id` is
  untouched, bulk edit can desync the text cache from the entity chain
  (group's `project_entity_id` goes NULL/stale — the "Halo Programs" ghost).
- **B31.** Client-side: selection + bulk panel reset whenever the filter
  changes; sticky dark toolbar (`#1f2937`) shows "N project group(s) selected
  (M entries)"; submit shows `window.confirm` with total entries, group
  count, and `field → "value"` lines; no field set →
  `alert('Set at least one field to update.')`.

### /projects/manage — entity pages

- **B32.** Page lists three sections — Operators (Name · Clients · Entries ·
  actions), Clients (Operator · Client · Projects · Entries · actions),
  Projects (Operator · Client · Project · Entries · Income (`—`/`$X.XX`) ·
  actions) — org-scoped, ordered by name. Anyone in the org can view;
  all affordances (Rename/Move/Delete links, "+ New" toolbars, inline forms)
  require `canManageImports(eu)`.
- **B33.** Search params: `error`/`info` flash; `edit=op:<id>|cl:<id>|pr:<id>`
  (inline rename row); `delete=` same shapes (inline delete/reassign row);
  `move=pr:<id>` (projects only); `new=op|cl|pr` (inline create form at
  section top).
- **B34.** Entry counts are computed in TS: ALL `time_entries (id,
  project_id)` for the org are fetched and counted by `project_id`; client
  and operator counts roll up over their descendants. `cc_expense_lines (id,
  project_id)` is fetched with **no org filter** (relies on RLS). Known
  scaling wart: PostgREST's default 1000-row page can silently truncate these
  counts.
- **B35.** Combobox options: client picker = clients with a resolvable
  operator, label `"{operator} : {client}"`; project picker label
  `"{client} : {project}"` (no operator); delete/move pickers exclude the
  source entity itself.
- **B36.** Inline shells: `EditRow` (yellow `#fff8e1`; name input defaulted to
  current name; note "Will rename {context} and update N entr(y|ies).");
  `DeleteRowShell` (red `#fef2f2`; when `hasChildren` shows target picker +
  "Delete & reassign", else "Nothing references this — straight delete." +
  "Delete"); `MoveProjectRow` (blue `#eff6ff`). Project delete's
  `hasChildren = entries > 0 || ccLines > 0` deliberately mirrors the
  action's check so UI copy and server behavior agree.

### Rename actions (text-cache propagation)

- **B37.** `renameOperator(id, name)` / `renameClient` / `renameProject` — all
  gate on `canManageImports` ("You do not have permission to rename
  entities."). Shared flow: trim name ("Name is required."); load existing
  ("(Operator|Client|Project) not found."); identical name → ok "No change —
  name was already that."; case-insensitive duplicate pre-check via `ilike`
  excluding self, scoped to org / same operator / same client respectively —
  `Another operator already has the name "X".` / `Another client under this
  operator already has the name "X".` / `Another project under this client
  already has the name "X".` (DB unique indexes would reject anyway; this is
  the friendly error).
- **B38.** After the entity UPDATE, propagate to the text cache **via the
  entity chain, not text match**: operator → its clients → their projects →
  `UPDATE time_entries SET operator = newName WHERE project_id IN (…)`
  (client rename writes `client`; project rename writes `project` WHERE
  `project_id = id`). Count via `.select('id')`. Consequence: entries with
  matching text but NULL `project_id` are NOT updated. Success:
  `Renamed to "X". Updated N entries.` Revalidates `/projects/manage`,
  `/projects`, `/entries`, `/`.

### Create actions

- **B39.** `createOperator(name)` — dup check org-wide (`An operator named "X"
  already exists.`) → INSERT `{org_id, name}` → `Created operator "X".`
  `createClientEntity(operator_id, name)` (named to avoid clashing with the
  Supabase `createClient` import) — "Pick an operator." / "Operator not found
  in this organization." / `A client named "X" already exists under {op}.` →
  INSERT `{org_id, operator_id, name}`. `createProject(client_id, name)` —
  "Pick a client." / "Client not found in this organization." / `A project
  named "X" already exists under that client.` → INSERT `{org_id, client_id,
  name}`. All gate on `canManageImports` with per-action permission messages
  ("You do not have permission to create (operators|clients|projects).").

### Move project

- **B40.** `moveProject(id, target_client_id)` — gate `canManageImports`
  ("You do not have permission to move projects."). Guards: "Missing project
  id." / "Pick a client to move this project under." / "Project not found." /
  same client → ok "No change — project is already under that client." /
  "Target client not found." / "Target client is missing its operator (data
  inconsistency)."
- **B41.** Name-collision pre-check inside the target client is done by
  fetching all sibling projects and comparing `name.toLowerCase()` in JS
  (deliberately not `ilike`, so `lower(name)` collisions can't slip past).
  Collision → guidance error telling the user to use Delete + reassign to
  merge instead. Race fallback: UPDATE error code `23505` or message
  containing `projects_client_lower_name_unique` → friendly "Couldn't move…
  merge instead" error.
- **B42.** Effects: `projects.client_id = target`; then rewrite
  `time_entries.{operator, client}` for every entry with `project_id =
  source` — **including invoiced/transferred entries**. Project keeps id,
  name, rate overrides, income, and billout adjustments. **Invoices are NOT
  touched** (`invoices.client_id/operator_id` stay — "the invoice was issued
  to whoever it was issued to"). Success: `Moved "X" to {op} / {client}.
  Rewrote N entr(y|ies).`

### Delete-with-merge actions

- **B43.** `deleteProject(id, target_project_id?)` — semantics: **merge into
  target, then delete source.** Guards: "Missing source project id." /
  self-merge → "Source and target are the same project — can't merge into
  self." / "Source project not found." Children counted with
  `{count:'exact', head:true}` on `time_entries` (org+project) and
  `cc_expense_lines` (project only). `hasChildren` and no target → `Can't
  delete "X" — {N time entries[ and ]M CC expense lines} still point at it.
  Pick a target project to reassign them to.` Target guards: "Target project
  not found." / "Target project is missing its client / operator chain."
- **B44.** Merge effects, in order (NO transaction — sequential PostgREST
  calls): (1) `time_entries` → `{project_id: target, operator/client/project
  : target chain names}` (full re-point including text cache); (2)
  `cc_expense_lines.project_id → target`; (3) always DELETE
  `project_rate_overrides` of the source (**overrides dropped, not merged**);
  (4) DELETE source project with `.select('id')` and **silent-no-op
  detection**: zero rows deleted → `Could not delete "X" — the database
  accepted the request but no row was removed. This usually means RLS denied
  it or a foreign key still references the project. …` Source's
  `income_usd`/adjustments are **lost by design** (target's values win).
  Success: `Deleted "X". Reassigned N entries[ + M CC expense lines] to
  "{client} : {project}".` / `Deleted empty project "X".` Revalidates
  `/projects/manage`, `/projects`, `/entries`, `/invoices`,
  `/tools/cc-expenses`, `/`.
- **B45.** `deleteClient(id, target_client_id?)` — `hasChildren` = ≥1 project.
  Effects: (1) re-parent projects (`projects.client_id → target`, projects
  keep identity); (2) text-cache refresh `time_entries.{client, operator}`
  (operator too — cross-operator merges allowed) for entries under the moved
  projects; (3) **`invoices.client_id → target`** (contrast moveProject);
  (4) DELETE source (no zero-row detection). Messages: "Pick a client to
  reassign projects to." / `Deleted "X". Re-parented N projects (M entries
  refreshed) to "Y".` / `Deleted empty client "X".`
- **B46.** `deleteOperator(id, target_operator_id?)` — `hasChildren` = ≥1
  client. Effects: (1) re-parent clients; (2) `time_entries.operator` refresh
  for entries under the moved clients' projects (client text untouched);
  (3) `invoices.operator_id → target`; (4) DELETE source (no zero-row
  detection). **No client-name-collision pre-check** — a duplicate client
  name under the target operator hits the DB unique index mid-sequence after
  the re-parent may already have committed (known wart).
- **B47. THE UNRESOLVED MERGE-COLLISION BUG (2026-06-23, still open):**
  merging duplicate *clients* (or operators) whose children have the same
  names is impossible — e.g. "PlusROI / The Palms" into "Bowden Works / The
  Palms": `deleteClient` re-parents the source's project "The Palms" into a
  client that already has a project "The Palms", violating
  `projects_client_lower_name_unique`; the operation fails with a raw DB
  error (and may leave partial state). The workaround is a manual bottom-up
  merge (deleteProject each colliding project first, then deleteClient).
  There is no recursive/first-class merge. This is judgment call #10's
  mandate for the rebuild.

## Data model

Tables read/written by this feature (semantics that matter):

- `operators (id, org_id, name, created_at)` — top of the entity chain
  ("Bowden Works", "PlusROI"). Case-insensitive-unique name per org (implied
  by the dup pre-checks + DB index).
- `clients (id, org_id, operator_id, name, created_at)` — case-insensitive-
  unique per operator.
- `projects (id, org_id, client_id, name, income_usd, billout_adjustment_pct,
  billout_adjustment_amount, created_at)` — unique index
  **`projects_client_lower_name_unique`** on `(client_id, lower(name))`.
  `income_usd` NULL = "not set"; RLS hides it from non-owners and restricts
  UPDATE to owners. Adjustments are display-time only.
- `project_rate_overrides (id, org_id, project_id, team_member_id NULL,
  override_rate_usd, override_pct)` — CHECK: exactly one of rate/pct set;
  one rule per `(project_id, team_member_id)` including the NULL
  ("all hours") scope.
- `time_entries` — this feature touches `project_id` (FK, nullable),
  the **denormalized text cache** `operator/client/project` (grouping +
  filtering source of truth; must be rewritten on every entity mutation),
  `transferred_at` (non-owner bulk-edit guard), `import_id` (view-as scope),
  `billout_amount_usd` / cost stamps (aggregated, re-stamped by overrides).
- `cc_expense_lines (id, project_id FK ON DELETE SET NULL)` — reassigned on
  project merge; counted for the delete guard.
- `invoices (client_id, operator_id)` — reassigned by deleteClient/
  deleteOperator; NOT touched by moveProject or deleteProject.
- `clockify_imports (id, org_id, name, filename, imported_at, imported_by)` —
  batch + importer dropdowns; view-as scope source.
- `profiles (id, full_name, email)`, `team_members (id, org_id, display_name,
  email, team_id)`, `teams (name)` — labels for importer dropdown and
  override scope picker.

RPCs / SQL functions: `project_summary`, `entries_filter_options`,
`project_entries_by_description`, `restamp_billout_for_project`,
`effective_billout_rate`.

**Core invariant (hand-maintained, no DB enforcement):**
`time_entries.operator/client/project` are text caches of the chain reachable
via `time_entries.project_id`; every rename/move/merge must rewrite them.
`bulkUpdateProjectFields` violates this by design (writes text only), which is
how text/entity drift happens.

## Permissions

Exact gates (from `lib/permissions.ts` — note there is **no** `canSeeIncome`
function despite older docs naming one; the income gate IS `canTransfer`):

- `canTransfer(eu)` — true iff (`eu.is_super_admin && !eu.is_viewing_as`) OR
  `eu.org_role === 'owner'`. Gates: Billout/Income/Margin columns + headline
  income/margin, `updateProjectIncome`, `upsertProjectRateOverride`,
  `deleteProjectRateOverride`, importer dropdown, overrides data loading, and
  exemption from the bulk-edit `transferred_at` restriction.
- `canManageImports(eu)` — true iff super-admin-not-viewing-as OR
  `eu.org_role ∈ ('owner','manager')`. Gates: selection checkboxes + bulk
  edit, "Manage entities →" link, rename pencil, and EVERY /projects/manage
  action: `renameOperator/Client/Project`, `createOperator`,
  `createClientEntity`, `createProject`, `moveProject`, `deleteProject`,
  `deleteClient`, `deleteOperator`.
- Anyone in the org: view both pages (manage page renders read-only tables
  without affordances), sort, filter, expand, per-description breakdown.
- View-as sessions: `imported_by` forced to the impersonated user (B4);
  bulk edit restricted to the impersonated user's imports (B30).
- Defense in depth: RLS on `projects` independently hides `income_usd` from
  non-owners and blocks their UPDATEs, regardless of the app-layer gate.

## Edge cases & error behavior

- All server actions redirect on failure: `/projects?error=<msg>` or
  `/projects/manage?error=<msg>` (never-returning `fail()`); successes use
  `?info=` or dedicated params (`bulk_edited`). Full message catalog is in
  B21–B47.
- `_return_url` open-redirect guard: must start with `/projects` else `/projects`
  (income editor); override actions parse via `new URL(ret, 'http://x')` and
  redirect to `pathname + search` only.
- No transactions anywhere: deleteProject (4 steps), deleteClient/Operator
  (4 steps), moveProject (2 steps) are sequential calls; a mid-sequence
  failure leaves partial state. Only deleteProject detects the silent-no-op
  delete (B44); deleteClient/deleteOperator do not.
- deleteOperator's missing collision pre-check (B46) + the merge-collision
  bug (B47) are the two known data-integrity landmines.
- `ilike`-as-equality treats `% _ \` as wildcards (dup pre-checks,
  updateProjectIncome resolution). moveProject's JS `toLowerCase()` compare
  is the safe pattern.
- `projectKey` breaks if a name ever contains `|` (no validation prevents it).
- Pre-expanded `?expand=` rows render empty (B14); "(missing)" group expand
  may return zero rows due to the `__null__` filter collision (B20).
- Missing-info groups cannot have income (B22 hard-fails on NULL tuple).
- `colCount` off-by-one for owners: 3 owner columns added to the header but
  `canSeeIncome ? 2` in the colSpan math — expanded/inline rows are one
  column short of full width (cosmetic). Similarly `EditRow`/`DeleteRowShell`
  colSpan math assumes 6 columns; operators (4-col) and clients (5-col)
  tables get over-wide colSpans (browsers tolerate).
- Manage-page counts load every org `time_entries (id, project_id)` row into
  JS (and `cc_expense_lines` with no org filter, RLS-dependent) — subject to
  PostgREST's 1000-row default page silently truncating counts at scale.
- `Section`'s `emptyMessage` is dead code (`display:'none'`) — empty entity
  tables render bare headers.
- Precision inconsistency: `billout_adjustment_pct` rounds to 2 decimals,
  `override_pct` to 4.
- `dir` sort param unvalidated (any string cast to `'asc'|'desc'`).

## UI notes

Current UI: server-rendered tables with inline styles; colored inline rows as
state (yellow `#fff8e1` = income edit, indigo `#eef2ff` = rate overrides, blue
`#eff6ff` = move, red `#fef2f2` = delete); sticky dark bulk toolbar
(`#1f2937`); icon affordances (✏️ rename/income, ⚙️ overrides with green
rule-count superscript); muted "(missing)" placeholders; sortable headers on
every data column; `window.confirm` with exact counts before bulk edits.

From rian's taste profile (transcript-requirements.md §17), as applied here:

- **Liked / demanded:** sortable every column; compact rows; icons over words
  (the pencil/gear pattern); quick-filter chips for recurring views; sensible
  defaults; searchable comboboxes "similar to a google sheet with data
  validation" (the entity pickers on manage); dependent/contextual dropdowns
  (pivot options come from the current result set); progressive disclosure
  (income editors hidden behind icons, delete pickers only when children
  exist); confirmation counts that match reality — bulk-action trust is
  paramount after the 3,274-rows-from-a-353-row-selection incident.
- **Disliked (avoid in rebuild):** page jumping to top on inline edit (the
  current `scroll={false}` links are the mitigation); actions with no
  progress feedback (the "Re-stamping entries…" pending label exists for
  overrides — keep that pattern everywhere); important info in small grey
  font while noise dominates; messy/overkill chrome; slow full-page reload
  per interaction (every sort/filter/edit here is a server round-trip —
  the #1 perceived-slowness source); table headers "out of whack" (the
  colCount/colSpan bugs above are exactly this class).
- The delete-merge collision (B47) was an explicit end-of-session frustration
  — the rebuild's merge must handle it without manual bottom-up workarounds.

## Rebuild deltas

Per `docs/rebuild/00-vision-and-scope.md`, `02-domain-model.md`,
`04-architecture.md`, `07-judgment-calls.md` — link, don't restate:

- **Text-cache ROLE dropped, columns carried as raw_*** (02 §3): the
  `operator/client/project` text columns survive as `raw_operator/
  raw_client/raw_project` — an immutable import payload (sole identity
  + display for unassigned/Blocked rows; provenance elsewhere) — but
  nothing reads them for a resolved row's display, grouping, or joins.
  This deletes B30's drift trap, B37–B38's propagation layer, and the
  group key becomes `project_id` for resolved rows (raw-text grouping
  only for the unassigned bucket; no more `op|cl|pr` pipe keys or
  `__null__` sentinels).
- **Party model mapping** (02 §7): `operators` and `clients` → `parties
  (kind=org)`; `projects` gains `operator_party_id` + `client_party_id` +
  `status active|archived`; income/adjustments carry over on `projects` until
  the R4 invoice engine; `project_rate_overrides` → the dedicated
  `rate_overrides` table, near-verbatim — not flattened into
  `compensation_terms` in R1 (02 §2; 07 judgment call #25).
- **First-class merge** (07 judgment call #10): R1 ships real merge
  (project→project, client→client, operator→operator) — move children,
  re-point entries, snapshot-safe for invoiced rows, delete the husk — fixing
  B47; Move remains for the no-collision case. Invoice lines snapshot display
  fields at attach time (judgment call #9), resolving the B42/B45 invoice
  inconsistency.
- **Transactions + bulk safety** (04 §services, 07 #11): multi-step
  merges/moves become single service-layer transactions; mutation scope
  derives from the same query object as display scope, with expected-count
  preconditions and undo.
- **Permissions** (02 §4): `canTransfer`/`canManageImports` become named
  capabilities with identical R1 truth tables in `services/authz/`; RLS is
  deleted; view-as becomes app-layer context swap.
- **Reporting** (04 §Reporting): `project_summary`,
  `project_entries_by_description`, `entries_filter_options` become
  `services/reporting/` functions with semantics pinned by tests; the expand
  breakdown loads via TanStack Query (fixes B14/B20 by construction).
- **Toggl phase-out** (00): `transferred_at` disappears — B30's non-owner
  restriction re-expresses as "invoiced rows are locked".
- **Prepaid blocks stay sentinel-based in R1** (02 §2, 00 R3): R3 models
  support blocks as first-class objects (purchase / drawdown / balance);
  until then the sentinel-baseline-row convention (B48) continues and the
  hourly chip remains the drawdown view.
- UX: SPA + design tokens replace inline styles; quick filters and
  URL-addressable filter state carry over (bookmarkable views are a real
  workflow); chips could become data-driven (open consideration in the old
  doc).

## Test specs

Carried-over IDs (existing catalog): PRJ-001, PRJ-010, PRJ-011.

- T-PRJ-001: given entries with stamped cost/billout, when `project_summary`
  aggregates them, then per-group entries/billout-seconds/cost match the sum
  of per-entry stamps (existing PRJ-001).
- T-PRJ-010: given a project with `billout_adjustment_pct`/`amount`, when the
  summary renders, then displayed Billout = `raw × (1+pct/100) + amount` and
  no entry stamps changed (existing PRJ-010; ADR #040).
- T-PRJ-011: given an entity rename, when it completes, then every entry
  reachable via `project_id` shows the new text and entry counts are
  unchanged (existing PRJ-011).
- T-PRJ-020: given filter params, when /projects loads, then defaults are
  date=all, status=all, sort=billout_hrs desc; invalid `sort` falls back to
  billout_hrs.
- T-PRJ-021: given rows with NULL income, when sorting by income or margin in
  either direction, then NULL rows sort to the bottom.
- T-PRJ-022: given some rows with income, when the headline renders, then
  total margin = Σincome − Σ(cost over income-set rows only) and shows
  `(k/n with income)`.
- T-PRJ-023: given a non-owner, when /projects renders, then
  Billout/Income/Margin columns, income totals, importer dropdown, and both
  inline editors are absent AND `income_usd` is NULL at the data layer.
- T-PRJ-024: given a view-as session, when `imported_by` is in the URL, then
  the effective filter is the impersonated user's id and batches are limited
  to their import scope.
- T-PRJ-025: given a group with NULL operator, when expanding it, then the
  breakdown request maps `__null__` → SQL NULL and returns that group's rows
  (currently at risk per B20 — rebuild must pass).
- T-PRJ-026: given `?expand=<key>`, when the page loads, then the row is open
  AND its breakdown is loaded (fixes B14).
- T-PRJ-027: given the missing-info chip, when clicked, then the URL becomes
  exactly `date=all&missing_info=1` (all other params cleared) and only
  groups with a NULL tuple member are listed.
- T-PRJ-028: given income input "1,234.50" with pct "-10" and amount "5",
  when saved by an owner, then stored values are 1234.50 / −10 / 5 and
  clearing all three writes NULLs.
- T-PRJ-029: given a group with any NULL tuple member, when income save is
  attempted, then it fails with the "Fix the entries first" error.
- T-PRJ-030: given a non-owner, when calling
  updateProjectIncome/upsertProjectRateOverride/deleteProjectRateOverride,
  then each fails with its owner-only message.
- T-PRJ-031: given a user-specific override and a project-wide override, when
  rates resolve, then user-specific wins; absent both, the member default
  applies (B24 precedence).
- T-PRJ-032: given a pct override of −150, when saved, then it is rejected
  (≥ −100 rule); given −20, then `override_pct` stores −20 (4-decimal
  precision) and `override_rate_usd` is NULL.
- T-PRJ-033: given an override upsert with NULL team_member scope, when
  saved, then ALL project entries re-stamp via `effective_billout_rate`;
  a member-scoped rule re-stamps only that member's rows; invoiced/locked
  rows are skipped; the reported count matches rows changed.
- T-PRJ-034: given 2 selected groups and only `bulk_project` set, when bulk
  edit runs, then every entry in both groups (regardless of active date
  filter) gets the new project text, the confirm count equals rows updated,
  and unselected groups are untouched.
- T-PRJ-035: given a manager (non-owner), when bulk-editing a group with
  transferred entries, then only untransferred rows update (rebuild:
  uninvoiced).
- T-PRJ-036: given the 20 sentinel baseline entries (B48), when the
  migration runs, then they arrive as ordinary manual entries — 2025-12-31
  date, description, and source email intact — and are excluded from any
  Toggl-residue cleanup (05-migration-plan step 2.6; parity asserted via
  T-MIG-001/T-MIG-010 in 06-test-plan.md).
- T-PRJ-037: given the hourly chip with `date=all`, when per-block lifetime
  totals render, then each block's total = its baseline row + all subsequent
  entries (the drawdown balance); removing or date-filtering out a baseline
  row understates the balance — which is why the chip pins date=all.
- T-ENT-001: given a rename to an existing sibling name (case-insensitive),
  when submitted, then it fails with the per-scope duplicate message and
  nothing changes; same-name rename returns the no-change info.
- T-ENT-002: given an operator rename, when it completes, then entries under
  ALL its clients' projects have the new operator text; entries with matching
  text but NULL project_id are untouched (current behavior — rebuild makes
  this moot via FK display).
- T-ENT-003: given create operator/client/project with a duplicate name in
  scope, when submitted, then each fails with its exact duplicate message;
  otherwise the row is inserted under the right parent.
- T-ENT-004: given a member-role user, when calling any manage action, then
  it fails with that action's permission message; given a manager, all
  succeed.
- T-ENT-005: given moveProject to a client already holding a same-lower(name)
  project, when submitted, then it is refused with the merge guidance (and a
  concurrent 23505 race maps to the friendly error).
- T-ENT-006: given moveProject cross-operator, when it completes, then
  entries' operator+client text is rewritten (including invoiced entries),
  the project keeps id/name/overrides/income/adjustments, and invoices are
  NOT reassigned.
- T-ENT-007: given deleteProject with entries or CC lines and no target,
  when submitted, then it fails listing exact blocking counts; with a target,
  entries re-point (project_id + full text chain), CC lines re-point, source
  overrides are deleted, source income/adjustments are lost, and the source
  row is gone.
- T-ENT-008: given deleteProject where the final DELETE removes zero rows,
  when it completes, then the user sees the "no row was removed" error, not
  a success message.
- T-ENT-009: given deleteClient with a target under a different operator,
  when it completes, then projects re-parent, entries' client AND operator
  text refresh, and `invoices.client_id` re-points to the target.
- T-ENT-010: given deleteOperator with a target, when it completes, then
  clients re-parent, entries' operator text refreshes (client text
  unchanged), and `invoices.operator_id` re-points.
- T-ENT-011: given source and target the same entity, when any delete-merge
  runs, then it fails with the can't-merge-into-self message.
- T-ENT-012: given an empty source (no children per that scope's rule), when
  deleted, then no target is required and the "Deleted empty …" message shows;
  a project with 0 entries but >0 CC lines still REQUIRES a target.
- T-ENT-013 (rebuild, judgment call #10): given two clients with same-named
  projects, when client A merges into client B, then colliding projects merge
  recursively (entries re-pointed, husk deleted), non-colliding projects
  re-parent, and invoiced rows keep their snapshots — no unique-constraint
  error (fixes B47).
- T-ENT-014 (rebuild): given any multi-step merge/move that fails midway,
  when it aborts, then no partial state persists (transactional; replaces
  B44–B46's sequential calls).
- T-ENT-015: given the manage page for an org with entries and CC lines,
  when counts render, then per-entity entry counts equal true DB counts
  (rebuild: computed by aggregate query, immune to the 1000-row page cap).
