Compare commits

...

47 Commits

Author SHA1 Message Date
Gjermund Høsøien Wiggen
1d4dc38d06 docs: update CLAUDE.md, redesign AGENTS.md, add design-system.md
- CLAUDE.md: updated API endpoints, dev workflow, current stack
- web/AGENTS.md: replaced auto-generated text with Tessera design rules
- docs/design-system.md: new — component patterns, typography scale,
  color tokens, anti-patterns to avoid

Ensures design consistency across all future work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:07:32 +02:00
Gjermund Høsøien Wiggen
f12b24e042 redesign: admin tab inner content — softer borders, cleaner items
- List items: border/50, bg-primary/5 on active, hover:bg-accent/30
- Side panels: border/50 dividers
- Form panels: border/50 on section headers
- Section title bars: bg-muted/20 instead of /35
- Softer list item dividers (border/30)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:05:53 +02:00
Gjermund Høsøien Wiggen
96a71a34fe redesign: admin pages — clean 2026 style, no heavy boxes
- AdminHeader: stripped backdrop-blur, cleaner typography, shorter
- Tabs: softer pill bar, no outer border, rounded-lg background
- Section wrappers: removed shadow-sm, bg-card/82, border→border/50
- ScripFlowNode: softened borders, removed card bg, cleaner label
- All 8 section wrappers updated with replace_all

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:04:02 +02:00
Gjermund Høsøien Wiggen
1c780be710 redesign: sidebar nav — clean 2026 style
- Softer sidebar border (50% opacity)
- Cleaner nav items: subtle accent bg on active, no inset shadow
- Icons dimmed when inactive, brighten on hover
- Section headers: simple text labels, no chevron toggles
- Removed collapsible sections (all always visible)
- Cleaner brand: single-line name, shorter height
- Bottom: removed hardcoded User placeholder, just Admin + Theme
- Softer border dividers throughout

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:01:42 +02:00
Gjermund Høsøien Wiggen
dfcdbc623a fix: SetTeam shows team name, TransactionCard receives teams
- Pass teams list to TransactionCard
- Resolve team name from tx.new_value (team_id)
- Shows 'Team -> Support' instead of just 'Team changed'

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:58:29 +02:00
Gjermund Høsøien Wiggen
6f1d7bfa9b fix: SetTeam transaction now shows as system event in timeline
- Added SetTeam to isSystem check
- Shows 'Team changed' in the timeline instead of raw 'SetTeam' text

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:52:25 +02:00
Gjermund Høsøien Wiggen
c023079a1a chore: remove useless Work mode filler box from ticket sidebar
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:51:24 +02:00
Gjermund Høsøien Wiggen
cee263944b redesign: ticket detail sidebar — flat, modern, no boxed sections
- Status selector: colored capsule with ring inset (visual, prominent)
- Assignment: simple label+select, no bordered dl/dt/dd grid
- Details: clean justify-between lines, no box container
- Custom fields: flat spacing, no bordered wrapper
- Removed PropertyRow component (no longer used)
- Removed heavy rounded-md border border-border bg-background/60 boxes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:48:53 +02:00
Gjermund Høsøien Wiggen
8b371ae3c2 redesign: ticket detail — cleaner transaction cards, system events as timeline
- System events now render as subtle timeline entries (icon dot + text)
  instead of heavy bordered boxes
- Message cards are cleaner: rounded avatars, no card borders,
  just typography and spacing. Internal badge is subtler.
- Removed border/shadow from message cards — cleaner, more modern

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:47:00 +02:00
Gjermund Høsøien Wiggen
5308ee8653 redesign: clean ticket list with status dot + hover checkbox
- Colored status dot per row for instant status recognition
- Checkbox appears on hover (group-hover) for batch selection
- No side panel — full width, clean list
- Click row → navigate to ticket detail
- Removed hover status change dots per user feedback

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:40:56 +02:00
Gjermund Høsøien Wiggen
667979c4b2 cleanup: remove side panel and broken inline expansion
Stripped back to clean table before redesign. Removed selectedId tracking,
transactions fetching, and all side-panel related code. Row click now
navigates directly to ticket detail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:38:54 +02:00
Gjermund Høsøien Wiggen
1f308b4342 redesign: triage panel — compact, visual, actionable
- Colored status dot with glow ring in header (at-a-glance)
- Status change via colored dots (click circles, not text pills)
- Subject + ID + queue + owner in one compact line
- Open full view is now a subtle chevron button, not a giant CTA
- Take it button appears inline when unassigned
- Activity feed as a timeline with connecting line and dots
- Shorter transaction labels with inline values

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:25:17 +02:00
Gjermund Høsøien Wiggen
ed5d96a74b fix: add-filter button uses ref + state for positioning
Store button position in state on click, then pass to the portal
popover via style. Eliminates getElementById race condition where
the portal hadn't rendered yet when trying to set DOM styles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:23:56 +02:00
Gjermund Høsøien Wiggen
dd747946ea fix: wider resize handles, table minWidth instead of width 100%
- table-layout: fixed with minWidth instead of width:100% to avoid
  browser recalculating explicit column widths
- Wider resize handles (w-3, 12px) for easier grabbing
- Better visibility on hover

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:18:49 +02:00
Gjermund Høsøien Wiggen
dde19f5fab fix: table-layout fixed + consistent column widths
- table-layout: fixed on the table wrapper so browser respects explicit widths
- All cells (header + rows) now use the same col.width consistently
- Subject column no longer special-cased for width

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:16:49 +02:00
Gjermund Høsøien Wiggen
5970e3fe9d fix: resize adjusts both adjacent columns (left expands, right shrinks)
Left column gets wider, right column gets narrower by same amount.
Subject column now has fixed width (not flexible) so the table
doesn't redistribute space unpredictably.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:12:44 +02:00
Gjermund Høsøien Wiggen
7f91a51e32 fix: resize handle now adjusts column to its left
Handle on the left edge resizes the PREVIOUS column, not itself.
This matches the mental model: dragging the boundary between Subject
and Status changes Subject's width. First column has no handle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:10:14 +02:00
Gjermund Høsøien Wiggen
30108c7600 fix: move resize handles to left edge of columns
Handles now sit on the left edge (the boundary between columns).
Dragging feels natural — you pull the dividing line between two columns.
Wider hit area (w-2) for easier grabbing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:08:36 +02:00
Gjermund Høsøien Wiggen
d7a5b5ba1d fix: use CSS table layout for column alignment
Replaced flex containers with display: table/table-row/table-cell.
This guarantees column widths are shared between header and all rows,
fixing the misalignment. Subject column gets remaining width, all
others use fixed pixel widths. Resize handles on header cells still work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:06:12 +02:00
Gjermund Høsøien Wiggen
b2fb69ffc5 fix: add checkbox spacer to column header for alignment
Column header was missing the w-9 spacer that ticket rows have
for the batch checkbox. Added spacer so columns align properly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:03:40 +02:00
Gjermund Høsøien Wiggen
dd7bd867bf fix: remove duplicate column header, portal column picker, clean widths
- Remove old fixed column header (was showing above dynamic one)
- Column picker now renders via createPortal (no longer behind elements)
- Remove forced inline widths on header and rows (flex naturally)
- Cleaner column header styling (subtle muted, no forced min-width)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:01:48 +02:00
Gjermund Høsøien Wiggen
e486558309 feat: batch ticket operations — multi-select, bulk status, bulk assign
- Checkbox column on every ticket row
- Select multiple tickets via checkboxes
- Floating sticky action bar at bottom when tickets selected:
  - Shows count: "3 selected" with Clear button
  - Quick status change buttons
  - Assign to me button
- Checkbox click stops propagation (doesn't select ticket for triage panel)
- Batch operations run sequentially via API

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 21:58:09 +02:00
Gjermund Høsøien Wiggen
38a82ad0d8 feat: persist columns to localStorage, custom fields as columns
- Column config saves to localStorage on every change
- Load from localStorage on mount (survive reloads without saved view)
- Custom fields appear as column options in picker
- Custom field values render in ticket rows
- Backend now always includes custom_fields in GET /tickets response

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 21:51:16 +02:00
Gjermund Høsøien Wiggen
7ddf82f93f feat: customizable, resizable columns on ticket list, saved per view
- Column picker dropdown (grid icon next to sort/density)
  - Check/uncheck columns: ID, Subject, Status, Queue, Owner, Created, Updated
  - Subject column auto-expands (flex), others have fixed width
- Column resize handles: drag right edge of any column header
  - Min 50px, max 800px, body gets select-none during drag
- Columns persist with saved views (columns jsonb field)
- Reset to defaults when navigating away from a saved view
- Sticky column header row with muted background

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:57:34 +02:00
Gjermund Høsøien Wiggen
d5d6a209bd fix: All tickets link, redesign side panel into triage panel
- Fix sidebar All tickets link: /?view=all instead of / (avoids dashboard redirect)
- Replace useless side panel with triage command center:
  - Quick status change buttons (click to transition inline)
  - Assign to me button (appears when unassigned)
  - Mini activity feed showing last 8 transactions with type labels,
    status badges, old→new values, and comment previews
  - Relative timestamps
  - Open full view button
- Fetches ticket transactions on selection

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:51:51 +02:00
Gjermund Høsøien Wiggen
4e285f8c4d feat: queues have default team, tickets inherit it
- team_id on queues table (optional, can be overridden per-ticket)
- Ticket creation auto-sets team_id from the queue's default
- Queue admin form has team selector (scrip flow node 03)
- Queue API (POST/PATCH) accepts team_id

No enforcement — just a helpful default. Teams and queues
are loosely coupled, not hierarchically locked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:47:20 +02:00
Gjermund Høsøien Wiggen
3d7ba0d6a7 feat: team assignment on tickets + My team's tickets view
Backend:
- team_id column on tickets table
- team_id filter in GET /tickets (resolves team members)
- team_id in UpdateTicketSchema + PATCH handler
- SetTeam transaction type

Frontend:
- Team selector in ticket detail properties sidebar
- My team's tickets in sidebar (when user belongs to a team)
- team_id passed through to API from ticket list page

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:40:00 +02:00
Gjermund Høsøien Wiggen
4157a7b0af fix: replace HTML5 DnD with mouse-based drag for smooth widget movement
- Grip handle now uses mousedown/mousemove/mouseup (same as resize)
- Widget position updates in real-time as you drag — no ghost image
- Grid snapping from actual mouse coordinates
- Overlap resolution on mouseup
- Cleaner: no draggable attribute, no dataTransfer

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:31:46 +02:00
Gjermund Høsøien Wiggen
6a277f9c36 fix: free-form drag positioning instead of swap-only
Widgets can now be dragged anywhere on the grid, not just swapped.
Drop position is calculated from mouse coordinates relative to the grid,
snapped to grid units. Overlapping widgets are pushed down automatically.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:18:00 +02:00
Gjermund Høsøien Wiggen
a2005d007e fix: resize now uses functional setState to avoid stale closure
The onUp handler was capturing stale widgets from the render closure,
overwriting the resize dimensions. Now uses setWidgets(current => ...)
to read latest state and apply overlap resolution correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:50:03 +02:00
Gjermund Høsøien Wiggen
b3da204bd0 fix: resolve widget overlaps after resize
When resizing a widget, any widget that gets overlapped is automatically
pushed down to clear the collision. Multi-pass overlap detection ensures
cascading widgets are all resolved. Positions persisted to API.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:48:07 +02:00
Gjermund Høsøien Wiggen
41fb10120c feat: add drag-to-rearrange and resize handles to dashboard widgets
Edit mode now supports:
- Drag handle (grip icon, top-left) to rearrange widgets via HTML5 DnD
  (drops swap widget positions, persists via API)
- Resize handle (corner icon, bottom-right) with mousedown→mousemove→mouseup
  tracking to change widget width/height in grid units, persists via API
- Cursor feedback: grab cursor on draggable widgets, se-resize on handle
- Visual feedback: dragging widget shows 50% opacity

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:41:04 +02:00
Gjermund Høsøien Wiggen
6ca8974eb9 feat: add edit mode toggle to dashboard page
- Edit/Done toggle button in header (pencil icon)
- Widget delete buttons only visible in edit mode
- Add widget button only visible in edit mode
- Empty state prompts to enter edit mode instead of adding directly
- Default is view mode — clean, no accidental deletes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:38:28 +02:00
Gjermund Høsøien Wiggen
9938c7a7ad feat: add team selector to dashboard page header
- Dropdown to assign/unassign dashboard to a team
- Updates immediately via PATCH
- createDashboard and updateDashboard API now accept team_id

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:35:40 +02:00
Gjermund Høsøien Wiggen
3616046b78 feat: add teams/groups with dashboard scoping
Schema:
- teams table (name unique, description)
- team_members table (team_id, user_id, unique constraint)
- team_id column on dashboards

API:
- GET/POST/PATCH/DELETE /teams
- POST /teams/:id/members (add user)
- DELETE /teams/:id/members/:userId (remove user)
- dashboards support team_id on create/update

Frontend:
- Teams tab in admin: CRUD + member management with add/remove
- Sidebar: dashboards filtered to user's teams
  (unassigned dashboards visible to all)
- Compact dashboard picker dropdown in sidebar

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:32:39 +02:00
Gjermund Høsøien Wiggen
c79cd183d4 refactor: replace dashboard sidebar list with compact dropdown
- Single-line select dropdown instead of one list item per dashboard
- Scales to any number of teams without clutter
- "+ New dashboard" as last option in dropdown
- Preserves the create flow with inline name input

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:24:33 +02:00
Gjermund Høsøien Wiggen
35b7f49518 fix: add-filter popover renders via portal to avoid stacking context
- Popover now renders via createPortal into document.body with z-index 9999
- This avoids the header backdrop-blur stacking context trapping it
- Add + button in Dashboards sidebar section to create dashboards
- Inline input on Dashboards section header, Enter to create/Escape to cancel

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:19:22 +02:00
Gjermund Høsøien Wiggen
f7e34f1690 feat: dashboard auto-refresh, collapsible sidebar, error retry
- Dashboard: auto-refresh toggle (30s interval, spins when live)
- Dashboard: responsive grid (6 cols mobile, 12 cols desktop)
- Sidebar: Dashboards, Saved views, Queues sections now collapsible
  with chevron toggle
- Error banner: added Retry button next to error message

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:14:47 +02:00
Gjermund Høsøien Wiggen
6263ce1332 feat: seed dashboard, fix My tickets filter
- Add demo dashboard with 7 widgets to seed script
- Dashboard is_default=true — appears as home page on fresh seed
- Add views/dashboards/dashboardWidgets to seed reset
- Fix My tickets: now filters by first non-system user (not any owner)
- Pass owner param in sidebar My tickets link
- Update page.tsx view=my to respect owner URL param
- Scrip engine already sorts by sort_order (verified)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:09:02 +02:00
Gjermund Høsøien Wiggen
c6c5272e50 feat: SQL filtering, Users admin tab, dashboard polish
- Move ticket filtering from in-memory to SQL WHERE clauses
  (queue_id, status, owner use Drizzle eq/isNull; text search uses ilike;
  custom field filters use EXISTS subqueries)
- Add limit param to GET /tickets
- Add POST/PATCH/DELETE /users routes
- Add Users tab to admin page with create/edit/delete
- Smart widget positioning in dashboard (3-column grid fill)
- Show pattern hint below CF inputs in New Ticket dialog

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:04:10 +02:00
Gjermund Høsøien Wiggen
affbbdaa46 feat: add template delete — backend DELETE route, frontend trash button
- DELETE /templates/:id — backend route
- deleteTemplate() API client function
- Trash icon on each template list item (shows on hover)
- Confirms inline, no dialog needed
- Resets builder if the deleted template was being edited

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 12:49:45 +02:00
Gjermund Høsøien Wiggen
7be90684fb fix: replace broken add-filter button with stepped filter builder
- Fixed popover z-index: uses fixed positioning with z-50 above backdrop
- Stepped flow: select field → set operator (is/is_not) → choose/write value → Apply
- Removed old inline CF value inputs (handled inline in the new flow)
- Fixed filter persistence: clear filters when navigating away from saved view
- Fixed home redirect: check for default dashboard on load

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:30:17 +02:00
Gjermund Høsøien Wiggen
b70a133ea2 feat: add dashboards — tables, CRUD API, widget data endpoint
- New dashboards table (name, description, layout, is_default)
- New dashboard_widgets table (view_id, title, widget_type, position, config)
- GET/POST/PATCH/DELETE /dashboards
- GET/POST/PATCH/DELETE /dashboards/:id/widgets
- GET /dashboards/:id/widgets/:id/data — runs saved view filters,
  returns pre-aggregated data for count/ticket_list/status_chart/grouped_counts
- is_default uniqueness enforced on PATCH

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:26:22 +02:00
Gjermund Høsøien Wiggen
aa90b88991 feat: add saved views — database table, CRUD API, migration
- New views table (id, name, filters jsonb, sort_key, is_public, creator_id)
- GET/POST/PATCH/DELETE /views endpoints
- Register views router in server

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:10:25 +02:00
Gjermund Høsøien Wiggen
000e97e1bd feat: implement inline editing for custom fields in ticket sidebar
- Show CF values as read-only text with edit affordance (pencil icon on hover)
- Click to enter edit mode: inline input (free-text) or select (choice fields)
- Save on blur or Enter, cancel on Escape — reverts to original value
- Auto-save for select fields on change
- Loading spinner while saving
- Remove now-unused customFieldValue helper

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:55:45 +02:00
Gjermund Høsøien Wiggen
2501bcbad1 chore: add .codegraph to .gitignore, untrack daemon files
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:51:43 +02:00
Gjermund Høsøien Wiggen
aa808f1d3f feat: return scrip results on ticket create, update frontend types
- POST /tickets now returns { ticket, scrip_results } matching PATCH pattern
- createTicket API function returns UpdateResult instead of Ticket
- Update call site to use data.ticket.id

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:51:14 +02:00
38 changed files with 9894 additions and 781 deletions

3
.gitignore vendored
View File

@@ -33,3 +33,6 @@ bun.lock
# Finder (MacOS) folder config # Finder (MacOS) folder config
.DS_Store .DS_Store
# Codegraph index (MCP tool)
.codegraph

View File

@@ -14,8 +14,10 @@ tessera/
│ ├── models/ # TypeScript types + Zod schemas │ ├── models/ # TypeScript types + Zod schemas
│ ├── scrip/ # Scrip engine (prepare/commit two-phase) │ ├── scrip/ # Scrip engine (prepare/commit two-phase)
│ └── lifecycle/ # State machine validator │ └── lifecycle/ # State machine validator
├── web/ # Frontend: Next.js 15 + shadcn/ui ├── web/ # Frontend: Next.js 16 + shadcn/ui
── src/app/ # App Router pages ── src/app/ # App Router pages
│ ├── src/components/ # Reusable components + widgets
│ └── src/lib/ # API client + types + utils
├── drizzle/ # SQL migration files ├── drizzle/ # SQL migration files
└── docs/ # Architecture + design specs └── docs/ # Architecture + design specs
``` ```
@@ -24,9 +26,9 @@ tessera/
**Backend:** Bun runtime, Hono web framework, Drizzle ORM, PostgreSQL 17, Zod validation, Handlebars templates, nodemailer **Backend:** Bun runtime, Hono web framework, Drizzle ORM, PostgreSQL 17, Zod validation, Handlebars templates, nodemailer
**Frontend:** Next.js 15 App Router, shadcn/ui (Tailwind CSS), next-themes (light/dark), React Hook Form + Zod, TanStack Table, date-fns, lucide-react icons **Frontend:** Next.js 16 App Router (Turbopack), shadcn/ui (Tailwind CSS), next-themes, date-fns, lucide-react icons
**Fonts:** Inter (variable, with cv01+ss03 OpenType features), JetBrains Mono **Fonts:** Inter (variable), JetBrains Mono
## Running Locally ## Running Locally
@@ -39,45 +41,47 @@ tessera/
### Start backend ### Start backend
```bash ```bash
cd ~/projects/tessera cd ~/projects/tessera
cp .env.example .env # Edit DATABASE_URL to point to postgres://tessera:***@127.0.0.1:5433/tessera cp .env.example .env
npm run dev:backend # Starts API on port 9876 npm run dev:backend # Starts API on port 9876
``` ```
### Run migrations ### Run migrations
```bash ```bash
npm run db:migrate npm run db:migrate
npm run db:seed # Optional demo data for UI review npm run db:seed # Demo data
npm run db:seed:reset # Reset local app data, then recreate demo data npm run db:seed:reset # Reset + re-seed
``` ```
### Start frontend ### Start frontend
```bash ```bash
cd web cd web
npm install # Use npm, NOT bun (bun has compatibility issues with Next.js dev server) npm install # Use npm, NOT bun
npm run build # Production build bun run dev # Dev server on 127.0.0.1:3100 (HMR)
npm run start # Production server on 127.0.0.1:3100
``` ```
**Note:** `bun run dev` (Turbopack) has WebSocket HMR issues in this environment. Use production mode only.
## API Endpoints ## API Endpoints
All endpoints are served by the backend on port 9876. The frontend proxies `/api/*` to the backend via `next.config.ts`. All endpoints on port 9876. Frontend proxies `/api/*` via `next.config.ts`.
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| |--------|------|-------------|
| GET | /health | Health check | | GET | /health | Health check |
| GET | /tickets | List tickets (?queue_id=&status=) | | GET | /tickets | List tickets (?queue_id=&status=&owner_id=&team_id=&q=&limit=&cf.*=) |
| POST | /tickets | Create ticket | | POST | /tickets | Create ticket |
| GET | /tickets/:id | Get ticket with custom fields | | GET | /tickets/:id | Get ticket with custom fields |
| PATCH | /tickets/:id | Update ticket (validates lifecycle, runs scrips) | | PATCH | /tickets/:id | Update ticket (validates lifecycle, runs scrips, returns scrip_results) |
| POST | /tickets/:id/preview | Dry-run scrips for status change | | POST | /tickets/:id/preview | Dry-run scrips for status change |
| POST | /tickets/:id/comment | Add comment to ticket | | POST | /tickets/:id/comment | Add comment to ticket |
| GET | /tickets/:id/transactions | List ticket transactions | | GET | /tickets/:id/transactions | List ticket transactions |
| GET/POST | /queues | List/create queues | | GET/POST/PATCH | /queues | CRUD queues |
| GET/POST/PATCH | /scrips | CRUD scrips | | GET/POST/PATCH/DELETE | /scrips | CRUD scrips |
| GET/POST | /custom-fields | List/create custom fields | | GET/POST/PATCH | /custom-fields | CRUD custom fields |
| GET/POST | /lifecycles | List/create lifecycles | | GET/POST/PATCH | /lifecycles | CRUD lifecycles |
| GET/POST/PATCH/DELETE | /users | CRUD users |
| GET/POST/PATCH/DELETE | /templates | CRUD templates + POST /preview |
| GET/POST/PATCH/DELETE | /views | CRUD saved views |
| GET/POST/PATCH/DELETE | /teams | CRUD teams + POST/DELETE members |
| GET/POST/PATCH/DELETE | /dashboards | CRUD dashboards + widgets + widget data |
## Key Design Decisions ## Key Design Decisions
@@ -85,32 +89,22 @@ All endpoints are served by the backend on port 9876. The frontend proxies `/api
- **Transaction-centric:** Every state change creates a transaction record. The scrip engine runs on transactions. - **Transaction-centric:** Every state change creates a transaction record. The scrip engine runs on transactions.
- **Two-phase scrip engine:** Prepare (no side effects) then Commit (execute actions). Supports dry-run mode. - **Two-phase scrip engine:** Prepare (no side effects) then Commit (execute actions). Supports dry-run mode.
- **Lifecycle state machines:** Per-queue configurable status transitions with wildcard support. - **Lifecycle state machines:** Per-queue configurable status transitions with wildcard support.
- **Light mode is default.** Dark mode available via theme toggle (next-themes). - **SQL-level filtering:** Ticket filters (status, queue, owner, team, custom fields) pushed to PostgreSQL via Drizzle WHERE clauses.
- **No ORM for frontend:** Drizzle is only on the backend. Frontend uses a typed fetch wrapper (`web/src/lib/api.ts`). - **No ORM for frontend:** Drizzle is only on the backend. Frontend uses a typed fetch wrapper (`web/src/lib/api.ts`).
- **Dev server over production:** Use `bun run dev` (port 3100) with HMR. Build+restart only when dev server has issues.
- **Design consistency:** See `docs/design-system.md` for the design rules applied across the app.
## Git Workflow ## Git Workflow
Repo: `https://git.gjermund.xyz/gjermund/tessera` Repo: `https://git.gjermund.xyz/gjermund/tessera`
Push via HTTPS with token auth (SSH port 2222 is not configured on Gitea):
```bash ```bash
git remote set-url origin https://gjermund:TOKEN@git.gjermund.xyz/gjermund/tessera.git git remote set-url origin https://gjermund:TOKEN@git.gjermund.xyz/gjermund/tessera.git
``` ```
## Development Workflow
All code is written by **OpenCode** (AI coding agent). Hermes writes specs, OpenCode writes code, Gjermund reviews.
OpenCode server: `opencode serve --port 4096` (Gjermund attaches with `opencode --attach http://127.0.0.1:4096`)
After OpenCode makes changes:
1. `cd web && npx next build` — verify zero errors
2. `npm run start` — restart production server on 127.0.0.1:3100
3. `git push` — push to origin
## Common Issues ## Common Issues
- **Frontend shows skeleton/blank page:** The frontend dev server (Turbopack) has WebSocket HMR issues. Use production mode (`npx next build` + `npx next start`). - **Frontend shows skeleton/blank page:** Dev server may have HMR issues. Kill port 3100, rebuild with `npm run build`, restart with `npm run start`.
- **Backend not running on 9876:** Restart with `bun run src/index.ts`. Check port with `ss -tlnp | grep 9876`. - **Backend not running on 9876:** Restart with `bun run src/index.ts`. Check port with `ss -tlnp | grep 9876`.
- **Database connection refused:** Docker container may be stopped. `docker start tessera-db`. - **Database connection refused:** Docker container may be stopped. `docker start tessera-db`.
- **Build errors after migration:** Run `bun run src/db/migrate.ts` to apply new migrations. - **Build errors after migration:** Run `bun run src/db/migrate.ts` to apply new migrations.

118
docs/design-system.md Normal file
View File

@@ -0,0 +1,118 @@
# Tessera Design System
The app follows a clean, minimal aesthetic — no heavy borders, no unnecessary shadows, flat hierarchy.
## Principles
1. **Flat, not boxed.** Avoid bordered containers around every section. Use whitespace for separation.
2. **Soft borders.** Never full-opacity borders. Always `/50` or `/30`.
3. **No shadows.** Cards and sections don't need shadows. The sidebar has the only subtle shadow.
4. **Consistent typography.** Three font sizes: labels (10px), content (12-13px), headings (14-24px).
5. **Dimmed by default.** Icons, borders, and secondary text start dimmed. They brighten on hover/focus.
## Component Patterns
### Section Wrapper
```tsx
<section className="overflow-hidden rounded-lg border border-border/50">
...
</section>
```
### Section Header / Title Bar
```tsx
<div className="flex items-center justify-between border-b border-border/50 bg-muted/20 px-4 py-3">
<h2 className="text-sm font-semibold">Title</h2>
<Button size="sm">Action</Button>
</div>
```
### List Item (clickable)
```tsx
<button className={cn(
"rounded-lg border p-3 text-left transition",
"border-border/50 hover:border-primary/30 hover:bg-accent/30",
isActive && "border-primary/50 bg-primary/5"
)}>
<div className="text-sm font-semibold">Name</div>
<div className="text-xs text-muted-foreground">Description</div>
</button>
```
### Property Row (key-value)
```tsx
<div className="flex items-baseline justify-between gap-2 text-sm">
<span className="text-muted-foreground">Label</span>
<span className="text-foreground">Value</span>
</div>
```
### Sidebar Nav Item
```tsx
<Link href={href} className={cn(
"flex items-center px-2.5 py-1.5 rounded-md text-[13px] transition-colors",
active
? "bg-sidebar-accent text-sidebar-foreground font-medium"
: "text-sidebar-foreground/55 hover:text-sidebar-foreground hover:bg-sidebar-accent/50"
)}>
<Icon className={cn("w-4 h-4", active ? "opacity-90" : "opacity-50 group-hover:opacity-70")} />
<span className="truncate">{label}</span>
</Link>
```
### Section Label (small caps)
```tsx
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">
LABEL
</div>
```
### Form Input
```tsx
<input className="h-8 w-full rounded-md border border-input bg-transparent px-2.5 text-sm outline-none focus:border-ring" />
```
### Form Select
```tsx
<select className="h-8 w-full rounded-md border border-input bg-transparent px-2.5 text-sm outline-none focus:border-ring">
<option>...</option>
</select>
```
## Typography Scale
| Size | Weight | Use |
|------|--------|-----|
| `text-[10px]` | `font-medium` | Section labels, form labels |
| `text-[11px]` | `font-medium` | Meta, timestamps, badges |
| `text-[13px]` | `font-normal` | Nav items, list items |
| `text-sm` | `font-normal` | Body, property values |
| `text-sm` | `font-semibold` | Card titles, list item names |
| `text-base` | `font-semibold` | Section headings |
| `text-lg` | `font-semibold` | Form titles |
| `text-xl` | `font-semibold` | Page headings |
## Color Tokens
| Token | Use |
|-------|-----|
| `border-border/50` | Section borders, card borders |
| `border-border/30` | Subtle dividers, list separators |
| `border-border/50` | Sidebar border |
| `bg-muted/20` | Section header background |
| `bg-muted/40` | Tab bar background |
| `bg-sidebar-accent` | Active nav item |
| `text-sidebar-foreground/55` | Inactive nav item |
| `text-muted-foreground/60` | Section labels |
| `text-foreground/90` | Slightly muted body text |
## What to Avoid
-`rounded-md` — use `rounded-lg` for cards, `rounded-md` for small elements
-`shadow-sm` or `shadow-md` on any card or section
-`bg-card/82` — use solid `bg-card` or `bg-transparent`
-`border-border` (full opacity) — always `/50` or `/30`
-`backdrop-blur` on headers
-`<dl>`/`<dt>`/`<dd>` grids for property lists — use flex rows
-`text-[11px] font-semibold uppercase` — use the section label pattern
- ❌ Heavy icon usage in section headers — keep icons small (`h-3 w-3`)

View File

@@ -0,0 +1,12 @@
CREATE TABLE "views" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"filters" jsonb DEFAULT '[]' NOT NULL,
"sort_key" text DEFAULT 'updated',
"columns" jsonb DEFAULT '[]',
"is_public" boolean DEFAULT false,
"creator_id" uuid,
"created_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
ALTER TABLE "views" ADD CONSTRAINT "views_creator_id_users_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,22 @@
CREATE TABLE "dashboard_widgets" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"dashboard_id" uuid NOT NULL,
"view_id" uuid NOT NULL,
"title" text NOT NULL,
"widget_type" text NOT NULL,
"position" jsonb DEFAULT '{"x":0,"y":0,"w":4,"h":2}',
"config" jsonb DEFAULT '{}',
"created_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "dashboards" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"description" text,
"layout" jsonb DEFAULT '[]',
"is_default" boolean DEFAULT false,
"created_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
ALTER TABLE "dashboard_widgets" ADD CONSTRAINT "dashboard_widgets_dashboard_id_dashboards_id_fk" FOREIGN KEY ("dashboard_id") REFERENCES "public"."dashboards"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "dashboard_widgets" ADD CONSTRAINT "dashboard_widgets_view_id_views_id_fk" FOREIGN KEY ("view_id") REFERENCES "public"."views"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,19 @@
CREATE TABLE "team_members" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"team_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
CONSTRAINT "team_members_team_id_user_id_unique" UNIQUE("team_id","user_id")
);
--> statement-breakpoint
CREATE TABLE "teams" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"description" text,
"created_at" timestamp with time zone DEFAULT now(),
CONSTRAINT "teams_name_unique" UNIQUE("name")
);
--> statement-breakpoint
ALTER TABLE "dashboards" ADD COLUMN "team_id" uuid;--> statement-breakpoint
ALTER TABLE "team_members" ADD CONSTRAINT "team_members_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "team_members" ADD CONSTRAINT "team_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "dashboards" ADD CONSTRAINT "dashboards_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE set null ON UPDATE no action;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "tickets" ADD COLUMN "team_id" uuid;--> statement-breakpoint
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE set null ON UPDATE no action;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "queues" ADD COLUMN "team_id" uuid;--> statement-breakpoint
ALTER TABLE "queues" ADD CONSTRAINT "queues_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE set null ON UPDATE no action;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,41 @@
"when": 1780904200000, "when": 1780904200000,
"tag": "0002_short_custom_field_keys", "tag": "0002_short_custom_field_keys",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1780995910694,
"tag": "0003_dry_caretaker",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1780996807814,
"tag": "0004_sturdy_natasha_romanoff",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1781004398567,
"tag": "0005_spotty_leader",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1781008559188,
"tag": "0006_nosy_black_queen",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1781009018666,
"tag": "0007_flimsy_roughhouse",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,5 +1,5 @@
import { pgTable, uuid, text, jsonb, integer, boolean, timestamp, unique, index } from 'drizzle-orm/pg-core'; import { pgTable, uuid, text, jsonb, integer, boolean, timestamp, unique, index } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm'; import { relations, sql } from 'drizzle-orm';
export const users = pgTable('users', { export const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
@@ -13,6 +13,7 @@ export const queues = pgTable('queues', {
name: text('name').notNull().unique(), name: text('name').notNull().unique(),
description: text('description'), description: text('description'),
lifecycle_id: uuid('lifecycle_id').references(() => lifecycles.id), lifecycle_id: uuid('lifecycle_id').references(() => lifecycles.id),
team_id: uuid('team_id').references(() => teams.id, { onDelete: 'set null' }),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(), created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
}); });
@@ -29,6 +30,7 @@ export const tickets = pgTable('tickets', {
queue_id: uuid('queue_id').notNull().references(() => queues.id), queue_id: uuid('queue_id').notNull().references(() => queues.id),
status: text('status').notNull(), status: text('status').notNull(),
owner_id: uuid('owner_id').references(() => users.id), owner_id: uuid('owner_id').references(() => users.id),
team_id: uuid('team_id').references(() => teams.id, { onDelete: 'set null' }),
creator_id: uuid('creator_id').notNull().references(() => users.id), creator_id: uuid('creator_id').notNull().references(() => users.id),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(), created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
updated_at: timestamp('updated_at', { withTimezone: true }).defaultNow(), updated_at: timestamp('updated_at', { withTimezone: true }).defaultNow(),
@@ -112,3 +114,59 @@ export const customFieldValues = pgTable('custom_field_values', {
ticketIdIdx: index('custom_field_values_ticket_id_idx').on(table.ticket_id), ticketIdIdx: index('custom_field_values_ticket_id_idx').on(table.ticket_id),
cfIdIdx: index('custom_field_values_custom_field_id_idx').on(table.custom_field_id), cfIdIdx: index('custom_field_values_custom_field_id_idx').on(table.custom_field_id),
})); }));
export const views = pgTable('views', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
filters: jsonb('filters').notNull().default('[]'),
sort_key: text('sort_key').default('updated'),
columns: jsonb('columns').default('[]'),
is_public: boolean('is_public').default(false),
creator_id: uuid('creator_id').references(() => users.id),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
});
export const teams = pgTable('teams', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull().unique(),
description: text('description'),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
});
export const teamsRelations = relations(teams, ({ many }) => ({
members: many(teamMembers),
}));
export const teamMembers = pgTable('team_members', {
id: uuid('id').primaryKey().defaultRandom(),
team_id: uuid('team_id').notNull().references(() => teams.id, { onDelete: 'cascade' }),
user_id: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
}, (table) => ({
uniqueMember: unique('team_members_team_id_user_id_unique').on(table.team_id, table.user_id),
}));
export const teamMembersRelations = relations(teamMembers, ({ one }) => ({
team: one(teams, { fields: [teamMembers.team_id], references: [teams.id] }),
user: one(users, { fields: [teamMembers.user_id], references: [users.id] }),
}));
export const dashboards = pgTable('dashboards', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
description: text('description'),
team_id: uuid('team_id').references(() => teams.id, { onDelete: 'set null' }),
layout: jsonb('layout').default('[]'),
is_default: boolean('is_default').default(false),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
});
export const dashboardWidgets = pgTable('dashboard_widgets', {
id: uuid('id').primaryKey().defaultRandom(),
dashboard_id: uuid('dashboard_id').notNull().references(() => dashboards.id, { onDelete: 'cascade' }),
view_id: uuid('view_id').notNull().references(() => views.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
widget_type: text('widget_type').notNull(),
position: jsonb('position').default('{"x":0,"y":0,"w":4,"h":2}'),
config: jsonb('config').default('{}'),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
});

View File

@@ -12,6 +12,9 @@ import {
templates, templates,
tickets, tickets,
transactions, transactions,
views,
dashboards,
dashboardWidgets,
users, users,
} from './schema.ts'; } from './schema.ts';
@@ -314,6 +317,9 @@ async function resetDatabase(db: Db) {
await db.delete(customFieldValues); await db.delete(customFieldValues);
await db.delete(transactions); await db.delete(transactions);
await db.delete(queueCustomFields); await db.delete(queueCustomFields);
await db.delete(dashboardWidgets);
await db.delete(dashboards);
await db.delete(views);
await db.delete(scrips); await db.delete(scrips);
await db.delete(templates); await db.delete(templates);
await db.delete(tickets); await db.delete(tickets);
@@ -775,6 +781,56 @@ async function main() {
}))); })));
console.log(`${reset ? 'Reset and seeded' : 'Seeded'} ${demoTickets.length} demo tickets across 4 queues`); console.log(`${reset ? 'Reset and seeded' : 'Seeded'} ${demoTickets.length} demo tickets across 4 queues`);
// ── Dashboard seeding ──
const dashboardViews = [
{ name: 'Open tickets', filters: [{ field: 'status', operator: 'is', value: 'open' }] },
{ name: 'My tickets', filters: [{ field: 'owner', operator: 'is', value: userIds.dispatcher }] },
{ name: 'Unassigned', filters: [{ field: 'owner', operator: 'is', value: 'unassigned' }] },
{ name: 'All tickets', filters: [] },
];
const viewRecords: Record<string, string> = {};
for (const v of dashboardViews) {
const [row] = await db.insert(views).values({
name: v.name,
filters: v.filters,
is_public: true,
}).returning();
if (row) viewRecords[v.name] = row.id;
}
const [dashboard] = await db.insert(dashboards).values({
name: 'Support overview',
description: 'Daily support team dashboard',
is_default: true,
}).returning();
if (dashboard) {
const widgetDefs = [
{ view: 'Open tickets', type: 'count', title: 'Open tickets', x: 0, y: 0, w: 3, h: 1 },
{ view: 'My tickets', type: 'count', title: 'My tickets', x: 3, y: 0, w: 3, h: 1 },
{ view: 'Unassigned', type: 'count', title: 'Unassigned', x: 6, y: 0, w: 3, h: 1 },
{ view: 'All tickets', type: 'count', title: 'Total tickets', x: 9, y: 0, w: 3, h: 1 },
{ view: 'Open tickets', type: 'status_chart', title: 'Status breakdown', x: 0, y: 1, w: 4, h: 2 },
{ view: 'Open tickets', type: 'ticket_list', title: 'Recent open', x: 4, y: 1, w: 5, h: 2, config: { limit: 5 } },
{ view: 'All tickets', type: 'grouped_counts', title: 'By queue', x: 9, y: 1, w: 3, h: 2, config: { group_by: 'queue' } },
];
for (const w of widgetDefs) {
await db.insert(dashboardWidgets).values({
dashboard_id: dashboard.id,
view_id: viewRecords[w.view],
title: w.title,
widget_type: w.type,
position: { x: w.x, y: w.y, w: w.w, h: w.h },
config: w.config ?? {},
});
}
console.log(`Seeded dashboard "${dashboard.name}" with ${widgetDefs.length} widgets`);
}
console.log('Demo data ready'); console.log('Demo data ready');
} finally { } finally {
await pool.end(); await pool.end();

View File

@@ -12,6 +12,9 @@ import { createCustomFieldsRouter } from './routes/custom-fields.ts';
import { createLifecyclesRouter } from './routes/lifecycles.ts'; import { createLifecyclesRouter } from './routes/lifecycles.ts';
import { createUsersRouter } from './routes/users.ts'; import { createUsersRouter } from './routes/users.ts';
import { createTemplatesRouter } from './routes/templates.ts'; import { createTemplatesRouter } from './routes/templates.ts';
import { createViewsRouter } from './routes/views.ts';
import { createDashboardsRouter } from './routes/dashboards.ts';
import { createTeamsRouter } from './routes/teams.ts';
let db: Db | null = null; let db: Db | null = null;
@@ -35,6 +38,9 @@ app.route('/custom-fields', createCustomFieldsRouter(getDb()));
app.route('/lifecycles', createLifecyclesRouter(getDb())); app.route('/lifecycles', createLifecyclesRouter(getDb()));
app.route('/users', createUsersRouter(getDb())); app.route('/users', createUsersRouter(getDb()));
app.route('/templates', createTemplatesRouter(getDb())); app.route('/templates', createTemplatesRouter(getDb()));
app.route('/views', createViewsRouter(getDb()));
app.route('/dashboards', createDashboardsRouter(getDb()));
app.route('/teams', createTeamsRouter(getDb()));
export default app; export default app;
export { app }; export { app };

View File

@@ -8,4 +8,5 @@ export const CreateQueueSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
description: z.string().optional(), description: z.string().optional(),
lifecycle_id: z.string().uuid().optional(), lifecycle_id: z.string().uuid().optional(),
team_id: z.string().uuid().nullable().optional(),
}); });

View File

@@ -15,6 +15,7 @@ export const UpdateTicketSchema = z.object({
subject: z.string().min(1).optional(), subject: z.string().min(1).optional(),
status: z.string().min(1).optional(), status: z.string().min(1).optional(),
owner_id: z.string().uuid().nullable().optional(), owner_id: z.string().uuid().nullable().optional(),
team_id: z.string().uuid().nullable().optional(),
}); });
export const CommentSchema = z.object({ export const CommentSchema = z.object({

386
src/routes/dashboards.ts Normal file
View File

@@ -0,0 +1,386 @@
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { asc, eq } from 'drizzle-orm';
import type { Db } from '../db/index.ts';
import {
dashboards,
dashboardWidgets,
tickets,
customFieldValues,
customFields,
lifecycles,
queues,
views,
} from '../db/schema.ts';
function statusClass(def: { statuses: { initial: string[]; active: string[]; inactive: string[] } }, status: string): string {
if (def.statuses.initial.includes(status)) return 'initial';
if (def.statuses.active.includes(status)) return 'active';
if (def.statuses.inactive.includes(status)) return 'inactive';
return 'unknown';
}
export function createDashboardsRouter(db: Db): Hono {
const router = new Hono();
// ── Dashboards CRUD ──
router.get('/', async (c) => {
const result = await db.query.dashboards.findMany({
orderBy: asc(dashboards.name),
});
return c.json(result);
});
router.post('/', async (c) => {
const body = await c.req.json();
const name = String(body.name ?? '').trim();
if (!name) {
throw new HTTPException(400, { message: 'name is required' });
}
const [dashboard] = await db.insert(dashboards).values({
name,
description: body.description ?? null,
team_id: body.team_id || null,
layout: body.layout ?? [],
is_default: body.is_default ?? false,
}).returning();
if (!dashboard) {
throw new HTTPException(500, { message: 'Failed to create dashboard' });
}
return c.json(dashboard, 201);
});
router.get('/:id', async (c) => {
const id = c.req.param('id');
const dashboard = await db.query.dashboards.findFirst({
where: eq(dashboards.id, id),
});
if (!dashboard) {
throw new HTTPException(404, { message: 'Dashboard not found' });
}
const widgets = await db.query.dashboardWidgets.findMany({
where: eq(dashboardWidgets.dashboard_id, id),
orderBy: asc(dashboardWidgets.created_at),
});
return c.json({ ...dashboard, widgets });
});
router.patch('/:id', async (c) => {
const id = c.req.param('id');
const body = await c.req.json();
const existing = await db.query.dashboards.findFirst({
where: eq(dashboards.id, id),
});
if (!existing) {
throw new HTTPException(404, { message: 'Dashboard not found' });
}
const updateData: Partial<typeof dashboards.$inferInsert> = {};
if (body.name !== undefined) updateData.name = String(body.name).trim();
if (body.description !== undefined) updateData.description = body.description ?? null;
if (body.layout !== undefined) updateData.layout = body.layout;
if (body.team_id !== undefined) updateData.team_id = body.team_id || null;
if (body.is_default !== undefined) {
updateData.is_default = body.is_default;
if (body.is_default) {
await db.update(dashboards)
.set({ is_default: false })
.where(eq(dashboards.is_default, true));
}
}
const [updated] = await db.update(dashboards)
.set(updateData)
.where(eq(dashboards.id, id))
.returning();
return c.json(updated);
});
router.delete('/:id', async (c) => {
const id = c.req.param('id');
const existing = await db.query.dashboards.findFirst({
where: eq(dashboards.id, id),
});
if (!existing) {
throw new HTTPException(404, { message: 'Dashboard not found' });
}
await db.delete(dashboards).where(eq(dashboards.id, id));
return c.json({ ok: true });
});
// ── Widgets CRUD ──
router.get('/:id/widgets', async (c) => {
const dashboardId = c.req.param('id');
const result = await db.query.dashboardWidgets.findMany({
where: eq(dashboardWidgets.dashboard_id, dashboardId),
orderBy: asc(dashboardWidgets.created_at),
});
return c.json(result);
});
router.post('/:id/widgets', async (c) => {
const dashboardId = c.req.param('id');
const dashboard = await db.query.dashboards.findFirst({
where: eq(dashboards.id, dashboardId),
});
if (!dashboard) {
throw new HTTPException(404, { message: 'Dashboard not found' });
}
const body = await c.req.json();
const title = String(body.title ?? 'Widget').trim();
const widgetType = String(body.widget_type ?? 'count').trim();
const viewId = String(body.view_id ?? '').trim();
if (!viewId) {
throw new HTTPException(400, { message: 'view_id is required' });
}
const [widget] = await db.insert(dashboardWidgets).values({
dashboard_id: dashboardId,
view_id: viewId,
title,
widget_type: widgetType,
position: body.position ?? { x: 0, y: 0, w: 4, h: 2 },
config: body.config ?? {},
}).returning();
if (!widget) {
throw new HTTPException(500, { message: 'Failed to create widget' });
}
return c.json(widget, 201);
});
router.patch('/:id/widgets/:widgetId', async (c) => {
const widgetId = c.req.param('widgetId');
const body = await c.req.json();
const existing = await db.query.dashboardWidgets.findFirst({
where: eq(dashboardWidgets.id, widgetId),
});
if (!existing) {
throw new HTTPException(404, { message: 'Widget not found' });
}
const updateData: Partial<typeof dashboardWidgets.$inferInsert> = {};
if (body.title !== undefined) updateData.title = String(body.title).trim();
if (body.widget_type !== undefined) updateData.widget_type = String(body.widget_type);
if (body.position !== undefined) updateData.position = body.position;
if (body.config !== undefined) updateData.config = body.config;
const [updated] = await db.update(dashboardWidgets)
.set(updateData)
.where(eq(dashboardWidgets.id, widgetId))
.returning();
return c.json(updated);
});
router.delete('/:id/widgets/:widgetId', async (c) => {
const widgetId = c.req.param('widgetId');
const existing = await db.query.dashboardWidgets.findFirst({
where: eq(dashboardWidgets.id, widgetId),
});
if (!existing) {
throw new HTTPException(404, { message: 'Widget not found' });
}
await db.delete(dashboardWidgets).where(eq(dashboardWidgets.id, widgetId));
return c.json({ ok: true });
});
// ── Widget data endpoint ──
router.get('/:id/widgets/:widgetId/data', async (c) => {
const widgetId = c.req.param('widgetId');
const widget = await db.query.dashboardWidgets.findFirst({
where: eq(dashboardWidgets.id, widgetId),
});
if (!widget) {
throw new HTTPException(404, { message: 'Widget not found' });
}
const view = await db.query.views.findFirst({
where: eq(views.id, widget.view_id),
});
if (!view) {
return c.json({ error: 'View not found' }, 404);
}
// Apply saved view filters
const savedFilters = (view.filters ?? []) as { field: string; operator: string; value: string }[];
let result = await db.query.tickets.findMany({
orderBy: asc(tickets.created_at),
});
for (const f of savedFilters) {
if (f.field === 'status') {
result = result.filter((t) => t.status === f.value);
} else if (f.field === 'queue') {
result = result.filter((t) => t.queue_id === f.value);
} else if (f.field === 'owner') {
result = f.value === 'unassigned'
? result.filter((t) => !t.owner_id)
: result.filter((t) => t.owner_id === f.value);
} else if (f.field.startsWith('cf.')) {
const cfKey = f.field.slice(3);
const ticketIds = result.map((t) => t.id);
if (ticketIds.length > 0) {
const cfValues = await db.query.customFieldValues.findMany({
where: (table, { and, inArray, eq }) =>
and(
inArray(table.ticket_id, ticketIds),
eq(table.value, f.value),
),
});
const matchingIds = new Set(cfValues.map((v) => v.ticket_id));
// Also find the field ID for the key
const cfField = await db.query.customFields.findFirst({
where: eq(customFields.key, cfKey),
});
if (cfField) {
const cfValuesForField = await db.query.customFieldValues.findMany({
where: (table, { and, inArray, eq }) =>
and(
inArray(table.ticket_id, ticketIds),
eq(table.custom_field_id, cfField.id),
eq(table.value, f.value),
),
});
const matchSet = new Set(cfValuesForField.map((v) => v.ticket_id));
result = result.filter((t) => matchSet.has(t.id));
} else {
result = result.filter((t) => matchingIds.has(t.id));
}
}
}
}
const limit = (widget.config as Record<string, unknown>)?.limit as number ?? 5;
// Find lifecycle for status classification
const queueIds = [...new Set(result.map((r) => r.queue_id))];
const queueRecords = queueIds.length > 0
? await db.query.queues.findMany({
where: (table, { inArray }) => inArray(table.id, queueIds),
})
: [];
const lifecycleIds = [...new Set(queueRecords.map((q) => q.lifecycle_id).filter(Boolean))] as string[];
const lifecycleRecords = lifecycleIds.length > 0
? await db.query.lifecycles.findMany({
where: (table, { inArray }) => inArray(table.id, lifecycleIds),
})
: [];
const lifecycleByQueue = new Map<string, { statuses: { initial: string[]; active: string[]; inactive: string[] } }>();
for (const qr of queueRecords) {
if (qr.lifecycle_id) {
const lc = lifecycleRecords.find((l) => l.id === qr.lifecycle_id);
if (lc) lifecycleByQueue.set(qr.id, lc.definition as any);
}
}
// Get owner usernames
const ownerIds = [...new Set(result.map((t) => t.owner_id).filter(Boolean))] as string[];
const ownerUsers = ownerIds.length > 0
? await db.query.users.findMany({
where: (table, { inArray }) => inArray(table.id, ownerIds),
})
: [];
const ownerName = new Map(ownerUsers.map((u) => [u.id, u.username]));
// Get queue names
const queueName = new Map(queueRecords.map((q) => [q.id, q.name]));
switch (widget.widget_type) {
case 'count': {
return c.json({ type: 'count', total: result.length, title: widget.title, view_id: view.id });
}
case 'ticket_list': {
const slice = result.slice(0, limit).map((ticket) => ({
id: ticket.id,
subject: ticket.subject,
status: ticket.status,
owner_id: ticket.owner_id,
owner_name: ticket.owner_id ? ownerName.get(ticket.owner_id) ?? null : null,
queue_name: queueName.get(ticket.queue_id) ?? ticket.queue_id.slice(0, 8),
updated_at: ticket.updated_at?.toISOString(),
}));
return c.json({ type: 'ticket_list', tickets: slice, total: result.length, title: widget.title, view_id: view.id });
}
case 'status_chart': {
const counts: Record<string, number> = {};
for (const ticket of result) {
counts[ticket.status] = (counts[ticket.status] ?? 0) + 1;
}
return c.json({ type: 'status_chart', counts, total: result.length, title: widget.title, view_id: view.id });
}
case 'grouped_counts': {
const groupBy = (widget.config as Record<string, unknown>)?.group_by as string ?? 'owner';
const groups: Record<string, number> = {};
if (groupBy === 'owner') {
for (const ticket of result) {
const label = ticket.owner_id
? (ownerName.get(ticket.owner_id) ?? ticket.owner_id.slice(0, 8))
: 'Unassigned';
groups[label] = (groups[label] ?? 0) + 1;
}
} else if (groupBy === 'queue') {
for (const ticket of result) {
const label = queueName.get(ticket.queue_id) ?? ticket.queue_id.slice(0, 8);
groups[label] = (groups[label] ?? 0) + 1;
}
} else if (groupBy.startsWith('cf.')) {
const cfKey = groupBy.slice(3);
const cfField = await db.query.customFields.findFirst({
where: eq(customFields.key, cfKey),
});
if (cfField) {
const ticketIds = result.map((t) => t.id);
const cfValues = ticketIds.length > 0
? await db.query.customFieldValues.findMany({
where: (table, { and, inArray, eq }) =>
and(
inArray(table.ticket_id, ticketIds),
eq(table.custom_field_id, cfField.id),
),
})
: [];
for (const v of cfValues) {
groups[v.value] = (groups[v.value] ?? 0) + 1;
}
}
}
return c.json({ type: 'grouped_counts', groups, total: result.length, group_by: groupBy, title: widget.title, view_id: view.id });
}
default:
return c.json({ type: 'count', total: result.length, title: widget.title, view_id: view.id });
}
});
return router;
}

View File

@@ -23,6 +23,7 @@ export function createQueuesRouter(db: Db): Hono {
name: parsed.name, name: parsed.name,
description: parsed.description ?? null, description: parsed.description ?? null,
lifecycle_id: parsed.lifecycle_id ?? null, lifecycle_id: parsed.lifecycle_id ?? null,
team_id: parsed.team_id ?? null,
}).returning(); }).returning();
if (!queue) { if (!queue) {
@@ -48,6 +49,7 @@ export function createQueuesRouter(db: Db): Hono {
if (body.name !== undefined) updateData.name = String(body.name); if (body.name !== undefined) updateData.name = String(body.name);
if (body.description !== undefined) updateData.description = body.description ? String(body.description) : null; if (body.description !== undefined) updateData.description = body.description ? String(body.description) : null;
if (body.lifecycle_id !== undefined) updateData.lifecycle_id = body.lifecycle_id || null; if (body.lifecycle_id !== undefined) updateData.lifecycle_id = body.lifecycle_id || null;
if (body.team_id !== undefined) updateData.team_id = body.team_id || null;
const [updated] = await db.update(queues) const [updated] = await db.update(queues)
.set(updateData) .set(updateData)

98
src/routes/teams.ts Normal file
View File

@@ -0,0 +1,98 @@
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { and, asc, eq, inArray } from 'drizzle-orm';
import type { Db } from '../db/index.ts';
import { teams, teamMembers, users, dashboards } from '../db/schema.ts';
export function createTeamsRouter(db: Db): Hono {
const router = new Hono();
// GET /teams — list all with member details
router.get('/', async (c) => {
const allTeams = await db.query.teams.findMany({
orderBy: asc(teams.name),
});
const result = await Promise.all(allTeams.map(async (team) => {
const members = await db.query.teamMembers.findMany({
where: eq(teamMembers.team_id, team.id),
});
const memberUsers = members.length > 0
? await db.query.users.findMany({
where: (table, { inArray }) => inArray(table.id, members.map((m) => m.user_id)),
})
: [];
return { ...team, members: memberUsers };
}));
return c.json(result);
});
// POST /teams
router.post('/', async (c) => {
const body = await c.req.json();
const name = String(body.name ?? '').trim();
if (!name) throw new HTTPException(400, { message: 'name is required' });
const [team] = await db.insert(teams).values({
name,
description: body.description ?? null,
}).returning();
if (!team) throw new HTTPException(500, { message: 'Failed to create team' });
return c.json(team, 201);
});
// PATCH /teams/:id
router.patch('/:id', async (c) => {
const id = c.req.param('id');
const body = await c.req.json();
const existing = await db.query.teams.findFirst({ where: eq(teams.id, id) });
if (!existing) throw new HTTPException(404, { message: 'Team not found' });
const updateData: Partial<typeof teams.$inferInsert> = {};
if (body.name !== undefined) updateData.name = String(body.name).trim();
if (body.description !== undefined) updateData.description = body.description ?? null;
const [updated] = await db.update(teams).set(updateData).where(eq(teams.id, id)).returning();
return c.json(updated);
});
// DELETE /teams/:id
router.delete('/:id', async (c) => {
const id = c.req.param('id');
const existing = await db.query.teams.findFirst({ where: eq(teams.id, id) });
if (!existing) throw new HTTPException(404, { message: 'Team not found' });
await db.delete(teams).where(eq(teams.id, id));
return c.json({ ok: true });
});
// POST /teams/:id/members — add member
router.post('/:id/members', async (c) => {
const teamId = c.req.param('id');
const body = await c.req.json();
const userId = String(body.user_id ?? '').trim();
if (!userId) throw new HTTPException(400, { message: 'user_id is required' });
const team = await db.query.teams.findFirst({ where: eq(teams.id, teamId) });
if (!team) throw new HTTPException(404, { message: 'Team not found' });
const [member] = await db.insert(teamMembers).values({
team_id: teamId,
user_id: userId,
}).returning();
if (!member) throw new HTTPException(500, { message: 'Failed to add member' });
return c.json(member, 201);
});
// DELETE /teams/:id/members/:userId
router.delete('/:id/members/:userId', async (c) => {
const teamId = c.req.param('id');
const userId = c.req.param('userId');
await db.delete(teamMembers).where(
and(eq(teamMembers.team_id, teamId), eq(teamMembers.user_id, userId))
);
return c.json({ ok: true });
});
return router;
}

View File

@@ -151,6 +151,21 @@ export function createTemplatesRouter(db: Db): Hono {
return c.json(updated); return c.json(updated);
}); });
router.delete('/:id', async (c) => {
const id = c.req.param('id');
const existing = await db.query.templates.findFirst({
where: eq(templates.id, id),
});
if (!existing) {
throw new HTTPException(404, { message: 'Template not found' });
}
await db.delete(templates).where(eq(templates.id, id));
return c.json({ ok: true });
});
router.post('/preview', async (c) => { router.post('/preview', async (c) => {
const body = await c.req.json(); const body = await c.req.json();
const subjectTemplate = String(body.subject_template ?? ''); const subjectTemplate = String(body.subject_template ?? '');

View File

@@ -1,8 +1,8 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception'; import { HTTPException } from 'hono/http-exception';
import type { Db } from '../db/index.ts'; import type { Db } from '../db/index.ts';
import { tickets, transactions, customFieldValues, customFields, queues, lifecycles, queueCustomFields } from '../db/schema.ts'; import { tickets, transactions, customFieldValues, customFields, queues, lifecycles, queueCustomFields, teamMembers } from '../db/schema.ts';
import { and, eq, asc } from 'drizzle-orm'; import { and, eq, asc, or, ilike, isNull, inArray, exists, sql } from 'drizzle-orm';
import { CreateTicketSchema, UpdateTicketSchema, CommentSchema } from '../models/ticket.ts'; import { CreateTicketSchema, UpdateTicketSchema, CommentSchema } from '../models/ticket.ts';
import { ScripEngine } from '../scrip/engine.ts'; import { ScripEngine } from '../scrip/engine.ts';
import { LifecycleValidator } from '../lifecycle/validator.ts'; import { LifecycleValidator } from '../lifecycle/validator.ts';
@@ -26,94 +26,130 @@ export function createTicketsRouter(db: Db): Hono {
const queueId = c.req.query('queue_id'); const queueId = c.req.query('queue_id');
const status = c.req.query('status'); const status = c.req.query('status');
const ownerId = c.req.query('owner_id'); const ownerId = c.req.query('owner_id');
const query = c.req.query('q')?.trim().toLowerCase() ?? ''; const teamId = c.req.query('team_id');
const query = c.req.query('q')?.trim() ?? '';
const limit = c.req.query('limit') ? Number(c.req.query('limit')) : undefined;
const cfFilters = [...params.entries()] const cfFilters = [...params.entries()]
.filter(([key, value]) => key.startsWith('cf.') && value.trim()) .filter(([key, value]) => key.startsWith('cf.') && value.trim())
.map(([key, value]) => ({ .map(([key, value]) => ({
key: key.slice(3), key: key.slice(3),
value: value.trim().toLowerCase(), value: value.trim(),
})); }));
let result = await db.query.tickets.findMany({ // Build SQL WHERE conditions
orderBy: asc(tickets.created_at), const conditions: ReturnType<typeof eq>[] = [];
});
if (queueId) { if (queueId) {
result = result.filter((ticket) => ticket.queue_id === queueId); conditions.push(eq(tickets.queue_id, queueId));
} }
if (status) { if (status) {
result = result.filter((ticket) => ticket.status === status); conditions.push(eq(tickets.status, status));
} }
if (ownerId) { if (ownerId) {
result = ownerId === 'unassigned' conditions.push(
? result.filter((ticket) => !ticket.owner_id) ownerId === 'unassigned' ? isNull(tickets.owner_id) : eq(tickets.owner_id, ownerId)
: result.filter((ticket) => ticket.owner_id === ownerId); );
}
if (teamId) {
// Resolve team members and filter tickets by those owner_ids
const members = await db.query.teamMembers.findMany({
where: eq(teamMembers.team_id, teamId),
});
const memberIds = members.map((m) => m.user_id);
if (memberIds.length > 0) {
conditions.push(inArray(tickets.owner_id, memberIds));
} else {
conditions.push(isNull(tickets.owner_id)); // empty team = no results
}
} }
const needsCustomFields = query || cfFilters.length > 0; // Text search: push to SQL via ilike on ticket columns + queue name join
const valuesByTicket = new Map<number, { fieldId: string; fieldKey: string; fieldName: string; value: string }[]>(); if (query) {
const pattern = `%${query}%`;
conditions.push(
or(
ilike(tickets.subject, pattern),
ilike(tickets.status, pattern),
sql`${tickets.id}::text ILIKE ${pattern}`
)!
);
// Queue name search requires join — keep as post-filter
}
if (needsCustomFields && result.length > 0) { // Custom field filters: use EXISTS subquery
const ticketIds = result.map((ticket) => ticket.id); for (const cf of cfFilters) {
const cfValues = await db.query.customFieldValues.findMany({ conditions.push(
exists(
db.select({ n: sql`1` })
.from(customFieldValues)
.innerJoin(customFields, eq(customFieldValues.custom_field_id, customFields.id))
.where(
and(
eq(customFieldValues.ticket_id, tickets.id),
eq(customFields.key, cf.key),
eq(customFieldValues.value, cf.value)
)
)
)
);
}
const result = await db.query.tickets.findMany({
where: conditions.length > 0 ? and(...conditions) : undefined,
orderBy: asc(tickets.created_at),
limit,
});
// Post-filter for queue name text search (requires in-memory join)
let filtered = result;
if (query) {
const queuesForSearch = await db.query.queues.findMany();
const queueNameById = new Map(queuesForSearch.map((queue) => [queue.id, queue.name]));
filtered = result.filter((ticket) =>
(queueNameById.get(ticket.queue_id) ?? '').toLowerCase().includes(query.toLowerCase())
);
}
// Attach custom field values to all tickets
if (filtered.length > 0) {
const ticketIds = filtered.map((t) => t.id);
const allCfValues = await db.query.customFieldValues.findMany({
where: (table, { inArray }) => inArray(table.ticket_id, ticketIds), where: (table, { inArray }) => inArray(table.ticket_id, ticketIds),
}); });
const fieldIds = [...new Set(cfValues.map((value) => value.custom_field_id))]; const fieldIds = [...new Set(allCfValues.map((v) => v.custom_field_id))];
const fields = fieldIds.length > 0 const allFields = fieldIds.length > 0
? await db.query.customFields.findMany({ ? await db.query.customFields.findMany({
where: (table, { inArray }) => inArray(table.id, fieldIds), where: (table, { inArray }) => inArray(table.id, fieldIds),
}) })
: []; : [];
const fieldMap = new Map(fields.map((field) => [field.id, field])); const fieldMap = new Map(allFields.map((f) => [f.id, f]));
for (const value of cfValues) { const ticketsWithCf = filtered.map((ticket) => {
const rows = valuesByTicket.get(value.ticket_id) ?? []; const cfs = allCfValues
rows.push({ .filter((v) => v.ticket_id === ticket.id)
fieldId: value.custom_field_id, .map((v) => ({
fieldKey: fieldMap.get(value.custom_field_id)?.key ?? value.custom_field_id, id: v.id,
fieldName: fieldMap.get(value.custom_field_id)?.name ?? value.custom_field_id, custom_field_id: v.custom_field_id,
value: value.value, ticket_id: v.ticket_id,
value: v.value,
created_at: v.created_at?.toISOString(),
custom_field: fieldMap.has(v.custom_field_id) ? {
id: v.custom_field_id,
key: fieldMap.get(v.custom_field_id)!.key,
name: fieldMap.get(v.custom_field_id)!.name,
field_type: fieldMap.get(v.custom_field_id)!.field_type,
values: fieldMap.get(v.custom_field_id)!.values,
max_values: fieldMap.get(v.custom_field_id)!.max_values,
pattern: fieldMap.get(v.custom_field_id)!.pattern,
} : undefined,
}));
return { ...ticket, custom_fields: cfs };
}); });
valuesByTicket.set(value.ticket_id, rows);
} return c.json(ticketsWithCf);
} }
if (query) { return c.json(filtered);
const queuesForSearch = await db.query.queues.findMany();
const queueNameById = new Map(queuesForSearch.map((queue) => [queue.id, queue.name]));
result = result.filter((ticket) => {
const customFields = valuesByTicket.get(ticket.id) ?? [];
return (
ticket.subject.toLowerCase().includes(query) ||
String(ticket.id).includes(query) ||
ticket.status.toLowerCase().includes(query) ||
(queueNameById.get(ticket.queue_id) ?? '').toLowerCase().includes(query) ||
customFields.some((field) =>
field.fieldName.toLowerCase().includes(query) ||
field.fieldKey.toLowerCase().includes(query) ||
field.value.toLowerCase().includes(query)
)
);
});
}
if (cfFilters.length > 0) {
result = result.filter((ticket) => {
const customFields = valuesByTicket.get(ticket.id) ?? [];
return cfFilters.every((filter) =>
customFields.some((field) =>
(
field.fieldId === filter.key ||
field.fieldKey.toLowerCase() === filter.key.toLowerCase() ||
field.fieldName.toLowerCase() === filter.key.toLowerCase()
) &&
field.value.toLowerCase() === filter.value
)
);
});
}
return c.json(result);
}); });
// POST / — create ticket // POST / — create ticket
@@ -186,6 +222,7 @@ export function createTicketsRouter(db: Db): Hono {
queue_id: parsed.queue_id, queue_id: parsed.queue_id,
status: initialStatus, status: initialStatus,
creator_id: creatorId, creator_id: creatorId,
team_id: (queue as any).team_id ?? null,
}).returning(); }).returning();
if (!ticket) { if (!ticket) {
@@ -231,9 +268,9 @@ export function createTicketsRouter(db: Db): Hono {
const createdTransactions = await db.insert(transactions).values(txList as any).returning(); const createdTransactions = await db.insert(transactions).values(txList as any).returning();
const prepared = await scripEngine.prepare(ticket.id, createdTransactions as any); const prepared = await scripEngine.prepare(ticket.id, createdTransactions as any);
await scripEngine.commit(prepared); const results = await scripEngine.commit(prepared);
return c.json(ticket, 201); return c.json({ ticket, scrip_results: results }, 201);
}); });
// GET /:id — get ticket with custom field values // GET /:id — get ticket with custom field values
@@ -339,6 +376,17 @@ export function createTicketsRouter(db: Db): Hono {
}); });
} }
if (parsed.team_id !== undefined && parsed.team_id !== (ticket as any).team_id) {
txList.push({
ticket_id: id,
transaction_type: 'SetTeam' as const,
field: 'team_id',
old_value: (ticket as any).team_id ?? null,
new_value: parsed.team_id,
creator_id: '00000000-0000-0000-0000-000000000000',
});
}
// Update the ticket // Update the ticket
const updateData: Record<string, unknown> = {}; const updateData: Record<string, unknown> = {};
if (parsed.subject) updateData.subject = parsed.subject; if (parsed.subject) updateData.subject = parsed.subject;
@@ -364,6 +412,7 @@ export function createTicketsRouter(db: Db): Hono {
} }
} }
if (parsed.owner_id !== undefined) updateData.owner_id = parsed.owner_id; if (parsed.owner_id !== undefined) updateData.owner_id = parsed.owner_id;
if (parsed.team_id !== undefined) updateData.team_id = parsed.team_id;
updateData.updated_at = new Date(); updateData.updated_at = new Date();
const [updated] = await db.update(tickets) const [updated] = await db.update(tickets)

View File

@@ -1,5 +1,6 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { asc } from 'drizzle-orm'; import { HTTPException } from 'hono/http-exception';
import { asc, eq } from 'drizzle-orm';
import type { Db } from '../db/index.ts'; import type { Db } from '../db/index.ts';
import { users } from '../db/schema.ts'; import { users } from '../db/schema.ts';
@@ -13,5 +14,65 @@ export function createUsersRouter(db: Db): Hono {
return c.json(result); return c.json(result);
}); });
router.post('/', async (c) => {
const body = await c.req.json();
const username = String(body.username ?? '').trim();
const email = body.email ? String(body.email).trim() : null;
if (!username) {
throw new HTTPException(400, { message: 'username is required' });
}
const [user] = await db.insert(users).values({
username,
email,
}).returning();
if (!user) {
throw new HTTPException(500, { message: 'Failed to create user' });
}
return c.json(user, 201);
});
router.patch('/:id', async (c) => {
const id = c.req.param('id');
const body = await c.req.json();
const existing = await db.query.users.findFirst({
where: eq(users.id, id),
});
if (!existing) {
throw new HTTPException(404, { message: 'User not found' });
}
const updateData: Partial<typeof users.$inferInsert> = {};
if (body.username !== undefined) updateData.username = String(body.username).trim();
if (body.email !== undefined) updateData.email = body.email ? String(body.email).trim() : null;
const [updated] = await db.update(users)
.set(updateData)
.where(eq(users.id, id))
.returning();
return c.json(updated);
});
router.delete('/:id', async (c) => {
const id = c.req.param('id');
const existing = await db.query.users.findFirst({
where: eq(users.id, id),
});
if (!existing) {
throw new HTTPException(404, { message: 'User not found' });
}
await db.delete(users).where(eq(users.id, id));
return c.json({ ok: true });
});
return router; return router;
} }

84
src/routes/views.ts Normal file
View File

@@ -0,0 +1,84 @@
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { asc, eq } from 'drizzle-orm';
import type { Db } from '../db/index.ts';
import { views } from '../db/schema.ts';
export function createViewsRouter(db: Db): Hono {
const router = new Hono();
router.get('/', async (c) => {
const result = await db.query.views.findMany({
orderBy: asc(views.name),
});
return c.json(result);
});
router.post('/', async (c) => {
const body = await c.req.json();
const name = String(body.name ?? '').trim();
if (!name) {
throw new HTTPException(400, { message: 'name is required' });
}
const [view] = await db.insert(views).values({
name,
filters: body.filters ?? [],
sort_key: body.sort_key ?? 'updated',
columns: body.columns ?? [],
is_public: body.is_public ?? false,
creator_id: body.creator_id || null,
}).returning();
if (!view) {
throw new HTTPException(500, { message: 'Failed to create view' });
}
return c.json(view, 201);
});
router.patch('/:id', async (c) => {
const id = c.req.param('id');
const body = await c.req.json();
const existing = await db.query.views.findFirst({
where: eq(views.id, id),
});
if (!existing) {
throw new HTTPException(404, { message: 'View not found' });
}
const updateData: Partial<typeof views.$inferInsert> = {};
if (body.name !== undefined) updateData.name = String(body.name).trim();
if (body.filters !== undefined) updateData.filters = body.filters;
if (body.sort_key !== undefined) updateData.sort_key = body.sort_key;
if (body.columns !== undefined) updateData.columns = body.columns;
if (body.is_public !== undefined) updateData.is_public = body.is_public;
const [updated] = await db.update(views)
.set(updateData)
.where(eq(views.id, id))
.returning();
return c.json(updated);
});
router.delete('/:id', async (c) => {
const id = c.req.param('id');
const existing = await db.query.views.findFirst({
where: eq(views.id, id),
});
if (!existing) {
throw new HTTPException(404, { message: 'View not found' });
}
await db.delete(views).where(eq(views.id, id));
return c.json({ ok: true });
});
return router;
}

View File

@@ -1,5 +1,49 @@
<!-- BEGIN:nextjs-agent-rules --> # Tessera Design Rules
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. Follow these rules when writing any frontend code. The design is clean, modern, and minimal — avoid the "2015 admin panel" look.
<!-- END:nextjs-agent-rules -->
## Borders
- **Never** use `border-border` (full opacity). Always `border-border/50` or `border-border/30`.
- Section dividers: `border-b border-border/50`
- Subtle item separators: `border-b border-border/30`
- Card borders: `border border-border/50`
## Shadows
- **Never** use `shadow-sm` or `shadow-md` on cards, sections, or tabs.
- The sidebar is the only element with a shadow (subtle).
- No `bg-card/82`, no `bg-background/55` — use `bg-card` or `bg-transparent`.
## Section wrappers
- `rounded-lg border border-border/50` — NOT `rounded-md border border-border bg-card shadow-sm`
## List items
- `rounded-lg border border-border/50 p-3`, hover: `hover:border-primary/30 hover:bg-accent/30`
- Active: `border-primary/50 bg-primary/5`
## Nav items (sidebar)
- Default: `text-sidebar-foreground/55`, icons at 50% opacity
- Hover: `text-sidebar-foreground hover:bg-sidebar-accent/50`, icons at 70%
- Active: `bg-sidebar-accent text-sidebar-foreground font-medium`, icons at 90%
- No inset shadow on active items
## Typography
- Section headers: `text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60`
- No `text-[11px] font-semibold uppercase` anywhere — use the above pattern
- Body text: `text-sm` for main, `text-xs` for secondary
- Keep font sizes consistent: 10px (labels), 12-13px (content), 14px (headings)
## Icons
- Section headers: `h-3 w-3` (not `h-4 w-4`)
- In nav items: `w-4 h-4`
- Always dimmed when inactive (opacity-50), bright when active
## Forms
- Inputs/selects: `h-8 rounded-md border border-input bg-transparent px-2.5 text-sm`
- No `bg-card` on inputs unless inside a card
- Labels: `text-[10px] font-medium text-muted-foreground`
## General
- No `backdrop-blur` on headers or sections
- No `bg-card/82` or `bg-background/55` — use solid colors or `bg-transparent`
- Content areas: `bg-background/80` for the main page background
- Avoid `<dl>`/`<dt>`/`<dd>` grids for properties — use simple `flex justify-between` rows

View File

@@ -10,6 +10,8 @@ import {
PlusIcon, PlusIcon,
Settings2Icon, Settings2Icon,
SlidersHorizontalIcon, SlidersHorizontalIcon,
Trash2Icon,
UsersIcon,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -52,32 +54,36 @@ import {
createTemplate, createTemplate,
updateTemplate, updateTemplate,
previewTemplate, previewTemplate,
deleteTemplate,
getCustomFields, getCustomFields,
getQueueCustomFields, getQueueCustomFields,
assignQueueCustomField, assignQueueCustomField,
unassignQueueCustomField, unassignQueueCustomField,
createCustomField, createCustomField,
updateCustomField, updateCustomField,
getUsers,
createUser,
updateUser,
deleteUser,
getTeams,
createTeam,
updateTeam,
deleteTeam,
addTeamMember,
removeTeamMember,
} from "@/lib/api"; } from "@/lib/api";
import type { Queue, Ticket, Lifecycle, LifecycleDefinition, Scrip, Template, CustomField, QueueCustomField, TemplatePreview } from "@/lib/types"; import type { Queue, Ticket, Lifecycle, LifecycleDefinition, Scrip, Template, CustomField, QueueCustomField, TemplatePreview, User, Team } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function AdminHeader() { function AdminHeader() {
return ( return (
<header className="border-b border-border bg-card/90 px-5 py-5 backdrop-blur lg:px-6"> <header className="border-b border-border/50 px-5 py-4 lg:px-6">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div> <div>
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase text-muted-foreground"> <div className="flex items-center gap-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">
<Settings2Icon className="h-3.5 w-3.5" /> <Settings2Icon className="h-3 w-3" />
Configuration Configuration
</div> </div>
<h1 className="mt-1 text-2xl font-semibold text-foreground"> <h1 className="mt-1 text-xl font-semibold tracking-tight text-foreground">Admin</h1>
Admin console
</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Configure queues, lifecycle state machines, automation rules, and custom ticket metadata.
</p>
</div>
</div> </div>
</header> </header>
); );
@@ -125,8 +131,8 @@ export default function AdminPage() {
<div className="flex h-full flex-col bg-background/80"> <div className="flex h-full flex-col bg-background/80">
<AdminHeader /> <AdminHeader />
<Tabs defaultValue="queues" className="min-h-0 flex-1 gap-0"> <Tabs defaultValue="queues" className="min-h-0 flex-1 gap-0">
<div className="border-b border-border bg-card/70 px-5 py-3 lg:px-6"> <div className="border-b border-border/50 px-5 py-2.5 lg:px-6">
<TabsList className="h-9 rounded-md border border-border bg-muted/55 p-1"> <TabsList className="h-8 rounded-lg bg-muted/40 p-0.5">
<TabsTrigger value="queues" className="px-3"> <TabsTrigger value="queues" className="px-3">
<DatabaseIcon className="h-4 w-4" /> <DatabaseIcon className="h-4 w-4" />
Queues Queues
@@ -147,6 +153,14 @@ export default function AdminPage() {
<SlidersHorizontalIcon className="h-4 w-4" /> <SlidersHorizontalIcon className="h-4 w-4" />
Custom fields Custom fields
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="users" className="px-3">
<UsersIcon className="h-4 w-4" />
Users
</TabsTrigger>
<TabsTrigger value="teams" className="px-3">
<UsersIcon className="h-4 w-4" />
Teams
</TabsTrigger>
</TabsList> </TabsList>
</div> </div>
<div className="min-h-0 flex-1 overflow-auto p-5 lg:p-6"> <div className="min-h-0 flex-1 overflow-auto p-5 lg:p-6">
@@ -165,6 +179,12 @@ export default function AdminPage() {
<TabsContent value="customfields" className="m-0"> <TabsContent value="customfields" className="m-0">
<CustomFieldsTab /> <CustomFieldsTab />
</TabsContent> </TabsContent>
<TabsContent value="users" className="m-0">
<UsersTab />
</TabsContent>
<TabsContent value="teams" className="m-0">
<TeamsTab />
</TabsContent>
</div> </div>
</Tabs> </Tabs>
</div> </div>
@@ -174,23 +194,27 @@ export default function AdminPage() {
function QueuesTab() { function QueuesTab() {
const [queues, setQueues] = useState<Queue[]>([]); const [queues, setQueues] = useState<Queue[]>([]);
const [lifecycles, setLifecycles] = useState<Lifecycle[]>([]); const [lifecycles, setLifecycles] = useState<Lifecycle[]>([]);
const [teams, setTeams] = useState<Team[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [lifecycleId, setLifecycleId] = useState(""); const [lifecycleId, setLifecycleId] = useState("");
const [teamId, setTeamId] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
const fetchQueues = useCallback(async () => { const fetchQueues = useCallback(async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
const [queueRes, lifecycleRes] = await Promise.all([getQueues(), getLifecycles()]); const [queueRes, lifecycleRes, teamsRes] = await Promise.all([getQueues(), getLifecycles(), getTeams()]);
if (queueRes.error) setError(queueRes.error); if (queueRes.error) setError(queueRes.error);
else setQueues(queueRes.data ?? []); else setQueues(queueRes.data ?? []);
if (lifecycleRes.error) setError((prev) => prev || lifecycleRes.error); if (lifecycleRes.error) setError((prev) => prev || lifecycleRes.error);
else setLifecycles(lifecycleRes.data ?? []); else setLifecycles(lifecycleRes.data ?? []);
if (teamsRes.error) setError((prev) => prev || teamsRes.error);
else setTeams(teamsRes.data ?? []);
setLoading(false); setLoading(false);
}, []); }, []);
@@ -203,6 +227,7 @@ function QueuesTab() {
setName(""); setName("");
setDescription(""); setDescription("");
setLifecycleId(""); setLifecycleId("");
setTeamId("");
setSaveError(null); setSaveError(null);
}; };
@@ -211,6 +236,7 @@ function QueuesTab() {
setName(queue.name); setName(queue.name);
setDescription(queue.description ?? ""); setDescription(queue.description ?? "");
setLifecycleId(queue.lifecycle_id ?? ""); setLifecycleId(queue.lifecycle_id ?? "");
setTeamId(queue.team_id ?? "");
setSaveError(null); setSaveError(null);
}; };
@@ -222,6 +248,7 @@ function QueuesTab() {
name: name.trim(), name: name.trim(),
description: description.trim() || null, description: description.trim() || null,
lifecycle_id: lifecycleId || null, lifecycle_id: lifecycleId || null,
team_id: teamId || null,
}; };
const { data, error } = editingId const { data, error } = editingId
? await updateQueue(editingId, payload) ? await updateQueue(editingId, payload)
@@ -241,8 +268,8 @@ function QueuesTab() {
}; };
return ( return (
<section className="overflow-hidden rounded-md border border-border bg-card/82 shadow-sm"> <section className="overflow-hidden rounded-lg border border-border/50">
<div className="flex flex-col gap-3 border-b border-border bg-muted/35 px-4 py-3 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-3 border-b border-border/50 bg-muted/20 px-4 py-3 lg:flex-row lg:items-center lg:justify-between">
<div> <div>
<h2 className="text-base font-semibold text-foreground">Queues ({queues.length})</h2> <h2 className="text-base font-semibold text-foreground">Queues ({queues.length})</h2>
<p className="mt-0.5 text-sm text-muted-foreground">Route work into lifecycle-bound operational lanes.</p> <p className="mt-0.5 text-sm text-muted-foreground">Route work into lifecycle-bound operational lanes.</p>
@@ -257,7 +284,7 @@ function QueuesTab() {
<LoadingState /> <LoadingState />
) : ( ) : (
<div className="grid min-h-[560px] lg:grid-cols-[320px_minmax(0,1fr)]"> <div className="grid min-h-[560px] lg:grid-cols-[320px_minmax(0,1fr)]">
<aside className="min-w-0 overflow-hidden border-b border-border bg-background/70 lg:border-b-0 lg:border-r"> <aside className="min-w-0 overflow-hidden border-b border-border/50 lg:border-b-0 lg:border-r border-border/50">
<div className="flex min-w-0 items-center justify-between gap-3 border-b border-border px-4 py-3"> <div className="flex min-w-0 items-center justify-between gap-3 border-b border-border px-4 py-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-[11px] font-semibold uppercase text-muted-foreground">Queue library</div> <div className="text-[11px] font-semibold uppercase text-muted-foreground">Queue library</div>
@@ -278,8 +305,8 @@ function QueuesTab() {
type="button" type="button"
onClick={() => selectQueue(queue)} onClick={() => selectQueue(queue)}
className={cn( className={cn(
"min-w-0 max-w-full overflow-hidden rounded-md border p-3 text-left transition hover:border-primary/45 hover:bg-accent/45", "min-w-0 max-w-full overflow-hidden rounded-lg border p-3 text-left transition hover:border-primary/30",
editingId === queue.id ? "border-primary bg-primary/10" : "border-border bg-card" editingId === queue.id ? "border-primary/50 bg-primary/5" : "border-border/50 hover:bg-accent/30"
)} )}
> >
<div className="truncate text-sm font-semibold text-foreground">{queue.name}</div> <div className="truncate text-sm font-semibold text-foreground">{queue.name}</div>
@@ -293,7 +320,7 @@ function QueuesTab() {
</div> </div>
</aside> </aside>
<div className="min-w-0 p-4"> <div className="min-w-0 p-4">
<div className="mb-4 flex flex-col gap-3 border-b border-border pb-4 lg:flex-row lg:items-center lg:justify-between"> <div className="mb-4 flex flex-col gap-3 border-b border-border/50 pb-4 lg:flex-row lg:items-center lg:justify-between">
<div> <div>
<div className="text-[11px] font-semibold uppercase text-muted-foreground"> <div className="text-[11px] font-semibold uppercase text-muted-foreground">
{editingId ? "Editing queue" : "New queue"} {editingId ? "Editing queue" : "New queue"}
@@ -329,6 +356,19 @@ function QueuesTab() {
</Select> </Select>
</div> </div>
</ScripFlowNode> </ScripFlowNode>
<ScripFlowNode label="03" title="Default team" description="New tickets in this queue inherit this team. Can be changed per-ticket.">
<div className="grid gap-1.5">
<Select value={teamId || "_none"} onValueChange={(value) => setTeamId(value === "_none" || !value ? "" : value)}>
<SelectTrigger id="q-team"><SelectValue placeholder="No default team" /></SelectTrigger>
<SelectContent>
<SelectItem value="_none">No default team</SelectItem>
{teams.map((team) => (
<SelectItem key={team.id} value={team.id}>{team.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</ScripFlowNode>
{saveError && <div className="text-sm text-destructive">{saveError}</div>} {saveError && <div className="text-sm text-destructive">{saveError}</div>}
</div> </div>
</div> </div>
@@ -437,8 +477,8 @@ function LifecyclesTab() {
}; };
return ( return (
<section className="overflow-hidden rounded-md border border-border bg-card/82 shadow-sm"> <section className="overflow-hidden rounded-lg border border-border/50">
<div className="flex flex-col gap-3 border-b border-border bg-muted/35 px-4 py-3 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-3 border-b border-border/50 bg-muted/20 px-4 py-3 lg:flex-row lg:items-center lg:justify-between">
<div> <div>
<h2 className="text-base font-semibold text-foreground">Lifecycles ({lifecycles.length})</h2> <h2 className="text-base font-semibold text-foreground">Lifecycles ({lifecycles.length})</h2>
<p className="mt-0.5 text-sm text-muted-foreground">Define status classes and allowed movement through ticket states.</p> <p className="mt-0.5 text-sm text-muted-foreground">Define status classes and allowed movement through ticket states.</p>
@@ -453,7 +493,7 @@ function LifecyclesTab() {
<LoadingState /> <LoadingState />
) : ( ) : (
<div className="grid min-h-[560px] lg:grid-cols-[320px_minmax(0,1fr)]"> <div className="grid min-h-[560px] lg:grid-cols-[320px_minmax(0,1fr)]">
<aside className="min-w-0 overflow-hidden border-b border-border bg-background/70 lg:border-b-0 lg:border-r"> <aside className="min-w-0 overflow-hidden border-b border-border/50 lg:border-b-0 lg:border-r border-border/50">
<div className="flex min-w-0 items-center justify-between gap-3 border-b border-border px-4 py-3"> <div className="flex min-w-0 items-center justify-between gap-3 border-b border-border px-4 py-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-[11px] font-semibold uppercase text-muted-foreground">Lifecycle library</div> <div className="text-[11px] font-semibold uppercase text-muted-foreground">Lifecycle library</div>
@@ -490,7 +530,7 @@ function LifecyclesTab() {
</div> </div>
</aside> </aside>
<div className="min-w-0 p-4"> <div className="min-w-0 p-4">
<div className="mb-4 flex flex-col gap-3 border-b border-border pb-4 lg:flex-row lg:items-center lg:justify-between"> <div className="mb-4 flex flex-col gap-3 border-b border-border/50 pb-4 lg:flex-row lg:items-center lg:justify-between">
<div> <div>
<div className="text-[11px] font-semibold uppercase text-muted-foreground"> <div className="text-[11px] font-semibold uppercase text-muted-foreground">
{editingId ? "Editing lifecycle" : "New lifecycle"} {editingId ? "Editing lifecycle" : "New lifecycle"}
@@ -696,9 +736,9 @@ function ScripFlowNode({
children: ReactNode; children: ReactNode;
}) { }) {
return ( return (
<section className="rounded-md border border-border bg-background"> <section className="rounded-lg border border-border/50">
<div className="flex items-start gap-3 border-b border-border bg-muted/30 px-4 py-3"> <div className="flex items-start gap-3 border-b border-border/50 bg-muted/20 px-4 py-3">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded border border-border bg-card font-mono text-[11px] font-semibold text-muted-foreground"> <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted/40 font-mono text-[11px] font-semibold text-muted-foreground">
{label} {label}
</div> </div>
<div> <div>
@@ -1141,8 +1181,8 @@ return { message: "Metadata fetched" };`);
const resolvedToStatusOptions = uniqueSortedStatuses([...toStatusOptions, toStatus]); const resolvedToStatusOptions = uniqueSortedStatuses([...toStatusOptions, toStatus]);
return ( return (
<section className="overflow-hidden rounded-md border border-border bg-card/82 shadow-sm"> <section className="overflow-hidden rounded-lg border border-border/50">
<div className="flex flex-col gap-3 border-b border-border bg-muted/35 px-4 py-3 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-3 border-b border-border/50 bg-muted/20 px-4 py-3 lg:flex-row lg:items-center lg:justify-between">
<div> <div>
<h2 className="text-base font-semibold text-foreground">Scrips ({scrips.length})</h2> <h2 className="text-base font-semibold text-foreground">Scrips ({scrips.length})</h2>
<p className="mt-0.5 text-sm text-muted-foreground"> <p className="mt-0.5 text-sm text-muted-foreground">
@@ -1159,7 +1199,7 @@ return { message: "Metadata fetched" };`);
<LoadingState /> <LoadingState />
) : ( ) : (
<div className="grid min-h-[640px] lg:grid-cols-[320px_minmax(0,1fr)]"> <div className="grid min-h-[640px] lg:grid-cols-[320px_minmax(0,1fr)]">
<aside className="min-w-0 overflow-hidden border-b border-border bg-background/70 lg:border-b-0 lg:border-r"> <aside className="min-w-0 overflow-hidden border-b border-border/50 lg:border-b-0 lg:border-r border-border/50">
<div className="flex min-w-0 items-center justify-between gap-3 border-b border-border px-4 py-3"> <div className="flex min-w-0 items-center justify-between gap-3 border-b border-border px-4 py-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-[11px] font-semibold uppercase text-muted-foreground">Automation library</div> <div className="text-[11px] font-semibold uppercase text-muted-foreground">Automation library</div>
@@ -1219,7 +1259,7 @@ return { message: "Metadata fetched" };`);
</aside> </aside>
<div className="min-w-0 p-4"> <div className="min-w-0 p-4">
<div className="mb-4 flex flex-col gap-3 border-b border-border pb-4 lg:flex-row lg:items-center lg:justify-between"> <div className="mb-4 flex flex-col gap-3 border-b border-border/50 pb-4 lg:flex-row lg:items-center lg:justify-between">
<div> <div>
<div className="text-[11px] font-semibold uppercase text-muted-foreground"> <div className="text-[11px] font-semibold uppercase text-muted-foreground">
{activeScrip ? "Editing automation" : "New automation"} {activeScrip ? "Editing automation" : "New automation"}
@@ -1699,6 +1739,7 @@ Location: {{custom_fields.location}}`);
const [previewError, setPreviewError] = useState<string | null>(null); const [previewError, setPreviewError] = useState<string | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const fetchTemplates = useCallback(async () => { const fetchTemplates = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -1741,6 +1782,14 @@ Location: {{custom_fields.location}}`;
setSaveError(null); setSaveError(null);
}; };
const handleDeleteTemplate = async (templateId: string) => {
setDeletingId(templateId);
await deleteTemplate(templateId);
if (editingId === templateId) resetBuilder();
await fetchTemplates();
setDeletingId(null);
};
const selectTemplate = (template: Template) => { const selectTemplate = (template: Template) => {
setEditingId(template.id); setEditingId(template.id);
setName(template.name); setName(template.name);
@@ -1794,8 +1843,8 @@ Location: {{custom_fields.location}}`;
}; };
return ( return (
<section className="overflow-hidden rounded-md border border-border bg-card/82 shadow-sm"> <section className="overflow-hidden rounded-lg border border-border/50">
<div className="flex flex-col gap-3 border-b border-border bg-muted/35 px-4 py-3 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-3 border-b border-border/50 bg-muted/20 px-4 py-3 lg:flex-row lg:items-center lg:justify-between">
<div> <div>
<h2 className="text-base font-semibold text-foreground">Templates ({templates.length})</h2> <h2 className="text-base font-semibold text-foreground">Templates ({templates.length})</h2>
<p className="mt-0.5 text-sm text-muted-foreground"> <p className="mt-0.5 text-sm text-muted-foreground">
@@ -1812,7 +1861,7 @@ Location: {{custom_fields.location}}`;
<LoadingState /> <LoadingState />
) : ( ) : (
<div className="grid min-h-[640px] lg:grid-cols-[320px_minmax(0,1fr)]"> <div className="grid min-h-[640px] lg:grid-cols-[320px_minmax(0,1fr)]">
<aside className="min-w-0 overflow-hidden border-b border-border bg-background/70 lg:border-b-0 lg:border-r"> <aside className="min-w-0 overflow-hidden border-b border-border/50 lg:border-b-0 lg:border-r border-border/50">
<div className="flex min-w-0 items-center justify-between gap-3 border-b border-border px-4 py-3"> <div className="flex min-w-0 items-center justify-between gap-3 border-b border-border px-4 py-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-[11px] font-semibold uppercase text-muted-foreground">Template library</div> <div className="text-[11px] font-semibold uppercase text-muted-foreground">Template library</div>
@@ -1835,7 +1884,7 @@ Location: {{custom_fields.location}}`;
type="button" type="button"
onClick={() => selectTemplate(template)} onClick={() => selectTemplate(template)}
className={cn( className={cn(
"min-w-0 max-w-full overflow-hidden rounded-md border p-3 text-left transition hover:border-primary/45 hover:bg-accent/45", "group min-w-0 max-w-full overflow-hidden rounded-md border p-3 text-left transition hover:border-primary/45 hover:bg-accent/45",
editingId === template.id ? "border-primary bg-primary/10" : "border-border bg-card" editingId === template.id ? "border-primary bg-primary/10" : "border-border bg-card"
)} )}
> >
@@ -1844,9 +1893,23 @@ Location: {{custom_fields.location}}`;
<div className="truncate text-sm font-semibold text-foreground">{template.name}</div> <div className="truncate text-sm font-semibold text-foreground">{template.name}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">{queueName(template.queue_id)}</div> <div className="mt-1 truncate text-xs text-muted-foreground">{queueName(template.queue_id)}</div>
</div> </div>
<Badge variant="outline" className="shrink-0 rounded"> <div className="flex shrink-0 items-center gap-1">
<Badge variant="outline" className="rounded">
{template.queue_id ? "Queue" : "Global"} {template.queue_id ? "Queue" : "Global"}
</Badge> </Badge>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void handleDeleteTemplate(template.id);
}}
disabled={deletingId === template.id}
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground/60 opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100 disabled:opacity-50"
title="Delete template"
>
<Trash2Icon className="h-3.5 w-3.5" />
</button>
</div>
</div> </div>
<div className="mt-3 truncate font-mono text-[11px] text-muted-foreground"> <div className="mt-3 truncate font-mono text-[11px] text-muted-foreground">
{template.subject_template} {template.subject_template}
@@ -1858,7 +1921,7 @@ Location: {{custom_fields.location}}`;
</aside> </aside>
<div className="min-w-0 p-4"> <div className="min-w-0 p-4">
<div className="mb-4 flex flex-col gap-3 border-b border-border pb-4 lg:flex-row lg:items-center lg:justify-between"> <div className="mb-4 flex flex-col gap-3 border-b border-border/50 pb-4 lg:flex-row lg:items-center lg:justify-between">
<div> <div>
<div className="text-[11px] font-semibold uppercase text-muted-foreground"> <div className="text-[11px] font-semibold uppercase text-muted-foreground">
{editingId ? "Editing template" : "New template"} {editingId ? "Editing template" : "New template"}
@@ -1986,6 +2049,331 @@ Location: {{custom_fields.location}}`;
); );
} }
function UsersTab() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const fetchUsers = useCallback(async () => {
setLoading(true);
setError(null);
const { data, error } = await getUsers();
if (error) setError(error);
else setUsers(data ?? []);
setLoading(false);
}, []);
useEffect(() => {
void Promise.resolve().then(() => fetchUsers());
}, [fetchUsers]);
const resetForm = () => {
setEditingId(null);
setUsername("");
setEmail("");
setSaveError(null);
};
const handleSave = async () => {
if (!username.trim()) return;
setSaving(true);
setSaveError(null);
const payload = { username: username.trim(), email: email.trim() || null };
const { error } = editingId
? await updateUser(editingId, payload)
: await createUser(payload);
setSaving(false);
if (error) { setSaveError(error); return; }
resetForm();
await fetchUsers();
};
const handleDelete = async (id: string) => {
setDeletingId(id);
await deleteUser(id);
if (editingId === id) resetForm();
await fetchUsers();
setDeletingId(null);
};
return (
<section className="overflow-hidden rounded-lg border border-border/50">
<div className="flex flex-col gap-3 border-b border-border/50 bg-muted/20 px-4 py-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 className="text-base font-semibold text-foreground">Users ({users.length})</h2>
<p className="mt-0.5 text-sm text-muted-foreground">Create, update, and manage user accounts for ticket assignment.</p>
</div>
<Button size="sm" onClick={resetForm} className="h-8 bg-primary">
<PlusIcon className="h-4 w-4" />
New user
</Button>
</div>
<ErrorBanner error={error} />
{loading ? <LoadingState /> : (
<div className="grid min-h-[400px] lg:grid-cols-[320px_minmax(0,1fr)]">
<aside className="min-w-0 border-b border-border bg-background/70 lg:border-b-0 lg:border-r">
<div className="px-4 py-3">
<div className="text-[11px] font-semibold uppercase text-muted-foreground">User directory</div>
</div>
<div className="max-h-[400px] overflow-auto">
{users.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground">No users yet.</div>
) : (
users.map((user) => (
<div
key={user.id}
className={cn(
"flex items-center justify-between border-b border-border/30 px-4 py-2.5 transition-colors hover:bg-accent/30",
editingId === user.id && "bg-primary/10"
)}
>
<button
type="button"
onClick={() => { setEditingId(user.id); setUsername(user.username); setEmail(user.email ?? ""); setSaveError(null); }}
className="min-w-0 flex-1 text-left"
>
<div className="truncate text-sm font-medium text-foreground">{user.username}</div>
<div className="truncate text-xs text-muted-foreground">{user.email ?? "No email"}</div>
</button>
<button
type="button"
onClick={() => void handleDelete(user.id)}
disabled={deletingId === user.id}
className="ml-2 flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground/60 transition-all hover:bg-destructive/10 hover:text-destructive disabled:opacity-50"
title="Delete user"
>
<Trash2Icon className="h-3.5 w-3.5" />
</button>
</div>
))
)}
</div>
</aside>
<div className="min-w-0 p-4">
<div className="mb-4 border-b border-border pb-4">
<div className="text-[11px] font-semibold uppercase text-muted-foreground">
{editingId ? "Editing user" : "New user"}
</div>
<h3 className="mt-0.5 text-lg font-semibold text-foreground">{username.trim() || "Untitled"}</h3>
</div>
<div className="grid gap-4">
<div className="grid gap-1.5">
<Label htmlFor="u-username">Username</Label>
<Input id="u-username" placeholder="gjermund" value={username} onChange={(e) => setUsername(e.target.value)} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="u-email">Email</Label>
<Input id="u-email" type="email" placeholder="gjermund@example.com" value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
{saveError && <div className="text-sm text-destructive">{saveError}</div>}
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={resetForm}>Cancel</Button>
<Button onClick={() => void handleSave()} disabled={!username.trim() || saving} size="sm" className="bg-primary">
{saving ? "Saving..." : editingId ? "Save changes" : "Create user"}
</Button>
</div>
</div>
</div>
</div>
)}
</section>
);
}
function TeamsTab() {
const [teams, setTeams] = useState<Team[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [addingMember, setAddingMember] = useState<string | null>(null); // team id being managed
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
const [teamsRes, usersRes] = await Promise.all([getTeams(), getUsers()]);
if (teamsRes.error) setError(teamsRes.error);
else setTeams(teamsRes.data ?? []);
if (usersRes.error) setError((prev) => prev || usersRes.error);
else setUsers(usersRes.data ?? []);
setLoading(false);
}, []);
useEffect(() => { void Promise.resolve().then(() => fetchData()); }, [fetchData]);
const resetForm = () => {
setEditingId(null);
setName("");
setDescription("");
setSaveError(null);
};
const handleSave = async () => {
if (!name.trim()) return;
setSaving(true);
setSaveError(null);
const payload = { name: name.trim(), description: description.trim() || undefined };
const { error } = editingId
? await updateTeam(editingId, payload)
: await createTeam(payload);
setSaving(false);
if (error) { setSaveError(error); return; }
resetForm();
await fetchData();
};
const handleDelete = async (id: string) => {
setDeletingId(id);
await deleteTeam(id);
if (editingId === id) resetForm();
await fetchData();
setDeletingId(null);
};
const handleAddMember = async (teamId: string, userId: string) => {
await addTeamMember(teamId, userId);
await fetchData();
};
const handleRemoveMember = async (teamId: string, userId: string) => {
await removeTeamMember(teamId, userId);
await fetchData();
};
const selectedTeam = editingId ? teams.find((t) => t.id === editingId) : null;
return (
<section className="overflow-hidden rounded-lg border border-border/50">
<div className="flex flex-col gap-3 border-b border-border/50 bg-muted/20 px-4 py-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 className="text-base font-semibold text-foreground">Teams ({teams.length})</h2>
<p className="mt-0.5 text-sm text-muted-foreground">Organize users into teams. Assign dashboards to teams.</p>
</div>
<Button size="sm" onClick={resetForm} className="h-8 bg-primary">
<PlusIcon className="h-4 w-4" /> New team
</Button>
</div>
<ErrorBanner error={error} />
{loading ? <LoadingState /> : (
<div className="grid min-h-[400px] lg:grid-cols-[320px_minmax(0,1fr)]">
<aside className="min-w-0 border-b border-border bg-background/70 lg:border-b-0 lg:border-r">
<div className="px-4 py-3">
<div className="text-[11px] font-semibold uppercase text-muted-foreground">Teams</div>
</div>
<div className="max-h-[400px] overflow-auto">
{teams.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground">No teams yet.</div>
) : (
teams.map((team) => (
<div key={team.id}
className={cn(
"flex items-center justify-between border-b border-border/30 px-4 py-2.5 transition-colors hover:bg-accent/30",
editingId === team.id && "bg-primary/10"
)}
>
<button type="button"
onClick={() => { setEditingId(team.id); setName(team.name); setDescription(team.description ?? ""); setSaveError(null); }}
className="min-w-0 flex-1 text-left"
>
<div className="truncate text-sm font-medium text-foreground">{team.name}</div>
<div className="text-xs text-muted-foreground">{(team.members ?? []).length} members</div>
</button>
<button type="button"
onClick={() => void handleDelete(team.id)}
disabled={deletingId === team.id}
className="ml-2 flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground/60 hover:bg-destructive/10 hover:text-destructive"
>
<Trash2Icon className="h-3.5 w-3.5" />
</button>
</div>
))
)}
</div>
</aside>
<div className="min-w-0 p-4">
<div className="mb-4 border-b border-border pb-4">
<div className="text-[11px] font-semibold uppercase text-muted-foreground">
{editingId ? "Editing team" : "New team"}
</div>
<h3 className="mt-0.5 text-lg font-semibold text-foreground">{name.trim() || "Untitled"}</h3>
</div>
<div className="grid gap-4">
<div className="grid gap-1.5">
<Label htmlFor="t-name">Name</Label>
<Input id="t-name" placeholder="Support" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="t-desc">Description</Label>
<Input id="t-desc" placeholder="First-line support team" value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
{saveError && <div className="text-sm text-destructive">{saveError}</div>}
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={resetForm}>Cancel</Button>
<Button onClick={() => void handleSave()} disabled={!name.trim() || saving} size="sm" className="bg-primary">
{saving ? "Saving..." : editingId ? "Save changes" : "Create team"}
</Button>
</div>
{selectedTeam && (
<div className="mt-4 rounded-md border border-border">
<div className="border-b border-border bg-muted/30 px-3 py-2">
<h4 className="text-sm font-semibold text-foreground">Members</h4>
</div>
<div className="p-3 space-y-2">
{(selectedTeam.members ?? []).map((user) => (
<div key={user.id} className="flex items-center justify-between text-sm">
<div>
<span className="font-medium text-foreground">{user.username}</span>
<span className="ml-2 text-xs text-muted-foreground">{user.email ?? "no email"}</span>
</div>
<button type="button"
onClick={() => void handleRemoveMember(selectedTeam.id, user.id)}
className="text-xs text-muted-foreground hover:text-destructive"
>Remove</button>
</div>
))}
{(selectedTeam.members ?? []).length === 0 && (
<p className="text-xs text-muted-foreground">No members yet.</p>
)}
<select
value=""
onChange={(e) => {
if (e.target.value) {
void handleAddMember(selectedTeam.id, e.target.value);
e.target.value = "";
}
}}
className="mt-2 h-8 w-full rounded border border-input bg-card px-2 text-sm outline-none"
>
<option value="">Add member...</option>
{users
.filter((u) => !(selectedTeam.members ?? []).find((m) => m.id === u.id))
.map((u) => (
<option key={u.id} value={u.id}>{u.username}</option>
))}
</select>
</div>
</div>
)}
</div>
</div>
</div>
)}
</section>
);
}
function CustomFieldsTab() { function CustomFieldsTab() {
const [fields, setFields] = useState<CustomField[]>([]); const [fields, setFields] = useState<CustomField[]>([]);
const [queues, setQueues] = useState<Queue[]>([]); const [queues, setQueues] = useState<Queue[]>([]);
@@ -2123,8 +2511,8 @@ function CustomFieldsTab() {
return ( return (
<> <>
<section className="overflow-hidden rounded-md border border-border bg-card/82 shadow-sm"> <section className="overflow-hidden rounded-lg border border-border/50">
<div className="flex flex-col gap-3 border-b border-border bg-muted/35 px-4 py-3 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-3 border-b border-border/50 bg-muted/20 px-4 py-3 lg:flex-row lg:items-center lg:justify-between">
<div> <div>
<h2 className="text-base font-semibold text-foreground">Custom fields ({fields.length})</h2> <h2 className="text-base font-semibold text-foreground">Custom fields ({fields.length})</h2>
<p className="mt-0.5 text-sm text-muted-foreground">Metadata that makes tickets queryable and operationally useful.</p> <p className="mt-0.5 text-sm text-muted-foreground">Metadata that makes tickets queryable and operationally useful.</p>
@@ -2139,7 +2527,7 @@ function CustomFieldsTab() {
<LoadingState /> <LoadingState />
) : ( ) : (
<div className="grid min-h-[560px] lg:grid-cols-[320px_minmax(0,1fr)]"> <div className="grid min-h-[560px] lg:grid-cols-[320px_minmax(0,1fr)]">
<aside className="min-w-0 overflow-hidden border-b border-border bg-background/70 lg:border-b-0 lg:border-r"> <aside className="min-w-0 overflow-hidden border-b border-border/50 lg:border-b-0 lg:border-r border-border/50">
<div className="flex min-w-0 items-center justify-between gap-3 border-b border-border px-4 py-3"> <div className="flex min-w-0 items-center justify-between gap-3 border-b border-border px-4 py-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-[11px] font-semibold uppercase text-muted-foreground">Field library</div> <div className="text-[11px] font-semibold uppercase text-muted-foreground">Field library</div>
@@ -2180,7 +2568,7 @@ function CustomFieldsTab() {
</div> </div>
</aside> </aside>
<div className="min-w-0 p-4"> <div className="min-w-0 p-4">
<div className="mb-4 flex flex-col gap-3 border-b border-border pb-4 lg:flex-row lg:items-center lg:justify-between"> <div className="mb-4 flex flex-col gap-3 border-b border-border/50 pb-4 lg:flex-row lg:items-center lg:justify-between">
<div> <div>
<div className="text-[11px] font-semibold uppercase text-muted-foreground"> <div className="text-[11px] font-semibold uppercase text-muted-foreground">
{editingId ? "Editing custom field" : "New custom field"} {editingId ? "Editing custom field" : "New custom field"}
@@ -2238,7 +2626,7 @@ function CustomFieldsTab() {
)} )}
</section> </section>
<section className="mt-5 overflow-hidden rounded-md border border-border bg-card/82 shadow-sm"> <section className="mt-5 overflow-hidden rounded-lg border border-border/50">
<div className="border-b border-border bg-muted/35 px-4 py-3"> <div className="border-b border-border bg-muted/35 px-4 py-3">
<h2 className="text-base font-semibold text-foreground">Queue field assignments</h2> <h2 className="text-base font-semibold text-foreground">Queue field assignments</h2>
<p className="mt-0.5 text-sm text-muted-foreground"> <p className="mt-0.5 text-sm text-muted-foreground">

View File

@@ -0,0 +1,589 @@
"use client";
import { useState, useEffect, use, useCallback, useRef } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
GripIcon,
PencilIcon,
PlusIcon,
Trash2Icon,
RefreshCwIcon,
LayoutGridIcon,
} from "lucide-react";
import {
getDashboard,
createWidget,
deleteWidget,
updateWidget,
getWidgetData,
getViews,
getTeams,
updateDashboard,
} from "@/lib/api";
import type {
Dashboard,
DashboardWidget,
SavedView,
Team,
WidgetData,
} from "@/lib/types";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { CountWidget } from "@/components/widgets/count-widget";
import { TicketListWidget } from "@/components/widgets/ticket-list-widget";
import { StatusChartWidget } from "@/components/widgets/status-chart-widget";
import { GroupedCountsWidget } from "@/components/widgets/grouped-counts-widget";
import { cn } from "@/lib/utils";
function widgetGridStyle(position: { x: number; y: number; w: number; h: number }) {
return {
gridColumn: `${position.x + 1} / span ${position.w}`,
gridRow: `${position.y + 1} / span ${position.h}`,
};
}
export default function DashboardPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const router = useRouter();
const [dashboard, setDashboard] = useState<Dashboard | null>(null);
const [widgets, setWidgets] = useState<(DashboardWidget & { data?: WidgetData })[]>([]);
const [views, setViews] = useState<SavedView[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [autoRefresh, setAutoRefresh] = useState(false);
const [editMode, setEditMode] = useState(false);
const [teams, setTeams] = useState<Team[]>([]);
// Add widget dialog
const [addOpen, setAddOpen] = useState(false);
const [addViewId, setAddViewId] = useState("");
const [addTitle, setAddTitle] = useState("");
const [addType, setAddType] = useState("count");
const [addGroupBy, setAddGroupBy] = useState("owner");
const [adding, setAdding] = useState(false);
const fetchDashboard = useCallback(async () => {
const { data, error } = await getDashboard(id);
if (error || !data) {
setError(error ?? "Dashboard not found");
setLoading(false);
return;
}
setDashboard(data);
const widgetList = data.widgets ?? [];
setWidgets(widgetList);
// Fetch data for each widget
for (const widget of widgetList) {
const { data: wData } = await getWidgetData(id, widget.id);
if (wData) {
setWidgets((prev) =>
prev.map((w) => (w.id === widget.id ? { ...w, data: wData } : w))
);
}
}
setLoading(false);
}, [id]);
useEffect(() => {
fetchDashboard();
getViews().then(({ data }) => { if (data) setViews(data); });
getTeams().then(({ data }) => { if (data) setTeams(data); });
}, [fetchDashboard]);
// Auto-refresh: only refresh widget data, not structure
useEffect(() => {
if (!autoRefresh || !dashboard) return;
const interval = setInterval(() => {
for (const widget of widgets) {
getWidgetData(dashboard.id, widget.id).then(({ data: wData }) => {
if (wData) {
setWidgets((prev) =>
prev.map((w) => (w.id === widget.id ? { ...w, data: wData } : w))
);
}
});
}
}, 30_000);
return () => clearInterval(interval);
}, [autoRefresh, dashboard?.id]);
const handleAddWidget = async () => {
if (!addViewId || !addTitle.trim()) return;
setAdding(true);
// Smart positioning: fill a 3-column grid (4 units each in 12-col grid)
const COLS = 3; const W = 4; const H = 2;
const occupied = new Set(widgets.map((w) => `${w.position.x},${w.position.y}`));
let x = 0; let y = 0;
while (occupied.has(`${x},${y}`)) {
x += W;
if (x >= COLS * W) { x = 0; y += H; }
}
const pos = { x, y, w: W, h: H };
const config = addType === "grouped_counts" ? { group_by: addGroupBy } : {};
const { data, error } = await createWidget(id, {
view_id: addViewId,
title: addTitle.trim(),
widget_type: addType,
position: pos,
config,
});
if (!error && data) {
setWidgets((prev) => [...prev, data]);
const { data: wData } = await getWidgetData(id, data.id);
if (wData) {
setWidgets((prev) => prev.map((w) => (w.id === data.id ? { ...w, data: wData } : w)));
}
setAddOpen(false);
setAddViewId("");
setAddTitle("");
setAddType("count");
}
setAdding(false);
};
const handleDeleteWidget = async (widgetId: string) => {
await deleteWidget(id, widgetId);
setWidgets((prev) => prev.filter((w) => w.id !== widgetId));
};
const gridRef = useRef<HTMLDivElement>(null);
const [resizingId, setResizingId] = useState<string | null>(null);
const [draggingId, setDraggingId] = useState<string | null>(null);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
// Resize: track mousedown → mousemove → mouseup
const handleResizeStart = (e: React.MouseEvent, widgetId: string) => {
e.preventDefault();
e.stopPropagation();
setResizingId(widgetId);
const startX = e.clientX;
const startY = e.clientY;
const widget = widgets.find((w) => w.id === widgetId);
if (!widget || !gridRef.current) return;
const startW = widget.position.w;
const startH = widget.position.h;
const gridWidth = gridRef.current.offsetWidth;
const unitSize = gridWidth / 12;
const onMove = (ev: MouseEvent) => {
const dx = Math.round((ev.clientX - startX) / unitSize);
const dy = Math.round((ev.clientY - startY) / unitSize);
const newW = Math.max(1, Math.min(12, startW + dx));
const newH = Math.max(1, startH + dy);
setWidgets((prev) =>
prev.map((w) =>
w.id === widgetId
? { ...w, position: { ...w.position, w: newW, h: newH } }
: w
)
);
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
setResizingId(null);
// Resolve overlaps using latest state via functional updater
setWidgets((current) => {
let resolved = current.map((w) => ({ ...w, position: { ...w.position } }));
for (let pass = 0; pass < 10; pass++) {
let hasOverlap = false;
for (let i = 0; i < resolved.length; i++) {
for (let j = i + 1; j < resolved.length; j++) {
const a = resolved[i].position;
const b = resolved[j].position;
if (a.x + a.w > b.x && a.x < b.x + b.w && a.y + a.h > b.y && a.y < b.y + b.h) {
hasOverlap = true;
const toMove = widgetId === resolved[i].id ? j : i;
const fixedW = resolved[widgetId === resolved[i].id ? i : j];
resolved[toMove] = {
...resolved[toMove],
position: { ...resolved[toMove].position, y: fixedW.position.y + fixedW.position.h },
};
}
}
}
if (!hasOverlap) break;
}
// Persist changed positions
for (const w of resolved) {
const orig = current.find((o) => o.id === w.id);
if (orig && (orig.position.y !== w.position.y || orig.position.h !== w.position.h)) {
updateWidget(id, w.id, { position: w.position });
}
}
return resolved;
});
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
};
// Mouse-based drag: mousedown on grip → mousemove → mouseup
const handleDragMouseDown = (e: React.MouseEvent, widgetId: string) => {
e.preventDefault();
e.stopPropagation();
if (!gridRef.current) return;
setDraggingId(widgetId);
const widget = widgets.find((w) => w.id === widgetId);
if (!widget) return;
const unitSize = gridRef.current.offsetWidth / 12;
const startX = e.clientX;
const startY = e.clientY;
const startGridX = widget.position.x;
const startGridY = widget.position.y;
// Store offset from widget origin to mouse for visual tracking
const widgetEl = (e.target as HTMLElement).closest('[data-widget-id]') as HTMLElement;
if (widgetEl) {
const rect = widgetEl.getBoundingClientRect();
setDragOffset({ x: e.clientX - rect.left, y: e.clientY - rect.top });
}
const onMove = (ev: MouseEvent) => {
const dx = Math.round((ev.clientX - startX) / unitSize);
const dy = Math.round((ev.clientY - startY) / unitSize);
const newX = Math.max(0, Math.min(12 - widget.position.w, startGridX + dx));
const newY = Math.max(0, startGridY + dy);
setWidgets((prev) =>
prev.map((w) =>
w.id === widgetId
? { ...w, position: { ...w.position, x: newX, y: newY } }
: w
)
);
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
setDraggingId(null);
setWidgets((current) => {
const updated = current.find((w) => w.id === widgetId);
if (!updated) return current;
let resolved = current.map((w) => ({ ...w, position: { ...w.position } }));
// Push overlapping widgets down
for (let pass = 0; pass < 10; pass++) {
let hasOverlap = false;
for (let i = 0; i < resolved.length; i++) {
for (let j = i + 1; j < resolved.length; j++) {
const a = resolved[i].position;
const b = resolved[j].position;
if (a.x + a.w > b.x && a.x < b.x + b.w && a.y + a.h > b.y && a.y < b.y + b.h) {
hasOverlap = true;
const moveIdx = resolved[i].id === widgetId ? j : i;
const fixedIdx = moveIdx === i ? j : i;
resolved[moveIdx] = {
...resolved[moveIdx],
position: { ...resolved[moveIdx].position, y: resolved[fixedIdx].position.y + resolved[fixedIdx].position.h },
};
}
}
}
if (!hasOverlap) break;
}
// Persist changed positions
for (const w of resolved) {
const orig = current.find((o) => o.id === w.id);
if (orig && (orig.position.x !== w.position.x || orig.position.y !== w.position.y)) {
updateWidget(id, w.id, { position: w.position });
}
}
return resolved;
});
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
};
const renderWidget = (widget: DashboardWidget & { data?: WidgetData }) => {
if (!widget.data) {
return (
<div className="flex h-full items-center justify-center rounded-lg border border-border bg-card p-4">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
switch (widget.data.type) {
case "count":
return <CountWidget data={widget.data} />;
case "ticket_list":
return <TicketListWidget data={widget.data} />;
case "status_chart":
return <StatusChartWidget data={widget.data} />;
case "grouped_counts":
return <GroupedCountsWidget data={widget.data} />;
default:
return (
<div className="flex h-full items-center justify-center rounded-lg border border-border bg-card p-4">
<p className="text-xs text-muted-foreground">Unknown type: {widget.data.type}</p>
</div>
);
}
};
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
if (error || !dashboard) {
return (
<div className="flex h-full flex-col items-center justify-center gap-4">
<p className="text-sm text-muted-foreground">{error ?? "Dashboard not found"}</p>
<Link href="/" className="text-sm text-primary hover:underline">
Go to ticket list
</Link>
</div>
);
}
return (
<div className="flex h-full flex-col bg-background/80">
<header className="shrink-0 border-b border-border bg-card/82 backdrop-blur">
<div className="flex items-center justify-between px-5 py-3 lg:px-6">
<div className="min-w-0">
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase text-muted-foreground">
<LayoutGridIcon className="h-3.5 w-3.5" />
Dashboard
</div>
<h1 className="mt-1 text-xl font-semibold text-foreground">{dashboard.name}</h1>
<div className="mt-2 flex items-center gap-2">
<select
value={dashboard.team_id ?? ""}
onChange={async (e) => {
const teamId = e.target.value || null;
await updateDashboard(dashboard.id, { team_id: teamId });
setDashboard((prev) => prev ? { ...prev, team_id: teamId } : prev);
}}
className="h-7 rounded border border-border bg-card px-2 text-xs text-muted-foreground outline-none"
>
<option value="">No team</option>
{teams.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
{dashboard.description && (
<p className="text-sm text-muted-foreground">{dashboard.description}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setEditMode((v) => !v)}
className={cn("h-8 border-border/80", editMode ? "bg-primary/20 text-primary border-primary/40" : "bg-card/70")}
>
<PencilIcon className="h-4 w-4" />
{editMode ? "Done" : "Edit"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setAutoRefresh((v) => !v)}
className={cn("h-8 border-border/80", autoRefresh ? "bg-primary/20 text-primary" : "bg-card/70")}
>
<RefreshCwIcon className={cn("h-4 w-4", autoRefresh && "animate-spin")} />
{autoRefresh ? "Live" : "Auto"}
</Button>
<Button
variant="outline"
size="sm"
onClick={fetchDashboard}
className="h-8 border-border/80 bg-card/70"
>
<RefreshCwIcon className="h-4 w-4" />
Refresh
</Button>
{editMode && (
<Button size="sm" onClick={() => setAddOpen(true)} className="h-8 bg-primary shadow-sm">
<PlusIcon className="h-4 w-4" />
Add widget
</Button>
)}
</div>
</div>
</header>
<div className="flex-1 overflow-auto p-5 lg:p-6">
{widgets.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-3">
<LayoutGridIcon className="h-10 w-10 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">No widgets yet</p>
{editMode ? (
<Button variant="outline" size="sm" onClick={() => setAddOpen(true)}>
<PlusIcon className="h-4 w-4" />
Add your first widget
</Button>
) : (
<Button variant="outline" size="sm" onClick={() => setEditMode(true)}>
<PencilIcon className="h-4 w-4" />
Enter edit mode
</Button>
)}
</div>
) : (
<div
ref={gridRef}
className="grid auto-rows-[minmax(100px,auto)] grid-cols-6 gap-3 md:grid-cols-12 md:gap-4"
>
{widgets.map((widget) => (
<div
key={widget.id}
data-widget-id={widget.id}
className={cn(
"group relative transition-none",
draggingId === widget.id && "z-10",
resizingId === widget.id && "select-none",
)}
style={widgetGridStyle(widget.position)}
>
{/* Drag handle */}
{editMode && (
<div
className="absolute left-2 top-2 z-10 hidden h-6 w-6 cursor-grab items-center justify-center rounded bg-background/80 text-muted-foreground group-hover:flex active:cursor-grabbing"
onMouseDown={(e) => handleDragMouseDown(e, widget.id)}
>
<GripIcon className="h-3.5 w-3.5" />
</div>
)}
{renderWidget(widget)}
{editMode && (
<>
<button
type="button"
onClick={() => handleDeleteWidget(widget.id)}
className="absolute right-2 top-2 z-10 hidden h-6 w-6 items-center justify-center rounded bg-destructive/90 text-destructive-foreground transition-opacity hover:bg-destructive group-hover:flex"
title="Remove widget"
>
<Trash2Icon className="h-3.5 w-3.5" />
</button>
{/* Resize handle */}
<div
className="absolute bottom-0 right-0 z-10 hidden h-5 w-5 cursor-se-resize items-center justify-center group-hover:flex"
onMouseDown={(e) => handleResizeStart(e, widget.id)}
>
<svg width="10" height="10" viewBox="0 0 10 10" className="text-muted-foreground">
<path d="M0 10 L10 0" stroke="currentColor" strokeWidth="1.5" />
<path d="M5 10 L10 5" stroke="currentColor" strokeWidth="1.5" />
<path d="M0 10 L10 10" stroke="transparent" />
</svg>
</div>
</>
)}
</div>
))}
</div>
)}
</div>
<Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add widget</DialogTitle>
<DialogDescription>
Choose a saved view and widget type to add to this dashboard.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">Widget title</label>
<input
value={addTitle}
onChange={(e) => setAddTitle(e.target.value)}
placeholder="e.g. Open tickets"
className="mt-1 h-9 w-full rounded-md border border-input bg-card px-3 text-sm outline-none focus:border-ring"
/>
</div>
<div>
<label className="text-sm font-medium">Saved view</label>
<select
value={addViewId}
onChange={(e) => {
setAddViewId(e.target.value);
const view = views.find((v) => v.id === e.target.value);
if (view && !addTitle) setAddTitle(view.name);
}}
className="mt-1 h-9 w-full rounded-md border border-input bg-card px-3 text-sm outline-none focus:border-ring"
>
<option value="">Select a view...</option>
{views.map((v) => (
<option key={v.id} value={v.id}>
{v.name}
</option>
))}
</select>
</div>
<div>
<label className="text-sm font-medium">Widget type</label>
<select
value={addType}
onChange={(e) => setAddType(e.target.value)}
className="mt-1 h-9 w-full rounded-md border border-input bg-card px-3 text-sm outline-none focus:border-ring"
>
<option value="count">Count (big number)</option>
<option value="ticket_list">Ticket list (mini table)</option>
<option value="status_chart">Status chart (donut)</option>
<option value="grouped_counts">Grouped counts (bar chart)</option>
</select>
</div>
{addType === "grouped_counts" && (
<div>
<label className="text-sm font-medium">Group by</label>
<select
value={addGroupBy}
onChange={(e) => setAddGroupBy(e.target.value)}
className="mt-1 h-9 w-full rounded-md border border-input bg-card px-3 text-sm outline-none focus:border-ring"
>
<option value="owner">Owner</option>
<option value="queue">Queue</option>
</select>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setAddOpen(false)}>
Cancel
</Button>
<Button
size="sm"
disabled={!addViewId || !addTitle.trim() || adding}
onClick={handleAddWidget}
>
{adding ? "Adding..." : "Add widget"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -26,6 +26,7 @@ import {
getQueues, getQueues,
getLifecycles, getLifecycles,
getUsers, getUsers,
getTeams,
getQueueCustomFields, getQueueCustomFields,
previewTicket, previewTicket,
updateTicket, updateTicket,
@@ -37,6 +38,7 @@ import type {
Transaction, Transaction,
Queue, Queue,
Lifecycle, Lifecycle,
Team,
User, User,
QueueCustomField, QueueCustomField,
PreviewResult, PreviewResult,
@@ -102,15 +104,18 @@ function userLabel(users: User[], userId: string | null) {
function TransactionCard({ function TransactionCard({
tx, tx,
users, users,
teams,
customFieldLabels, customFieldLabels,
}: { }: {
tx: Transaction; tx: Transaction;
users: User[]; users: User[];
teams: Team[];
customFieldLabels: Record<string, string>; customFieldLabels: Record<string, string>;
}) { }) {
const isSystem = const isSystem =
tx.transaction_type === "StatusChange" || tx.transaction_type === "StatusChange" ||
tx.transaction_type === "SetOwner" || tx.transaction_type === "SetOwner" ||
tx.transaction_type === "SetTeam" ||
tx.transaction_type === "CustomFieldChange" || tx.transaction_type === "CustomFieldChange" ||
tx.transaction_type === "Create"; tx.transaction_type === "Create";
const isInternal = tx.transaction_type === "Comment"; const isInternal = tx.transaction_type === "Comment";
@@ -123,70 +128,57 @@ function TransactionCard({
if (isSystem) { if (isSystem) {
let message = tx.transaction_type; let message = tx.transaction_type;
if (tx.transaction_type === "Create") { if (tx.transaction_type === "Create") message = "Ticket created";
message = "Ticket created"; else if (tx.transaction_type === "StatusChange") {
} else if (tx.transaction_type === "StatusChange") {
const oldLabel = tx.old_value ? statusLabel(tx.old_value) : "?"; const oldLabel = tx.old_value ? statusLabel(tx.old_value) : "?";
const newLabel = tx.new_value ? statusLabel(tx.new_value) : "?"; const newLabel = tx.new_value ? statusLabel(tx.new_value) : "?";
message = `Status changed from ${oldLabel} to ${newLabel}`; message = `Status changed from ${oldLabel} to ${newLabel}`;
} else if (tx.transaction_type === "SetOwner") { } else if (tx.transaction_type === "SetOwner") {
message = tx.new_value ? `Assigned to ${userLabel(users, tx.new_value)}` : "Unassigned"; message = tx.new_value ? `Assigned to ${userLabel(users, tx.new_value)}` : "Unassigned";
} else if (tx.transaction_type === "SetTeam") {
const teamName = tx.new_value ? (teams.find((t) => t.id === tx.new_value)?.name ?? "?") : null;
message = teamName ? `Team → ${teamName}` : "Team cleared";
} else if (tx.transaction_type === "CustomFieldChange") { } else if (tx.transaction_type === "CustomFieldChange") {
const fieldName = tx.field ? customFieldLabels[tx.field] ?? "Custom field" : "Custom field"; const fieldName = tx.field ? customFieldLabels[tx.field] ?? "Custom field" : "Custom field";
message = tx.new_value message = tx.new_value ? `${fieldName} set to ${tx.new_value}` : `${fieldName} cleared`;
? `${fieldName} set to ${tx.new_value}`
: `${fieldName} cleared`;
} }
return ( return (
<div className="grid grid-cols-[28px_minmax(0,1fr)] gap-3 px-6 py-3"> <div className="flex items-center gap-3 px-8 py-2.5">
<div className="flex justify-center"> <div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted/40">
<span className="mt-1 flex h-5 w-5 items-center justify-center rounded bg-muted text-muted-foreground"> <CircleIcon className="h-2.5 w-2.5 text-muted-foreground/60" />
<BotIcon className="h-3.5 w-3.5" />
</span>
</div>
<div className="rounded-md border border-border bg-muted/55 px-3 py-2 text-sm">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="font-medium text-foreground">{message}</span>
<span className="text-xs text-muted-foreground">{timeAgo}</span>
</div>
</div> </div>
<span className="text-xs text-muted-foreground">{message}</span>
<span className="text-[10px] text-muted-foreground/50">{timeAgo}</span>
</div> </div>
); );
} }
return ( return (
<article className="grid grid-cols-[28px_minmax(0,1fr)] gap-3 px-6 py-4"> <div className="px-8 py-3">
<div className="flex items-start gap-3">
<div <div
className="flex h-7 w-7 items-center justify-center rounded-md text-[11px] font-semibold text-white" className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold text-white"
style={{ backgroundColor: getInitialColor(userLabel(users, tx.creator_id)) }} style={{ backgroundColor: getInitialColor(userLabel(users, tx.creator_id)) }}
> >
{getInitial(userLabel(users, tx.creator_id))} {getInitial(userLabel(users, tx.creator_id))}
</div> </div>
<div className="overflow-hidden rounded-md border border-border bg-card shadow-sm"> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border bg-muted/35 px-3 py-2"> <div className="flex items-baseline gap-2">
<div className="flex items-center gap-2"> <span className="text-sm font-semibold text-foreground">{userLabel(users, tx.creator_id)}</span>
{isMessage ? (
<MessageSquareIcon className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<FileTextIcon className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="text-sm font-semibold text-foreground">
{userLabel(users, tx.creator_id)}
</span>
{isInternal && ( {isInternal && (
<span className="rounded bg-amber-500/12 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-amber-700 dark:text-amber-300"> <span className="rounded bg-amber-500/10 px-1 py-0 text-[10px] font-semibold text-amber-600 dark:text-amber-400">
Internal Internal
</span> </span>
)} )}
<span className="text-[11px] text-muted-foreground/50">{timeAgo}</span>
</div> </div>
<span className="text-xs text-muted-foreground">{timeAgo}</span> <p className="mt-1.5 whitespace-pre-wrap text-sm leading-relaxed text-foreground/90">
</div>
<p className="whitespace-pre-wrap px-3 py-3 text-sm leading-6 text-foreground">
{body} {body}
</p> </p>
</div> </div>
</article> </div>
</div>
); );
} }
@@ -212,6 +204,7 @@ export default function TicketDetailPage({
const [queue, setQueue] = useState<Queue | null>(null); const [queue, setQueue] = useState<Queue | null>(null);
const [lifecycles, setLifecycles] = useState<Lifecycle[]>([]); const [lifecycles, setLifecycles] = useState<Lifecycle[]>([]);
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [teams, setTeams] = useState<Team[]>([]);
const [queueFields, setQueueFields] = useState<QueueCustomField[]>([]); const [queueFields, setQueueFields] = useState<QueueCustomField[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -230,20 +223,22 @@ export default function TicketDetailPage({
const [scripResults, setScripResults] = useState<UpdateResult["scrip_results"] | null>(null); const [scripResults, setScripResults] = useState<UpdateResult["scrip_results"] | null>(null);
const [editingSubject, setEditingSubject] = useState(false); const [editingSubject, setEditingSubject] = useState(false);
const [subjectDraft, setSubjectDraft] = useState(""); const [subjectDraft, setSubjectDraft] = useState("");
const [fieldSaving, setFieldSaving] = useState<"subject" | "owner" | null>(null); const [fieldSaving, setFieldSaving] = useState<"subject" | "owner" | "team" | null>(null);
const [customFieldDrafts, setCustomFieldDrafts] = useState<Record<string, string>>({}); const [customFieldDrafts, setCustomFieldDrafts] = useState<Record<string, string>>({});
const [customFieldSaving, setCustomFieldSaving] = useState<string | null>(null); const [customFieldSaving, setCustomFieldSaving] = useState<string | null>(null);
const [editingFieldId, setEditingFieldId] = useState<string | null>(null);
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
const [ticketRes, txRes, queuesRes, lifecycleRes, usersRes] = await Promise.all([ const [ticketRes, txRes, queuesRes, lifecycleRes, usersRes, teamsRes] = await Promise.all([
getTicket(id), getTicket(id),
getTicketTransactions(id), getTicketTransactions(id),
getQueues(), getQueues(),
getLifecycles(), getLifecycles(),
getUsers(), getUsers(),
getTeams(),
]); ]);
if (ticketRes.error) { if (ticketRes.error) {
@@ -289,6 +284,7 @@ export default function TicketDetailPage({
setError((prev) => prev || usersRes.error); setError((prev) => prev || usersRes.error);
} else { } else {
setUsers(usersRes.data ?? []); setUsers(usersRes.data ?? []);
setTeams(teamsRes.data ?? []);
} }
setLoading(false); setLoading(false);
@@ -378,6 +374,29 @@ export default function TicketDetailPage({
} }
}; };
const handleTeamChange = async (teamId: string) => {
if (!ticket || fieldSaving) return;
const nextTeamId = teamId || null;
if (nextTeamId === ticket.team_id) return;
setFieldSaving("team");
setFieldError(null);
const { data, error } = await updateTicket(id, { team_id: nextTeamId });
setFieldSaving(null);
if (error) {
setFieldError(error);
return;
}
if (data) {
setTicket(data.ticket);
await refreshTransactions();
}
};
const handleOwnerChange = async (ownerId: string) => { const handleOwnerChange = async (ownerId: string) => {
if (!ticket || fieldSaving) return; if (!ticket || fieldSaving) return;
const nextOwnerId = ownerId || null; const nextOwnerId = ownerId || null;
@@ -401,11 +420,14 @@ export default function TicketDetailPage({
} }
}; };
const handleCustomFieldSave = async (fieldId: string) => { const handleCustomFieldSave = async (fieldId: string, valueOverride?: string) => {
if (!ticket || customFieldSaving) return; if (!ticket || customFieldSaving) return;
const value = customFieldDrafts[fieldId]?.trim() ?? ""; const value = (valueOverride ?? customFieldDrafts[fieldId] ?? "").trim();
const currentValue = ticket.custom_fields?.find((item) => item.custom_field_id === fieldId)?.value ?? ""; const currentValue = ticket.custom_fields?.find((item) => item.custom_field_id === fieldId)?.value ?? "";
if (value === currentValue) return; if (value === currentValue) {
setEditingFieldId(null);
return;
}
const field = queueFields.find((assignment) => assignment.custom_field_id === fieldId)?.custom_field; const field = queueFields.find((assignment) => assignment.custom_field_id === fieldId)?.custom_field;
if (value && field?.pattern) { if (value && field?.pattern) {
const regex = new RegExp(field.pattern); const regex = new RegExp(field.pattern);
@@ -440,11 +462,9 @@ export default function TicketDetailPage({
); );
} }
if (txRes.data) setTransactions(txRes.data); if (txRes.data) setTransactions(txRes.data);
setEditingFieldId(null);
}; };
const customFieldValue = (fieldId: string) =>
ticket?.custom_fields?.find((item) => item.custom_field_id === fieldId)?.value ?? "";
const handleSendComment = async () => { const handleSendComment = async () => {
if (!replyText.trim() || sending) return; if (!replyText.trim() || sending) return;
setSending(true); setSending(true);
@@ -647,6 +667,7 @@ export default function TicketDetailPage({
key={tx.id} key={tx.id}
tx={tx} tx={tx}
users={users} users={users}
teams={teams}
customFieldLabels={customFieldLabels} customFieldLabels={customFieldLabels}
/> />
))} ))}
@@ -733,34 +754,31 @@ export default function TicketDetailPage({
</footer> </footer>
</main> </main>
<aside className="hidden min-h-0 overflow-y-auto border-l border-border bg-card/78 backdrop-blur xl:block"> <aside className="hidden min-h-0 overflow-y-auto border-l border-border/50 bg-card/90 xl:block">
<div className="space-y-5 p-5"> <div className="space-y-6 p-5">
{/* Status — prominent, visual */}
<section> <section>
<div className="mb-2 flex items-center justify-between">
<h2 className="text-xs font-semibold uppercase text-muted-foreground">Status</h2>
<CircleIcon
className="h-3.5 w-3.5"
style={{ color: currentStatusColor }}
/>
</div>
<div className="relative"> <div className="relative">
<button <button
onClick={() => setStatusSelectOpen(!statusSelectOpen)} onClick={() => setStatusSelectOpen(!statusSelectOpen)}
className="flex w-full items-center gap-3 rounded-md border border-border bg-background/70 px-3 py-2.5 text-sm shadow-sm transition-colors hover:bg-accent" className={cn(
"flex w-full items-center gap-3 rounded-lg px-4 py-3 text-left transition-all",
"ring-1 ring-inset",
)}
style={{
backgroundColor: `${currentStatusColor}12`,
color: currentStatusColor,
boxShadow: `inset 0 0 0 1px ${currentStatusColor}40`,
}}
type="button" type="button"
> >
<span <span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: currentStatusColor }} />
className="h-3 w-3 rounded-full" <span className="flex-1 text-sm font-semibold">{currentStatusLabel}</span>
style={{ backgroundColor: currentStatusColor }} <ChevronDownIcon className="h-4 w-4 opacity-60" />
/>
<span className="flex-1 text-left font-semibold text-foreground">
{currentStatusLabel}
</span>
<ChevronDownIcon className="h-4 w-4 text-muted-foreground" />
</button> </button>
{statusSelectOpen && ( {statusSelectOpen && (
<div className="absolute left-0 right-0 top-full z-20 mt-1 overflow-hidden rounded-md border border-border bg-popover shadow-xl"> <div className="absolute left-0 right-0 top-full z-20 mt-1 overflow-hidden rounded-lg border border-border bg-popover shadow-lg">
{statusOptions.map((status) => { {statusOptions.map((status) => {
const isCurrent = status === ticket.status; const isCurrent = status === ticket.status;
return ( return (
@@ -769,19 +787,14 @@ export default function TicketDetailPage({
onClick={() => handleStatusSelect(status)} onClick={() => handleStatusSelect(status)}
disabled={isCurrent} disabled={isCurrent}
className={cn( className={cn(
"flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm transition-colors", "flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm transition-colors",
isCurrent isCurrent ? "bg-accent/50 text-muted-foreground" : "text-foreground hover:bg-accent"
? "bg-accent text-muted-foreground"
: "text-foreground hover:bg-accent"
)} )}
type="button" type="button"
> >
<span <span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: STATUS_COLORS[status] ?? STATUS_COLORS.new }} />
className="h-3 w-3 rounded-full"
style={{ backgroundColor: STATUS_COLORS[status] ?? STATUS_COLORS.new }}
/>
{statusLabel(status)} {statusLabel(status)}
{isCurrent && <span className="ml-auto text-xs">current</span>} {isCurrent && <span className="ml-auto text-[10px]">current</span>}
</button> </button>
); );
})} })}
@@ -791,187 +804,138 @@ export default function TicketDetailPage({
</section> </section>
{preview && ( {preview && (
<section className="rounded-md border border-border bg-background/70 p-3 shadow-sm"> <section className="rounded-lg border border-border bg-accent/20 p-3">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-foreground"> <div className="flex items-center gap-2 text-xs font-semibold text-foreground">
<BotIcon className="h-4 w-4 text-primary" /> <BotIcon className="h-3.5 w-3.5 text-primary" /> Automation preview
Automation preview
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="mt-1 text-[11px] text-muted-foreground">
Changing to{" "} Changing to <span className="font-semibold text-foreground">{pendingStatus ? statusLabel(pendingStatus) : ""}</span>
<span className="font-semibold text-foreground">
{pendingStatus ? statusLabel(pendingStatus) : ""}
</span>
</p> </p>
<div className="my-3 space-y-1.5"> <div className="my-2 space-y-1">
{preview.prepared_scrips.length > 0 ? ( {preview.prepared_scrips.length > 0
preview.prepared_scrips.map((scrip) => ( ? preview.prepared_scrips.map((scrip) => (
<div <div key={scrip.scripId} className="flex items-center gap-2 text-[11px] text-foreground">
key={scrip.scripId} <span className="h-1.5 w-1.5 rounded-full bg-primary" /> {scrip.scripName}
className="flex items-center gap-2 rounded border border-border bg-card px-2 py-1.5 text-xs text-foreground"
>
<span className="h-1.5 w-1.5 rounded-full bg-primary" />
{scrip.scripName}
</div> </div>
)) ))
) : ( : <p className="text-[11px] text-muted-foreground">No scrips will fire</p>}
<p className="rounded border border-border bg-muted/40 px-2 py-1.5 text-xs text-muted-foreground">
No scrips will fire
</p>
)}
</div> </div>
<div className="flex items-center gap-2"> <div className="mt-2 flex gap-2">
<button <button onClick={handleApplyStatus} disabled={applyLoading} className="rounded-md bg-primary px-2.5 py-1 text-[11px] font-semibold text-primary-foreground hover:bg-primary/90 disabled:opacity-50" type="button">
onClick={handleApplyStatus} {applyLoading ? "Applying..." : "Apply"}
disabled={applyLoading}
className="rounded-md bg-primary px-2.5 py-1.5 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
type="button"
>
{applyLoading ? "Applying..." : "Apply change"}
</button>
<button
onClick={handleCancelStatus}
disabled={applyLoading}
className="rounded-md px-2.5 py-1.5 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
type="button"
>
Cancel
</button> </button>
<button onClick={handleCancelStatus} disabled={applyLoading} className="rounded-md px-2.5 py-1 text-[11px] font-medium text-muted-foreground hover:bg-accent hover:text-foreground" type="button">Cancel</button>
</div> </div>
</section> </section>
)} )}
{previewError && <p className="text-xs text-destructive">{previewError}</p>}
{previewError && (
<div className="rounded-md border border-destructive/20 bg-destructive/10 p-3">
<p className="text-xs text-destructive">{previewError}</p>
</div>
)}
{scripResults && ( {scripResults && (
<section className="rounded-md border border-border bg-background/70 p-3 shadow-sm"> <section className="rounded-lg border border-border bg-accent/20 p-3">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-foreground"> <div className="flex items-center gap-2 text-xs font-semibold text-foreground"><CheckCircle2Icon className="h-3.5 w-3.5 text-emerald-500" /> Scrip results</div>
<CheckCircle2Icon className="h-4 w-4 text-emerald-600" /> <div className="mt-2 space-y-1">
Scrip results
</div>
<div className="space-y-1.5">
{scripResults.map((result) => ( {scripResults.map((result) => (
<div <div key={result.scripId} className={cn("flex items-center gap-2 text-[11px]", result.success ? "text-emerald-600 dark:text-emerald-400" : "text-destructive")}>
key={result.scripId} <span className={cn("h-1.5 w-1.5 rounded-full", result.success ? "bg-emerald-500" : "bg-destructive")} /> {result.message}
className={cn(
"flex items-center gap-2 text-xs",
result.success ? "text-emerald-700 dark:text-emerald-300" : "text-destructive"
)}
>
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
result.success ? "bg-emerald-600" : "bg-destructive"
)}
/>
{result.message}
</div> </div>
))} ))}
</div> </div>
<button <button onClick={() => setScripResults(null)} className="mt-1 text-[10px] text-muted-foreground hover:text-foreground" type="button">Dismiss</button>
onClick={() => setScripResults(null)}
className="mt-2 text-xs font-medium text-muted-foreground hover:text-foreground"
type="button"
>
Dismiss
</button>
</section> </section>
)} )}
<Separator /> <Separator />
{/* Assignment — no bordered boxes */}
<section> <section>
<h2 className="mb-2 text-xs font-semibold uppercase text-muted-foreground"> <h2 className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Assignment</h2>
Assignment <div className="space-y-3">
</h2> <div>
<dl className="overflow-hidden rounded-md border border-border bg-background/60"> <label className="mb-1 block text-[10px] font-medium text-muted-foreground">Owner</label>
<div className="grid grid-cols-[92px_minmax(0,1fr)] gap-3 border-b border-border px-3 py-2.5">
<dt className="text-[11px] font-semibold uppercase text-muted-foreground">Owner</dt>
<dd className="min-w-0">
<select <select
value={ticket.owner_id ?? ""} value={ticket.owner_id ?? ""}
onChange={(event) => void handleOwnerChange(event.target.value)} onChange={(event) => void handleOwnerChange(event.target.value)}
disabled={fieldSaving === "owner"} disabled={fieldSaving === "owner"}
className="h-8 w-full rounded-md border border-input bg-card px-2 text-sm text-foreground outline-none transition-colors focus:border-ring disabled:opacity-60" className="h-8 w-full rounded-md border border-input bg-transparent px-2.5 text-sm text-foreground outline-none focus:border-ring disabled:opacity-50"
aria-label="Owner" aria-label="Owner"
> >
<option value="">Unassigned</option> <option value="">Unassigned</option>
{users.map((user) => ( {users.map((user) => (<option key={user.id} value={user.id}>{user.username}</option>))}
<option key={user.id} value={user.id}>
{user.username}
</option>
))}
</select> </select>
</dd>
</div> </div>
<PropertyRow label="Priority" value="Not set" /> <div>
</dl> <label className="mb-1 block text-[10px] font-medium text-muted-foreground">Team</label>
<select
value={ticket.team_id ?? ""}
onChange={(event) => void handleTeamChange(event.target.value)}
disabled={fieldSaving === "team"}
className="h-8 w-full rounded-md border border-input bg-transparent px-2.5 text-sm text-foreground outline-none focus:border-ring disabled:opacity-50"
aria-label="Team"
>
<option value="">No team</option>
{teams.map((team) => (<option key={team.id} value={team.id}>{team.name}</option>))}
</select>
</div>
</div>
</section> </section>
{/* Details — simple key-value lines */}
<section> <section>
<h2 className="mb-2 text-xs font-semibold uppercase text-muted-foreground"> <h2 className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Details</h2>
Details <div className="space-y-2">
</h2> <div className="flex items-baseline justify-between gap-2 text-sm">
<dl className="overflow-hidden rounded-md border border-border bg-background/60"> <span className="text-muted-foreground">Queue</span>
<PropertyRow label="Queue" value={queue?.name || ticket.queue_id} /> <span className="text-foreground">{queue?.name || ticket.queue_id}</span>
<PropertyRow </div>
label="Created" <div className="flex items-baseline justify-between gap-2 text-sm">
value={formatDistanceToNow(new Date(ticket.created_at), { addSuffix: true })} <span className="text-muted-foreground">Created</span>
/> <span className="text-foreground">{formatDistanceToNow(new Date(ticket.created_at), { addSuffix: true })}</span>
<PropertyRow </div>
label="Updated" <div className="flex items-baseline justify-between gap-2 text-sm">
value={formatDistanceToNow(new Date(ticket.updated_at), { addSuffix: true })} <span className="text-muted-foreground">Updated</span>
/> <span className="text-foreground">{formatDistanceToNow(new Date(ticket.updated_at), { addSuffix: true })}</span>
</div>
{ticket.resolved_at && ( {ticket.resolved_at && (
<PropertyRow <div className="flex items-baseline justify-between gap-2 text-sm">
label="Resolved" <span className="text-muted-foreground">Resolved</span>
value={formatDistanceToNow(new Date(ticket.resolved_at), { addSuffix: true })} <span className="text-foreground">{formatDistanceToNow(new Date(ticket.resolved_at), { addSuffix: true })}</span>
/> </div>
)} )}
</dl> </div>
</section> </section>
{/* Custom fields — flat, no heavy borders */}
<section> <section>
<h2 className="mb-2 text-xs font-semibold uppercase text-muted-foreground"> <h2 className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Custom fields</h2>
Custom fields
</h2>
{queueFields.length === 0 ? ( {queueFields.length === 0 ? (
<div className="rounded-md border border-border bg-background/60 px-3 py-3 text-sm text-muted-foreground"> <p className="text-xs text-muted-foreground">No fields assigned.</p>
No fields are assigned to this queue.
</div>
) : ( ) : (
<div className="overflow-hidden rounded-md border border-border bg-background/60"> <div className="space-y-3">
{queueFields.map((assignment) => { {queueFields.map((assignment) => {
const field = assignment.custom_field; const field = assignment.custom_field;
const fieldId = assignment.custom_field_id; const fieldId = assignment.custom_field_id;
const options = Array.isArray(field?.values) const options = Array.isArray(field?.values) ? field.values.map((v) => String(v)) : [];
? field.values.map((value) => String(value)) const currentValue = ticket?.custom_fields?.find((item) => item.custom_field_id === fieldId)?.value ?? "";
: []; const isEditing = editingFieldId === fieldId;
const fieldType = field?.field_type.toLowerCase() ?? ""; const draftValue = customFieldDrafts[fieldId] ?? currentValue;
const currentDraft = customFieldDrafts[fieldId] ?? customFieldValue(fieldId);
const dirty = currentDraft !== customFieldValue(fieldId);
const isSaving = customFieldSaving === fieldId; const isSaving = customFieldSaving === fieldId;
return ( return (
<div <div key={assignment.id}>
key={assignment.id} <label className="mb-1 block text-[10px] font-medium text-muted-foreground">{field?.name ?? fieldId}</label>
className="grid gap-2 border-b border-border px-3 py-3 last:border-b-0"
> {isEditing ? (
<label className="text-[11px] font-semibold uppercase text-muted-foreground"> <div className="flex items-center gap-1.5">
{field?.name ?? fieldId} {options.length > 0 ? (
</label>
<div className="flex items-center gap-2">
{(fieldType.includes("select") || options.length > 0) && options.length > 0 ? (
<select <select
value={currentDraft} value={draftValue}
onChange={(event) => { onChange={(event) => {
const nextValue = event.target.value; const nextValue = event.target.value;
setCustomFieldDrafts((current) => ({ ...current, [fieldId]: nextValue })); setCustomFieldDrafts((c) => ({ ...c, [fieldId]: nextValue }));
void handleCustomFieldSave(fieldId, nextValue);
}} }}
className="h-8 min-w-0 flex-1 rounded-md border border-input bg-card px-2 text-sm text-foreground outline-none transition-colors focus:border-ring" onBlur={() => setEditingFieldId(null)}
autoFocus
className="h-8 flex-1 rounded-md border border-ring bg-card px-2 text-sm text-foreground outline-none"
> >
<option value="">Not set</option> <option value="">Not set</option>
{options.map((option) => ( {options.map((option) => (
@@ -982,34 +946,66 @@ export default function TicketDetailPage({
</select> </select>
) : ( ) : (
<input <input
value={currentDraft} value={draftValue}
onChange={(event) => onChange={(event) =>
setCustomFieldDrafts((current) => ({ ...current, [fieldId]: event.target.value })) setCustomFieldDrafts((c) => ({ ...c, [fieldId]: event.target.value }))
} }
onKeyDown={(event) => { onBlur={() => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") { if (draftValue.trim() !== currentValue) {
void handleCustomFieldSave(fieldId); void handleCustomFieldSave(fieldId);
} else {
setEditingFieldId(null);
} }
}} }}
placeholder={field?.pattern ? field.pattern : "Not set"} onKeyDown={(event) => {
className="h-8 min-w-0 flex-1 rounded-md border border-input bg-card px-2 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-ring" if (event.key === "Enter") {
void handleCustomFieldSave(fieldId);
} else if (event.key === "Escape") {
setCustomFieldDrafts((c) => ({ ...c, [fieldId]: currentValue }));
setEditingFieldId(null);
}
}}
autoFocus
placeholder="Not set"
className="h-8 flex-1 rounded-md border border-ring bg-card px-2 text-sm text-foreground outline-none placeholder:text-muted-foreground"
/> />
)} )}
<button {isSaving && (
onClick={() => void handleCustomFieldSave(fieldId)} <div className="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-primary border-t-transparent" />
disabled={!dirty || isSaving}
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-md transition-colors",
dirty && !isSaving
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "border border-border bg-card text-muted-foreground"
)} )}
title="Save custom field" {!isSaving && (
<button
type="button" type="button"
onClick={() => {
setCustomFieldDrafts((c) => ({ ...c, [fieldId]: currentValue }));
setEditingFieldId(null);
}}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<SaveIcon className="h-4 w-4" /> <XIcon className="h-3.5 w-3.5" />
</button> </button>
)}
</div> </div>
) : (
<button
type="button"
onClick={() => {
setCustomFieldDrafts((c) => ({ ...c, [fieldId]: currentValue }));
setEditingFieldId(fieldId);
}}
className="group flex items-center gap-1.5 text-sm min-w-0 -mx-1 rounded px-1 py-0.5 hover:bg-accent/60 transition-colors"
>
<span
className={cn(
"truncate",
currentValue ? "text-foreground" : "text-muted-foreground"
)}
>
{currentValue || "Not set"}
</span>
<PencilIcon className="h-3 w-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
</button>
)}
</div> </div>
); );
})} })}
@@ -1017,15 +1013,6 @@ export default function TicketDetailPage({
)} )}
</section> </section>
<section className="rounded-md border border-border bg-accent/38 p-3">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
<UserRoundIcon className="h-4 w-4 text-primary" />
Work mode
</div>
<p className="mt-2 text-sm text-muted-foreground">
Reply from the dock, preview status automation, then commit changes when the side effects look right.
</p>
</section>
</div> </div>
</aside> </aside>
</div> </div>

View File

@@ -4,8 +4,10 @@ import { useState, useEffect, Suspense, createContext, useContext } from "react"
import { usePathname, useSearchParams } from "next/navigation"; import { usePathname, useSearchParams } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { import {
CircleIcon,
LayoutGridIcon, LayoutGridIcon,
UserIcon, UserIcon,
UsersIcon,
InboxIcon, InboxIcon,
ClockIcon, ClockIcon,
SettingsIcon, SettingsIcon,
@@ -13,8 +15,8 @@ import {
PanelLeftIcon, PanelLeftIcon,
CommandIcon, CommandIcon,
} from "lucide-react"; } from "lucide-react";
import { getTickets, getQueues } from "@/lib/api"; import { getTickets, getQueues, getViews, getDashboards, getUsers, getTeams, createDashboard } from "@/lib/api";
import type { Queue } from "@/lib/types"; import type { Dashboard, Queue, SavedView, Team, User } from "@/lib/types";
import { CommandPalette } from "@/components/command-palette"; import { CommandPalette } from "@/components/command-palette";
import { ThemeToggle } from "@/components/theme-toggle"; import { ThemeToggle } from "@/components/theme-toggle";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -52,22 +54,19 @@ function SidebarNavItem({
href={href} href={href}
title={collapsed ? label : undefined} title={collapsed ? label : undefined}
className={cn( className={cn(
"group flex items-center px-2 py-1.5 rounded-md text-[13px] transition-all duration-150 mb-0.5", "group flex items-center px-2.5 py-1.5 rounded-md text-[13px] transition-colors",
collapsed ? "justify-center w-full" : "justify-between", collapsed ? "justify-center w-full" : "justify-between",
active active
? "bg-sidebar-primary text-sidebar-primary-foreground font-semibold shadow-[inset_3px_0_0_color-mix(in_oklch,var(--sidebar-primary-foreground)_55%,transparent)]" ? "bg-sidebar-accent text-sidebar-foreground font-medium"
: "text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent font-normal" : "text-sidebar-foreground/55 hover:text-sidebar-foreground hover:bg-sidebar-accent/50 font-normal"
)} )}
> >
<span className={cn("flex items-center", collapsed ? "" : "gap-2.5")}> <span className={cn("flex items-center min-w-0", collapsed ? "" : "gap-2.5")}>
<Icon className="w-4 h-4 flex-shrink-0" /> <Icon className={cn("w-4 h-4 flex-shrink-0", active ? "opacity-90" : "opacity-50 group-hover:opacity-70")} />
{!collapsed && label} {!collapsed && <span className="truncate">{label}</span>}
</span> </span>
{!collapsed && count !== undefined && count > 0 && ( {!collapsed && count !== undefined && count > 0 && (
<span className={cn( <span className="min-w-5 rounded px-1 text-right text-[11px] tabular-nums text-sidebar-foreground/35">
"min-w-5 rounded px-1 text-right text-[11px] tabular-nums",
active ? "text-sidebar-primary-foreground/80" : "text-sidebar-foreground/45"
)}>
{count} {count}
</span> </span>
)} )}
@@ -86,35 +85,67 @@ function SidebarNav() {
recent: 0, recent: 0,
}); });
const [queues, setQueues] = useState<(Queue & { count: number })[]>([]); const [queues, setQueues] = useState<(Queue & { count: number })[]>([]);
const [savedViews, setSavedViews] = useState<SavedView[]>([]);
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
const [myTeamId, setMyTeamId] = useState<string | null>(null);
const [newDashboardName, setNewDashboardName] = useState("");
const [addingDashboard, setAddingDashboard] = useState(false);
useEffect(() => { useEffect(() => {
getTickets().then(({ data }) => { async function load() {
// Find current user
const [ticketRes, userRes] = await Promise.all([getTickets(), getUsers()]);
const data = ticketRes.data;
const users = userRes.data ?? [];
const currentUser = users.find((u) => u.username !== 'system') ?? users[0] ?? null;
const myId = currentUser?.id ?? null;
setCurrentUserId(myId);
if (data) { if (data) {
const now = Date.now(); const now = Date.now();
const week = 7 * 24 * 60 * 60 * 1000; const week = 7 * 24 * 60 * 60 * 1000;
setCounts({ setCounts({
all: data.length, all: data.length,
my: data.filter((t) => t.owner_id).length, my: myId ? data.filter((t) => t.owner_id === myId).length : 0,
unassigned: data.filter((t) => !t.owner_id).length, unassigned: data.filter((t) => !t.owner_id).length,
recent: data.filter( recent: data.filter((t) => new Date(t.updated_at).getTime() > now - week).length,
(t) => new Date(t.updated_at).getTime() > now - week
).length,
}); });
} }
});
getQueues().then(({ data }) => { // Queues
if (data) { const queueRes = await getQueues();
Promise.all( if (queueRes.data) {
data.map((q) => const qs = await Promise.all(
queueRes.data.map((q) =>
getTickets({ queue_id: q.id }).then(({ data: tickets }) => ({ getTickets({ queue_id: q.id }).then(({ data: tickets }) => ({
...q, ...q,
count: tickets?.length ?? 0, count: tickets?.length ?? 0,
})) }))
) )
).then(setQueues); );
setQueues(qs);
} }
});
// Views
const viewRes = await getViews();
if (viewRes.data) setSavedViews(viewRes.data);
// Dashboards scoped to user's teams
const [dashRes, teamRes] = await Promise.all([getDashboards(), getTeams()]);
const allDashboards = dashRes.data ?? [];
const allTeams = teamRes.data ?? [];
const userTeams = allTeams.filter((t) =>
(t.members ?? []).some((m) => m.id === myId)
);
setMyTeamId(userTeams[0]?.id ?? null);
const teamIds = new Set(userTeams.map((t) => t.id));
const visible = allDashboards.filter((d) =>
!d.team_id || teamIds.has(d.team_id)
);
setDashboards(visible);
}
void load();
}, []); }, []);
const collapsed = useSidebarCollapsed(); const collapsed = useSidebarCollapsed();
@@ -122,18 +153,25 @@ function SidebarNav() {
const views = [ const views = [
{ {
label: "All tickets", label: "All tickets",
href: "/", href: "/?view=all",
param: null, param: "all",
count: counts.all, count: counts.all,
icon: LayoutGridIcon, icon: LayoutGridIcon,
}, },
{ {
label: "My tickets", label: "My tickets",
href: "/?view=my", href: currentUserId ? `/?view=my&owner=${currentUserId}` : "/?view=my",
param: "my", param: "my",
count: counts.my, count: counts.my,
icon: UserIcon, icon: UserIcon,
}, },
...(myTeamId ? [{
label: "My team's tickets",
href: `/?view=team&team_id=${myTeamId}`,
param: "team",
count: undefined as number | undefined,
icon: UsersIcon,
}] : []),
{ {
label: "Unassigned", label: "Unassigned",
href: "/?view=unassigned", href: "/?view=unassigned",
@@ -172,24 +210,107 @@ function SidebarNav() {
})} })}
</div> </div>
{queues.length > 0 && ( {dashboards.length > 0 && (
<div> <div className="mt-5">
{!collapsed && ( {!collapsed && (
<div className="px-2 py-1.5 text-[11px] font-semibold text-sidebar-foreground/45 uppercase"> <div className="px-2.5 mb-1 text-[10px] font-medium text-sidebar-foreground/30 uppercase tracking-wider">
Dashboards
</div>
)}
{dashboards.length > 0 && (
<div className="px-2.5">
<select
value={dashboards.find((d) => pathname.startsWith("/dashboards/") && pathname.endsWith(d.id))?.id ?? ""}
onChange={(e) => {
const id = e.target.value;
if (id === "_new") {
setAddingDashboard(true);
e.target.value = dashboards.find((d) => pathname.startsWith("/dashboards/") && pathname.endsWith(d.id))?.id ?? "";
return;
}
if (id) window.location.href = `/dashboards/${id}`;
}}
className="h-7 w-full rounded border border-sidebar-border bg-sidebar-accent px-1.5 text-[12px] text-sidebar-foreground outline-none"
>
{dashboards.map((dash) => (
<option key={dash.id} value={dash.id}>{dash.name}</option>
))}
<option value="_new">+ New dashboard</option>
</select>
</div>
)}
{addingDashboard && (
<div className="mt-1 px-2">
<input
value={newDashboardName}
onChange={(e) => setNewDashboardName(e.target.value)}
placeholder="Dashboard name"
className="h-7 w-full rounded border border-sidebar-border bg-sidebar-accent px-1.5 text-[12px] text-sidebar-foreground outline-none"
autoFocus
onKeyDown={async (e) => {
if (e.key === "Enter" && newDashboardName.trim()) {
const { data } = await createDashboard({ name: newDashboardName.trim(), is_default: false });
if (data) {
setDashboards((prev) => [...prev, data]);
setNewDashboardName("");
setAddingDashboard(false);
window.location.href = `/dashboards/${data.id}`;
}
} else if (e.key === "Escape") {
setNewDashboardName("");
setAddingDashboard(false);
}
}}
onBlur={() => {
if (!newDashboardName.trim()) {
setNewDashboardName("");
setAddingDashboard(false);
}
}}
/>
</div>
)}
</div>
)}
{savedViews.length > 0 && (
<div className="mt-5">
{!collapsed && (
<div className="px-2.5 mb-1 text-[10px] font-medium text-sidebar-foreground/30 uppercase tracking-wider">
Saved views
</div>
)}
{savedViews.map((view) => {
const active =
pathname === "/" && searchParams.get("view_id") === view.id;
return (
<SidebarNavItem
key={view.id}
href={`/?view_id=${view.id}`}
icon={ClockIcon}
label={view.name}
active={active}
/>
);
})}
</div>
)}
{queues.length > 0 && (
<div className="mt-5">
{!collapsed && (
<div className="px-2.5 mb-1 text-[10px] font-medium text-sidebar-foreground/30 uppercase tracking-wider">
Queues Queues
</div> </div>
)} )}
{queues.map((queue) => { {queues.map((queue) => {
const active = const active =
pathname === "/" && searchParams.get("queue") === queue.id; pathname === "/" && searchParams.get("queue") === queue.id;
const QueueIcon = () => (
<span className="w-2 h-2 rounded-full bg-sidebar-primary flex-shrink-0 shadow-[0_0_0_3px_color-mix(in_oklch,var(--sidebar-primary)_18%,transparent)]" />
);
return ( return (
<SidebarNavItem <SidebarNavItem
key={queue.id} key={queue.id}
href={`/?queue=${queue.id}`} href={`/?queue=${queue.id}`}
icon={QueueIcon} icon={CircleIcon}
label={queue.name} label={queue.name}
count={queue.count} count={queue.count}
active={active} active={active}
@@ -207,32 +328,14 @@ function SidebarBottom() {
const collapsed = useSidebarCollapsed(); const collapsed = useSidebarCollapsed();
return ( return (
<div className="border-t border-sidebar-border p-2"> <div className="border-t border-sidebar-border/50 p-2">
<SidebarNavItem <SidebarNavItem
href="/admin" href="/admin"
icon={SettingsIcon} icon={SettingsIcon}
label="Admin" label="Admin"
active={pathname === "/admin"} active={pathname === "/admin"}
/> />
<div <div className={cn("flex", collapsed ? "justify-center mt-2" : "mt-2 px-1")}>
className={cn(
"flex items-center mt-0.5 px-2 py-1.5",
collapsed ? "justify-center" : "gap-2"
)}
title={collapsed ? "User" : undefined}
>
<div className="w-5 h-5 rounded-md bg-sidebar-primary flex items-center justify-center flex-shrink-0">
<span className="text-sidebar-primary-foreground text-[10px] font-semibold">
U
</span>
</div>
{!collapsed && (
<span className="text-[13px] text-sidebar-foreground/65 truncate">
User
</span>
)}
</div>
<div className={cn("flex", collapsed ? "justify-center mt-1" : "mt-1")}>
<ThemeToggle /> <ThemeToggle />
</div> </div>
</div> </div>
@@ -267,43 +370,33 @@ export function AppShell({ children }: { children: React.ReactNode }) {
{/* Sidebar */} {/* Sidebar */}
<aside <aside
className={cn( className={cn(
"flex-shrink-0 flex flex-col bg-sidebar border-r border-sidebar-border transition-all duration-150 shadow-[16px_0_42px_color-mix(in_oklch,var(--sidebar)_18%,transparent)]", "flex-shrink-0 flex flex-col bg-sidebar border-r border-sidebar-border/50 transition-all duration-200",
sidebarCollapsed ? "w-[60px]" : "w-60" sidebarCollapsed ? "w-[56px]" : "w-[232px]"
)} )}
> >
{/* Brand */} {/* Brand */}
<div className="h-14 flex items-center justify-between gap-2 px-3 border-b border-sidebar-border"> <div className="h-12 flex items-center justify-between gap-2 px-3 border-b border-sidebar-border/50">
<Link href="/" className="flex items-center gap-2"> <Link href="/" className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-md bg-sidebar-primary flex items-center justify-center shadow-[0_0_0_1px_color-mix(in_oklch,var(--sidebar-primary)_55%,white_20%)]"> <div className="w-6 h-6 rounded-md bg-sidebar-primary flex items-center justify-center">
<span className="text-sidebar-primary-foreground text-[12px] font-bold"> <span className="text-sidebar-primary-foreground text-[11px] font-bold">T</span>
T
</span>
</div> </div>
{!sidebarCollapsed && ( {!sidebarCollapsed && (
<span className="leading-tight"> <span className="text-sm font-semibold text-sidebar-foreground tracking-tight">Tessera</span>
<span className="block font-semibold text-sidebar-foreground text-sm">
Tessera
</span>
<span className="block text-[10px] text-sidebar-foreground/45">
ScripFoundry
</span>
</span>
)} )}
</Link> </Link>
{!sidebarCollapsed && ( {!sidebarCollapsed && (
<button <button
onClick={() => setCommandOpen(true)} onClick={() => setCommandOpen(true)}
className="flex h-7 items-center gap-1 rounded-md border border-sidebar-border px-2 text-[11px] text-sidebar-foreground/55 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground" className="flex h-6 items-center gap-1 rounded border border-sidebar-border/50 px-1.5 text-[10px] text-sidebar-foreground/40 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground/60"
aria-label="Open command palette" aria-label="Open command palette"
> >
<CommandIcon className="h-3.5 w-3.5" /> <CommandIcon className="h-3 w-3" />K
K
</button> </button>
)} )}
</div> </div>
{/* Nav */} {/* Nav */}
<nav className="flex-1 overflow-y-auto py-3 px-2"> <nav className="flex-1 overflow-y-auto py-2.5 px-2">
<Suspense <Suspense
fallback={ fallback={
<div className="space-y-1.5 px-2"> <div className="space-y-1.5 px-2">

View File

@@ -0,0 +1,21 @@
"use client";
import Link from "next/link";
import type { WidgetData } from "@/lib/types";
export function CountWidget({ data }: { data: WidgetData }) {
const params = new URLSearchParams();
if (data.view_id) params.set("view_id", data.view_id);
return (
<Link
href={`/?${params.toString()}`}
className="flex h-full flex-col items-center justify-center rounded-lg border border-border bg-card p-4 transition-colors hover:border-ring/50 hover:bg-accent/30"
>
<span className="text-3xl font-bold tabular-nums text-foreground">
{data.total}
</span>
<span className="mt-1 text-sm text-muted-foreground">{data.title}</span>
</Link>
);
}

View File

@@ -0,0 +1,39 @@
"use client";
import type { WidgetData } from "@/lib/types";
export function GroupedCountsWidget({ data }: { data: WidgetData }) {
const groups = data.groups ?? {};
const entries = Object.entries(groups).sort(([, a], [, b]) => b - a);
const max = entries.length > 0 ? Math.max(...entries.map(([, c]) => c)) : 1;
if (entries.length === 0) {
return (
<div className="flex h-full items-center justify-center rounded-lg border border-border bg-card p-4">
<p className="text-xs text-muted-foreground">No data</p>
</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden rounded-lg border border-border bg-card">
<div className="border-b border-border px-3 py-2">
<span className="text-xs font-semibold text-foreground">{data.title}</span>
</div>
<div className="flex-1 space-y-1.5 overflow-auto p-3">
{entries.map(([label, count]) => (
<div key={label} className="flex items-center gap-2 text-xs">
<span className="w-20 shrink-0 truncate text-foreground">{label}</span>
<div className="flex-1">
<div
className="h-2.5 rounded-sm bg-primary/60 transition-all"
style={{ width: `${Math.round((count / max) * 100)}%` }}
/>
</div>
<span className="w-8 shrink-0 text-right tabular-nums text-muted-foreground">{count}</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
"use client";
import type { WidgetData } from "@/lib/types";
const STATUS_COLORS: Record<string, string> = {
new: "#64748b",
open: "#2563eb",
in_progress: "#d97706",
resolved: "#16a34a",
closed: "#71717a",
};
function statusLabel(status: string) {
return status.replaceAll("_", " ");
}
export function StatusChartWidget({ data }: { data: WidgetData }) {
const counts = data.counts ?? {};
const entries = Object.entries(counts).sort(([, a], [, b]) => b - a);
if (entries.length === 0) {
return (
<div className="flex h-full items-center justify-center rounded-lg border border-border bg-card p-4">
<p className="text-xs text-muted-foreground">No data</p>
</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden rounded-lg border border-border bg-card">
<div className="border-b border-border px-3 py-2">
<span className="text-xs font-semibold text-foreground">{data.title}</span>
</div>
<div className="flex flex-1 items-center gap-4 p-4">
{/* Donut */}
<svg viewBox="0 0 40 40" className="h-16 w-16 shrink-0">
{entries.map(([, count], index) => {
const total = entries.reduce((sum, [, c]) => sum + c, 0);
const offset = entries
.slice(0, index)
.reduce((sum, [, c]) => sum + (c / total) * 100, 0);
const pct = (count / total) * 100;
const circumference = 2 * Math.PI * 15;
const dash = (pct / 100) * circumference;
const color = STATUS_COLORS[entries[index][0]] ?? "#71717a";
return (
<circle
key={entries[index][0]}
cx="20" cy="20" r="15"
fill="none"
stroke={color}
strokeWidth="6"
strokeDasharray={`${dash} ${circumference - dash}`}
strokeDashoffset={-(offset / 100) * circumference}
transform="rotate(-90 20 20)"
/>
);
})}
</svg>
{/* Legend */}
<div className="min-w-0 flex-1 space-y-1.5">
{entries.map(([status, count]) => (
<div key={status} className="flex items-center gap-2 text-xs">
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: STATUS_COLORS[status] ?? "#71717a" }}
/>
<span className="flex-1 capitalize text-foreground">{statusLabel(status)}</span>
<span className="tabular-nums text-muted-foreground">{count}</span>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,59 @@
"use client";
import Link from "next/link";
import { formatDistanceToNow } from "date-fns";
import { CircleIcon } from "lucide-react";
import type { WidgetData } from "@/lib/types";
import { cn, formatTicketId } from "@/lib/utils";
const STATUS_COLORS: Record<string, string> = {
new: "#64748b",
open: "#2563eb",
in_progress: "#d97706",
resolved: "#16a34a",
closed: "#71717a",
};
function statusLabel(status: string) {
return status.replaceAll("_", " ");
}
export function TicketListWidget({ data }: { data: WidgetData }) {
const tickets = data.tickets ?? [];
return (
<div className="flex h-full flex-col overflow-hidden rounded-lg border border-border bg-card">
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<span className="text-xs font-semibold text-foreground">{data.title}</span>
<span className="text-[11px] tabular-nums text-muted-foreground">{data.total}</span>
</div>
<div className="flex-1 overflow-auto">
{tickets.length === 0 ? (
<p className="px-3 py-4 text-center text-xs text-muted-foreground">No tickets</p>
) : (
tickets.map((ticket) => (
<Link
key={ticket.id}
href={`/tickets/${ticket.id}`}
className="flex items-center gap-2 border-b border-border/50 px-3 py-2 text-xs transition-colors hover:bg-accent/40 last:border-b-0"
>
<CircleIcon
className="h-2 w-2 shrink-0"
style={{ color: STATUS_COLORS[ticket.status] ?? "#71717a", fill: STATUS_COLORS[ticket.status] ?? "#71717a" }}
/>
<span className="min-w-0 flex-1 truncate font-medium text-foreground">
{ticket.subject}
</span>
<span className="shrink-0 text-[11px] text-muted-foreground">
{ticket.owner_name ?? "unassigned"}
</span>
<span className="shrink-0 text-[11px] text-muted-foreground/60">
{formatDistanceToNow(new Date(ticket.updated_at), { addSuffix: true })}
</span>
</Link>
))
)}
</div>
</div>
);
}

View File

@@ -1,8 +1,13 @@
import type { import type {
Ticket, Ticket,
Queue, Queue,
Dashboard,
DashboardWidget,
WidgetData,
Team,
User, User,
Transaction, Transaction,
SavedView,
Scrip, Scrip,
Template, Template,
TemplatePreview, TemplatePreview,
@@ -38,6 +43,7 @@ export async function getTickets(params?: {
status?: string; status?: string;
q?: string; q?: string;
owner_id?: string; owner_id?: string;
team_id?: string;
custom_fields?: Record<string, string>; custom_fields?: Record<string, string>;
}): Promise<{ data: Ticket[] | null; error: string | null }> { }): Promise<{ data: Ticket[] | null; error: string | null }> {
const sp = new URLSearchParams(); const sp = new URLSearchParams();
@@ -45,6 +51,7 @@ export async function getTickets(params?: {
if (params?.status) sp.set("status", params.status); if (params?.status) sp.set("status", params.status);
if (params?.q) sp.set("q", params.q); if (params?.q) sp.set("q", params.q);
if (params?.owner_id) sp.set("owner_id", params.owner_id); if (params?.owner_id) sp.set("owner_id", params.owner_id);
if (params?.team_id) sp.set("team_id", params.team_id);
if (params?.custom_fields) { if (params?.custom_fields) {
for (const [fieldId, value] of Object.entries(params.custom_fields)) { for (const [fieldId, value] of Object.entries(params.custom_fields)) {
if (value) sp.set(`cf.${fieldId}`, value); if (value) sp.set(`cf.${fieldId}`, value);
@@ -63,11 +70,11 @@ export async function createTicket(data: {
queue_id: string; queue_id: string;
description?: string; description?: string;
custom_fields?: Record<string, string>; custom_fields?: Record<string, string>;
}): Promise<{ data: Ticket | null; error: string | null }> { }): Promise<{ data: UpdateResult | null; error: string | null }> {
return request<Ticket>("/tickets", { method: "POST", body: JSON.stringify(data) }); return request<UpdateResult>("/tickets", { method: "POST", body: JSON.stringify(data) });
} }
export async function updateTicket(id: number, data: { subject?: string; status?: string; owner_id?: string | null }): Promise<{ data: UpdateResult | null; error: string | null }> { export async function updateTicket(id: number, data: { subject?: string; status?: string; owner_id?: string | null; team_id?: string | null }): Promise<{ data: UpdateResult | null; error: string | null }> {
return request<UpdateResult>(`/tickets/${id}`, { method: "PATCH", body: JSON.stringify(data) }); return request<UpdateResult>(`/tickets/${id}`, { method: "PATCH", body: JSON.stringify(data) });
} }
@@ -91,11 +98,29 @@ export async function getUsers(): Promise<{ data: User[] | null; error: string |
return request<User[]>("/users"); return request<User[]>("/users");
} }
export async function createQueue(data: { name: string; description?: string | null; lifecycle_id?: string | null }): Promise<{ data: Queue | null; error: string | null }> { export async function createUser(data: {
username: string;
email?: string | null;
}): Promise<{ data: User | null; error: string | null }> {
return request<User>("/users", { method: "POST", body: JSON.stringify(data) });
}
export async function updateUser(id: string, data: {
username?: string;
email?: string | null;
}): Promise<{ data: User | null; error: string | null }> {
return request<User>(`/users/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function deleteUser(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/users/${id}`, { method: "DELETE" });
}
export async function createQueue(data: { name: string; description?: string | null; lifecycle_id?: string | null; team_id?: string | null }): Promise<{ data: Queue | null; error: string | null }> {
return request<Queue>("/queues", { method: "POST", body: JSON.stringify(data) }); return request<Queue>("/queues", { method: "POST", body: JSON.stringify(data) });
} }
export async function updateQueue(id: string, data: { name?: string; description?: string | null; lifecycle_id?: string | null }): Promise<{ data: Queue | null; error: string | null }> { export async function updateQueue(id: string, data: { name?: string; description?: string | null; lifecycle_id?: string | null; team_id?: string | null }): Promise<{ data: Queue | null; error: string | null }> {
return request<Queue>(`/queues/${id}`, { method: "PATCH", body: JSON.stringify(data) }); return request<Queue>(`/queues/${id}`, { method: "PATCH", body: JSON.stringify(data) });
} }
@@ -165,6 +190,10 @@ export async function previewTemplate(data: {
return request<TemplatePreview>("/templates/preview", { method: "POST", body: JSON.stringify(data) }); return request<TemplatePreview>("/templates/preview", { method: "POST", body: JSON.stringify(data) });
} }
export async function deleteTemplate(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/templates/${id}`, { method: "DELETE" });
}
export async function getLifecycles(): Promise<{ data: Lifecycle[] | null; error: string | null }> { export async function getLifecycles(): Promise<{ data: Lifecycle[] | null; error: string | null }> {
return request<Lifecycle[]>("/lifecycles"); return request<Lifecycle[]>("/lifecycles");
} }
@@ -230,3 +259,117 @@ export async function updateCustomField(id: string, data: {
}): Promise<{ data: CustomField | null; error: string | null }> { }): Promise<{ data: CustomField | null; error: string | null }> {
return request<CustomField>(`/custom-fields/${id}`, { method: "PATCH", body: JSON.stringify(data) }); return request<CustomField>(`/custom-fields/${id}`, { method: "PATCH", body: JSON.stringify(data) });
} }
export async function getViews(): Promise<{ data: SavedView[] | null; error: string | null }> {
return request<SavedView[]>("/views");
}
export async function createView(data: {
name: string;
filters: { field: string; operator: string; value: string }[];
sort_key?: string;
columns?: { key: string; label: string; width: number; visible: boolean }[];
is_public?: boolean;
}): Promise<{ data: SavedView | null; error: string | null }> {
return request<SavedView>("/views", { method: "POST", body: JSON.stringify(data) });
}
export async function updateView(id: string, data: {
name?: string;
filters?: { field: string; operator: string; value: string }[];
sort_key?: string;
columns?: unknown[];
is_public?: boolean;
}): Promise<{ data: SavedView | null; error: string | null }> {
return request<SavedView>(`/views/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function deleteView(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/views/${id}`, { method: "DELETE" });
}
export async function getDashboards(): Promise<{ data: Dashboard[] | null; error: string | null }> {
return request<Dashboard[]>("/dashboards");
}
export async function getDashboard(id: string): Promise<{ data: Dashboard | null; error: string | null }> {
return request<Dashboard>(`/dashboards/${id}`);
}
export async function createDashboard(data: {
name: string;
description?: string;
team_id?: string | null;
is_default?: boolean;
}): Promise<{ data: Dashboard | null; error: string | null }> {
return request<Dashboard>("/dashboards", { method: "POST", body: JSON.stringify(data) });
}
export async function updateDashboard(id: string, data: {
name?: string;
description?: string | null;
team_id?: string | null;
is_default?: boolean;
layout?: unknown[];
}): Promise<{ data: Dashboard | null; error: string | null }> {
return request<Dashboard>(`/dashboards/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function deleteDashboard(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/dashboards/${id}`, { method: "DELETE" });
}
export async function getDashboardWidgets(dashboardId: string): Promise<{ data: DashboardWidget[] | null; error: string | null }> {
return request<DashboardWidget[]>(`/dashboards/${dashboardId}/widgets`);
}
export async function createWidget(dashboardId: string, data: {
view_id: string;
title: string;
widget_type: string;
position?: { x: number; y: number; w: number; h: number };
config?: Record<string, unknown>;
}): Promise<{ data: DashboardWidget | null; error: string | null }> {
return request<DashboardWidget>(`/dashboards/${dashboardId}/widgets`, { method: "POST", body: JSON.stringify(data) });
}
export async function updateWidget(dashboardId: string, widgetId: string, data: {
title?: string;
widget_type?: string;
position?: { x: number; y: number; w: number; h: number };
config?: Record<string, unknown>;
}): Promise<{ data: DashboardWidget | null; error: string | null }> {
return request<DashboardWidget>(`/dashboards/${dashboardId}/widgets/${widgetId}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function deleteWidget(dashboardId: string, widgetId: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/dashboards/${dashboardId}/widgets/${widgetId}`, { method: "DELETE" });
}
export async function getWidgetData(dashboardId: string, widgetId: string): Promise<{ data: WidgetData | null; error: string | null }> {
return request<WidgetData>(`/dashboards/${dashboardId}/widgets/${widgetId}/data`);
}
export async function getTeams(): Promise<{ data: Team[] | null; error: string | null }> {
return request<Team[]>("/teams");
}
export async function createTeam(data: { name: string; description?: string | null }): Promise<{ data: Team | null; error: string | null }> {
return request<Team>("/teams", { method: "POST", body: JSON.stringify(data) });
}
export async function updateTeam(id: string, data: { name?: string; description?: string | null }): Promise<{ data: Team | null; error: string | null }> {
return request<Team>(`/teams/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function deleteTeam(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/teams/${id}`, { method: "DELETE" });
}
export async function addTeamMember(teamId: string, userId: string): Promise<{ data: unknown | null; error: string | null }> {
return request<unknown>(`/teams/${teamId}/members`, { method: "POST", body: JSON.stringify({ user_id: userId }) });
}
export async function removeTeamMember(teamId: string, userId: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/teams/${teamId}/members/${userId}`, { method: "DELETE" });
}

View File

@@ -4,6 +4,7 @@ export interface Ticket {
queue_id: string; queue_id: string;
status: string; status: string;
owner_id: string | null; owner_id: string | null;
team_id: string | null;
creator_id: string; creator_id: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -17,6 +18,7 @@ export interface Queue {
name: string; name: string;
description: string | null; description: string | null;
lifecycle_id: string | null; lifecycle_id: string | null;
team_id: string | null;
} }
export interface User { export interface User {
@@ -128,3 +130,71 @@ export interface ScripResult {
success: boolean; success: boolean;
message: string; message: string;
} }
export interface SavedFilter {
field: string;
operator: string;
value: string;
}
export interface SavedView {
id: string;
name: string;
filters: SavedFilter[];
sort_key: string;
columns: unknown[];
is_public: boolean;
creator_id: string | null;
created_at: string;
}
export interface Team {
id: string;
name: string;
description: string | null;
created_at: string;
members?: User[];
}
export interface Dashboard {
id: string;
name: string;
description: string | null;
team_id: string | null;
layout: unknown[];
is_default: boolean;
created_at: string;
widgets?: DashboardWidget[];
}
export interface DashboardWidget {
id: string;
dashboard_id: string;
view_id: string;
title: string;
widget_type: string;
position: { x: number; y: number; w: number; h: number };
config: Record<string, unknown>;
created_at: string;
}
export interface WidgetTicket {
id: number;
subject: string;
status: string;
owner_id: string | null;
owner_name: string | null;
queue_name: string;
updated_at: string;
}
export interface WidgetData {
type: string;
title: string;
total: number;
view_id: string;
tickets?: WidgetTicket[];
counts?: Record<string, number>;
groups?: Record<string, number>;
group_by?: string;
}