diff --git a/docs/web-spec.md b/docs/web-spec.md new file mode 100644 index 0000000..ac62d70 --- /dev/null +++ b/docs/web-spec.md @@ -0,0 +1,245 @@ +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 +```bash +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 +```bash +npx shadcn@latest init +``` +- Style: Default +- Base color: Neutral +- CSS variables: Yes + +### 3. Install shadcn/ui components +```bash +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: +```bash +bun add @tanstack/react-table zod react-hook-form @hookform/resolvers lucide-react date-fns +``` + +### 4. Proxy config +In `web/next.config.ts`: +```typescript +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: +```typescript +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; 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; } +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: +```json +{ + "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