# Projects

**Status:** in production
**Lives at:** `/projects` · `app/(app)/projects/page.tsx`,
`app/(app)/projects/projects-table.tsx`, `app/(app)/projects/actions.ts`,
`app/(app)/projects/quick-filters.tsx`, `app/(app)/projects/expand/`,
`app/(app)/projects/manage/page.tsx`, `app/(app)/projects/manage/actions.ts`
· uses `projects` / `clients` / `operators` first-class tables.

## Summary
The /projects page is a per-(operator, client, project) summary
table for the current filter. One row per project; sums respect the
filter so changing it changes every number. Below the summary,
projects expand inline to show their entries grouped by description.

Adjacent: `/projects/manage` is the entity-rename UI for operators /
clients / projects — renames cascade to every `time_entries.operator
/ client / project` text cell that references them.

## Why
2026-05-24 — "Build /projects summary page" — a project-grouped
counterpart to /entries so Rian can see what's billable + costs at a
glance.
2026-05-22 (entity migration) — moved operators / clients /
projects from free-text columns on `time_entries` into their own
first-class tables. See decisions.md #035.

## Data model
- `operators(id, org_id, name, created_at)` — top-level seller
  identity (Bowden Works, PlusROI, ...).
- `clients(id, org_id, operator_id, name, created_at)` — end-client
  under an operator.
- `projects(id, org_id, client_id, name, income_usd?,
  billout_adjustment_pct?, billout_adjustment_amount?, created_at)`.
- `project_rate_overrides(...)` — see [adjustments.md](adjustments.md).
- `time_entries.project_id` FK + the legacy `operator` / `client` /
  `project` text columns (kept as a denormalized cache for fast
  filtering). The text columns are re-written by renames so they
  stay in sync.

## Behavior
- **Filter bar** at the top: date (default = this month), pivots
  (operator / client / user / source email), status (default =
  all), missing-info checkbox. Same FilterBar component as /entries.
- **Quick filters** chips below: clicking a chip pivots the table
  (e.g. "Just BW", "Just PlusROI") and persists the choice in the
  URL.
- **Summary table**: one row per (operator, client, project)
  visible in the filter. Columns: Operator / Client / Project /
  Entries / Source hrs / Cost / **Billout** (adjusted by project
  adjustment, with raw + adjustment shown on hover) / **Income** /
  **Margin** — last three owner-only. The project name has a small
  ✏️ rename pencil that deep-links to /projects/manage?edit=pr:&lt;id&gt;.
- **Edit income** (pencil): inline form for `projects.income_usd` +
  billout adjustment (pct + fixed). Owner-only.
- **Edit rate overrides** (⚙️ gear): inline panel for
  per-(project, team_member) rate overrides. Owner-only. See
  [adjustments.md](adjustments.md).
- **Expand row**: clicking a row's chevron renders a sub-table of
  per-description aggregates for that project (entry count, hours,
  cost, billout, margin). Same /projects/expand/ subroute, RSC-loaded
  inline.
- **/projects/manage**: 3-column page for renaming operators /
  clients / projects. Each section lists entities + entry counts +
  rename buttons. Renames propagate to `time_entries` text columns
  via explicit update. Idempotent. Reachable from the top-nav
  **Projects ▾ → Manage** submenu (added 2026-05-25).
- **/projects/manage → Projects section** additionally supports:
  - **+ New project** (`?new=pr`) — inline form. Picks an existing
    client via combobox (`{operator} : {client}` labels), enters a
    project name, submits to `createProject`. Fails on duplicate
    name under the same client (per the unique constraint).
  - **Delete project** (`?delete=pr:<id>`) — inline confirmation
    row. Picks a TARGET project (combobox, `{client} : {project}`).
    The action reassigns every `time_entries.project_id` (and the
    text cache `operator` / `client` / `project`) to the target,
    reassigns every `cc_expense_lines.project_id` to the target,
    deletes the source's `project_rate_overrides` rows, then deletes
    the source project. The source's `income_usd` /
    `billout_adjustment_*` are NOT migrated — the owner is choosing
    to merge, so the target's values apply going forward.
- **/projects/manage → Clients section** supports the same delete
  pattern via `deleteClient`:
  - **Delete client** (`?delete=cl:<id>`) — inline confirmation row.
    Picks a TARGET client. The action re-parents every project under
    source → target (`projects.client_id` update), then propagates
    both `target.name` (client) AND `target.operator.name` to
    `time_entries.client` / `time_entries.operator` for entries
    under the moved projects (handles cross-operator moves
    correctly). Reassigns `invoices.client_id`. Deletes the source.
- **/projects/manage → Operators section** supports delete via
  `deleteOperator`:
  - **Delete operator** (`?delete=op:<id>`) — inline confirmation
    row. Picks a TARGET operator. The action re-parents every
    client under source → target (`clients.operator_id` update),
    propagates `target.name` to `time_entries.operator` for entries
    under the moved clients' projects. Reassigns
    `invoices.operator_id`. Deletes the source.
- **Empty-source delete** — for all three scopes, if the source has
  no children (entries for project, projects for client, clients
  for operator), the target picker disappears and the row collapses
  to a "Nothing references this — straight delete" confirmation.

## Income + margin (owner-only)
- `projects.income_usd` is the "what Rian charges the end-client"
  number. NULL = "not set".
- Displayed billout = `sum(billout_amount_usd) × (1 + adj_pct/100) +
  adj_amount`. Displayed margin = `income_usd - cost` (cost is
  `sum(billout_cost_usd)`). If `income_usd` is NULL, the income +
  margin cells render as `—`.
- These columns are gated by `canSeeIncome(eu)` — owners + super
  admins only. RLS on `projects.income_usd` also restricts read
  access at the database level (see decisions.md ADR for the
  income column).

## Backing RPC
`project_summary(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?)` returns one row per (operator,
client, project) with all the totals + `project_entity_id` so
inline-edit buttons can deep-link. After the invoice migration the
status filter inside it checks `invoice_id IS NOT NULL` (plus
`transferred_at` for safety) — see decisions.md #038.

## Constraints & edge cases
- The text columns on `time_entries` are the source of truth for
  filtering / display, even after the entity migration. The FK
  `project_id` is the source of truth for joining to
  `projects.income_usd` + adjustment columns. Renames update both.
- An entry can be "linked" (has `project_id`) and yet have the text
  columns drift from the entity name. The /projects/manage rename
  flow keeps them in sync, but manual SQL edits could break it.
- Rows with NULL operator / client / project all collapse into one
  bucket per missing-field combination. The "missing info" filter
  in the filter bar zooms in on those.
- Project-level adjustments don't re-stamp entries — they're
  display-time arithmetic. Rate-level overrides DO re-stamp on
  save. See [adjustments.md](adjustments.md).
- The summary RPC runs `stable security invoker`, so RLS applies.
  View-as restricts to the impersonated user's batches at the app
  layer (the SQL function itself isn't view-as-aware).

## Permissions
- **Anyone in the org**: see project list, sort, filter, expand,
  view per-description breakdown.
- **Owners only**: see Income + Margin columns, edit income +
  adjustments + rate overrides, rename entities via /projects/manage.

## Tests
- ✅ PRJ-001 … (see tests/project-summary.test.ts) Source / billout /
  cost aggregates match per-entry stamps.
- 🟡 PRJ-010 Pct adjustment is applied to the displayed Billout.
- 🟡 PRJ-011 Manage rename propagates to `time_entries` text
  columns + preserves entry count.

## Open considerations
- No project archive / inactive flag. To "retire" a project today
  you'd just stop logging to it. Could matter once the list grows.
- /projects/manage's rename UI doesn't yet show the user which
  invoices reference a project. Renaming under an active invoice is
  fine (the join is by FK, not text), but visibility would help.
- Quick-filter chip set is hard-coded. Could be data-driven from
  operators / clients in the future.
- The expand-row sub-table doesn't paginate. Projects with hundreds
  of distinct descriptions would render slowly.

## Changelog
- **2026-05-22** — Entity migration phase 1: schema + backfill for
  `operators` / `clients` / `projects`. See decisions.md #035.
- **2026-05-22** — Entity migration phase 2: /projects/manage rename
  UI.
- **2026-05-23** — Entity migration phase 3: import resolver creates
  entity rows if missing.
- **2026-05-23** — Entity migration phase 4: combobox on /entries
  edit + cascading inheritance (typing a known client auto-fills
  the operator, etc.).
- **2026-05-24** — /projects summary page (this doc).
- **2026-05-24** — Income column + margin column (owner-only).
- **2026-05-24** — Project billout adjustment (pct + fixed) — see
  [adjustments.md](adjustments.md).
- **2026-05-24** — Per-project rate overrides + pct variant — see
  [adjustments.md](adjustments.md).
- **2026-05-25** — Single-query `restamp_billout_for_project` SQL
  function replaced N HTTP PATCHes when saving an override.
- **2026-05-25** — Rename pencil shortcut next to project name on
  /projects (deep-links to manage page).
- **2026-05-25** — **Create + Delete project** support on
  /projects/manage. New action `createProject` (under an existing
  client). New action `deleteProject(source, target)` that
  reassigns entries + CC expense lines to the target, drops the
  source's rate overrides, then deletes the source. Projects
  submenu in the top nav now exposes Manage directly.
- **2026-05-25** — **Delete client** + **Delete operator** support
  with the same reassignment pattern. `deleteClient(source, target)`
  re-parents projects + invoices.client_id + propagates client+operator
  text cache. `deleteOperator(source, target)` re-parents clients
  + invoices.operator_id + propagates operator text cache.
  Empty-source variant collapses to a straight delete (no target
  picker shown). The `deleteProject` action gained the same
  empty-source path for consistency.
