layout · in-panel app

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

List & statusSee every document generated for the deal, grouped by type (Quotes / Invoices / Contracts / Proposals). Each row shows the document number, title, source, created date, current state and total amount.
CreateA single "Create document" button opens a template picker with live search. Each tile names a specific template (Standard invoice, Master Services Agreement, Onboarding proposal, …) and shows its version. We snapshot the current Company, deal-level fields and line items, hand them to the upstream service, and a new card appears in Draft.
CRM snapshotClick any row to open the snapshot modal. This is NOT a final-PDF preview — it is what the deal looked like in CRM at the moment the document was rendered. Useful when the deal moves on and a document looks suspicious.
Act on itPer-row ⋯ menu: open the document in its upstream service · view CRM data · regenerate (overwrites the same document with current state) · mark voided. From the snapshot modal: the same Regenerate / Open external actions live in the footer.

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.

Documents5
Generated for this opportunity — quotes, invoices, contracts and proposals.
Quotes1
QUO-2025-0019Sent
Annual subscription quote · QuickBooks · 08 Apr 2025
€8,964
Invoices2
INV-2025-0061Stale
Expansion invoice (added migration) · QuickBooks · 02 May 2025
€11,722
INV-2025-0042Paid
Initial subscription invoice · QuickBooks · 14 Apr 2025
€8,964
Contracts1
CON-2025-0007Signed
Master services agreement · DocuFly · 10 Apr 2025
€8,964
Proposals1
PRO-2025-0011Viewed
Onboarding proposal deck · DocuFly · 07 Apr 2025
€8,964
Demo logReady.

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.

DraftgrayDocument created locally, not yet sent to the recipient or upstream service.
SentblueDelivered to the recipient — QuickBooks email, DocuFly signing link, etc.
ViewedirisRecipient opened the document (DocuFly tracking).
SignedturquoiseContract or proposal signed.
PaidgreenInvoice settled in QuickBooks.
VoidedredManually marked void by the deal owner. Excluded from totals.
StaleorangeDeal terms changed after the document was generated. Per-row badge only — no banner.

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.

PanelHeaderTop of the panel — section title "Documents" with a live count Tag + the primary "Create document" action.
Card (interactive)One row per document. Whole card is clickable → opens the CRM-snapshot Modal. Hover state via useState on the row (Remote-DOM safe).
StatusDocument state pill — Draft / Sent / Viewed / Signed / Paid / Voided / Stale. Color is the soft Tag palette: gray / blue / iris / turquoise / green / red / orange.
TagCount badge next to each group title and the global total in PanelHeader.
DropdownMenuPer-row overflow ⋯ — Open in QuickBooks/DocuFly · View CRM data · Regenerate · Mark voided (destructive). Stop click propagation so the row click doesn't also fire.
Modal · Create picker"Create document" picker (size="lg") — SearchInput at the top + an auto-fill grid of TemplateTile buttons. Each tile carries the template version Tag for reference. Scales to many templates: pure client-side filter over name / kind / source / version.
SearchInputInside the picker — filters the template grid live as the user types. Auto-focused on open.
Modal · Snapshot previewCRM-data preview (size="lg") — three blocks (Company / Deal / Line items) plus a footer with Close, Regenerate, Open in <service>.
Table (dense)Line-items grid inside the snapshot modal. Right-aligned numeric columns, fixed widths on price/qty.
EmptyStateShown when no documents have been generated yet for the deal. Primary action mirrors the panel header.
Box + DividerSection frames inside the snapshot modal and the totals row under the line-items table.

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 flat Doc[]; grouping by kind happens in the component using KIND_ORDER. No nested payload, no pagination on this surface.
  • DocSnapshot is 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.
ts
// ─── Doc-generator catalog ─────────────────────────────────────────────────
// Static-ish registry the picker reads from. In production this comes from
// the doc-generator service (HTTP endpoint or persisted Twenty object).
// New document types are added by extending these unions and adding a
// matching KIND_DESCRIPTORS entry — no schema migration required.
export type DocSource = 'quickbooks' | 'docufly'; // service we route the generation through
export type DocKind = 'quote' | 'invoice' | 'contract' | 'proposal'; // semantic family — drives grouping in the panel
export type DocStatus =
| 'draft' | 'sent' | 'viewed' | 'signed' | 'paid' | 'voided' | 'stale'; // 'stale' is server-computed (snapshot ≠ current deal)
export type Template = {
id: string;
name: string; // human label — "Standard invoice"
version: string; // "v4.0" — informational, persisted on each created Doc for audit
kind: DocKind;
source: DocSource;
hint: string; // one-line description shown on the picker tile
};
// ─── A generated document ──────────────────────────────────────────────────
// One row per record in the panel's list. Returned by a single GraphQL query:
// query opportunityDocuments($opportunityId: UUID!): Doc[]
// Server applies the 'stale' detection and includes it in `status`.
export type Doc = {
id: string;
number: string; // QUO-2025-0019 — issued by the upstream service
title: string; // editable label; defaults to template name
kind: DocKind;
source: DocSource;
status: DocStatus;
createdAt: string; // ISO; server-generated
externalUrl: string; // deeplink to QuickBooks / DocuFly etc.
snapshot: DocSnapshot; // FROZEN at generation time — NEVER re-resolved
template?: { id: string; version: string }; // optional audit pointer to the Template used
};
// ─── Frozen CRM snapshot ───────────────────────────────────────────────────
// Captured at generation time and stored verbatim (JSONB on the server). The
// 'View CRM data' modal renders it as-is. Do NOT re-resolve against current
// CRM data — divergence is exactly what the user is trying to see.
export type LineItem = {
id: string;
name: string;
unit: number; // micros / cents — render with Intl.NumberFormat
qty: number;
};
export type DocSnapshot = {
company: { name: string; vatId: string; address: string };
deal: {
period: string; // human-readable; backend already formats "Apr 2025 – Mar 2026 · 12 months"
currency: string; // ISO code; drives all money formatting in the modal
discountPct: number; // 0-100
owner: string; // workspace member display name
};
items: LineItem[];
};

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.

tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { useFrontComponentExecutionContext } from 'twenty-sdk/front-component';
// Overlapping primitives — Twenty's own, via the bridge:
import { Button, IconButton, ThemeProvider } from '@8maverik8/twenty-design/twenty-ui';
// Everything else — our own, Remote-DOM-safe:
import {
PanelHeader,
Card,
Stack,
Text,
Tag,
Status,
Modal,
Table,
DropdownMenu,
DropdownMenuItem,
DropdownMenuSeparator,
EmptyState,
Divider,
Box,
} from '@8maverik8/twenty-design';
import {
IconPlus,
IconDots,
IconExternalLink,
IconRefresh,
IconEye,
IconBan,
IconReceipt,
IconFileText,
IconFileSignature,
} from '@8maverik8/twenty-icons';

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.id from the execution context and queries opportunityDocuments(opportunityId) — a single GraphQL list with kind, source, status, number, createdAt, externalUrl, snapshot.
  • Templates come from the doc-generator service, not hard-coded. The demo inlines a TEMPLATES array; in production the picker fetches the registry (with versionfield) 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 overname / kind / source / version. Modal body already has overflowY: auto so 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 kind in the component using the same KIND_ORDER array. 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-state rules — Twenty Remote DOM strips them. The demo uses onMouseEnter/onMouseLeave and 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.