Document Hub
Right-panel app that lists every document generated for an Opportunity — invoices and quotes from QuickBooks today, contracts and proposals from DocuFly next, and any service we plug in after. The user sees what was issued, what state it is in, what CRM data went into it, and how to act on it. Composed entirely from components in @8maverik8/twenty-design + Twenty primitives via the /twenty-ui bridge.
The four jobs of this panel
Live demo
Five sample documents across all four types. Try the row click → snapshot, the ⋯ menu → Regenerate (watch the snapshot update inside the modal), and the Create document button. The panel fills the container width — resize the docs page to verify narrow and full-width layouts.
Status palette
The seven document states map onto the soft Status pill colors. Stale is shown per-row only — no top-of-panel banner, on user's decision.
What composes it
Every visible piece maps to a documented component or recipe — no bespoke styling buried in this demo file. Two tiny inline helpers (SourceMark for the colored source pill and TypeTilefor picker tiles) are kept local because they are 30-line wrappers and aren't reused yet; promote them to recipes when a second consumer appears.
Domain shape
The data contract the panel renders against. The demo inlines these types in DocumentHubDemo.tsx; when porting to the real oapps-document-hub the backend should expose them as the GraphQL types and the front-component imports them directly. Two things matter most:
- The list comes from one query —
opportunityDocuments(opportunityId)returns a flatDoc[]; grouping bykindhappens in the component usingKIND_ORDER. No nested payload, no pagination on this surface. DocSnapshotis frozen JSONB on the server— the server captures Company / Deal / Line items at generation time and never re-resolves them. The "View CRM data" modal renders the snapshot as-is. This is the whole point of the surface: see what the deal looked like when the document was issued vs the current CRM state.
Imports for the real front-component
The demo uses our root @8maverik8/twenty-design imports (Normal DOM). When porting to a real front-component, the bridge subpath provides Button / IconButton in Remote-DOM-safe form. Everything else keeps the same import — works in both.
For the agent rebuilding the real app
Translation checklist when porting this layout to oapps-document-hub/src/components/document-hub.front-component.tsx:
- Source of truth is the Opportunity. The component reads
opportunity.idfrom the execution context and queriesopportunityDocuments(opportunityId)— a single GraphQL list withkind,source,status,number,createdAt,externalUrl,snapshot. - Templates come from the doc-generator service, not hard-coded. The demo inlines a
TEMPLATESarray; in production the picker fetches the registry (withversionfield) at open time and re-renders the grid. Versions are informational on this surface but should be persisted on each generated Doc so the snapshot view can later show “rendered from template X v4.0”. - Picker scales — never truncate the list. Search is pure client-side over
name / kind / source / version. Modal body already hasoverflowY: autoso the grid can be arbitrarily tall. Add filter chips above the search if the registry grows past ~30 entries. - Group on render, not in the query. Keep the list flat in the API; group by
kindin the component using the sameKIND_ORDERarray. New document types are added by extending that array + adding a descriptor — no schema migration needed. - Snapshot is frozen JSON, not a live join. The
snapshotcolumn captures Company / Deal / Line items at generation time. Never re-resolve it against current CRM data — that is the whole point of the "view CRM data" flow. - Stale detection lives on the server. The backend compares the snapshot to the current Opportunity on read and returns
status: 'stale'; the panel doesn't recompute it client-side. - Regenerate overwrites. Per user decision the action overwrites the existing document in place (new snapshot, status flips back to
draft, upstream service is updated). No version history is kept on this surface — if audit is ever required, add it on the server, not by surfacing two cards. - Open external goes through the host.Inside a Twenty front-component you can't call
window.opendirectly — dispatch the URL through the SDK's navigation helper so the host opens it in a new tab with the right session. - The whole-row click and the ⋯ menu coexist. The menu's wrapper must
event.stopPropagation()on click — otherwise opening the dropdown also opens the snapshot modal. This is wired in the demo and the pattern is worth copying verbatim. - Hover/active states via useState. Don't add CSS
data-staterules — Twenty Remote DOM strips them. The demo usesonMouseEnter/onMouseLeaveand inline styles, which work in both Normal and Remote DOM. - Wrap the whole component in
<ThemeProvider colorScheme="light">from@8maverik8/twenty-design/twenty-uiat the top — Twenty's tokens won't apply otherwise.