Skip to content

Usability overhaul: payment workflow, person-centric contacts, command palette, and systemic fixes - #2

Merged
Coriana merged 28 commits into
mainfrom
claude/project-usability-review-ad4940
Jul 12, 2026
Merged

Usability overhaul: payment workflow, person-centric contacts, command palette, and systemic fixes#2
Coriana merged 28 commits into
mainfrom
claude/project-usability-review-ad4940

Conversation

@Coriana

@Coriana Coriana commented Jul 11, 2026

Copy link
Copy Markdown
Owner

Summary

This branch is the outcome of a full usability review of the app (driven live in the browser, not just read from code), followed by three tiers of fixes and two larger features. 13 commits, each scoped to one concern.

Verified bug fixes

  • UTC date off-by-one: every date default resolved to yesterday for UTC+ timezones (toISOString().split('T')[0], 19 call sites). New src/lib/dates.ts local-date helpers used everywhere, including derived-date math (due dates, start-of-month, rental billing).
  • First invoice broke the setup wizard's promise (INV-0001 previewed, INV00001 created): wizard now uses the exact server numbering formula.
  • CRUD shim silently dropped unmapped/nested relations — the payments list showed "-" for invoice/client on every row. The shim now resolves nested relations recursively (depth-capped), relationMappings covers everything the frontend requests, unknown relations fail loudly with a 400, and issue_bookmark_links was added to the table allowlist (requests to it always 400'd before).
  • Dashboard stats undercounted past 10 rows (limit-10 lists fed the stat cards): counts/sums now come from full-data queries.
  • New-account billable toggle displayed OFF but saved ON (server default override): form now sends explicit values.

Workflow / UX improvements

  • Payment workflow: one-click Mark as Sent on draft invoices and Record Payment on the invoice page (dialog preselects the invoice and pre-fills the balance due); the payment dialog explains an empty invoice list instead of showing a silently empty dropdown; ambiguous buttons renamed Receive Payment / Pay Vendor.
  • Terminology unified on "Client" (was a mix of Clients and Accounts across nav, list pages, forms, and docs).
  • All 12 native confirm() calls replaced with a promise-based useConfirm() AlertDialog provider (queue-safe, destructive styling, multi-line descriptions).
  • Currency honors Settings everywhere: 24 hardcoded en-AU/AUD formatters replaced with useBranding().formatCurrency (verified by flipping Settings to GBP and watching lists re-render); tables show localized dates instead of raw ISO strings.
  • Grouped sidebar (Work / Finance / Operations / Insights / Admin, permission-aware headers) and a Ctrl/Cmd+K command palette with quick actions, page navigation, and permission-gated fuzzy search across 11 entity types.

Person-centric contacts (new feature)

People change companies — contacts are now first-class entities:

  • New contacts + contact_affiliations tables: a contact is affiliated with a client or vendor (CHECK-enforced) for a period (end_date NULL = current), with per-org title and primary flag. Concurrent affiliations are supported (contractors can be current at several orgs), stints can be back-dated or ended, and every affiliation's title/dates are editable — clearing an end date makes it current again.
  • /contacts list (search by name/email/phone/current org) and /contacts/:id with the full affiliation timeline; contacts searchable from the command palette.
  • Client and vendor detail pages share an AffiliatedContacts panel (New Person / Link Existing) and a PrimaryContactSelector dropdown replacing the old free-text contact fields.
  • Migrations: both legacy tables (client_contacts, vendor_contacts) convert into the new model (ids preserved, idempotent) and remain as frozen archives; orphaned free-text contact info on clients/vendors is converted into real contacts rather than wiped.
  • SQLite triggers keep the legacy contact_name/email/phone columns mirroring each org's current primary contact, so invoice emailing and the external API needed no changes.

Reviewer notes

  • Server tests went from 26 to 57 (relations, contacts CRUD, both legacy migrations, orphan conversion, trigger sync). npm run test:run, npm run typecheck, and npm run build are all green.
  • server/__tests__/backup.test.ts's retention test is occasionally flaky under full-suite parallel load (timing-sensitive, pre-existing); it passes in isolation.
  • Every user-facing change was verified in the running app, including a full walkthrough of the contractor scenario (one person primary at a vendor while concurrently affiliated with a client, with an ended stint preserved in history).
  • The migrations run automatically on boot and are idempotent; existing databases keep their data (including hand-typed contact info, which becomes real contact records).

🤖 Generated with Claude Code

Coriana and others added 28 commits July 9, 2026 19:20
Make the scoped external API (/api/external) usable by AI agents and
codegen tools.

- openapi.yaml: OpenAPI 3.1 contract for /api/external — bearer API-key
  auth, the resource+id paths, CRUD operations, response/error shapes,
  the full resource enum, the resource→permission-group mapping, and
  request/response examples. Validates clean. Linked from api-examples.md.

- mcp-server/: a local stdio MCP server (TypeScript) wrapping the API so
  an agent can list/get/create/update/delete records through an API key.
  Six tools (list_resources, list_records, get_record, create_record,
  update_record, delete_record) with proper read-only/destructive/
  idempotent annotations and actionable error mapping (401/403/400/404).
  Config via CLIENTFLOW_API_URL/CLIENTFLOW_API_KEY; refuses to start
  without them and never prints the key. Includes README with a
  Claude Desktop config block and unit tests for the api-client (9 passing).

All access remains gated server-side by the key's scopes and the owning
user's role — this adds no privileges. No changes to existing app behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…DE.md

- package.json: rename vite_react_shadcn_ts -> client-flow, version 0.1.0
- remove @supabase/supabase-js (unused; the app uses the local client shim)
- make db:reset cross-platform (node fs.rmSync instead of `rm -f`)
- delete the dead supabase/ directory (Deno edge functions + migrations
  superseded by server/routes/ and server/db/) and the stale bun.lockb
  (the project uses npm)
- add a tracked CLAUDE.md documenting architecture, the security model,
  numbering, conventions, and gotchas (AGENTS.md is gitignored, so its
  guidance did not travel with the repo)

Build and the 23-test suite still pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Since the local SQLite file holds real business/financial data, add an
online backup (better-sqlite3 db.backup(), safe while the app serves
requests):
- server/utils/backup.ts: backupDatabase() writes a timestamped copy to
  BACKUP_DIR and prunes to the newest BACKUP_RETENTION files; it never
  throws, so a backup failure can't affect request handling.
  startBackupSchedule() runs one backup on boot and every
  BACKUP_INTERVAL_HOURS (unref'd so it doesn't block shutdown; disabled
  when BACKUP_INTERVAL_HOURS=0).
- index.ts starts the schedule after listen (skipped under test).
- Defaults: BACKUP_DIR=./data/backups (already gitignored), 24h, keep 7.
- Documented in .env.example and README; tested (valid independently-
  openable copy + retention). Suite now 26 passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
better-sqlite3 does not create the parent directory of the database file,
and data/ is gitignored — so `npm run dev` on a clean clone crashed with
"Cannot open database because the directory does not exist". getDatabase()
now mkdir -p's the DB's parent directory before opening. (Tests were
unaffected because the test helper creates its own temp dir.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TanStack Query was installed but unused; every page hand-rolled
useState/useEffect fetching. Start adopting it, beginning with the
read-only Dashboard as the template:
- App.tsx: give the QueryClient sensible defaults (30s staleTime, 5m
  gcTime, retry once, refetch on window focus)
- Dashboard: replace the big fetch-everything useEffect + seven useState
  slices with a single useQuery(['dashboard']); the queryFn returns the
  same composed data and every query/filter/derivation is unchanged.
  Manual refresh after "generate invoices" now calls refetch(). Behavior
  and rendered output are identical.

Verified in the running app: login, dashboard renders (stat cards,
activity, setup wizard branch) with no query errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Continue the TanStack rollout from Dashboard to the two simplest
high-traffic list pages. Both are read-only: extract the fetch into a
module-level queryFn and replace useState+useEffect with
useQuery(['clients']) / useQuery(['jobs']). Client-side search filtering
and all rendering are unchanged. Verified both render in the running app
with no query errors.

Invoices is intentionally left for a separate pass — it has bulk-mutation
actions (status update, send) that warrant useMutation + invalidation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Continue the rollout to the remaining read-only list pages, following the
Clients/Jobs pattern: module-level queryFn + useQuery, local UI state and
rendering unchanged. Locations composes its multi-step fetch (locations +
per-location counts) into one queryFn. Vendors was intentionally skipped —
it has an inline insert action that needs the useMutation pattern instead.

Verified Locations and Assets render in the running app with no query
errors. Build passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Add src/lib/dates.ts local-date helpers and replace every
  toISOString().split('T')[0] call site; date defaults and derived
  dates (due dates, start-of-month, rental billing) no longer resolve
  to yesterday in UTC+ timezones
- Setup wizard now previews and saves invoice numbers with the exact
  server formula (prefix includes separator, 5-digit padding), so the
  first invoice matches the wizard's promise
- New-account form sends billable defaults explicitly so the saved
  record matches what the toggles displayed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Extract the Record Payment dialog into RecordPaymentDialog.tsx
  (self-fetching, optional preselected invoice, form reset on close)
- Invoice page: one-click "Mark as Sent" on drafts and "Record
  Payment" on payable invoices, refreshing totals on success
- Payment dialog shows an explanatory empty state instead of a
  silently empty invoice dropdown, with submit disabled
- Rename ambiguous Payments-page buttons to "Receive Payment" /
  "Pay Vendor"; hide Void status on new invoices; fix stat-card
  pluralization

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ones

- parseSelect now keeps nested relation tokens (e.g.
  invoices(invoice_number, clients(name))) and attachRelations resolves
  them recursively, capped at depth 3
- Complete relationMappings for every relation the frontend requests
  (payments->invoices, issues->clients, user_roles->roles, expenses->
  vendors, issue/kb link tables, inventory_movements, job_assignments)
- Add issue_bookmark_links to the table allowlist; requests to it
  previously failed with 400 Invalid table
- Unknown relations now return 400 with a precise message instead of
  silently omitting the key from the response
- Add server/__tests__/relations.test.ts covering nested joins, hasMany
  nesting, and the 400 paths (32 tests total pass)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Outstanding-invoice count/amount and open-issue count were derived from
the same limit(10) queries that feed the display lists, so the headline
numbers were wrong past ten rows. Counts now use head+count queries and
the outstanding amount sums a narrow no-limit projection; the display
lists are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add ConfirmProvider/useConfirm (promise-based, queue-safe, destructive
styling, multi-line descriptions) and migrate all twelve window.confirm
call sites plus BankAccountDetail's two inline AlertDialogs to it, so
destructive actions get a consistent in-app dialog instead of the
browser popup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The nav, client list, client detail, and docs called clients
"Accounts" while the rest of the app said "Client". Rename the
client-entity copy to Clients everywhere; bank/user/service account
wording is untouched. (The invoice form and list finish the rename in
the following formatting commit.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Replace 24 hardcoded en-AU/AUD Intl.NumberFormat formatters (list
  pages, detail pages, dialogs, all reports) with useBranding()'s
  formatCurrency, so the currency chosen in Settings applies
  everywhere; ActivityDetails threads it into its module-level
  formatter as a parameter
- Add formatDisplayDate to src/lib/dates.ts and use it where tables
  rendered raw ISO strings (invoice list, payments/purchases tables,
  job timesheets/expenses/rentals, asset rental history); date inputs
  keep ISO values
- Complete the Client rename in the invoice form label, required-field
  toast, credit panel, and invoice-list column header

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Sidebar nav is now sectioned (Work / Finance / Operations /
  Insights / Admin, with Dashboard and Help & Docs ungrouped); section
  headers hide when permissions filter out every item in the group
- New CommandPalette (cmdk) opens via Ctrl/Cmd+K or a Search button at
  the top of the sidebar: quick actions (gated by write permission),
  page navigation, and fuzzy search across clients, jobs, invoices,
  vendors, inventory, assets, issues, KB articles, locations, bank
  accounts, and team members (each gated by read permission; data
  fetched lazily on first open)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New contacts and contact_affiliations tables: a contact is a person,
affiliated with a client OR a vendor (CHECK-enforced) for a period
(end_date NULL = current), with title and primary flag per
affiliation. People who change companies keep their identity and
history. One-time migration converts client_contacts rows (ids
preserved) and leaves the old table as a frozen archive. Shim gets
table/relation mappings; integration tests cover nested selects, the
change-of-company flow, CHECK rejections, and the migration itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- New /contacts list (search across name/email/phone/current org) and
  /contacts/:id detail with the affiliation timeline: add an
  affiliation to a client or vendor, end one (person keeps their
  history), set the primary contact per client
- New shared AffiliatedContacts panel (New Person / Link Existing)
  replaces the client-only ClientContacts component; ClientDetail and
  the Clients list now read primary contacts from current affiliations
- Contacts appear in the sidebar (Work) and the command palette
- Docs describe the person-centric model

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Migrate vendor_contacts rows into contacts + vendor affiliations
(ids preserved, idempotent via id-overlap guard) and freeze the
legacy table like client_contacts. VendorDetail drops the old
vendor_contacts tab and gets a single Contacts tab backed by
AffiliatedContacts. Migration tests now cover the vendor path and
client/vendor coexistence (47 tests total).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SQLite triggers keep clients/vendors contact_name/email/phone
mirroring each org's current primary contact (recompute-from-scratch
on affiliation insert/update/delete and on contact edits), so invoice
emailing and the external API stay correct without code changes. A
boot-time recompute backfills pre-trigger rows, and orphaned free-text
contact info (no matching affiliation) is first converted into a real
contact + primary affiliation - reusing an existing person on a
normalized name+email match - so nothing is wiped. Covered by
contact-sync and orphan-migration test suites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Affiliation dialogs take an optional end date (record past stints in
  one step) and every affiliation row on the contact page has an Edit
  dialog for title/start/end - clearing the end date makes it current
  again; start/end order is validated
- Overlapping affiliations are explicitly supported: adding an org
  never ends another, so contractors can be current at several
  clients/vendors at once
- Primary contacts now work for vendors like clients (star, badge,
  scoped clear-then-set)
- ClientDetail and VendorDetail replace free-text contact fields with
  a PrimaryContactSelector dropdown of currently affiliated people,
  linked to their contact page with live email/phone

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Below the md breakpoint each list renders tappable cards (identifier +
status badge, secondary line, dates, money right-aligned) instead of a
horizontally-scrolling table that hid Status and Total off-screen;
tables are unchanged on md+. Invoices keeps bulk selection working via
a checkbox outside the card's link target; Payments-made cards open
the same edit dialog as the desktop row. Also let the Payments header
actions wrap - the three buttons overflowed narrow viewports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sweeps the stragglers the original branding pass missed: hardcoded
$/toFixed and toLocaleString currency in Assets, ClientDetail,
BankAccountDetail, JobHistory, TeamMemberActivity, purchase dialogs,
bill import, and the setup tax preview now use
useBranding().formatCurrency; raw new Date(...).toLocaleDateString on
date-only columns (incl. the Dashboard due-date UTC off-by-one) now go
through formatDisplayDate/parseDateOnly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pills show live counts per status (only statuses that occur), combine
with the search box, and drive the desktop table, mobile cards, and
select-all alike.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New shared EmptyState component (icon, title, description, action) on
all eleven primary list pages. A truly empty list now explains the page
and offers the same permission-gated create action as the header; a
search/filter with no matches says so and offers one-click clearing
(resetting the status pills too on Invoices, type filter on Locations).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Shared src/lib/csv.ts (escaping, CRLF, UTF-8 BOM for Excel, blob
download) plus an Export CSV button on all eleven reports. Exports the
full underlying rows - raw numbers and YYYY-MM-DD dates, not display
strings - including rows the UI truncates, with date-ranged filenames.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
next-themes ThemeProvider (class attribute, system default) wraps the
app; a Light/Dark/System dropdown lives in the sidebar footer. Swept
feature components for light-only colors: gray tints now use semantic
tokens, status hues gained dark: variants, and report chart grids use
the border token instead of #ccc. Auth pages and the setup wizard were
already token-based.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pdfkit renders real PDFs (server/utils/invoicePdf.ts): paginated line
tables, totals with credit/paid/balance, payment details, notes/terms.
Single download from the invoice page; bulk download is now ONE request
returning one combined PDF instead of N print popups that silently
no-op under popup blockers. Currency comes from company settings
(validated locale/code, en-AU/AUD fallback) instead of hardcoded AUD.

Also fixed while in the area: bulk-email failures now aggregate into a
single summary toast instead of one toast per invoice; all invoice and
invite email HTML now escapes interpolated data (shared escapeHtml in
utils/email.ts); generate-job-invoices no longer derives dates via
toISOString (server-side UTC off-by-one); invoice-detail chips gained
dark-mode variants. Six new endpoint tests in invoice-pdf.test.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Recharts defaults (grey #666 ticks, white tooltip, light hover band)
now use muted-foreground/border/popover tokens; the Time by Job
non-billable bar swaps --muted for --muted-foreground so it stays
visible on dark cards, and its legend text uses the foreground token.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Coriana
Coriana merged commit bd0a891 into main Jul 12, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant