Files
tessera/docs/web-spec.md
2026-06-07 21:53:16 +02:00

11 KiB

Create a React/Next.js frontend with shadcn/ui for the Tessera ticketing system.

The backend API server lives at /home/gjermund/projects/tessera/src/ (Hono/Bun, serves on port 9876). The frontend will live at /home/gjermund/projects/tessera/web/ (Next.js 15 App Router, dev on port 5173).

Stack

  • Next.js 15 (App Router, TypeScript, strict mode)
  • shadcn/ui (component library — code lives in web/src/components/ui/)
  • TanStack Table v8 (for data tables — sorting, filtering, pagination)
  • Tailwind CSS v4 (via shadcn/ui)
  • React Hook Form + Zod (form validation)
  • lucide-react (icons)

Steps

1. Create Next.js app

cd /home/gjermund/projects/tessera
npx create-next-app@latest web --typescript --tailwind --eslint --app --src-dir --import-alias "@/*" --no-turbopack
cd web

2. Install shadcn/ui

npx shadcn@latest init
  • Style: Default
  • Base color: Neutral
  • CSS variables: Yes

3. Install shadcn/ui components

npx shadcn@latest add button input select textarea dialog tabs table card badge label separator dropdown-menu
npx shadcn@latest add form
npx shadcn@latest add data-table
npx shadcn@latest add sheet
npx shadcn@latest add tooltip

Also install:

bun add @tanstack/react-table zod react-hook-form @hookform/resolvers lucide-react date-fns

4. Proxy config

In web/next.config.ts:

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  async rewrites() {
    return [
      {
        source: '/api/:path*',
        destination: 'http://127.0.0.1:9876/api/:path*',
      },
    ];
  },
};

export default nextConfig;

5. API Client

File: web/src/lib/api.ts

A typed fetch wrapper for all Tessera endpoints. Every function returns { data, error }. On HTTP error, return { data: null, error: message }. Use proper TypeScript types.

Functions:

  • getTickets(params?: { queue_id?: string; status?: string }): Promise<{ data: Ticket[] | null; error: string | null }>
  • getTicket(id: string): Promise<{ data: Ticket | null; error: string | null }>
  • createTicket(data: { subject: string; queue_id: string }): Promise<{ data: Ticket | null; error: string | null }>
  • updateTicket(id: string, data: { subject?: string; status?: string }): Promise<{ data: UpdateResult | null; error: string | null }>
  • previewTicket(id: string, data: { status?: string }): Promise<{ data: PreviewResult | null; error: string | null }>
  • getTicketTransactions(id: string): Promise<{ data: Transaction[] | null; error: string | null }>
  • getQueues(): Promise<{ data: Queue[] | null; error: string | null }>
  • createQueue(data: { name: string; description?: string }): Promise<{ data: Queue | null; error: string | null }>
  • getScrips(): Promise<{ data: Scrip[] | null; error: string | null }>
  • createScrip(data): Promise<{ data: Scrip | null; error: string | null }>
  • updateScrip(id: string, data): Promise<{ data: Scrip | null; error: string | null }>
  • getLifecycles(): Promise<{ data: Lifecycle[] | null; error: string | null }>
  • createLifecycle(data): Promise<{ data: Lifecycle | null; error: string | null }>
  • getCustomFields(): Promise<{ data: CustomField[] | null; error: string | null }>
  • createCustomField(data): Promise<{ data: CustomField | null; error: string | null }>

6. Types

File: web/src/lib/types.ts

TypeScript interfaces:

interface Ticket {
  id: string; subject: string; queue_id: string; status: string;
  owner_id: string | null; creator_id: string;
  created_at: string; updated_at: string;
  started_at: string | null; resolved_at: string | null;
  custom_fields?: CustomFieldValue[];
}
interface Queue { id: string; name: string; description: string | null; lifecycle_id: string | null; }
interface Transaction { id: string; ticket_id: string; transaction_type: string; field: string | null; old_value: string | null; new_value: string | null; data: unknown; creator_id: string; created_at: string; }
interface Scrip { id: string; queue_id: string | null; name: string; condition_type: string; action_type: string; action_config: Record<string,unknown>; template_id: string | null; stage: string; sort_order: number; disabled: boolean; }
interface Template { id: string; name: string; queue_id: string | null; subject_template: string; body_template: string; }
interface Lifecycle { id: string; name: string; definition: LifecycleDefinition; }
interface LifecycleDefinition { statuses: { initial: string[]; active: string[]; inactive: string[] }; transitions: Record<string, string[]>; }
interface CustomField { id: string; name: string; field_type: string; values: unknown | null; max_values: number; }
interface CustomFieldValue { id: string; custom_field_id: string; ticket_id: string; value: string; custom_field?: CustomField; }
interface PreviewResult { prepared_scrips: PreparedScrip[]; }
interface PreparedScrip { scripId: string; scripName: string; actionType: string; actionPayload: unknown; dryRun: boolean; }
interface UpdateResult { ticket: Ticket; scrip_results: ScripResult[]; }
interface ScripResult { scripId: string; success: boolean; message: string; }

7. Layout

File: web/src/app/layout.tsx

  • Root layout with Inter font (from next/font/google)
  • Body: bg-neutral-950 text-neutral-100, antialiased
  • Nav bar at top: "Tessera" text on left (font-bold text-lg), nav links on right: "Tickets" (/) and "Admin" (/admin)
  • Nav bar styling: bg-neutral-900 border-b border-neutral-800, h-14, px-6, flex items-center justify-between
  • Active link: text-white font-medium. Inactive: text-neutral-400 hover:text-neutral-200
  • Main content below: container mx-auto px-6 py-8

8. Ticket List page

File: web/src/app/page.tsx (root = ticket list)

  • Fetch tickets + queues on load (useEffect)
  • Filter bar: queue dropdown (populated from queues, with "All queues" default), status dropdown (All, new, open, in_progress, resolved, closed), "Filter" button
  • Data table using shadcn/ui Table component:
    • Columns: ID (first 8 chars, monospace), Subject (link to /tickets/[id]), Queue (badge), Status (badge with color), Created (formatted date)
    • Status badges: new=default, open=blue, in_progress=yellow, resolved=green, closed=neutral
    • Clicking a row navigates to /tickets/${id}
  • Loading state: skeleton rows
  • Empty state: centered text "No tickets yet." with a "Create your first ticket" link
  • "New Ticket" button (top right) → opens Dialog with CreateTicketForm

CreateTicketForm (dialog):

  • Subject input (required)
  • Queue select dropdown (required, populated from queues)
  • Submit button
  • On success: close dialog, refresh ticket list

9. Ticket Detail page

File: web/src/app/tickets/[id]/page.tsx

  • Fetch ticket, transactions on load
  • Back link "← All tickets" at top

Ticket info card (Card component):

  • Subject (text-xl font-semibold)
  • Status badge (same colors as list)
  • Queue name, Created date, Updated date
  • Owner (or "Unassigned")

Status change section (Card, below info):

  • Status dropdown: populated from lifecycle definition. The PATCH /:id endpoint validates transitions.
  • "Preview" button: calls previewTicket, shows results in a dialog
    • Dialog shows: list of scrips that would fire (name, action type, "would execute X")
  • "Apply" button: calls updateTicket
    • Shows scrip results after: each result with success/error and message
    • Refreshes ticket data and transaction timeline

Transaction timeline (Card, below status):

  • List of transactions, newest first
  • Each transaction: icon + type badge + description + timestamp
  • Types: Create (green Plus icon), StatusChange (blue ArrowRightLeft icon), Comment (gray MessageSquare icon), CustomField (purple Pencil icon)
  • Show: field changed, old_value → new_value
  • Timestamps relative ("2 minutes ago") with date-fns formatDistanceToNow

Custom fields (Card, if any exist):

  • List of name: value pairs
  • Editable if CF permissions allow (MVP: read-only)

10. Admin page

File: web/src/app/admin/page.tsx

Tabs component with 4 tabs: Queues, Lifecycles, Scrips, Custom Fields

Each tab: list table + "Add" button → dialog with form.

Queues tab:

  • Table: Name, Description, Actions
  • Add form: name (required), description (optional textarea)

Lifecycles tab:

  • Table: Name, Actions
  • Add form: name (required), definition (JSON textarea with placeholder example)
  • Example placeholder:
{
  "statuses": {
    "initial": ["new"],
    "active": ["open", "in_progress"],
    "inactive": ["resolved", "closed"]
  },
  "transitions": {
    "new": ["open"],
    "open": ["in_progress", "resolved"],
    "in_progress": ["resolved"],
    "*": ["closed"]
  }
}

Scrips tab:

  • Table: Name, Queue, Condition, Action, Enabled, Actions
  • Enabled toggle: switch/checkbox
  • Add form: name (required), queue_id (select dropdown, "Global" option), condition_type (select: OnCreate, OnStatusChange, OnResolve), action_type (select: SendEmail, Webhook, SetCustomField), action_config (JSON textarea), stage (select: TransactionCreate, TransactionBatch), sort_order (number input)

Custom Fields tab:

  • Table: Name, Type, Max Values, Actions
  • Add form: name (required), field_type (select: Freeform, SelectSingle, SelectMulti), values (JSON array textarea for select types), max_values (number, 0=unlimited)

Admin page tables: use shadcn/ui Table component, not TanStack Table (simpler, no sorting needed for small admin tables)

11. Theme

shadcn/ui default neutral theme. Enhance:

  • Add dark-only mode (force dark)
  • Root layout: add class="dark" to html element
  • Tailwind config: darkMode: 'class'
  • Background: bg-neutral-950 (darkest)
  • Cards: bg-neutral-900 border-neutral-800
  • Inputs/Selects: bg-neutral-800 border-neutral-700 focus:ring-neutral-600 text-neutral-100

12. Navigation

Use Next.js Link component and usePathname for active state. The "Tickets" link at / should be active, "Admin" at /admin.

Verification

  1. Start backend: cd /home/gjermund/projects/tessera && bun run src/index.ts (port 9876)
  2. Start frontend: cd /home/gjermund/projects/tessera/web && bun run dev (port 5173)
  3. Open http://localhost:5173 — should show ticket list with dark theme, nav bar
  4. Navigate to /admin — should show 4 tabs
  5. Create a queue via Admin → Queues → Add
  6. Create a ticket via Tickets page → New Ticket
  7. Click ticket → detail page with timeline
  8. Change status → preview → apply → verify scrip results and timeline update
  9. All pages must render without errors

Rules

  • Use bun for package management (bun add, bun install, bun run dev)
  • All TypeScript, strict mode, no any unless truly unavoidable
  • Zero ESLint errors on build
  • All shadcn/ui components must work — test each import
  • Proper loading states everywhere (no blank pages during fetch)
  • Error states with retry buttons
  • Commit after each major page is complete
  • The backend must be running for API calls to work — handle connection refused gracefully