Compare commits
45 Commits
2501bcbad1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d4dc38d06 | ||
|
|
f12b24e042 | ||
|
|
96a71a34fe | ||
|
|
1c780be710 | ||
|
|
dfcdbc623a | ||
|
|
6f1d7bfa9b | ||
|
|
c023079a1a | ||
|
|
cee263944b | ||
|
|
8b371ae3c2 | ||
|
|
5308ee8653 | ||
|
|
667979c4b2 | ||
|
|
1f308b4342 | ||
|
|
ed5d96a74b | ||
|
|
dd747946ea | ||
|
|
dde19f5fab | ||
|
|
5970e3fe9d | ||
|
|
7f91a51e32 | ||
|
|
30108c7600 | ||
|
|
d7a5b5ba1d | ||
|
|
b2fb69ffc5 | ||
|
|
dd7bd867bf | ||
|
|
e486558309 | ||
|
|
38a82ad0d8 | ||
|
|
7ddf82f93f | ||
|
|
d5d6a209bd | ||
|
|
4e285f8c4d | ||
|
|
3d7ba0d6a7 | ||
|
|
4157a7b0af | ||
|
|
6a277f9c36 | ||
|
|
a2005d007e | ||
|
|
b3da204bd0 | ||
|
|
41fb10120c | ||
|
|
6ca8974eb9 | ||
|
|
9938c7a7ad | ||
|
|
3616046b78 | ||
|
|
c79cd183d4 | ||
|
|
35b7f49518 | ||
|
|
f7e34f1690 | ||
|
|
6263ce1332 | ||
|
|
c6c5272e50 | ||
|
|
affbbdaa46 | ||
|
|
7be90684fb | ||
|
|
b70a133ea2 | ||
|
|
aa90b88991 | ||
|
|
000e97e1bd |
60
CLAUDE.md
60
CLAUDE.md
@@ -14,8 +14,10 @@ tessera/
|
||||
│ ├── models/ # TypeScript types + Zod schemas
|
||||
│ ├── scrip/ # Scrip engine (prepare/commit two-phase)
|
||||
│ └── lifecycle/ # State machine validator
|
||||
├── web/ # Frontend: Next.js 15 + shadcn/ui
|
||||
│ └── src/app/ # App Router pages
|
||||
├── web/ # Frontend: Next.js 16 + shadcn/ui
|
||||
│ ├── src/app/ # App Router pages
|
||||
│ ├── src/components/ # Reusable components + widgets
|
||||
│ └── src/lib/ # API client + types + utils
|
||||
├── drizzle/ # SQL migration files
|
||||
└── docs/ # Architecture + design specs
|
||||
```
|
||||
@@ -24,9 +26,9 @@ tessera/
|
||||
|
||||
**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
|
||||
|
||||
@@ -39,45 +41,47 @@ tessera/
|
||||
### Start backend
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
### Run migrations
|
||||
```bash
|
||||
npm run db:migrate
|
||||
npm run db:seed # Optional demo data for UI review
|
||||
npm run db:seed:reset # Reset local app data, then recreate demo data
|
||||
npm run db:seed # Demo data
|
||||
npm run db:seed:reset # Reset + re-seed
|
||||
```
|
||||
|
||||
### Start frontend
|
||||
```bash
|
||||
cd web
|
||||
npm install # Use npm, NOT bun (bun has compatibility issues with Next.js dev server)
|
||||
npm run build # Production build
|
||||
npm run start # Production server on 127.0.0.1:3100
|
||||
npm install # Use npm, NOT bun
|
||||
bun run dev # Dev server on 127.0.0.1:3100 (HMR)
|
||||
```
|
||||
|
||||
**Note:** `bun run dev` (Turbopack) has WebSocket HMR issues in this environment. Use production mode only.
|
||||
|
||||
## 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 |
|
||||
|--------|------|-------------|
|
||||
| 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 |
|
||||
| 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/comment | Add comment to ticket |
|
||||
| GET | /tickets/:id/transactions | List ticket transactions |
|
||||
| GET/POST | /queues | List/create queues |
|
||||
| GET/POST/PATCH | /scrips | CRUD scrips |
|
||||
| GET/POST | /custom-fields | List/create custom fields |
|
||||
| GET/POST | /lifecycles | List/create lifecycles |
|
||||
| GET/POST/PATCH | /queues | CRUD queues |
|
||||
| GET/POST/PATCH/DELETE | /scrips | CRUD scrips |
|
||||
| GET/POST/PATCH | /custom-fields | CRUD custom fields |
|
||||
| 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
|
||||
|
||||
@@ -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.
|
||||
- **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.
|
||||
- **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`).
|
||||
- **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
|
||||
|
||||
Repo: `https://git.gjermund.xyz/gjermund/tessera`
|
||||
|
||||
Push via HTTPS with token auth (SSH port 2222 is not configured on Gitea):
|
||||
```bash
|
||||
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
|
||||
|
||||
- **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`.
|
||||
- **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.
|
||||
|
||||
118
docs/design-system.md
Normal file
118
docs/design-system.md
Normal 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`)
|
||||
12
drizzle/migrations/0003_dry_caretaker.sql
Normal file
12
drizzle/migrations/0003_dry_caretaker.sql
Normal 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;
|
||||
22
drizzle/migrations/0004_sturdy_natasha_romanoff.sql
Normal file
22
drizzle/migrations/0004_sturdy_natasha_romanoff.sql
Normal 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;
|
||||
19
drizzle/migrations/0005_spotty_leader.sql
Normal file
19
drizzle/migrations/0005_spotty_leader.sql
Normal 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;
|
||||
2
drizzle/migrations/0006_nosy_black_queen.sql
Normal file
2
drizzle/migrations/0006_nosy_black_queen.sql
Normal 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;
|
||||
2
drizzle/migrations/0007_flimsy_roughhouse.sql
Normal file
2
drizzle/migrations/0007_flimsy_roughhouse.sql
Normal 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;
|
||||
1011
drizzle/migrations/meta/0003_snapshot.json
Normal file
1011
drizzle/migrations/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1156
drizzle/migrations/meta/0004_snapshot.json
Normal file
1156
drizzle/migrations/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1291
drizzle/migrations/meta/0005_snapshot.json
Normal file
1291
drizzle/migrations/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1310
drizzle/migrations/meta/0006_snapshot.json
Normal file
1310
drizzle/migrations/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1329
drizzle/migrations/meta/0007_snapshot.json
Normal file
1329
drizzle/migrations/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,41 @@
|
||||
"when": 1780904200000,
|
||||
"tag": "0002_short_custom_field_keys",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
@@ -13,6 +13,7 @@ export const queues = pgTable('queues', {
|
||||
name: text('name').notNull().unique(),
|
||||
description: text('description'),
|
||||
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(),
|
||||
});
|
||||
|
||||
@@ -29,6 +30,7 @@ export const tickets = pgTable('tickets', {
|
||||
queue_id: uuid('queue_id').notNull().references(() => queues.id),
|
||||
status: text('status').notNull(),
|
||||
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),
|
||||
created_at: timestamp('created_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),
|
||||
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(),
|
||||
});
|
||||
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
templates,
|
||||
tickets,
|
||||
transactions,
|
||||
views,
|
||||
dashboards,
|
||||
dashboardWidgets,
|
||||
users,
|
||||
} from './schema.ts';
|
||||
|
||||
@@ -314,6 +317,9 @@ async function resetDatabase(db: Db) {
|
||||
await db.delete(customFieldValues);
|
||||
await db.delete(transactions);
|
||||
await db.delete(queueCustomFields);
|
||||
await db.delete(dashboardWidgets);
|
||||
await db.delete(dashboards);
|
||||
await db.delete(views);
|
||||
await db.delete(scrips);
|
||||
await db.delete(templates);
|
||||
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`);
|
||||
|
||||
// ── 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');
|
||||
} finally {
|
||||
await pool.end();
|
||||
|
||||
@@ -12,6 +12,9 @@ import { createCustomFieldsRouter } from './routes/custom-fields.ts';
|
||||
import { createLifecyclesRouter } from './routes/lifecycles.ts';
|
||||
import { createUsersRouter } from './routes/users.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;
|
||||
|
||||
@@ -35,6 +38,9 @@ app.route('/custom-fields', createCustomFieldsRouter(getDb()));
|
||||
app.route('/lifecycles', createLifecyclesRouter(getDb()));
|
||||
app.route('/users', createUsersRouter(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 { app };
|
||||
|
||||
@@ -8,4 +8,5 @@ export const CreateQueueSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
lifecycle_id: z.string().uuid().optional(),
|
||||
team_id: z.string().uuid().nullable().optional(),
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ export const UpdateTicketSchema = z.object({
|
||||
subject: z.string().min(1).optional(),
|
||||
status: z.string().min(1).optional(),
|
||||
owner_id: z.string().uuid().nullable().optional(),
|
||||
team_id: z.string().uuid().nullable().optional(),
|
||||
});
|
||||
|
||||
export const CommentSchema = z.object({
|
||||
|
||||
386
src/routes/dashboards.ts
Normal file
386
src/routes/dashboards.ts
Normal 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;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export function createQueuesRouter(db: Db): Hono {
|
||||
name: parsed.name,
|
||||
description: parsed.description ?? null,
|
||||
lifecycle_id: parsed.lifecycle_id ?? null,
|
||||
team_id: parsed.team_id ?? null,
|
||||
}).returning();
|
||||
|
||||
if (!queue) {
|
||||
@@ -48,6 +49,7 @@ export function createQueuesRouter(db: Db): Hono {
|
||||
if (body.name !== undefined) updateData.name = String(body.name);
|
||||
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.team_id !== undefined) updateData.team_id = body.team_id || null;
|
||||
|
||||
const [updated] = await db.update(queues)
|
||||
.set(updateData)
|
||||
|
||||
98
src/routes/teams.ts
Normal file
98
src/routes/teams.ts
Normal 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;
|
||||
}
|
||||
@@ -151,6 +151,21 @@ export function createTemplatesRouter(db: Db): Hono {
|
||||
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) => {
|
||||
const body = await c.req.json();
|
||||
const subjectTemplate = String(body.subject_template ?? '');
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { tickets, transactions, customFieldValues, customFields, queues, lifecycles, queueCustomFields } from '../db/schema.ts';
|
||||
import { and, eq, asc } from 'drizzle-orm';
|
||||
import { tickets, transactions, customFieldValues, customFields, queues, lifecycles, queueCustomFields, teamMembers } from '../db/schema.ts';
|
||||
import { and, eq, asc, or, ilike, isNull, inArray, exists, sql } from 'drizzle-orm';
|
||||
import { CreateTicketSchema, UpdateTicketSchema, CommentSchema } from '../models/ticket.ts';
|
||||
import { ScripEngine } from '../scrip/engine.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 status = c.req.query('status');
|
||||
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()]
|
||||
.filter(([key, value]) => key.startsWith('cf.') && value.trim())
|
||||
.map(([key, value]) => ({
|
||||
key: key.slice(3),
|
||||
value: value.trim().toLowerCase(),
|
||||
value: value.trim(),
|
||||
}));
|
||||
|
||||
let result = await db.query.tickets.findMany({
|
||||
orderBy: asc(tickets.created_at),
|
||||
});
|
||||
// Build SQL WHERE conditions
|
||||
const conditions: ReturnType<typeof eq>[] = [];
|
||||
|
||||
if (queueId) {
|
||||
result = result.filter((ticket) => ticket.queue_id === queueId);
|
||||
conditions.push(eq(tickets.queue_id, queueId));
|
||||
}
|
||||
if (status) {
|
||||
result = result.filter((ticket) => ticket.status === status);
|
||||
conditions.push(eq(tickets.status, status));
|
||||
}
|
||||
if (ownerId) {
|
||||
result = ownerId === 'unassigned'
|
||||
? result.filter((ticket) => !ticket.owner_id)
|
||||
: result.filter((ticket) => ticket.owner_id === ownerId);
|
||||
conditions.push(
|
||||
ownerId === 'unassigned' ? isNull(tickets.owner_id) : eq(tickets.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;
|
||||
const valuesByTicket = new Map<number, { fieldId: string; fieldKey: string; fieldName: string; value: string }[]>();
|
||||
// Text search: push to SQL via ilike on ticket columns + queue name join
|
||||
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) {
|
||||
const ticketIds = result.map((ticket) => ticket.id);
|
||||
const cfValues = await db.query.customFieldValues.findMany({
|
||||
// Custom field filters: use EXISTS subquery
|
||||
for (const cf of cfFilters) {
|
||||
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),
|
||||
});
|
||||
const fieldIds = [...new Set(cfValues.map((value) => value.custom_field_id))];
|
||||
const fields = fieldIds.length > 0
|
||||
const fieldIds = [...new Set(allCfValues.map((v) => v.custom_field_id))];
|
||||
const allFields = fieldIds.length > 0
|
||||
? await db.query.customFields.findMany({
|
||||
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 rows = valuesByTicket.get(value.ticket_id) ?? [];
|
||||
rows.push({
|
||||
fieldId: value.custom_field_id,
|
||||
fieldKey: fieldMap.get(value.custom_field_id)?.key ?? value.custom_field_id,
|
||||
fieldName: fieldMap.get(value.custom_field_id)?.name ?? value.custom_field_id,
|
||||
value: value.value,
|
||||
});
|
||||
valuesByTicket.set(value.ticket_id, rows);
|
||||
}
|
||||
}
|
||||
|
||||
if (query) {
|
||||
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)
|
||||
)
|
||||
);
|
||||
const ticketsWithCf = filtered.map((ticket) => {
|
||||
const cfs = allCfValues
|
||||
.filter((v) => v.ticket_id === ticket.id)
|
||||
.map((v) => ({
|
||||
id: v.id,
|
||||
custom_field_id: v.custom_field_id,
|
||||
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 };
|
||||
});
|
||||
|
||||
return c.json(ticketsWithCf);
|
||||
}
|
||||
|
||||
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);
|
||||
return c.json(filtered);
|
||||
});
|
||||
|
||||
// POST / — create ticket
|
||||
@@ -186,6 +222,7 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
queue_id: parsed.queue_id,
|
||||
status: initialStatus,
|
||||
creator_id: creatorId,
|
||||
team_id: (queue as any).team_id ?? null,
|
||||
}).returning();
|
||||
|
||||
if (!ticket) {
|
||||
@@ -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
|
||||
const updateData: Record<string, unknown> = {};
|
||||
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.team_id !== undefined) updateData.team_id = parsed.team_id;
|
||||
updateData.updated_at = new Date();
|
||||
|
||||
const [updated] = await db.update(tickets)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 { users } from '../db/schema.ts';
|
||||
|
||||
@@ -13,5 +14,65 @@ export function createUsersRouter(db: Db): Hono {
|
||||
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;
|
||||
}
|
||||
|
||||
84
src/routes/views.ts
Normal file
84
src/routes/views.ts
Normal 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;
|
||||
}
|
||||
@@ -1,5 +1,49 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
# This is NOT the Next.js you know
|
||||
# Tessera Design Rules
|
||||
|
||||
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.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
Follow these rules when writing any frontend code. The design is clean, modern, and minimal — avoid the "2015 admin panel" look.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
PlusIcon,
|
||||
Settings2Icon,
|
||||
SlidersHorizontalIcon,
|
||||
Trash2Icon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -52,32 +54,36 @@ import {
|
||||
createTemplate,
|
||||
updateTemplate,
|
||||
previewTemplate,
|
||||
deleteTemplate,
|
||||
getCustomFields,
|
||||
getQueueCustomFields,
|
||||
assignQueueCustomField,
|
||||
unassignQueueCustomField,
|
||||
createCustomField,
|
||||
updateCustomField,
|
||||
getUsers,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
getTeams,
|
||||
createTeam,
|
||||
updateTeam,
|
||||
deleteTeam,
|
||||
addTeamMember,
|
||||
removeTeamMember,
|
||||
} 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";
|
||||
|
||||
function AdminHeader() {
|
||||
return (
|
||||
<header className="border-b border-border bg-card/90 px-5 py-5 backdrop-blur lg:px-6">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase text-muted-foreground">
|
||||
<Settings2Icon className="h-3.5 w-3.5" />
|
||||
Configuration
|
||||
</div>
|
||||
<h1 className="mt-1 text-2xl font-semibold text-foreground">
|
||||
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>
|
||||
<header className="border-b border-border/50 px-5 py-4 lg:px-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">
|
||||
<Settings2Icon className="h-3 w-3" />
|
||||
Configuration
|
||||
</div>
|
||||
<h1 className="mt-1 text-xl font-semibold tracking-tight text-foreground">Admin</h1>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
@@ -125,8 +131,8 @@ export default function AdminPage() {
|
||||
<div className="flex h-full flex-col bg-background/80">
|
||||
<AdminHeader />
|
||||
<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">
|
||||
<TabsList className="h-9 rounded-md border border-border bg-muted/55 p-1">
|
||||
<div className="border-b border-border/50 px-5 py-2.5 lg:px-6">
|
||||
<TabsList className="h-8 rounded-lg bg-muted/40 p-0.5">
|
||||
<TabsTrigger value="queues" className="px-3">
|
||||
<DatabaseIcon className="h-4 w-4" />
|
||||
Queues
|
||||
@@ -147,6 +153,14 @@ export default function AdminPage() {
|
||||
<SlidersHorizontalIcon className="h-4 w-4" />
|
||||
Custom fields
|
||||
</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>
|
||||
</div>
|
||||
<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">
|
||||
<CustomFieldsTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="users" className="m-0">
|
||||
<UsersTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="teams" className="m-0">
|
||||
<TeamsTab />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
@@ -174,23 +194,27 @@ export default function AdminPage() {
|
||||
function QueuesTab() {
|
||||
const [queues, setQueues] = useState<Queue[]>([]);
|
||||
const [lifecycles, setLifecycles] = useState<Lifecycle[]>([]);
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
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 [lifecycleId, setLifecycleId] = useState("");
|
||||
const [teamId, setTeamId] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const fetchQueues = useCallback(async () => {
|
||||
setLoading(true);
|
||||
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);
|
||||
else setQueues(queueRes.data ?? []);
|
||||
if (lifecycleRes.error) setError((prev) => prev || lifecycleRes.error);
|
||||
else setLifecycles(lifecycleRes.data ?? []);
|
||||
if (teamsRes.error) setError((prev) => prev || teamsRes.error);
|
||||
else setTeams(teamsRes.data ?? []);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
@@ -203,6 +227,7 @@ function QueuesTab() {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setLifecycleId("");
|
||||
setTeamId("");
|
||||
setSaveError(null);
|
||||
};
|
||||
|
||||
@@ -211,6 +236,7 @@ function QueuesTab() {
|
||||
setName(queue.name);
|
||||
setDescription(queue.description ?? "");
|
||||
setLifecycleId(queue.lifecycle_id ?? "");
|
||||
setTeamId(queue.team_id ?? "");
|
||||
setSaveError(null);
|
||||
};
|
||||
|
||||
@@ -222,6 +248,7 @@ function QueuesTab() {
|
||||
name: name.trim(),
|
||||
description: description.trim() || null,
|
||||
lifecycle_id: lifecycleId || null,
|
||||
team_id: teamId || null,
|
||||
};
|
||||
const { data, error } = editingId
|
||||
? await updateQueue(editingId, payload)
|
||||
@@ -241,8 +268,8 @@ function QueuesTab() {
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="overflow-hidden rounded-md border border-border bg-card/82 shadow-sm">
|
||||
<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">
|
||||
<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">Queues ({queues.length})</h2>
|
||||
<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 />
|
||||
) : (
|
||||
<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="min-w-0">
|
||||
<div className="text-[11px] font-semibold uppercase text-muted-foreground">Queue library</div>
|
||||
@@ -278,8 +305,8 @@ function QueuesTab() {
|
||||
type="button"
|
||||
onClick={() => selectQueue(queue)}
|
||||
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",
|
||||
editingId === queue.id ? "border-primary bg-primary/10" : "border-border bg-card"
|
||||
"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/50 bg-primary/5" : "border-border/50 hover:bg-accent/30"
|
||||
)}
|
||||
>
|
||||
<div className="truncate text-sm font-semibold text-foreground">{queue.name}</div>
|
||||
@@ -293,7 +320,7 @@ function QueuesTab() {
|
||||
</div>
|
||||
</aside>
|
||||
<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 className="text-[11px] font-semibold uppercase text-muted-foreground">
|
||||
{editingId ? "Editing queue" : "New queue"}
|
||||
@@ -329,6 +356,19 @@ function QueuesTab() {
|
||||
</Select>
|
||||
</div>
|
||||
</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>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -437,8 +477,8 @@ function LifecyclesTab() {
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="overflow-hidden rounded-md border border-border bg-card/82 shadow-sm">
|
||||
<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">
|
||||
<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">Lifecycles ({lifecycles.length})</h2>
|
||||
<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 />
|
||||
) : (
|
||||
<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="min-w-0">
|
||||
<div className="text-[11px] font-semibold uppercase text-muted-foreground">Lifecycle library</div>
|
||||
@@ -490,7 +530,7 @@ function LifecyclesTab() {
|
||||
</div>
|
||||
</aside>
|
||||
<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 className="text-[11px] font-semibold uppercase text-muted-foreground">
|
||||
{editingId ? "Editing lifecycle" : "New lifecycle"}
|
||||
@@ -696,9 +736,9 @@ function ScripFlowNode({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-md border border-border bg-background">
|
||||
<div className="flex items-start gap-3 border-b border-border bg-muted/30 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">
|
||||
<section className="rounded-lg border border-border/50">
|
||||
<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-md bg-muted/40 font-mono text-[11px] font-semibold text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
<div>
|
||||
@@ -1141,8 +1181,8 @@ return { message: "Metadata fetched" };`);
|
||||
const resolvedToStatusOptions = uniqueSortedStatuses([...toStatusOptions, toStatus]);
|
||||
|
||||
return (
|
||||
<section className="overflow-hidden rounded-md border border-border bg-card/82 shadow-sm">
|
||||
<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">
|
||||
<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">Scrips ({scrips.length})</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
@@ -1159,7 +1199,7 @@ return { message: "Metadata fetched" };`);
|
||||
<LoadingState />
|
||||
) : (
|
||||
<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="min-w-0">
|
||||
<div className="text-[11px] font-semibold uppercase text-muted-foreground">Automation library</div>
|
||||
@@ -1219,7 +1259,7 @@ return { message: "Metadata fetched" };`);
|
||||
</aside>
|
||||
|
||||
<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 className="text-[11px] font-semibold uppercase text-muted-foreground">
|
||||
{activeScrip ? "Editing automation" : "New automation"}
|
||||
@@ -1699,6 +1739,7 @@ Location: {{custom_fields.location}}`);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const fetchTemplates = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -1741,6 +1782,14 @@ Location: {{custom_fields.location}}`;
|
||||
setSaveError(null);
|
||||
};
|
||||
|
||||
const handleDeleteTemplate = async (templateId: string) => {
|
||||
setDeletingId(templateId);
|
||||
await deleteTemplate(templateId);
|
||||
if (editingId === templateId) resetBuilder();
|
||||
await fetchTemplates();
|
||||
setDeletingId(null);
|
||||
};
|
||||
|
||||
const selectTemplate = (template: Template) => {
|
||||
setEditingId(template.id);
|
||||
setName(template.name);
|
||||
@@ -1794,8 +1843,8 @@ Location: {{custom_fields.location}}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="overflow-hidden rounded-md border border-border bg-card/82 shadow-sm">
|
||||
<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">
|
||||
<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">Templates ({templates.length})</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
@@ -1812,7 +1861,7 @@ Location: {{custom_fields.location}}`;
|
||||
<LoadingState />
|
||||
) : (
|
||||
<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="min-w-0">
|
||||
<div className="text-[11px] font-semibold uppercase text-muted-foreground">Template library</div>
|
||||
@@ -1835,7 +1884,7 @@ Location: {{custom_fields.location}}`;
|
||||
type="button"
|
||||
onClick={() => selectTemplate(template)}
|
||||
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"
|
||||
)}
|
||||
>
|
||||
@@ -1844,9 +1893,23 @@ Location: {{custom_fields.location}}`;
|
||||
<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>
|
||||
<Badge variant="outline" className="shrink-0 rounded">
|
||||
{template.queue_id ? "Queue" : "Global"}
|
||||
</Badge>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Badge variant="outline" className="rounded">
|
||||
{template.queue_id ? "Queue" : "Global"}
|
||||
</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 className="mt-3 truncate font-mono text-[11px] text-muted-foreground">
|
||||
{template.subject_template}
|
||||
@@ -1858,7 +1921,7 @@ Location: {{custom_fields.location}}`;
|
||||
</aside>
|
||||
|
||||
<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 className="text-[11px] font-semibold uppercase text-muted-foreground">
|
||||
{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() {
|
||||
const [fields, setFields] = useState<CustomField[]>([]);
|
||||
const [queues, setQueues] = useState<Queue[]>([]);
|
||||
@@ -2123,8 +2511,8 @@ function CustomFieldsTab() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="overflow-hidden rounded-md border border-border bg-card/82 shadow-sm">
|
||||
<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">
|
||||
<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">Custom fields ({fields.length})</h2>
|
||||
<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 />
|
||||
) : (
|
||||
<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="min-w-0">
|
||||
<div className="text-[11px] font-semibold uppercase text-muted-foreground">Field library</div>
|
||||
@@ -2180,7 +2568,7 @@ function CustomFieldsTab() {
|
||||
</div>
|
||||
</aside>
|
||||
<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 className="text-[11px] font-semibold uppercase text-muted-foreground">
|
||||
{editingId ? "Editing custom field" : "New custom field"}
|
||||
@@ -2238,7 +2626,7 @@ function CustomFieldsTab() {
|
||||
)}
|
||||
</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">
|
||||
<h2 className="text-base font-semibold text-foreground">Queue field assignments</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
|
||||
589
web/src/app/dashboards/[id]/page.tsx
Normal file
589
web/src/app/dashboards/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1047
web/src/app/page.tsx
1047
web/src/app/page.tsx
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@ import {
|
||||
getQueues,
|
||||
getLifecycles,
|
||||
getUsers,
|
||||
getTeams,
|
||||
getQueueCustomFields,
|
||||
previewTicket,
|
||||
updateTicket,
|
||||
@@ -37,6 +38,7 @@ import type {
|
||||
Transaction,
|
||||
Queue,
|
||||
Lifecycle,
|
||||
Team,
|
||||
User,
|
||||
QueueCustomField,
|
||||
PreviewResult,
|
||||
@@ -102,15 +104,18 @@ function userLabel(users: User[], userId: string | null) {
|
||||
function TransactionCard({
|
||||
tx,
|
||||
users,
|
||||
teams,
|
||||
customFieldLabels,
|
||||
}: {
|
||||
tx: Transaction;
|
||||
users: User[];
|
||||
teams: Team[];
|
||||
customFieldLabels: Record<string, string>;
|
||||
}) {
|
||||
const isSystem =
|
||||
tx.transaction_type === "StatusChange" ||
|
||||
tx.transaction_type === "SetOwner" ||
|
||||
tx.transaction_type === "SetTeam" ||
|
||||
tx.transaction_type === "CustomFieldChange" ||
|
||||
tx.transaction_type === "Create";
|
||||
const isInternal = tx.transaction_type === "Comment";
|
||||
@@ -123,70 +128,57 @@ function TransactionCard({
|
||||
|
||||
if (isSystem) {
|
||||
let message = tx.transaction_type;
|
||||
if (tx.transaction_type === "Create") {
|
||||
message = "Ticket created";
|
||||
} else if (tx.transaction_type === "StatusChange") {
|
||||
if (tx.transaction_type === "Create") message = "Ticket created";
|
||||
else if (tx.transaction_type === "StatusChange") {
|
||||
const oldLabel = tx.old_value ? statusLabel(tx.old_value) : "?";
|
||||
const newLabel = tx.new_value ? statusLabel(tx.new_value) : "?";
|
||||
message = `Status changed from ${oldLabel} to ${newLabel}`;
|
||||
} else if (tx.transaction_type === "SetOwner") {
|
||||
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") {
|
||||
const fieldName = tx.field ? customFieldLabels[tx.field] ?? "Custom field" : "Custom field";
|
||||
message = tx.new_value
|
||||
? `${fieldName} set to ${tx.new_value}`
|
||||
: `${fieldName} cleared`;
|
||||
message = tx.new_value ? `${fieldName} set to ${tx.new_value}` : `${fieldName} cleared`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[28px_minmax(0,1fr)] gap-3 px-6 py-3">
|
||||
<div className="flex justify-center">
|
||||
<span className="mt-1 flex h-5 w-5 items-center justify-center rounded bg-muted text-muted-foreground">
|
||||
<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 className="flex items-center gap-3 px-8 py-2.5">
|
||||
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted/40">
|
||||
<CircleIcon className="h-2.5 w-2.5 text-muted-foreground/60" />
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{message}</span>
|
||||
<span className="text-[10px] text-muted-foreground/50">{timeAgo}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="grid grid-cols-[28px_minmax(0,1fr)] gap-3 px-6 py-4">
|
||||
<div
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-[11px] font-semibold text-white"
|
||||
style={{ backgroundColor: getInitialColor(userLabel(users, tx.creator_id)) }}
|
||||
>
|
||||
{getInitial(userLabel(users, tx.creator_id))}
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-md border border-border bg-card shadow-sm">
|
||||
<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-center gap-2">
|
||||
{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>
|
||||
<div className="px-8 py-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
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)) }}
|
||||
>
|
||||
{getInitial(userLabel(users, tx.creator_id))}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-sm font-semibold text-foreground">{userLabel(users, tx.creator_id)}</span>
|
||||
{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
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[11px] text-muted-foreground/50">{timeAgo}</span>
|
||||
</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">
|
||||
{body}
|
||||
</p>
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap px-3 py-3 text-sm leading-6 text-foreground">
|
||||
{body}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -212,6 +204,7 @@ export default function TicketDetailPage({
|
||||
const [queue, setQueue] = useState<Queue | null>(null);
|
||||
const [lifecycles, setLifecycles] = useState<Lifecycle[]>([]);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
const [queueFields, setQueueFields] = useState<QueueCustomField[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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 [editingSubject, setEditingSubject] = useState(false);
|
||||
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 [customFieldSaving, setCustomFieldSaving] = useState<string | null>(null);
|
||||
const [editingFieldId, setEditingFieldId] = useState<string | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const [ticketRes, txRes, queuesRes, lifecycleRes, usersRes] = await Promise.all([
|
||||
const [ticketRes, txRes, queuesRes, lifecycleRes, usersRes, teamsRes] = await Promise.all([
|
||||
getTicket(id),
|
||||
getTicketTransactions(id),
|
||||
getQueues(),
|
||||
getLifecycles(),
|
||||
getUsers(),
|
||||
getTeams(),
|
||||
]);
|
||||
|
||||
if (ticketRes.error) {
|
||||
@@ -289,6 +284,7 @@ export default function TicketDetailPage({
|
||||
setError((prev) => prev || usersRes.error);
|
||||
} else {
|
||||
setUsers(usersRes.data ?? []);
|
||||
setTeams(teamsRes.data ?? []);
|
||||
}
|
||||
|
||||
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) => {
|
||||
if (!ticket || fieldSaving) return;
|
||||
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;
|
||||
const value = customFieldDrafts[fieldId]?.trim() ?? "";
|
||||
const value = (valueOverride ?? customFieldDrafts[fieldId] ?? "").trim();
|
||||
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;
|
||||
if (value && field?.pattern) {
|
||||
const regex = new RegExp(field.pattern);
|
||||
@@ -440,11 +462,9 @@ export default function TicketDetailPage({
|
||||
);
|
||||
}
|
||||
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 () => {
|
||||
if (!replyText.trim() || sending) return;
|
||||
setSending(true);
|
||||
@@ -647,6 +667,7 @@ export default function TicketDetailPage({
|
||||
key={tx.id}
|
||||
tx={tx}
|
||||
users={users}
|
||||
teams={teams}
|
||||
customFieldLabels={customFieldLabels}
|
||||
/>
|
||||
))}
|
||||
@@ -733,34 +754,31 @@ export default function TicketDetailPage({
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<aside className="hidden min-h-0 overflow-y-auto border-l border-border bg-card/78 backdrop-blur xl:block">
|
||||
<div className="space-y-5 p-5">
|
||||
<aside className="hidden min-h-0 overflow-y-auto border-l border-border/50 bg-card/90 xl:block">
|
||||
<div className="space-y-6 p-5">
|
||||
{/* Status — prominent, visual */}
|
||||
<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">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<span
|
||||
className="h-3 w-3 rounded-full"
|
||||
style={{ backgroundColor: currentStatusColor }}
|
||||
/>
|
||||
<span className="flex-1 text-left font-semibold text-foreground">
|
||||
{currentStatusLabel}
|
||||
</span>
|
||||
<ChevronDownIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: currentStatusColor }} />
|
||||
<span className="flex-1 text-sm font-semibold">{currentStatusLabel}</span>
|
||||
<ChevronDownIcon className="h-4 w-4 opacity-60" />
|
||||
</button>
|
||||
|
||||
{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) => {
|
||||
const isCurrent = status === ticket.status;
|
||||
return (
|
||||
@@ -769,19 +787,14 @@ export default function TicketDetailPage({
|
||||
onClick={() => handleStatusSelect(status)}
|
||||
disabled={isCurrent}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm transition-colors",
|
||||
isCurrent
|
||||
? "bg-accent text-muted-foreground"
|
||||
: "text-foreground hover:bg-accent"
|
||||
"flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm transition-colors",
|
||||
isCurrent ? "bg-accent/50 text-muted-foreground" : "text-foreground hover:bg-accent"
|
||||
)}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className="h-3 w-3 rounded-full"
|
||||
style={{ backgroundColor: STATUS_COLORS[status] ?? STATUS_COLORS.new }}
|
||||
/>
|
||||
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: STATUS_COLORS[status] ?? STATUS_COLORS.new }} />
|
||||
{statusLabel(status)}
|
||||
{isCurrent && <span className="ml-auto text-xs">current</span>}
|
||||
{isCurrent && <span className="ml-auto text-[10px]">current</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -791,225 +804,208 @@ export default function TicketDetailPage({
|
||||
</section>
|
||||
|
||||
{preview && (
|
||||
<section className="rounded-md border border-border bg-background/70 p-3 shadow-sm">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<BotIcon className="h-4 w-4 text-primary" />
|
||||
Automation preview
|
||||
<section className="rounded-lg border border-border bg-accent/20 p-3">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-foreground">
|
||||
<BotIcon className="h-3.5 w-3.5 text-primary" /> Automation preview
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Changing to{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
{pendingStatus ? statusLabel(pendingStatus) : ""}
|
||||
</span>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
Changing to <span className="font-semibold text-foreground">{pendingStatus ? statusLabel(pendingStatus) : ""}</span>
|
||||
</p>
|
||||
<div className="my-3 space-y-1.5">
|
||||
{preview.prepared_scrips.length > 0 ? (
|
||||
preview.prepared_scrips.map((scrip) => (
|
||||
<div
|
||||
key={scrip.scripId}
|
||||
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>
|
||||
))
|
||||
) : (
|
||||
<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 className="my-2 space-y-1">
|
||||
{preview.prepared_scrips.length > 0
|
||||
? preview.prepared_scrips.map((scrip) => (
|
||||
<div key={scrip.scripId} className="flex items-center gap-2 text-[11px] text-foreground">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-primary" /> {scrip.scripName}
|
||||
</div>
|
||||
))
|
||||
: <p className="text-[11px] text-muted-foreground">No scrips will fire</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleApplyStatus}
|
||||
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
|
||||
<div className="mt-2 flex gap-2">
|
||||
<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">
|
||||
{applyLoading ? "Applying..." : "Apply"}
|
||||
</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>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{previewError && (
|
||||
<div className="rounded-md border border-destructive/20 bg-destructive/10 p-3">
|
||||
<p className="text-xs text-destructive">{previewError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewError && <p className="text-xs text-destructive">{previewError}</p>}
|
||||
{scripResults && (
|
||||
<section className="rounded-md border border-border bg-background/70 p-3 shadow-sm">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<CheckCircle2Icon className="h-4 w-4 text-emerald-600" />
|
||||
Scrip results
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<section className="rounded-lg border border-border bg-accent/20 p-3">
|
||||
<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>
|
||||
<div className="mt-2 space-y-1">
|
||||
{scripResults.map((result) => (
|
||||
<div
|
||||
key={result.scripId}
|
||||
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 key={result.scripId} className={cn("flex items-center gap-2 text-[11px]", result.success ? "text-emerald-600 dark:text-emerald-400" : "text-destructive")}>
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", result.success ? "bg-emerald-500" : "bg-destructive")} /> {result.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setScripResults(null)}
|
||||
className="mt-2 text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
type="button"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
<button onClick={() => setScripResults(null)} className="mt-1 text-[10px] text-muted-foreground hover:text-foreground" type="button">Dismiss</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Assignment — no bordered boxes */}
|
||||
<section>
|
||||
<h2 className="mb-2 text-xs font-semibold uppercase text-muted-foreground">
|
||||
Assignment
|
||||
</h2>
|
||||
<dl className="overflow-hidden rounded-md border border-border bg-background/60">
|
||||
<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
|
||||
value={ticket.owner_id ?? ""}
|
||||
onChange={(event) => void handleOwnerChange(event.target.value)}
|
||||
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"
|
||||
aria-label="Owner"
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.username}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</dd>
|
||||
<h2 className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Assignment</h2>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-medium text-muted-foreground">Owner</label>
|
||||
<select
|
||||
value={ticket.owner_id ?? ""}
|
||||
onChange={(event) => void handleOwnerChange(event.target.value)}
|
||||
disabled={fieldSaving === "owner"}
|
||||
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"
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
{users.map((user) => (<option key={user.id} value={user.id}>{user.username}</option>))}
|
||||
</select>
|
||||
</div>
|
||||
<PropertyRow label="Priority" value="Not set" />
|
||||
</dl>
|
||||
<div>
|
||||
<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>
|
||||
|
||||
{/* Details — simple key-value lines */}
|
||||
<section>
|
||||
<h2 className="mb-2 text-xs font-semibold uppercase text-muted-foreground">
|
||||
Details
|
||||
</h2>
|
||||
<dl className="overflow-hidden rounded-md border border-border bg-background/60">
|
||||
<PropertyRow label="Queue" value={queue?.name || ticket.queue_id} />
|
||||
<PropertyRow
|
||||
label="Created"
|
||||
value={formatDistanceToNow(new Date(ticket.created_at), { addSuffix: true })}
|
||||
/>
|
||||
<PropertyRow
|
||||
label="Updated"
|
||||
value={formatDistanceToNow(new Date(ticket.updated_at), { addSuffix: true })}
|
||||
/>
|
||||
<h2 className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Details</h2>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-baseline justify-between gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Queue</span>
|
||||
<span className="text-foreground">{queue?.name || ticket.queue_id}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Created</span>
|
||||
<span className="text-foreground">{formatDistanceToNow(new Date(ticket.created_at), { addSuffix: true })}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Updated</span>
|
||||
<span className="text-foreground">{formatDistanceToNow(new Date(ticket.updated_at), { addSuffix: true })}</span>
|
||||
</div>
|
||||
{ticket.resolved_at && (
|
||||
<PropertyRow
|
||||
label="Resolved"
|
||||
value={formatDistanceToNow(new Date(ticket.resolved_at), { addSuffix: true })}
|
||||
/>
|
||||
<div className="flex items-baseline justify-between gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Resolved</span>
|
||||
<span className="text-foreground">{formatDistanceToNow(new Date(ticket.resolved_at), { addSuffix: true })}</span>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Custom fields — flat, no heavy borders */}
|
||||
<section>
|
||||
<h2 className="mb-2 text-xs font-semibold uppercase text-muted-foreground">
|
||||
Custom fields
|
||||
</h2>
|
||||
<h2 className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Custom fields</h2>
|
||||
{queueFields.length === 0 ? (
|
||||
<div className="rounded-md border border-border bg-background/60 px-3 py-3 text-sm text-muted-foreground">
|
||||
No fields are assigned to this queue.
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">No fields assigned.</p>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-md border border-border bg-background/60">
|
||||
<div className="space-y-3">
|
||||
{queueFields.map((assignment) => {
|
||||
const field = assignment.custom_field;
|
||||
const fieldId = assignment.custom_field_id;
|
||||
const options = Array.isArray(field?.values)
|
||||
? field.values.map((value) => String(value))
|
||||
: [];
|
||||
const fieldType = field?.field_type.toLowerCase() ?? "";
|
||||
const currentDraft = customFieldDrafts[fieldId] ?? customFieldValue(fieldId);
|
||||
const dirty = currentDraft !== customFieldValue(fieldId);
|
||||
const options = Array.isArray(field?.values) ? field.values.map((v) => String(v)) : [];
|
||||
const currentValue = ticket?.custom_fields?.find((item) => item.custom_field_id === fieldId)?.value ?? "";
|
||||
const isEditing = editingFieldId === fieldId;
|
||||
const draftValue = customFieldDrafts[fieldId] ?? currentValue;
|
||||
const isSaving = customFieldSaving === fieldId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={assignment.id}
|
||||
className="grid gap-2 border-b border-border px-3 py-3 last:border-b-0"
|
||||
>
|
||||
<label className="text-[11px] font-semibold uppercase text-muted-foreground">
|
||||
{field?.name ?? fieldId}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
{(fieldType.includes("select") || options.length > 0) && options.length > 0 ? (
|
||||
<select
|
||||
value={currentDraft}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setCustomFieldDrafts((current) => ({ ...current, [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"
|
||||
>
|
||||
<option value="">Not set</option>
|
||||
{options.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
value={currentDraft}
|
||||
onChange={(event) =>
|
||||
setCustomFieldDrafts((current) => ({ ...current, [fieldId]: event.target.value }))
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
||||
void handleCustomFieldSave(fieldId);
|
||||
<div key={assignment.id}>
|
||||
<label className="mb-1 block text-[10px] font-medium text-muted-foreground">{field?.name ?? fieldId}</label>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{options.length > 0 ? (
|
||||
<select
|
||||
value={draftValue}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setCustomFieldDrafts((c) => ({ ...c, [fieldId]: nextValue }));
|
||||
void handleCustomFieldSave(fieldId, nextValue);
|
||||
}}
|
||||
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>
|
||||
{options.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
value={draftValue}
|
||||
onChange={(event) =>
|
||||
setCustomFieldDrafts((c) => ({ ...c, [fieldId]: event.target.value }))
|
||||
}
|
||||
}}
|
||||
placeholder={field?.pattern ? field.pattern : "Not set"}
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={() => void handleCustomFieldSave(fieldId)}
|
||||
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"
|
||||
onBlur={() => {
|
||||
if (draftValue.trim() !== currentValue) {
|
||||
void handleCustomFieldSave(fieldId);
|
||||
} else {
|
||||
setEditingFieldId(null);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
title="Save custom field"
|
||||
{isSaving && (
|
||||
<div className="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
)}
|
||||
{!isSaving && (
|
||||
<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"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</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"
|
||||
>
|
||||
<SaveIcon className="h-4 w-4" />
|
||||
<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 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>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -4,8 +4,10 @@ import { useState, useEffect, Suspense, createContext, useContext } from "react"
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
CircleIcon,
|
||||
LayoutGridIcon,
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
InboxIcon,
|
||||
ClockIcon,
|
||||
SettingsIcon,
|
||||
@@ -13,8 +15,8 @@ import {
|
||||
PanelLeftIcon,
|
||||
CommandIcon,
|
||||
} from "lucide-react";
|
||||
import { getTickets, getQueues } from "@/lib/api";
|
||||
import type { Queue } from "@/lib/types";
|
||||
import { getTickets, getQueues, getViews, getDashboards, getUsers, getTeams, createDashboard } from "@/lib/api";
|
||||
import type { Dashboard, Queue, SavedView, Team, User } from "@/lib/types";
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -52,22 +54,19 @@ function SidebarNavItem({
|
||||
href={href}
|
||||
title={collapsed ? label : undefined}
|
||||
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",
|
||||
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)]"
|
||||
: "text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent font-normal"
|
||||
? "bg-sidebar-accent text-sidebar-foreground font-medium"
|
||||
: "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")}>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
{!collapsed && label}
|
||||
<span className={cn("flex items-center min-w-0", collapsed ? "" : "gap-2.5")}>
|
||||
<Icon className={cn("w-4 h-4 flex-shrink-0", active ? "opacity-90" : "opacity-50 group-hover:opacity-70")} />
|
||||
{!collapsed && <span className="truncate">{label}</span>}
|
||||
</span>
|
||||
{!collapsed && count !== undefined && count > 0 && (
|
||||
<span className={cn(
|
||||
"min-w-5 rounded px-1 text-right text-[11px] tabular-nums",
|
||||
active ? "text-sidebar-primary-foreground/80" : "text-sidebar-foreground/45"
|
||||
)}>
|
||||
<span className="min-w-5 rounded px-1 text-right text-[11px] tabular-nums text-sidebar-foreground/35">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
@@ -86,35 +85,67 @@ function SidebarNav() {
|
||||
recent: 0,
|
||||
});
|
||||
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(() => {
|
||||
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) {
|
||||
const now = Date.now();
|
||||
const week = 7 * 24 * 60 * 60 * 1000;
|
||||
setCounts({
|
||||
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,
|
||||
recent: data.filter(
|
||||
(t) => new Date(t.updated_at).getTime() > now - week
|
||||
).length,
|
||||
recent: data.filter((t) => new Date(t.updated_at).getTime() > now - week).length,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
getQueues().then(({ data }) => {
|
||||
if (data) {
|
||||
Promise.all(
|
||||
data.map((q) =>
|
||||
// Queues
|
||||
const queueRes = await getQueues();
|
||||
if (queueRes.data) {
|
||||
const qs = await Promise.all(
|
||||
queueRes.data.map((q) =>
|
||||
getTickets({ queue_id: q.id }).then(({ data: tickets }) => ({
|
||||
...q,
|
||||
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();
|
||||
@@ -122,18 +153,25 @@ function SidebarNav() {
|
||||
const views = [
|
||||
{
|
||||
label: "All tickets",
|
||||
href: "/",
|
||||
param: null,
|
||||
href: "/?view=all",
|
||||
param: "all",
|
||||
count: counts.all,
|
||||
icon: LayoutGridIcon,
|
||||
},
|
||||
{
|
||||
label: "My tickets",
|
||||
href: "/?view=my",
|
||||
href: currentUserId ? `/?view=my&owner=${currentUserId}` : "/?view=my",
|
||||
param: "my",
|
||||
count: counts.my,
|
||||
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",
|
||||
href: "/?view=unassigned",
|
||||
@@ -172,24 +210,107 @@ function SidebarNav() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{queues.length > 0 && (
|
||||
<div>
|
||||
{dashboards.length > 0 && (
|
||||
<div className="mt-5">
|
||||
{!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
|
||||
</div>
|
||||
)}
|
||||
{queues.map((queue) => {
|
||||
const active =
|
||||
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 (
|
||||
<SidebarNavItem
|
||||
key={queue.id}
|
||||
href={`/?queue=${queue.id}`}
|
||||
icon={QueueIcon}
|
||||
icon={CircleIcon}
|
||||
label={queue.name}
|
||||
count={queue.count}
|
||||
active={active}
|
||||
@@ -207,32 +328,14 @@ function SidebarBottom() {
|
||||
const collapsed = useSidebarCollapsed();
|
||||
|
||||
return (
|
||||
<div className="border-t border-sidebar-border p-2">
|
||||
<div className="border-t border-sidebar-border/50 p-2">
|
||||
<SidebarNavItem
|
||||
href="/admin"
|
||||
icon={SettingsIcon}
|
||||
label="Admin"
|
||||
active={pathname === "/admin"}
|
||||
/>
|
||||
<div
|
||||
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")}>
|
||||
<div className={cn("flex", collapsed ? "justify-center mt-2" : "mt-2 px-1")}>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
@@ -267,43 +370,33 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
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)]",
|
||||
sidebarCollapsed ? "w-[60px]" : "w-60"
|
||||
"flex-shrink-0 flex flex-col bg-sidebar border-r border-sidebar-border/50 transition-all duration-200",
|
||||
sidebarCollapsed ? "w-[56px]" : "w-[232px]"
|
||||
)}
|
||||
>
|
||||
{/* Brand */}
|
||||
<div className="h-14 flex items-center justify-between gap-2 px-3 border-b border-sidebar-border">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<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%)]">
|
||||
<span className="text-sidebar-primary-foreground text-[12px] font-bold">
|
||||
T
|
||||
</span>
|
||||
<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.5">
|
||||
<div className="w-6 h-6 rounded-md bg-sidebar-primary flex items-center justify-center">
|
||||
<span className="text-sidebar-primary-foreground text-[11px] font-bold">T</span>
|
||||
</div>
|
||||
{!sidebarCollapsed && (
|
||||
<span className="leading-tight">
|
||||
<span className="block font-semibold text-sidebar-foreground text-sm">
|
||||
Tessera
|
||||
</span>
|
||||
<span className="block text-[10px] text-sidebar-foreground/45">
|
||||
ScripFoundry
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-sidebar-foreground tracking-tight">Tessera</span>
|
||||
)}
|
||||
</Link>
|
||||
{!sidebarCollapsed && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<CommandIcon className="h-3.5 w-3.5" />
|
||||
K
|
||||
<CommandIcon className="h-3 w-3" />K
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
fallback={
|
||||
<div className="space-y-1.5 px-2">
|
||||
|
||||
21
web/src/components/widgets/count-widget.tsx
Normal file
21
web/src/components/widgets/count-widget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
web/src/components/widgets/grouped-counts-widget.tsx
Normal file
39
web/src/components/widgets/grouped-counts-widget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
web/src/components/widgets/status-chart-widget.tsx
Normal file
76
web/src/components/widgets/status-chart-widget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
web/src/components/widgets/ticket-list-widget.tsx
Normal file
59
web/src/components/widgets/ticket-list-widget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import type {
|
||||
Ticket,
|
||||
Queue,
|
||||
Dashboard,
|
||||
DashboardWidget,
|
||||
WidgetData,
|
||||
Team,
|
||||
User,
|
||||
Transaction,
|
||||
SavedView,
|
||||
Scrip,
|
||||
Template,
|
||||
TemplatePreview,
|
||||
@@ -38,6 +43,7 @@ export async function getTickets(params?: {
|
||||
status?: string;
|
||||
q?: string;
|
||||
owner_id?: string;
|
||||
team_id?: string;
|
||||
custom_fields?: Record<string, string>;
|
||||
}): Promise<{ data: Ticket[] | null; error: string | null }> {
|
||||
const sp = new URLSearchParams();
|
||||
@@ -45,6 +51,7 @@ export async function getTickets(params?: {
|
||||
if (params?.status) sp.set("status", params.status);
|
||||
if (params?.q) sp.set("q", params.q);
|
||||
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) {
|
||||
for (const [fieldId, value] of Object.entries(params.custom_fields)) {
|
||||
if (value) sp.set(`cf.${fieldId}`, value);
|
||||
@@ -67,7 +74,7 @@ export async function createTicket(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) });
|
||||
}
|
||||
|
||||
@@ -91,11 +98,29 @@ export async function getUsers(): Promise<{ data: User[] | null; error: string |
|
||||
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) });
|
||||
}
|
||||
|
||||
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) });
|
||||
}
|
||||
|
||||
@@ -165,6 +190,10 @@ export async function previewTemplate(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 }> {
|
||||
return request<Lifecycle[]>("/lifecycles");
|
||||
}
|
||||
@@ -230,3 +259,117 @@ export async function updateCustomField(id: string, data: {
|
||||
}): Promise<{ data: CustomField | null; error: string | null }> {
|
||||
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" });
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface Ticket {
|
||||
queue_id: string;
|
||||
status: string;
|
||||
owner_id: string | null;
|
||||
team_id: string | null;
|
||||
creator_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -17,6 +18,7 @@ export interface Queue {
|
||||
name: string;
|
||||
description: string | null;
|
||||
lifecycle_id: string | null;
|
||||
team_id: string | null;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
@@ -128,3 +130,71 @@ export interface ScripResult {
|
||||
success: boolean;
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user