Back office: Customers, a customer's page, Staff access

The staff-only back office, rebuilt to the shape the rest of the app is built in

09 Sep 2026 · branch fede/back-office-companies · captured against propflow-dev · at 4bbd605159+local

Data note. Every name and email below comes from propflow-dev, which is anonymized by construction (ADR-0097/0110 — phones in the impossible +1000 NPA, emails @example.test). The three customers shown were seeded for this capture and are invented; no real customer appears on any of these pages. The rendered text of all 6 pages was scanned by src/lib/anonymize/record-verify.ts:verifyRecordContacts (reachable contacts only (email, phone) — not names or balances) with 0 violations. The identical capture against prod would carry real customers and is not safe to attach anywhere, which is why the generator refuses to run against one.

1 · Customers — one flat table, no status column

The house list-page skeleton, in the house order: PageBody, a PageHeader carrying one primary action, one flat DataTable. It is the same four things the Prospects and Tenants lists are, with different columns.

Two columns are worth arguing for. Admin (name over muted email, or a dash) is what makes the list answer "did this one get an admin?" without opening the row — the job staff actually have during an onboarding window. Modules is joined text in the same muted style as PMS, not four coloured pills per row: what a customer bought is a fact about them, not a status, and four pills on every row turned a quiet table into a wall of colour.

There is no status column of any kind, on purpose. Nothing in the platform computes onboarding state in one place, and every organization row in production reads active — a column showing "Live" for every row is a fabricated value, not a status.

Asserted in the captured DOMCustomers · COMPANY · PMS · PROPERTIES · UNITS · ADMIN · MODULES · CREATED · + New customer. Every column this screen claims is present as text, and the one primary action with it.

Customers — one flat table, no status column
/admin/companies · 1512×1100

2 · New customer — one flat form, not a stepper

Standing a customer up is three jobs — name the company, record what they bought, invite their admin — and they are three fields in one form, because that is what the invite modal on Staff access already does with role and properties. This was a five-step stepper for one afternoon; nothing else in this app steps through creating an internal object, and inventing a pattern here made the back office read as a different product.

The admin half is optional and says so. A half-entered admin (an email with no name) is refused before anything is written, and a refused invite reports that the company was created rather than reading as though nothing happened.

Asserted in the captured DOMNew customer · Company name · PMS · Modules · Admin full name · Admin email · The admin will receive an email invitation to join PropFlow AI. · Create customer. Every field is on screen at once — that is the claim, and these strings are how you check it without counting steps.

New customer — one flat form, not a stepper
/admin/companies · 1512×1100

3 · A customer's page — two columns, the Leasing card idiom

A detail view is a page here, on the shared DetailPage host: the company name is the h1 and the last crumb in the top-bar trail, and the back chevron hangs in the page gutter, so it does not move when you navigate between this and anything else. It was a modal.

The cards are the Leasing cards, not a second card idiom: Card + SummaryCardHeader, and every fact a Field — label above value, label lighter, value stronger — the same primitives the prospect and tenant cards render. It wore the Organization Settings idiom until a screenshot at 1512px next to the prospect page showed the difference.

The right rail matters for a reason the code could not show. With sidebar omitted DetailPage renders ONE full-width column: every card stretched to ~1230px, a module's switch sat a thousand pixels from the label it belongs to, and five short facts needed three rows of a two-column grid. "Customer details" is still exactly one card of facts — Setup and Record merged, nothing repeated — it simply lives where a compact fact panel belongs.

This company has an admin, so the Access card carries no invite button — see the next section for the other half of that rule.

Asserted in the captured DOMCustomer details · PMS · PROPERTIES · UNITS · CREATED · PEOPLE WITH ACCESS · Modules · Leasing · Renewals · Maintenance · Collections · Access · Sam Rivers · Invited. All three cards, the five facts of the merged card, and the one person on the roster. If "Record" ever comes back as a second card, this fact list would be on screen twice. Note there is NO "+ Invite admin" button here — this company has an admin.

A customer's page — two columns, the Leasing card idiom
/admin/companies/org_0b274a71-26be-4afe-9a7a-2d7fd6c6c3f2 · 1512×1100

4 · Access — the invite button only when there is nobody to be the admin

Staff issue access to the customer's ONE admin, who then adds the rest of their team themselves in Settings. So "+ Invite admin" is a function of the roster, not a permanent fixture: on a company that already has an admin it is absent, because the founder's question looking at it — "does this add a second admin?" — has no good answer. An unclaimed invite counts as having one; that case is Resend on the row.

This company has nobody, so the button is here, and the empty section is one muted line rather than a centred illustration: the count is already on screen beside it, and the next action is the button on this card's own title row.

Asserted in the captured DOMAccess · Their admin manages the rest of the team in Settings. · + Invite admin · Nobody has access yet.. The button is asserted on a company with no admin; the previous section asserts the roster on one that has an admin, where the button is absent. The two together are the whole rule.

Access — the invite button only when there is nobody to be the admin
/admin/companies/org_bf0703f9-a7d8-4e35-a275-d16e57af7348 · 1512×1100

5 · Staff access — PropFlow's own roster, kept apart

Admin → Users was a flat list of every login on the platform, PropFlow's own staff mixed in with every customer's people. That is a database table, not a job anybody does. It splits in two, and this is our half: the same table, the same invite / edit / impersonate / remove actions, the same Waitlist tab, narrowed to platform_admin.

It is deliberately not a filter or a tab inside Customers. Our roster and a customer's roster are two lists with two owners, and the one way they leak into each other is by sharing a screen.

What the narrowing dropped: the Role column (one value now), the Properties column (staff are org-wide — it read "All" for every row), and the role pickers in both modals, since picking anything else made the row vanish from the list it was created in.

Asserted in the captured DOMStaff access · Staff · Waitlist · NAME · EMAIL · STATUS · CREATED. The two tabs are genuinely different datasets, which is the one case tabs survive here. No Role column and no Properties column — check they are absent.

Staff access — PropFlow's own roster, kept apart
/admin/staff · 1512×1100

6 · The screen it was compared against, at the same width

The design-bar loop requires naming the screen you compared with. The list above is the same skeleton as this one — page title, one primary action, one flat table with search and a count — and the customer page uses the card primitives the prospect and tenant detail pages render.

The intended capture here was /leasing/prospects, the closest sibling. It throws its error boundary against the propflow-dev table this artifact is captured from — nothing in this PR touches leasing, and the page renders normally on the branch preview, where the side-by-side screenshots in the PR were taken. Rather than ship a picture of an error page labelled as a reference, the reference here is Tenants, which is the same list skeleton.

Asserted in the captured DOMTenants. The reference, captured rather than described.

The screen it was compared against, at the same width
/tenants · 1512×1100

What the captures caught that the tests did not

A customer's whole roster rendered as dashes. Every person at a customer with existing people showed "—" for both name and email. The People route asked the identity spine and took its answer as final; people created before that migration carry name and email on their User row and have a Person with a role row and nothing else — no profile, so no display name; no claim, so no email. Staff access, four lines of code away, showed the same class of person correctly because it resolves through enrichUserRows, which treats the spine as an overlay with the residual columns as the fallback. Both now use that helper. No unit test was looking; the screenshot was.

The page did not match Leasing, and the code could not say so. Every primitive was the right one and the frame was the shared one. What was wrong was geometry: no sidebar means one full-width column, and at 1512px that stretched every card, separated each module's switch from its label by a thousand pixels, and spread five short facts over three rows. It took a screenshot beside the prospect page to see it.

PropFlow Docs