# Entries

**Status:** in production
**Lives at:** `/entries` · `app/(app)/entries/page.tsx`, `app/(app)/entries/actions.ts`

## Summary
The full list of every `time_entries` row, with filters, server-side
pagination, per-row edit / delete / re-resolve, and a status badge.

## Why
2026-05-21 — "I'd like to be able to see all entries somewhere and their
status (transferred/untransferred). I want to be able to edit any entry.
I want to be able to filter. I'd like to see which import batch they
were a part of and filter by import batch."

## Behavior
- Shows 100 entries per page, sortable per column via header clicks
  (server-side `?sort=`/`?dir=`).
- Each row has a Status badge: ✓ green = **Applied to invoice** (hover
  shows invoice name) / ○ grey = **Pending** (eligible, not yet on
  any invoice) / ⚠ red = **Blocked** (reason on hover).
- Filters (`<FilterBar>`):
  - **Dates**: this-month / last-month / custom range / all (default = all).
  - **Status**: all / pending / **Applied to invoice** / blocked.
    URL param value is `transferred` (kept for back-compat) or the
    newer alias `applied`.
  - **Batch** (import batch), **Operator** / **Client** / **Project**
    / **User** / **Source email** pivots, free-text search.
  - **Imported by** (owner-only): scope to a specific user's batches.
  - **?invoice=&lt;id&gt;** URL param scopes to one invoice (used by the
    invoice detail page's link).
- **Add entry** button reveals an inline NewEntryRow at the top. See
  "Manual entries" below.
- Clicking **Edit** (✏️) on a row swaps it for an inline edit form
  spanning the whole row width. Fields:
  - **Team member** dropdown (grouped by team) — canonical control
    for "who this entry is billed for". Picking a new member rewrites
    `team_member_id`, `converted_user`,
    `converted_duration_seconds`, re-stamps cost + billout.
  - **Logged as (audit)** read-only display of the original Clockify
    identity (`source_user_name · source_user_email`). Not editable;
    frozen at import time.
  - Operator / Client / Project (cascading combobox — typing a known
    client auto-fills its operator; project auto-fills both when
    unique).
  - Description, billable, start datetime, end datetime, source
    duration.
- **Save** updates the row using the picked team member's rates. The
  legacy ad-hoc field `converted_user` is no longer editable; it's
  derived from the picked team member every save.
- **Re-resolve (🔄)** is still available — useful for entries that
  failed to resolve at import time (no matching team member by
  source email). It looks up by `source_user_email` against the
  importer's team and re-stamps everything if a match is found.
- **Delete (🗑️)** removes the row.

### Manual entries
- The "+ Add entry" toggle reveals a NewEntryRow that creates a row
  in the user's "Manual entries" import batch (auto-created once).
- Required fields: date, duration, operator, client, project,
  description, team member (defaults to the current user's own row
  if present). Source email + name are derived from the picked
  member — no manual entry of those.

### Bulk actions (owner-only)
- Per-row checkboxes plus a header checkbox. When all 100 visible
  rows are selected and more than 100 match the filter, a banner
  offers **"Select all N matching the filter"** (all-matching mode).
- Sticky black action bar appears with the count and:
  - **Apply to invoice** — pick an invoice from the inline dropdown
    (open + sent only; paid hidden) and submit. Stamps `invoice_id`,
    `invoice_applied_at`, `invoice_applied_by`. Silently skips rows
    that are already attached or blocked.
  - **Detach** — clears the invoice link. Explicit-selection only
    (not all-matching, to keep destructive lock-removal scoped).
  - **Recalculate** — re-stamps `billout_cost_usd` +
    `billout_amount_usd` for the selection using the current
    member rates and project overrides. Single SQL UPDATE via
    `restamp_billout_for_entry_ids`. Doesn't re-resolve
    team_member_id from source email (use 🔄 for that).
  - **Edit fields** — bulk **reassign** the selection to an
    existing project and/or team member via two searchable
    combobox pickers (`{client} : {project}` and `{name} :
    {email}`). The action stamps `project_id` + the text-column
    cache from the resolved chain, and/or `team_member_id` +
    `converted_user` + re-stamped cost + billout. No free-text —
    rename via `/projects/manage`. Safety net (`expected_count`)
    still applies. See decisions.md #042.
  - **Clear** — drops the selection.
- All actions confirm via `window.confirm` with the count.
- After success the page redirects to `/entries?...` with an info
  banner indicating the count (e.g. "Applied 23 entries.",
  "Recalculated cost + billout for 47 entries.").
- Selection state resets when the filter changes.

## Constraints & edge cases
- Pagination uses `range(offset, offset+99)` and a `count: 'exact'`
  query. Both will slow down on multi-million-row tables; see
  considerations.
- The lock semantic is `invoice_id IS NOT NULL OR transferred_at IS
  NOT NULL`. The OR with `transferred_at` is defensive — the
  invoice-migration backfill linked every transferred entry to a
  "Legacy transfers" invoice, but the legacy column is checked too
  for safety.
- Status filter mapping (both at SQL and JS layer):
  - `transferred` / `applied` → `invoice_id IS NOT NULL OR
    transferred_at IS NOT NULL`
  - `pending` → `invoice_id IS NULL AND transferred_at IS NULL` and
    all eligibility columns set.
  - `blocked` → `invoice_id IS NULL AND transferred_at IS NULL` and
    at least one eligibility column NULL.
  - Eligibility columns: `team_member_id`, `operator`, `client`,
    `project`, `description`, `end_at`, `source_user_email`.
    `converted_*` are NOT in the list — they're Toggl-export
    plumbing, optional for invoice flow.
- Editing a duration accepts either decimal hours (`1.5`) or HH:MM:SS
  (`1:30:00`).
- The team-member dropdown is the canonical "who's this billed for"
  control. Editing `source_user_email` directly is no longer
  possible (the field is read-only audit display). Use the 🔄
  Re-resolve button for legacy entries that need their
  team_member_id repaired from `source_user_email`.
- Edit URL uses `?edit=<id>`. On error, the form re-renders with that
  same `edit` param plus `?error=…`.

## Permissions
- Managers, Owners, and super admins (not in view-as) can edit and
  delete. Members can view but not edit.

## Open considerations
- Search has no debounce — every keystroke updates URL and rerenders.
  Fine for hundreds of rows; will need debouncing past a few thousand.
- Count query is exact, not estimated. At 1M+ rows, switch to
  `count: 'estimated'` or use `pg_class.reltuples`.
- No bulk actions (bulk delete, bulk re-resolve, bulk reassign batch).
- No column sort — rows are always ordered by `start_at` desc. Adding
  a sortable header would mean either client-side sort of the current
  page (cheap, partial) or server-side sort via URL param (more work).
- The edit form is a 3-column grid. Doesn't collapse well on narrow
  screens. Should be a modal eventually.
- Deleting an entry doesn't trigger any cascade to recompute Summary —
  that's fine since Summary is computed on the fly from time_entries.
- No way to bulk-import/edit projects + clients. Project/client are
  free-text fields per entry. Eventually they'll be proper FK references.

## Tests
- (Filter URL parsing covered by FLT-001 … FLT-008.)
- 🟡 ENT-001 Editing an entry persists changes and revalidates `/entries`,
  `/transfer`, and `/`.
- 🟡 ENT-002 Re-resolve with an unknown source email errors out with
  guidance to add the team member.
- 🟡 ENT-003 Re-resolve clears converted fields if source email is empty.
- 🟡 ENT-004 Delete removes the row.
- 🟡 ENT-005 Bulk Mark-as-transferred on explicit IDs assigns one shared
  `transfer_batch_id` and skips already-transferred + blocked rows.
- 🟡 ENT-006 Bulk Mark "all matching filter" respects the active date /
  batch / search filters and the eligibility predicate.
- 🟡 ENT-007 Bulk Unmark clears `transferred_at` / `transferred_by` /
  `transfer_batch_id` only on currently-transferred rows.
- 🟡 ENT-008 Selecting "All dates" actually applies an unbounded filter
  (regression: a previous version silently snapped back to this-month
  because `date=all` was stripped from the URL).

## Changelog
- **2026-05-21** — Initial: list + filters + edit + re-resolve + delete +
  status badges + per-row batch link.
- **2026-05-21** — Bulk transfer marking: per-row checkboxes,
  "select all matching filter" mode, Mark / Unmark bulk actions
  (owner-only). Fixed the FilterBar bug where picking "All dates"
  silently reverted to this-month.
- **2026-05-21** — Source / Adi hrs columns now show `h:mm:ss` (exact,
  no rounding). Edit/Cancel links preserve the active filter and
  paging via `scroll={false}` Next.js Links plus a `_return_url`
  hidden field threaded through `updateEntry` / `resolveEntry`. Edit
  form layout compressed to a 4-column grid with smaller padding.
- **2026-05-21** — Column renames: "Source" → "Source hrs", "Adi hrs"
  → "Billout hrs". Added a "Cost" column =
  `billout_hrs × org.default_hourly_rate_usd`. All columns are now
  sortable via clickable headers (server-side `?sort=`/`?dir=` params
  via the SortableHeader component). One-shot back-computed
  `duration_seconds` for backfilled rows so Source hrs is no longer
  blank (see decisions.md #013).
- **2026-05-21** — Cost is now stored per-row in
  `time_entries.billout_cost_usd`, captured at import time. Changing
  the base rate or a team member's proportion no longer retroactively
  changes Cost on historical rows. The Cost column reads the stored
  value (see decisions.md #014). Updates and Re-resolve recompute the
  stored value from current values.
- **2026-05-21** — Transferred entries are locked for managers. Adi
  sees "Locked" instead of Edit / Delete buttons on transferred rows.
  Server actions reject with a clear error if attempted (see
  decisions.md #015).
- **2026-05-21** — Added totals card above the table showing
  row count + source hrs + billout hrs + cost for the **whole** filter
  match (across all pages), via the new `entries_filter_totals` RPC.
  Filter bar gained three pivot inputs: Project / User / Source email.
  All three feed both the data query and the totals RPC, and the bulk
  "all matching" action.
- **2026-05-22** — Per-row Re-resolve button (🔄) on every row, not
  just the edit form. Re-runs `resolveEntry` for that row inline.
- **2026-05-23** — Entity migration: operator / client / project
  text columns now have FK counterparts (`projects` + chain).
  Editing operator/client/project on a row creates the entity on
  the fly if absent (see decisions.md #035).
- **2026-05-23** — Cascading entity inheritance in EditRow: typing
  a known client auto-fills its operator; typing a known project
  auto-fills both, when the match is unique.
- **2026-05-27** — **Datalist narrowing in EditRow + NewEntryRow.**
  Project names aren't unique across clients (multiple clients
  have "10 Hour Support Block"), so the old global datalist
  surfaced every same-named project regardless of parent — user
  picks one belonging to a different client → server creates a
  new project under THEIR client, silently duplicating. New
  `clientsByOperator` and `projectsByClientPair` lookup maps
  computed on the server (entries/page.tsx). The Client datalist
  is now strictly scoped to the picked operator's clients; the
  Project datalist is scoped to the (operator, client) pair.
  Falls back to the full list only when the parent isn't picked
  yet (so cascade-up still works when the user types a unique
  project name first).
- **2026-05-24** — Manual entry creation: "+ Add entry" reveals
  NewEntryRow at the top of the table.
- **2026-05-24** — Owner-only Billout column (what we charge the
  client). Reads `time_entries.billout_amount_usd`.
- **2026-05-25** — Eliminate-proportion migration: cost is now per
  source hour (`team_members.cost_rate_usd`); the legacy
  `team.base_rate × proportion` calc is gone. Billout rates likewise
  per source hour. `converted_duration_seconds` is preserved on the
  row only to feed the legacy Toggl CSV export. See decisions.md #034.
- **2026-05-25** — Edit form reshaped around a Team-member dropdown.
  `source_user_email` / `source_user_name` are now read-only
  "Logged as (audit)" display. `converted_user` is no longer an
  editable field — it's derived from the picked team member on
  save. See decisions.md #037.
- **2026-05-25** — Bulk **Recalculate** action: re-stamps cost +
  billout for selected rows via a single SQL UPDATE
  (`restamp_billout_for_entry_ids`).
- **2026-05-25** — Bulk transfer actions replaced by **Apply to
  invoice** / **Detach**. Lock semantic shifted from
  `transferred_at` to `invoice_id`. See decisions.md #038 and
  [invoices.md](invoices.md).
- **2026-05-25** — Dropped the "Billout hrs" column (= source ×
  proportion, only meaningful for Toggl export now). The remaining
  "Source" column was renamed to **Hours**. EntriesSummary card
  shows Entries / Hours / Cost / Billout.
- **2026-05-25** — Bulk Edit fields reshaped to **reassignment-only**
  via two searchable comboboxes (Project + Team member). Free-text
  paths (`bulk_client` / `bulk_project` / `bulk_operator`) removed;
  the action now updates `project_id` from the resolved chain and/or
  re-stamps team member identity + cost + billout. Renames live on
  `/projects/manage`. See decisions.md #042.
- **2026-05-21** — Split end-client out of project. New **Client**
  column on the table, sortable, with its own filter input. Legacy
  `client` column renamed to `operator`. Edit row gets a separate
  Operator + Client + Project triple. See decisions.md #016.
- **2026-05-21** — Bulk edit fields: from the selection action bar,
  click "Edit fields ▾" to expand a sub-row with Client / Project /
  User inputs. Empty fields are skipped. Adi can't touch transferred
  rows via bulk edit; Rian can. See decisions.md #017.
- **2026-05-22** — Status filter is now SQL-level (decisions.md #018).
  Selecting `status=blocked` shows all blocked entries packed into
  page 1, not scattered across pages.
- **2026-05-22** — Added an **Operator** column with sortable header
  and an operator pivot filter. Bulk-edit fields now operate on
  Client / Project / **Operator** (user dropped — wasn't useful).
- **2026-05-22** — Filter pivots are now **dropdowns** populated by
  the `entries_filter_options` RPC — each select lists the distinct
  values present in the currently-filtered result set. See
  decisions.md #019.
- **2026-05-22** — Per-entry comments removed; replaced by an org-wide
  comment board in the top nav. See [comments.md](comments.md) +
  decisions.md #020 / #022.
- **2026-05-22** — UI compaction: Status shown as ✓ / ○ / ⚠ icons with
  full status on hover; Date as `YY/MMM/DD` with full timestamp on
  hover; Source email folded into the User cell's hover tooltip;
  Batch column removed from the table (still in DB and filter, shown
  in the edit form); Edit / Delete shown as ✏️ / 🗑️ icons.
- **2026-05-22** — Fixed a critical bug in `applyBulkFilter` (and by
  extension `bulkUpdateFields`) that dropped the `status` and
  `operator` filters when "select all matching" was used. See
  decisions.md #021. A regression in this code path can wipe vast
  amounts of data — see ENT-020 in the test catalog.
- **2026-05-22** — Bulk-edit safety net: form ships an
  `expected_count` hidden input; the action runs a pre-count with the
  same filter and aborts the write if the two don't match. See
  decisions.md #024.
- **2026-05-22** — Per-row Edit Save was silently broken (nested
  `<form>` — the bulk-action form wrapped the table, EditRow's form
  was inside). Restructured so the bulk form sits above the table.
  See decisions.md #025.
- **2026-05-22** — Default filter on a fresh visit is now
  `date=all + status=pending` (was this-month + all). The dropdowns
  reflect this. Added a **Clear filters** button next to the filter
  bar. See decisions.md #023.
- **2026-05-23** — Time entries are now scoped by importer: Adi
  sees only the rows in batches he uploaded, owner / super_admin
  sees all. Enforced via RLS, not client-side filtering. See
  decisions.md #027. The owner gets a new **Imported by** dropdown
  in the filter bar that constrains entries / totals / summary to
  a single uploader's batches — see decisions.md #028.
