Compare commits

...

25 Commits

Author SHA1 Message Date
josh 8c2139f6a6 Remove deprecated baseUrl from client tsconfig
Build & Push / Test (client) (push) Failing after 7m24s
Build & Push / Build Client (push) Has been skipped
Build & Push / Test (server) (push) Failing after 31s
Build & Push / Build Server (push) Has been skipped
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 16:45:09 -04:00
josh 7253068fee Phase 5: ship (healthz, CI test gates, v1.0 README)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 16:42:47 -04:00
josh ef22e92ac8 Phase 4: power UX (palette, shortcuts, mentions, mobile, PWA)
Command palette (cmd+K) with fuzzy nav, ticket search, people lookup
and action entries (new ticket, logout, show shortcuts). Opens from
keyboard or user dropdown.

Global keyboard shortcuts via a small useShortcut/useLeaderShortcut
hook: `?` help overlay, `c` new ticket, `g d|t|m|n|s` leader nav.
Tickets list: j/k cursor, Enter open, x toggle select. TicketDetail:
`e` edit, `r` focus comment composer. All guarded against firing
inside text fields.

@mention autocomplete in the comment composer (MentionTextarea) with
arrow-key nav and Tab/Enter insert. Rendered comments and audit log
rewrite @username tokens to links pointing at that user's assignee
filter; unknown usernames left as plain text.

Mobile sweep: TicketDetail sidebar stacks below content on <md,
Settings profile grid collapses to one column, admin tables get
horizontal scroll with a 640px min width, CTI 3-column grid stacks
vertically on <md, New ticket severity/assignee grid same.

PWA: manifest.webmanifest, SVG icon, minimal network-first service
worker for the app shell (never caches /api/*), registered in
production builds only. Theme-color meta + manifest link in index.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 16:36:28 -04:00
josh 4bade22410 Phase 3: UI redesign (Gitea-issues aesthetic)
Top-nav Layout replaces side-nav: brand, primary nav, global search
(debounced /search), notifications bell (Popover + unread badge),
user avatar DropdownMenu. Mobile hamburger collapse.

New pages:
- /dashboard: analytics home (open-by-severity, age buckets, queue
  load, median resolution)
- /tickets: Gitea-style list with status tabs, severity/assignee/CTI
  filters, server pagination (25/page), multi-select bulk bar
  (reassign/close/severity), saved views CRUD
- /notifications: full list with mark-all-read
- /settings: profile, notification prefs grid, API key (SERVICE role)
- /admin/webhooks: CRUD + rotate-secret + active toggle,
  reveal-once secret dialog

TicketDetail: inline Popover editing for Status/Severity/Assignee
(replaces modal chain), AlertDialog delete confirmation, comment
draft autosave to localStorage per ticket.

Admin Users: window.confirm swapped for AlertDialog on delete +
rotate API key with toast feedback.

React Query hooks added for paged tickets, bulk actions,
notifications, webhooks, saved views, analytics, notification prefs.
ThemeProvider wired (v1.0 ships dark-only; toggle deferred).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 16:20:28 -04:00
josh edf4c5eb3c Phase 2b: backend services, routes, and notification triggers
Attachments: multer-backed uploads with random-hex filenames,
streaming downloads with Content-Disposition, 25MB limit,
mimetype allowlist, audit entries, orphan cleanup on DB failure.

Full-text search: searchTicketIds + searchComments via raw SQL
ranked with ts_rank, composable filters via Prisma.sql/join,
hydrated with findMany and reordered via Map to preserve rank.

Pagination: listTicketsPaged returns {data,total,page,pageSize}
only when page/pageSize present (array response stays default,
so the Goddard n8n flow is unchanged).

Bulk actions: reassign/close/setSeverity/setStatus on POST /bulk,
writes one audit entry per ticket via createMany.

Analytics: summarize(window) runs 5 parallel groupBy + raw-SQL
queries for open-by-severity, status counts, queue load,
age buckets, percentile_cont median resolution hours.

CSV export streams matching tickets via res.write; saved views
CRUD with per-user ownership checks (403 cross-user, 404 missing).

Notifications: in-app Notification rows gated by prefs, email via
nodemailer (SMTP_HOST-gated, no-op when unset), outgoing webhooks
with HMAC-SHA256 signed POST and 3-retry exponential backoff.
Triggers wired into createTicket/updateTicket/addComment; mention
detection via parseMentions skips self-notify.

Infra: docker-compose uploads volume + SMTP env passthrough;
.env.example SMTP section.

43 server tests passing (attachment/webhook/notification/savedView
services covered; bulkAction covered in ticketService).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 15:56:33 -04:00
josh 0806aec4a4 Phase 2a: Prisma schema + shared schemas for v1.0 features
- New models: Attachment, Webhook, Notification, SavedView
- New fields: User.notificationPrefs (Json), indexes on Ticket
- post-push.sql manages the tsvector columns + GIN indexes + triggers for
  FTS on Ticket (title/overview/displayId) and Comment (body); Prisma can't
  express these
- package.json scripts: db:push and start:prod now chain `prisma db execute`
  against post-push.sql after `prisma db push`
- db:migrate script removed — project uses push workflow, not migrations
- Shared Zod schemas: attachment (25MB limit + mimetype allowlist), savedView,
  notification (prefs, mark-read, webhook CRUD)
- Shared type additions: Attachment, Notification, SavedView, Webhook,
  PaginatedResponse<T>
- Test fixtures updated for the new User.notificationPrefs column

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 15:52:16 -04:00
josh 77679922a8 Phase 1d: react-hook-form + zod on Login and NewTicket
- Both forms use useForm with zodResolver against shared schemas
  (loginSchema, createTicketSchema)
- Field-level errors rendered inline under inputs
- isSubmitting drives button disabled state
- NewTicket: severity registered with valueAsNumber; CTISelect wrapped in
  nested Controllers (one per categoryId/typeId/itemId) since it controls
  three form fields as a single compound input
- Admin forms stay on useState for now — they get redesigned with shadcn
  dialogs in Phase 3, RHF migration lands with that

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 15:35:29 -04:00
josh 17697ecf3b Phase 1c: shadcn/ui primitives + CSS-variable theme tokens
- components.json, @/* path alias in tsconfig + vite config
- Tailwind config: CSS-variable-backed color tokens, animation plugin
- index.css: :root (light) and .dark token blocks (slate base) — currently
  pinned to dark via class on <html> so visual appearance is unchanged
- src/lib/utils.ts: cn() helper (clsx + tailwind-merge)
- src/components/ui/: 16 primitives — button, input, label, textarea, badge,
  avatar, separator, skeleton, dialog, dropdown-menu, select, tabs, tooltip,
  sonner, alert-dialog, popover
- Nothing replaced yet; existing components still in place. Used in Phase 3.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 15:35:19 -04:00
josh 4eae11b5b0 Phase 1b: React Query + Vitest on client
- @tanstack/react-query v5 with QueryClientProvider at app root
- client/src/api/queries.ts: query-key factory, hooks for tickets, ticket, audit,
  comments, users, CTI tree + cascade, plus full mutation set
  (create/update/delete ticket, add/delete comment, CTI CRUD, user CRUD)
- All page-level useEffect + useState fetching replaced:
  Dashboard, MyTickets, TicketDetail, NewTicket, admin/CTI, admin/Users
- Dashboard preserves 300ms debounced search via separate debouncedSearch state
- CTISelect cascades via useCategories / useTypes(categoryId) / useItems(typeId);
  dependent hooks disabled until parent selected
- vitest + @testing-library/react + jsdom; 6 client tests cover SeverityBadge + StatusBadge

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 15:35:09 -04:00
josh aff52e5672 Phase 1a: shared schemas, service layer, server tooling
- shared/schemas/: move Zod schemas out of routes so client + server share them
- shared/types.ts: inferred types and enums for cross-package use
- server tsconfig rootDir raised to ".." so shared/ compiles in-tree
- server/src/services/: ticket, comment, cti, user, auth, notification (stub), search (stub)
- Routes thinned to validate-delegate-return; business logic now testable in isolation
- server/src/lib/httpError.ts: typed HttpError replaces ad-hoc throw shapes
- server/src/lib/logger.ts: pino structured logging replaces console.log
- autoClose job delegates to ticketService.closeStale()
- express-rate-limit on /api/auth/login (10 / 15min / IP)
- vitest + vitest-mock-extended; 20 service-level tests cover auth, ticket, comment, user flows
- CI: lint + test jobs before docker builds

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 15:34:57 -04:00
josh 27d2ab0f0d Add ESLint + Prettier + EditorConfig tooling at repo root
v1.0 Phase 1.1 — repo-wide lint/format baseline.

- eslint.config.mjs (flat config) lints server, client, shared
- .prettierrc.json, .prettierignore, .editorconfig, .nvmrc
- Root package.json holds shared devDeps; per-package scripts keep
  their typecheck + test runners
- Fix 7 lint issues surfaced by the baseline run:
  - TicketDetail.tsx: replace ternary-with-side-effects with if/else
  - admin/Users.tsx: escape apostrophe in JSX
  - errorHandler.ts: typed err as unknown with ErrorLike refinement
  - users.ts: Prisma.UserUpdateInput instead of Record<string, any>
  - seed.ts: drop unused goddard binding
- Run prettier across tracked sources for a clean formatting baseline
2026-04-18 14:47:34 -04:00
Josh Wright 2a6090e473 Redesign comments to GitHub/Gitea style
Build & Push / TypeScript Check (client) (push) Successful in 14s
Build & Push / Build Server (push) Successful in 36s
Build & Push / Build Client (push) Successful in 33s
- Each comment is a bordered card with a distinct header bar (author name,
  clickable relative timestamp, hover-to-reveal delete) and a body section
- Subtle spine line connects comments in the avatar column
- Composer matches card style: same header bar for Write/Preview tabs,
  transparent textarea inside, submit row with border-top
- Comment timestamps default to relative, click to toggle absolute
  (mirrors sidebar date toggle pattern)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 12:38:36 -04:00
Josh Wright 8bea999b93 Remove /tickets prefix from ticket detail URLs
Build & Push / TypeScript Check (client) (push) Successful in 15s
Build & Push / Build Server (push) Successful in 42s
Build & Push / Build Client (push) Successful in 35s
Routes and links now use /:id (e.g. /V675409888) instead of /tickets/:id.
API calls are unaffected as they go through /api/tickets/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 12:30:10 -04:00
Josh Wright 8c86ad7bb8 Move edit pencil to title card top-right; hide Actions card for non-admins
Build & Push / TypeScript Check (client) (push) Successful in 14s
Build & Push / Build Server (push) Successful in 34s
Build & Push / Build Client (push) Successful in 33s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 12:24:53 -04:00
Josh Wright b976f9a3a1 Remove npm cache from setup-node to avoid cache timeout in CI
Build & Push / TypeScript Check (client) (push) Successful in 16s
Build & Push / Build Server (push) Successful in 45s
Build & Push / Build Client (push) Successful in 41s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 12:20:36 -04:00
Josh Wright e6c2298ac2 Remove unused SidebarField; add CI typecheck job before client Docker build
Build & Push / Build Server (push) Has been cancelled
Build & Push / Build Client (push) Has been cancelled
Build & Push / TypeScript Check (client) (push) Has been cancelled
- Delete the now-unused SidebarField component from TicketDetail.tsx
- Add typecheck-client CI job that runs tsc --noEmit on the client before
  the Docker build, so TypeScript errors surface fast with a clear message
- build-client now depends on typecheck-client passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 12:14:48 -04:00
Josh Wright 618a9136f6 Toggle date fields between relative and absolute on click
Build & Push / Build Server (push) Successful in 34s
Build & Push / Build Client (push) Failing after 26s
Defaults to relative time (e.g. '5 hours ago'); clicking switches to
absolute timestamp and back.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 12:10:12 -04:00
Josh Wright 2638eb226b Remove unused selectClass constant
Build & Push / Build Server (push) Successful in 43s
Build & Push / Build Client (push) Successful in 36s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 12:06:41 -04:00
Josh Wright 402de82750 Unify sidebar fields: all editable fields use full-width clickable blocks
Build & Push / Build Server (push) Successful in 51s
Build & Push / Build Client (push) Failing after 26s
- Status and Severity now match CTI style (full-width button, hover bg, no chevron)
- Remove 'Change routing' hint text from CTI block
- Replace Assignee dropdown with clickable block that opens a modal picker with avatars
- Add Assignee modal consistent with Status/Severity modal pattern

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 12:03:39 -04:00
Josh Wright 44e5e2d373 Redesign ticket sidebar: modal pickers for status/severity, CTI as clickable unit
Build & Push / Build Server (push) Successful in 44s
Build & Push / Build Client (push) Successful in 37s
- Rename 'Details' card to 'Ticket Summary'
- Replace status/severity dropdowns with badge displays that open small modal pickers on click
- Show Category, Type, Issue as separate labeled rows that together act as one clickable unit opening the routing modal
- Reorganize into sections: status/severity, CTI routing, dates (created/modified/resolved), people (assignee + requester)
- Add Requester field showing the ticket creator with avatar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 11:56:53 -04:00
josh 2b76ad27b1 Reroute ticket via modal instead of inline sidebar expansion
Build & Push / Build Client (push) Successful in 37s
Build & Push / Build Server (push) Successful in 51s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 23:40:28 -04:00
josh d751e36ae8 Fix My Tickets and queue filter
Build & Push / Build Server (push) Successful in 58s
Build & Push / Build Client (push) Successful in 41s
- My Tickets: exclude RESOLVED and CLOSED, show active tickets only
- Queue filter: cascading Category > Type > Item picker — each leaf is
  a distinct queue (e.g. TheWrightServer > Automation > Backup vs Sync)
- Server: support typeId and itemId as ticket list filter params

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 23:35:33 -04:00
josh 725f91578d Dark theme, roles overhaul, modal New Ticket, My Tickets page, and more
Build & Push / Build Server (push) Successful in 2m5s
Build & Push / Build Client (push) Successful in 41s
- Dark UI across all pages and components (gray-950/900/800 palette)
- New Ticket is now a centered modal (triggered from sidebar), not a separate page
- Add USER role: view and comment only; AGENT and SERVICE can create/edit tickets
- Only admins can set ticket status to CLOSED (enforced server + UI)
- Add My Tickets page (/my-tickets) showing tickets assigned to current user
- Add queue (category) filter to Dashboard
- Audit log entries are clickable to expand detail; comment body shown as markdown
- Resolved date now includes time (HH:mm) in ticket sidebar
- Store comment body in audit log detail for COMMENT_ADDED and COMMENT_DELETED
- Clarify role descriptions in Admin Users modal
- Remove CI/CD section from README; add full API reference documentation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 23:17:14 -04:00
josh d8dc5b3ded Full-page ticket layout: two-column with sticky sidebar
Build & Push / Build Server (push) Successful in 1m26s
Build & Push / Build Client (push) Successful in 39s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 21:21:46 -04:00
josh 64477529ea Add dbgenerated default for displayId
Build & Push / Build Server (push) Successful in 57s
Build & Push / Build Client (push) Successful in 41s
prisma db push cannot add a non-nullable column to an existing table
without a database-level default. Using a PostgreSQL expression to
generate V + 9 random digits as the fallback default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 20:58:44 -04:00
141 changed files with 19687 additions and 2204 deletions
+15
View File
@@ -0,0 +1,15 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[Makefile]
indent_style = tab
+9
View File
@@ -17,3 +17,12 @@ CLIENT_URL=http://tickets.thewrightserver.net
# Host port NPM proxies to — change if 3080 is taken
PORT=3080
# ── Email notifications (optional) ────────────────────────────────────────────
# Leave SMTP_HOST empty to disable outgoing email entirely.
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM=tickets@thewrightserver.net
SMTP_SECURE=false
+58 -2
View File
@@ -10,8 +10,61 @@ env:
OWNER: ${{ github.repository_owner }}
jobs:
test-client:
name: Test (client)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
run: npm ci
working-directory: ./client
- name: Type check
run: npm run typecheck
working-directory: ./client
- name: Unit tests
run: npm test
working-directory: ./client
test-server:
name: Test (server)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
run: npm ci
working-directory: ./server
- name: Prisma generate
run: npx prisma generate
working-directory: ./server
- name: Type check
run: npm run typecheck
working-directory: ./server
- name: Unit tests
run: npm test
working-directory: ./server
build-server:
name: Build Server
needs: test-server
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -30,7 +83,8 @@ jobs:
- name: Build and push server
uses: docker/build-push-action@v6
with:
context: ./server
context: .
file: ./server/Dockerfile
push: true
tags: |
${{ env.REGISTRY }}/${{ env.OWNER }}/ticketing-server:latest
@@ -38,6 +92,7 @@ jobs:
build-client:
name: Build Client
needs: test-client
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -56,7 +111,8 @@ jobs:
- name: Build and push client
uses: docker/build-push-action@v6
with:
context: ./client
context: .
file: ./client/Dockerfile
push: true
tags: |
${{ env.REGISTRY }}/${{ env.OWNER }}/ticketing-client:latest
+1
View File
@@ -0,0 +1 @@
22
+10
View File
@@ -0,0 +1,10 @@
node_modules
dist
build
.vite
coverage
*.min.js
*.min.css
package-lock.json
server/prisma/migrations
client/src/components/ui
+10
View File
@@ -0,0 +1,10 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"arrowParens": "always",
"bracketSpacing": true,
"endOfLine": "lf"
}
+418 -38
View File
@@ -1,19 +1,44 @@
# TicketingSystem
Internal ticketing system with CTI-based routing, severity levels, and automation integration.
Internal ticketing system with CTI-based routing, severity levels, role-based access, and automation integration.
## Features
- **CTI routing** — tickets are categorised by Category → Type → Item, reroutable at any time
- **CTI routing** — tickets categorised by Category → Type → Item, reroutable at any time
- **Severity 15** — SEV 1 (critical) through SEV 5 (minimal); dashboard sorts by severity
- **Status lifecycle** — Open → In Progress → Resolved → Closed; resolved tickets auto-close after 14 days
- **Comments** — threaded comments per ticket with author attribution
- **Roles** — Admin, Agent, Service (API key auth for automation accounts)
- **Admin panel** — manage users and the full CTI hierarchy via UI
- **Full-text search** — Postgres-native tsvector search across tickets and comments
- **Saved views** — per-user filter presets on the tickets list
- **Pagination + bulk actions** — page through large result sets and reassign / close / reprioritise in batches
- **Attachments** — upload files on tickets and comments (25 MB each, local disk)
- **Notifications** — in-app bell + optional SMTP email on assignment, @mention, and resolution
- **Outgoing webhooks** — HMAC-signed POSTs on ticket/comment events, with per-endpoint secret and retries
- **@mentions** — autocomplete in comments; triggers notifications and deep-links to the mentioned user's queue
- **Analytics dashboard** — open-by-severity, aging tickets, resolution time, queue load
- **CSV export** — stream any filtered ticket view to CSV
- **Command palette + keyboard shortcuts** — ⌘K palette, `j/k` navigation, `g d/t/m/n/s` leaders, `?` help
- **PWA** — installable on mobile, offline app shell
- **Comments** — GitHub/Gitea-style threads with markdown, draft autosave, Ctrl+Enter submit
- **Roles** — Admin, Agent, User, Service (API key auth for automation)
- **Audit log** — every action tracked with actor, timestamp, and expandable detail
- **Admin panel** — manage users, CTI hierarchy, and webhooks via UI
- **n8n ready** — service accounts authenticate via `X-Api-Key` header
---
## Roles
| Role | Access |
| ----------- | ---------------------------------------------------------------------------- |
| **Admin** | Full access — manage users, CTI config, webhooks, close and delete tickets |
| **Agent** | Manage tickets — create, update, assign, comment, change status (not Closed) |
| **User** | Basic access — view tickets and add comments only |
| **Service** | Automation account — authenticates via API key, no password login |
> Only **Admins** can manually set a ticket status to **Closed**.
---
## Production Deployment
### Prerequisites
@@ -44,6 +69,14 @@ POSTGRES_PASSWORD=<strong password>
JWT_SECRET=<output of: openssl rand -hex 64>
CLIENT_URL=http://tickets.thewrightserver.net
PORT=3080
# Optional — email notifications
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASS=<smtp password>
SMTP_FROM=tickets@thewrightserver.net
SMTP_SECURE=false
```
Point NPM at `http://<host-ip>:3080` for the proxy host.
@@ -55,16 +88,30 @@ docker compose pull
docker compose up -d
```
### 5. Seed (first deploy only)
The server exposes an unauthenticated `GET /healthz` that returns `{"status":"ok"}` and is polled by the compose healthcheck.
### 4. Seed (first deploy only)
```bash
docker compose exec server npm run db:seed
```
This creates:
- `admin` user (password: `admin123`) — **change this immediately**
- `goddard` service account — API key is printed to the console; copy it now
### Upgrading from v0.9
v1.0 is drop-in for existing deployments — the schema changes are all additive (new tables for attachments, webhooks, notifications, saved views; new columns for notification prefs and search vectors). No data migration is required.
```bash
docker compose pull
docker compose up -d
```
`npm run start:prod` runs `prisma db push` on boot, which applies the new tables and the search-index migration automatically. Set the new `SMTP_*` env vars if you want email notifications — otherwise they're silently skipped.
---
## Development
@@ -92,9 +139,11 @@ docker run -d \
cd server
cp .env.example .env # set DATABASE_URL and JWT_SECRET
npm install
npm run db:migrate # creates tables + migration files
npm run db:push # creates tables + search indexes
npm run db:seed # seeds admin + Goddard + sample CTI
npm run dev # http://localhost:3000
npm test # vitest (service layer)
npm run typecheck
```
### Client
@@ -103,8 +152,339 @@ npm run dev # http://localhost:3000
cd client
npm install
npm run dev # http://localhost:5173 (proxies /api to :3000)
npm test # vitest + testing-library
npm run typecheck
```
CI runs typecheck + tests on both packages before building Docker images.
---
## API Reference
All endpoints (except `/api/auth/*` and `/healthz`) require authentication via one of:
- **JWT**: `Authorization: Bearer <token>` (obtained from `POST /api/auth/login`)
- **API Key**: `X-Api-Key: sk_<key>` (Service accounts only)
Base URL: `https://tickets.thewrightserver.net/api`
`POST /api/auth/login` is rate-limited to 10 attempts per 15 minutes per IP.
---
### Authentication
#### `POST /api/auth/login`
Authenticate and receive a JWT.
**Body:**
```json
{ "username": "string", "password": "string" }
```
**Response:**
```json
{
"token": "eyJ...",
"user": {
"id": "...",
"username": "admin",
"displayName": "Admin",
"email": "...",
"role": "ADMIN"
}
}
```
#### `GET /api/auth/me`
Returns the currently authenticated user.
---
### Tickets
#### `GET /api/tickets`
List tickets, sorted by severity (ASC) then created date (DESC).
**Query parameters:**
| Parameter | Type | Description |
| ------------ | ------ | --------------------------------------------------------------------- |
| `status` | string | Filter by status: `OPEN`, `IN_PROGRESS`, `RESOLVED`, `CLOSED` |
| `severity` | number | Filter by severity: `1``5` |
| `categoryId` | string | Filter by category (queue) |
| `assigneeId` | string | Filter by assignee user ID |
| `createdById`| string | Filter by author |
| `search` | string | Full-text search (tsvector) on title, overview, and display ID |
| `page` | number | Page number, 1-indexed. When omitted the endpoint returns a bare array (v0.9 shape) |
| `pageSize` | number | Page size (default 25, max 100) |
**Response (paginated):**
```json
{
"data": [ /* tickets */ ],
"total": 134,
"page": 1,
"pageSize": 25
}
```
**Response (unpaginated — `page` omitted):** Array of ticket objects with nested `category`, `type`, `item`, `assignee`, `createdBy`, and `_count.comments`. This preserves compatibility with v0.9 clients and the Goddard integration.
---
#### `GET /api/tickets/:id`
Fetch a single ticket by internal ID or display ID (e.g. `V325813929`). Includes full `comments` array.
---
#### `POST /api/tickets`
Create a new ticket. Requires **Agent**, **Admin**, or **Service** role.
**Body:**
```json
{
"title": "string",
"overview": "string (markdown)",
"severity": 1,
"categoryId": "string",
"typeId": "string",
"itemId": "string",
"assigneeId": "string (optional)"
}
```
**Response:** Created ticket object (201).
---
#### `PATCH /api/tickets/:id`
Update a ticket. Accepts any combination of fields. Requires **Agent**, **Admin**, or **Service** role.
> Setting `status` to `CLOSED` requires **Admin** role.
All changes are recorded in the audit log automatically.
---
#### `DELETE /api/tickets/:id`
Delete a ticket and all associated comments, audit logs, and attachments. **Admin only.**
---
#### `POST /api/tickets/bulk`
Apply an action to many tickets at once. **Agent** or **Admin**.
```json
{ "ids": ["..."], "action": "reassign" | "close" | "setSeverity", "value": "..." }
```
---
#### `GET /api/export/tickets.csv`
Stream the matching tickets (same filter params as `GET /api/tickets`) as CSV.
---
### Search
#### `GET /api/search?q=<query>&limit=10`
Cross-resource search over tickets and comments using Postgres `plainto_tsquery` with rank ordering.
**Response:**
```json
{
"tickets": [ /* ticket summaries */ ],
"comments": [ /* comment snippets with ticket context */ ]
}
```
---
### Analytics
#### `GET /api/analytics/summary?window=30`
Returns aggregates for the given window (days — 14 / 30 / 90):
```json
{
"openBySeverity": { "1": 2, "2": 5, "3": 14, "4": 8, "5": 3 },
"aging": { "over7d": 6, "over14d": 2 },
"queueLoad": [ { "categoryId": "...", "name": "TheWrightServer", "open": 12 } ],
"medianResolutionHours": 9.4
}
```
---
### Saved Views
Per-user filter presets.
| Method | Endpoint | Description |
| -------- | ------------------------- | ------------------------ |
| `GET` | `/api/saved-views` | List the caller's views |
| `POST` | `/api/saved-views` | Create — `{ name, filters }` |
| `PATCH` | `/api/saved-views/:id` | Rename / edit filters |
| `DELETE` | `/api/saved-views/:id` | Delete |
---
### Notifications
| Method | Endpoint | Description |
| -------- | ------------------------------- | ---------------------------------------- |
| `GET` | `/api/notifications` | Caller's notifications, newest first |
| `POST` | `/api/notifications/read` | Mark specific or all — returns `{ updated }` |
Notifications are created on assignment, @mention, and status → RESOLVED. Email delivery is attempted when SMTP is configured and the target user has email notifications enabled in `User.notificationPrefs`.
---
### Attachments
| Method | Endpoint | Description |
| -------- | ------------------------------------------ | ---------------------------------- |
| `POST` | `/api/tickets/:id/attachments` | Upload (multipart, field `file`) |
| `POST` | `/api/comments/:id/attachments` | Upload on a comment |
| `GET` | `/api/attachments/:id` | Stream with original filename |
| `DELETE` | `/api/attachments/:id` | Uploader or Admin |
Size limit 25 MB per file. Storage path is controlled by `UPLOADS_DIR` (defaults to `./uploads` in dev, `/data/uploads` in the Docker image backed by the `uploads` volume).
---
### Webhooks
Admin-only. Outgoing webhooks fire on `ticket.created`, `ticket.status_changed`, `ticket.assigned`, and `comment.created`.
| Method | Endpoint | Description |
| -------- | --------------------------------------- | -------------------------- |
| `GET` | `/api/webhooks` | List |
| `POST` | `/api/webhooks` | Create — `{ name, url, events, secret? }` |
| `PATCH` | `/api/webhooks/:id` | Update |
| `DELETE` | `/api/webhooks/:id` | Delete |
| `POST` | `/api/webhooks/:id/rotate-secret` | Rotate secret |
**Signature:** each delivery includes `X-Ticketing-Signature: sha256=<hex>`, which is the HMAC-SHA256 of the raw request body using the webhook's secret. Retries up to 3× with exponential backoff on non-2xx responses.
---
### Comments
#### `POST /api/tickets/:id/comments`
Add a comment. Markdown body. @mentions are parsed and fire notifications to matched users.
```json
{ "body": "string (markdown)" }
```
#### `DELETE /api/tickets/:id/comments/:commentId`
Delete a comment. Authors may delete their own; Admins may delete any.
---
### Audit Log
#### `GET /api/tickets/:id/audit`
Retrieve the full audit log for a ticket, ordered newest first.
**Action types:**
| Action | Detail |
| --------------------- | -------------------------------------------------------------- |
| `CREATED` | — |
| `STATUS_CHANGED` | e.g. `Open → In Progress` |
| `SEVERITY_CHANGED` | e.g. `SEV 3 → SEV 1` |
| `ASSIGNEE_CHANGED` | e.g. `Unassigned → Josh` |
| `REROUTED` | e.g. `OldCat OldType OldItem → NewCat NewType NewItem` |
| `TITLE_CHANGED` | New title |
| `OVERVIEW_CHANGED` | — |
| `COMMENT_ADDED` | Comment body |
| `COMMENT_DELETED` | Deleted comment body |
| `ATTACHMENT_ADDED` | Filename |
| `ATTACHMENT_REMOVED` | Filename |
---
### CTI (Category / Type / Item)
#### `GET /api/cti/categories`
#### `GET /api/cti/types?categoryId=<id>`
#### `GET /api/cti/items?typeId=<id>`
Read the CTI hierarchy. Used to resolve IDs when creating/rerouting tickets.
**Admin-only write operations:**
| Method | Endpoint | Body |
| -------- | ------------------------- | ---------------------------------------------- |
| `POST` | `/api/cti/categories` | `{ "name": "string" }` |
| `PUT` | `/api/cti/categories/:id` | `{ "name": "string" }` |
| `DELETE` | `/api/cti/categories/:id` | — |
| `POST` | `/api/cti/types` | `{ "name": "string", "categoryId": "string" }` |
| `PUT` | `/api/cti/types/:id` | `{ "name": "string" }` |
| `DELETE` | `/api/cti/types/:id` | — |
| `POST` | `/api/cti/items` | `{ "name": "string", "typeId": "string" }` |
| `PUT` | `/api/cti/items/:id` | `{ "name": "string" }` |
| `DELETE` | `/api/cti/items/:id` | — |
Deleting a category cascades to all child types and items.
---
### Users
#### `GET /api/users`
List all users (id, username, displayName, email, role). Used to populate assignee dropdowns.
**Admin-only operations:**
#### `POST /api/users`
Create a user.
```json
{
"username": "string",
"email": "string",
"displayName": "string",
"password": "string (not required for SERVICE role)",
"role": "ADMIN | AGENT | USER | SERVICE"
}
```
Service accounts receive an auto-generated API key returned in the response. Copy it immediately — it is not shown again.
#### `PATCH /api/users/:id`
Update a user — including `notificationPrefs` and `regenerateApiKey`.
#### `DELETE /api/users/:id`
Delete a user. Cannot delete your own account.
---
## n8n Integration (Goddard)
@@ -114,7 +494,7 @@ The `goddard` service account authenticates via API key — no login flow needed
**Create a ticket from n8n:**
```
POST https://tickets.thewrightserver.net/api/tickets
POST /api/tickets
X-Api-Key: sk_<goddard api key>
Content-Type: application/json
@@ -130,6 +510,7 @@ Content-Type: application/json
```
CTI IDs can be fetched from:
- `GET /api/cti/categories`
- `GET /api/cti/types?categoryId=<id>`
- `GET /api/cti/items?typeId=<id>`
@@ -138,43 +519,23 @@ To regenerate the Goddard API key: Admin → Users → refresh icon next to Godd
---
## CI/CD
Push to `main` triggers `.gitea/workflows/build.yml`, which builds and pushes two images in parallel:
| Image | Tag |
|---|---|
| `$REGISTRY/josh/ticketing-server` | `latest`, `<git sha>` |
| `$REGISTRY/josh/ticketing-client` | `latest`, `<git sha>` |
**Gitea repository secrets/variables required:**
| Name | Type | Value |
|---|---|---|
| `REGISTRY` | Variable | `gitea.thewrightserver.net` |
| `REGISTRY_TOKEN` | Secret | Gitea personal access token with `write:packages` |
Set these under **Repository → Settings → Actions → Variables / Secrets**.
To deploy a specific commit SHA instead of latest:
```bash
TAG=<sha> docker compose up -d
```
---
## Environment Variables
| Variable | Required | Description |
|---|---|---|
| ------------------- | -------- | ---------------------------------------------------- |
| `DATABASE_URL` | Yes | PostgreSQL connection string |
| `JWT_SECRET` | Yes | Secret for signing JWTs — use `openssl rand -hex 64` |
| `CLIENT_URL` | Yes | Allowed CORS origin (your domain) |
| `PORT` | No | Server port (default: `3000`) |
| `UPLOADS_DIR` | No | Attachment storage path (default `/data/uploads` in Docker) |
| `SMTP_HOST` | No | SMTP host — when set, email notifications are sent |
| `SMTP_PORT` | No | SMTP port (default `587`) |
| `SMTP_USER` | No | SMTP username |
| `SMTP_PASS` | No | SMTP password |
| `SMTP_FROM` | No | From address (default `noreply@localhost`) |
| `SMTP_SECURE` | No | `true` for implicit TLS (port 465), else `false` |
| `REGISTRY` | Deploy | Container registry hostname |
| `POSTGRES_PASSWORD` | Deploy | Postgres password |
| `DOMAIN` | Deploy | Public domain for Traefik routing |
| `TAG` | Deploy | Image tag to deploy (default: `latest`) |
---
@@ -182,14 +543,14 @@ TAG=<sha> docker compose up -d
## Ticket Severity
| Level | Label | Meaning |
|---|---|---|
| ----- | ----- | ------------------------------------ |
| 1 | SEV 1 | Critical — immediate action required |
| 2 | SEV 2 | High — significant impact |
| 3 | SEV 3 | Medium — standard priority |
| 4 | SEV 4 | Low — minor issue |
| 5 | SEV 5 | Minimal — informational / automated |
Tickets are sorted SEV 1 → SEV 5 on the dashboard. Paging by severity is planned for a future release.
Tickets are sorted SEV 1 → SEV 5 on the dashboard.
---
@@ -201,3 +562,22 @@ OPEN → IN_PROGRESS → RESOLVED ──(14 days)──→ CLOSED
re-opens reset
the 14-day timer
```
> CLOSED status can only be set manually by an **Admin**. The auto-close job runs hourly.
---
## Keyboard Shortcuts
| Context | Keys | Action |
| -------- | --------------------------------- | ---------------------------- |
| Global | `⌘K` / `Ctrl+K` | Command palette |
| Global | `?` | Shortcuts help |
| Global | `c` | Create ticket (Agent/Admin) |
| Navigate | `g` then `d` / `t` / `m` / `n` / `s` | Dashboard / Tickets / My Tickets / Notifications / Settings |
| List | `j` / `k` | Move cursor |
| List | `Enter` | Open focused ticket |
| List | `x` | Toggle selection |
| Detail | `e` | Edit title |
| Detail | `r` | Reply (focus composer) |
| Detail | `Ctrl+Enter` / `⌘Enter` | Submit comment |
+7 -6
View File
@@ -1,12 +1,13 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
COPY client/package*.json ./client/
RUN cd client && npm ci
COPY client ./client
COPY shared ./shared
RUN cd client && npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/client/dist /usr/share/nginx/html
COPY client/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+5 -1
View File
@@ -1,8 +1,12 @@
<!doctype html>
<html lang="en">
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#2563eb" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="apple-touch-icon" href="/icon.svg" />
<title>Ticketing System</title>
</head>
<body>
+4045 -72
View File
File diff suppressed because it is too large Load Diff
+30 -2
View File
@@ -6,11 +6,29 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.62.0",
"axios": "^1.7.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
"date-fns": "^3.6.0",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
@@ -19,16 +37,26 @@
"react-markdown": "^9.0.1",
"react-router-dom": "^6.28.0",
"remark-gfm": "^4.0.0",
"sonner": "^1.7.1",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8"
},
"devDependencies": {
"@tanstack/react-query-devtools": "^5.62.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^25.6.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"jsdom": "^25.0.1",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"typescript": "^5.7.2",
"vite": "^6.0.5"
"vite": "^6.0.5",
"vitest": "^2.1.8"
}
}
+1 -1
View File
@@ -3,4 +3,4 @@ export default {
tailwindcss: {},
autoprefixer: {},
},
}
};
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#2563eb"/>
<path fill="#ffffff" d="M128 144c0-8.8 7.2-16 16-16h224c8.8 0 16 7.2 16 16v48c0 8.8-7.2 16-16 16-8.8 0-16-7.2-16-16v-32H280v224h40c8.8 0 16 7.2 16 16s-7.2 16-16 16H192c-8.8 0-16-7.2-16-16s7.2-16 16-16h40V160h-72v32c0 8.8-7.2 16-16 16s-16-7.2-16-16v-48z"/>
</svg>

After

Width:  |  Height:  |  Size: 386 B

+13
View File
@@ -0,0 +1,13 @@
{
"name": "Ticketing System",
"short_name": "Tickets",
"description": "Homelab ticketing with CTI routing, severity triage and n8n integration.",
"start_url": "/dashboard",
"scope": "/",
"display": "standalone",
"background_color": "#0a0a0a",
"theme_color": "#2563eb",
"icons": [
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }
]
}
+58
View File
@@ -0,0 +1,58 @@
// Minimal service worker: offline shell only. No data caching.
const CACHE = 'ticketing-shell-v1';
const SHELL = ['/', '/icon.svg', '/manifest.webmanifest'];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE).then((cache) => cache.addAll(SHELL)),
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))),
),
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
const req = event.request;
if (req.method !== 'GET') return;
const url = new URL(req.url);
// Never cache API calls — they must always hit the server
if (url.pathname.startsWith('/api/')) return;
// Network-first for navigation, fall back to cached shell
if (req.mode === 'navigate') {
event.respondWith(
fetch(req)
.then((res) => {
const clone = res.clone();
caches.open(CACHE).then((c) => c.put('/', clone));
return res;
})
.catch(() => caches.match('/')),
);
return;
}
// Cache-first for static assets
if (url.origin === self.location.origin) {
event.respondWith(
caches.match(req).then((cached) => {
if (cached) return cached;
return fetch(req).then((res) => {
if (res.ok && res.type === 'basic') {
const clone = res.clone();
caches.open(CACHE).then((c) => c.put(req, clone));
}
return res;
});
}),
);
}
});
+23 -14
View File
@@ -1,13 +1,17 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider } from './contexts/AuthContext'
import PrivateRoute from './components/PrivateRoute'
import AdminRoute from './components/AdminRoute'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import TicketDetail from './pages/TicketDetail'
import NewTicket from './pages/NewTicket'
import AdminUsers from './pages/admin/Users'
import AdminCTI from './pages/admin/CTI'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext';
import PrivateRoute from './components/PrivateRoute';
import AdminRoute from './components/AdminRoute';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Tickets from './pages/Tickets';
import MyTickets from './pages/MyTickets';
import TicketDetail from './pages/TicketDetail';
import Notifications from './pages/Notifications';
import Settings from './pages/Settings';
import AdminUsers from './pages/admin/Users';
import AdminCTI from './pages/admin/CTI';
import AdminWebhooks from './pages/admin/Webhooks';
export default function App() {
return (
@@ -16,17 +20,22 @@ export default function App() {
<Routes>
<Route path="/login" element={<Login />} />
<Route element={<PrivateRoute />}>
<Route path="/" element={<Dashboard />} />
<Route path="/tickets/new" element={<NewTicket />} />
<Route path="/tickets/:id" element={<TicketDetail />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/tickets" element={<Tickets />} />
<Route path="/my-tickets" element={<MyTickets />} />
<Route path="/notifications" element={<Notifications />} />
<Route path="/settings" element={<Settings />} />
<Route path="/:id" element={<TicketDetail />} />
<Route element={<AdminRoute />}>
<Route path="/admin/users" element={<AdminUsers />} />
<Route path="/admin/cti" element={<AdminCTI />} />
<Route path="/admin/webhooks" element={<AdminWebhooks />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</AuthProvider>
)
);
}
+3 -3
View File
@@ -1,7 +1,7 @@
import axios from 'axios'
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
})
});
export default api
export default api;
+443
View File
@@ -0,0 +1,443 @@
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
import api from './client';
import { Ticket, Category, CTIType, Item, User, AuditLog, Comment } from '../types';
import type {
Notification,
SavedView,
Webhook,
PaginatedResponse,
} from '../../../shared/types';
// ── Keys ─────────────────────────────────────────────────────────────────────
export const qk = {
tickets: (filters?: Record<string, string | number | undefined>) =>
['tickets', filters ?? {}] as const,
ticketsPaged: (filters?: Record<string, string | number | undefined>) =>
['tickets-paged', filters ?? {}] as const,
ticket: (id: string) => ['ticket', id] as const,
ticketAudit: (id: string) => ['ticket', id, 'audit'] as const,
categories: () => ['cti', 'categories'] as const,
types: (categoryId?: string) => ['cti', 'types', categoryId ?? null] as const,
items: (typeId?: string) => ['cti', 'items', typeId ?? null] as const,
users: () => ['users'] as const,
notifications: (unread?: boolean) => ['notifications', { unread: !!unread }] as const,
webhooks: () => ['webhooks'] as const,
savedViews: () => ['saved-views'] as const,
analytics: (window: number) => ['analytics', window] as const,
};
// ── Tickets ──────────────────────────────────────────────────────────────────
export function useTickets(params: Record<string, string | number | undefined> = {}) {
// Strip undefined values for a stable key
const clean: Record<string, string | number> = {};
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== '' && v !== null) clean[k] = v;
});
return useQuery({
queryKey: qk.tickets(clean),
queryFn: async () => {
const res = await api.get<Ticket[]>('/tickets', { params: clean });
return res.data;
},
placeholderData: keepPreviousData,
});
}
export function useTicketsPaged(params: Record<string, string | number | undefined> = {}) {
const clean: Record<string, string | number> = {};
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== '' && v !== null) clean[k] = v;
});
return useQuery({
queryKey: qk.ticketsPaged(clean),
queryFn: async () => {
const res = await api.get<PaginatedResponse<Ticket>>('/tickets', { params: clean });
return res.data;
},
placeholderData: keepPreviousData,
});
}
export function useBulkTickets() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { ids: string[]; action: string; value?: unknown }) =>
(await api.post('/tickets/bulk', data)).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tickets'] });
qc.invalidateQueries({ queryKey: ['tickets-paged'] });
},
});
}
export function useTicket(id: string | undefined) {
return useQuery({
queryKey: qk.ticket(id ?? ''),
queryFn: async () => (await api.get<Ticket>(`/tickets/${id}`)).data,
enabled: !!id,
});
}
export function useTicketAudit(id: string | undefined, enabled = true) {
return useQuery({
queryKey: qk.ticketAudit(id ?? ''),
queryFn: async () => (await api.get<AuditLog[]>(`/tickets/${id}/audit`)).data,
enabled: !!id && enabled,
});
}
export function useCreateTicket() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: Record<string, unknown>) =>
(await api.post<Ticket>('/tickets', data)).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tickets'] });
},
});
}
export function useUpdateTicket() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, data }: { id: string; data: Record<string, unknown> }) =>
(await api.patch<Ticket>(`/tickets/${id}`, data)).data,
onSuccess: (ticket) => {
qc.setQueryData(qk.ticket(ticket.displayId), ticket);
qc.invalidateQueries({ queryKey: ['tickets'] });
qc.invalidateQueries({ queryKey: ['ticket', ticket.displayId, 'audit'] });
},
});
}
export function useDeleteTicket() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
await api.delete(`/tickets/${id}`);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tickets'] });
},
});
}
// ── Comments ─────────────────────────────────────────────────────────────────
export function useAddComment(ticketId: string | undefined) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: string) =>
(await api.post<Comment>(`/tickets/${ticketId}/comments`, { body })).data,
onSuccess: () => {
if (ticketId) {
qc.invalidateQueries({ queryKey: qk.ticket(ticketId) });
qc.invalidateQueries({ queryKey: qk.ticketAudit(ticketId) });
}
},
});
}
export function useDeleteComment(ticketId: string | undefined) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (commentId: string) => {
await api.delete(`/tickets/${ticketId}/comments/${commentId}`);
},
onSuccess: () => {
if (ticketId) {
qc.invalidateQueries({ queryKey: qk.ticket(ticketId) });
qc.invalidateQueries({ queryKey: qk.ticketAudit(ticketId) });
}
},
});
}
// ── CTI ──────────────────────────────────────────────────────────────────────
export function useCategories() {
return useQuery({
queryKey: qk.categories(),
queryFn: async () => (await api.get<Category[]>('/cti/categories')).data,
staleTime: 5 * 60 * 1000,
});
}
export function useTypes(categoryId?: string) {
return useQuery({
queryKey: qk.types(categoryId),
queryFn: async () =>
(await api.get<CTIType[]>('/cti/types', { params: categoryId ? { categoryId } : {} })).data,
enabled: !!categoryId,
staleTime: 5 * 60 * 1000,
});
}
export function useItems(typeId?: string) {
return useQuery({
queryKey: qk.items(typeId),
queryFn: async () =>
(await api.get<Item[]>('/cti/items', { params: typeId ? { typeId } : {} })).data,
enabled: !!typeId,
staleTime: 5 * 60 * 1000,
});
}
function useCtiMutation<TVars>(fn: (vars: TVars) => Promise<unknown>) {
const qc = useQueryClient();
return useMutation({
mutationFn: fn,
onSuccess: () => qc.invalidateQueries({ queryKey: ['cti'] }),
});
}
export function useCreateCategory() {
return useCtiMutation(async (data: { name: string }) =>
(await api.post<Category>('/cti/categories', data)).data,
);
}
export function useUpdateCategory() {
return useCtiMutation(async ({ id, name }: { id: string; name: string }) =>
(await api.put<Category>(`/cti/categories/${id}`, { name })).data,
);
}
export function useDeleteCategory() {
return useCtiMutation(async (id: string) => {
await api.delete(`/cti/categories/${id}`);
});
}
export function useCreateType() {
return useCtiMutation(async (data: { name: string; categoryId: string }) =>
(await api.post<CTIType>('/cti/types', data)).data,
);
}
export function useUpdateType() {
return useCtiMutation(async ({ id, name }: { id: string; name: string }) =>
(await api.put<CTIType>(`/cti/types/${id}`, { name })).data,
);
}
export function useDeleteType() {
return useCtiMutation(async (id: string) => {
await api.delete(`/cti/types/${id}`);
});
}
export function useCreateItem() {
return useCtiMutation(async (data: { name: string; typeId: string }) =>
(await api.post<Item>('/cti/items', data)).data,
);
}
export function useUpdateItem() {
return useCtiMutation(async ({ id, name }: { id: string; name: string }) =>
(await api.put<Item>(`/cti/items/${id}`, { name })).data,
);
}
export function useDeleteItem() {
return useCtiMutation(async (id: string) => {
await api.delete(`/cti/items/${id}`);
});
}
// ── Users ────────────────────────────────────────────────────────────────────
export function useUsers() {
return useQuery({
queryKey: qk.users(),
queryFn: async () => (await api.get<User[]>('/users')).data,
staleTime: 60 * 1000,
});
}
export function useCreateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: Record<string, unknown>) =>
(await api.post<User>('/users', data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: qk.users() }),
});
}
export function useUpdateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, data }: { id: string; data: Record<string, unknown> }) =>
(await api.patch<User>(`/users/${id}`, data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: qk.users() }),
});
}
export function useDeleteUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
await api.delete(`/users/${id}`);
},
onSuccess: () => qc.invalidateQueries({ queryKey: qk.users() }),
});
}
// ── Notifications ────────────────────────────────────────────────────────────
export function useNotifications(unread = false) {
return useQuery({
queryKey: qk.notifications(unread),
queryFn: async () =>
(
await api.get<Notification[]>('/notifications', {
params: unread ? { unread: 'true' } : {},
})
).data,
refetchInterval: 60_000,
});
}
export function useMarkNotificationsRead() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { ids?: string[]; all?: boolean }) =>
(await api.post<{ updated: number }>('/notifications/read', data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications'] }),
});
}
export function useUnreadCount() {
return useQuery({
queryKey: ['notifications', 'unread-count'],
queryFn: async () =>
(await api.get<{ count: number }>('/notifications/unread-count')).data.count,
refetchInterval: 60_000,
});
}
// ── Webhooks ─────────────────────────────────────────────────────────────────
export function useWebhooks() {
return useQuery({
queryKey: qk.webhooks(),
queryFn: async () => (await api.get<Webhook[]>('/webhooks')).data,
});
}
export function useCreateWebhook() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { name: string; url: string; events: string[] }) =>
(await api.post<Webhook>('/webhooks', data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: qk.webhooks() }),
});
}
export function useUpdateWebhook() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, data }: { id: string; data: Record<string, unknown> }) =>
(await api.patch<Webhook>(`/webhooks/${id}`, data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: qk.webhooks() }),
});
}
export function useDeleteWebhook() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
await api.delete(`/webhooks/${id}`);
},
onSuccess: () => qc.invalidateQueries({ queryKey: qk.webhooks() }),
});
}
export function useRotateWebhookSecret() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: string) =>
(await api.post<Webhook>(`/webhooks/${id}/rotate-secret`)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: qk.webhooks() }),
});
}
// ── Saved Views ──────────────────────────────────────────────────────────────
export function useSavedViews() {
return useQuery({
queryKey: qk.savedViews(),
queryFn: async () => (await api.get<SavedView[]>('/saved-views')).data,
staleTime: 60_000,
});
}
export function useCreateSavedView() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { name: string; filters: Record<string, unknown> }) =>
(await api.post<SavedView>('/saved-views', data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: qk.savedViews() }),
});
}
export function useDeleteSavedView() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
await api.delete(`/saved-views/${id}`);
},
onSuccess: () => qc.invalidateQueries({ queryKey: qk.savedViews() }),
});
}
// ── Analytics ────────────────────────────────────────────────────────────────
export interface AnalyticsSummary {
windowDays: number;
openBySeverity: { severity: number; count: number }[];
statusCounts: { status: string; count: number }[];
queueByAssignee: { assigneeId: string | null; count: number }[];
ageBuckets: Record<'d1' | 'd7' | 'd14' | 'older', number>;
medianResolutionHours: number | null;
}
export function useAnalytics(windowDays = 30) {
return useQuery({
queryKey: qk.analytics(windowDays),
queryFn: async () =>
(
await api.get<AnalyticsSummary>('/analytics/summary', {
params: { window: windowDays },
})
).data,
staleTime: 60_000,
});
}
// ── Notification prefs ───────────────────────────────────────────────────────
export interface NotificationPrefs {
email: { assignment: boolean; mention: boolean; resolved: boolean };
inApp: { assignment: boolean; mention: boolean; resolved: boolean };
}
export function useNotificationPrefs() {
return useQuery({
queryKey: ['notifications', 'prefs'],
queryFn: async () => (await api.get<NotificationPrefs>('/notifications/prefs')).data,
});
}
export function useUpdateNotificationPrefs() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (prefs: NotificationPrefs) =>
(await api.put<NotificationPrefs>('/notifications/prefs', prefs)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications', 'prefs'] }),
});
}
+11
View File
@@ -0,0 +1,11 @@
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 30 * 1000,
refetchOnWindowFocus: false,
},
},
});
+4 -4
View File
@@ -1,7 +1,7 @@
import { Navigate, Outlet } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
export default function AdminRoute() {
const { user } = useAuth()
return user?.role === 'ADMIN' ? <Outlet /> : <Navigate to="/" replace />
const { user } = useAuth();
return user?.role === 'ADMIN' ? <Outlet /> : <Navigate to="/" replace />;
}
+17 -11
View File
@@ -1,14 +1,20 @@
const PALETTE = [
'#ef4444', '#f97316', '#f59e0b', '#10b981',
'#06b6d4', '#3b82f6', '#8b5cf6', '#ec4899',
]
'#ef4444',
'#f97316',
'#f59e0b',
'#10b981',
'#06b6d4',
'#3b82f6',
'#8b5cf6',
'#ec4899',
];
function nameToColor(name: string): string {
let hash = 0
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash)
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return PALETTE[Math.abs(hash) % PALETTE.length]
return PALETTE[Math.abs(hash) % PALETTE.length];
}
function initials(name: string): string {
@@ -17,18 +23,18 @@ function initials(name: string): string {
.slice(0, 2)
.map((n) => n[0])
.join('')
.toUpperCase()
.toUpperCase();
}
const SIZES = {
sm: 'w-6 h-6 text-xs',
md: 'w-8 h-8 text-sm',
lg: 'w-10 h-10 text-base',
}
};
interface AvatarProps {
name: string
size?: keyof typeof SIZES
name: string;
size?: keyof typeof SIZES;
}
export default function Avatar({ name, size = 'md' }: AvatarProps) {
@@ -40,5 +46,5 @@ export default function Avatar({ name, size = 'md' }: AvatarProps) {
>
{initials(name)}
</div>
)
);
}
+18 -45
View File
@@ -1,62 +1,35 @@
import { useEffect, useState } from 'react'
import api from '../api/client'
import { Category, CTIType, Item } from '../types'
import { useCategories, useItems, useTypes } from '../api/queries';
interface CTISelectProps {
value: { categoryId: string; typeId: string; itemId: string }
onChange: (value: { categoryId: string; typeId: string; itemId: string }) => void
disabled?: boolean
value: { categoryId: string; typeId: string; itemId: string };
onChange: (value: { categoryId: string; typeId: string; itemId: string }) => void;
disabled?: boolean;
}
export default function CTISelect({ value, onChange, disabled }: CTISelectProps) {
const [categories, setCategories] = useState<Category[]>([])
const [types, setTypes] = useState<CTIType[]>([])
const [items, setItems] = useState<Item[]>([])
useEffect(() => {
api.get<Category[]>('/cti/categories').then((r) => setCategories(r.data))
}, [])
useEffect(() => {
if (!value.categoryId) {
setTypes([])
setItems([])
return
}
api
.get<CTIType[]>('/cti/types', { params: { categoryId: value.categoryId } })
.then((r) => setTypes(r.data))
}, [value.categoryId])
useEffect(() => {
if (!value.typeId) {
setItems([])
return
}
api
.get<Item[]>('/cti/items', { params: { typeId: value.typeId } })
.then((r) => setItems(r.data))
}, [value.typeId])
const { data: categories = [] } = useCategories();
const { data: types = [] } = useTypes(value.categoryId || undefined);
const { data: items = [] } = useItems(value.typeId || undefined);
const handleCategory = (categoryId: string) => {
onChange({ categoryId, typeId: '', itemId: '' })
}
onChange({ categoryId, typeId: '', itemId: '' });
};
const handleType = (typeId: string) => {
onChange({ ...value, typeId, itemId: '' })
}
onChange({ ...value, typeId, itemId: '' });
};
const handleItem = (itemId: string) => {
onChange({ ...value, itemId })
}
onChange({ ...value, itemId });
};
const selectClass =
'block w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-50 disabled:text-gray-400'
'block w-full bg-gray-800 border border-gray-700 text-gray-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed';
return (
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Category</label>
<label className="block text-xs font-medium text-gray-400 mb-1">Category</label>
<select
value={value.categoryId}
onChange={(e) => handleCategory(e.target.value)}
@@ -73,7 +46,7 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Type</label>
<label className="block text-xs font-medium text-gray-400 mb-1">Type</label>
<select
value={value.typeId}
onChange={(e) => handleType(e.target.value)}
@@ -90,7 +63,7 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Item</label>
<label className="block text-xs font-medium text-gray-400 mb-1">Item</label>
<select
value={value.itemId}
onChange={(e) => handleItem(e.target.value)}
@@ -106,5 +79,5 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
</select>
</div>
</div>
)
);
}
+236
View File
@@ -0,0 +1,236 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
LayoutDashboard,
ListChecks,
User as UserIcon,
Bell,
Settings as SettingsIcon,
Shield,
Plus,
LogOut,
Keyboard,
Ticket as TicketIcon,
} from 'lucide-react';
import {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandSeparator,
} from '@/components/ui/command';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { useAuth } from '../contexts/AuthContext';
import { useUsers } from '../api/queries';
import api from '../api/client';
import type { Ticket } from '../types';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
onNewTicket?: () => void;
onShowShortcuts?: () => void;
}
export default function CommandPalette({
open,
onOpenChange,
onNewTicket,
onShowShortcuts,
}: Props) {
const navigate = useNavigate();
const { user, logout } = useAuth();
const isAdmin = user?.role === 'ADMIN';
const [query, setQuery] = useState('');
const [tickets, setTickets] = useState<Ticket[]>([]);
const { data: users = [] } = useUsers();
// Debounced ticket search
useEffect(() => {
if (!open) return;
const q = query.trim();
if (q.length < 2) {
setTickets([]);
return;
}
const t = setTimeout(async () => {
try {
const res = await api.get<{ tickets: Ticket[] }>('/search', { params: { q, limit: 8 } });
setTickets(res.data.tickets.slice(0, 8));
} catch {
setTickets([]);
}
}, 180);
return () => clearTimeout(t);
}, [query, open]);
// Reset when closed
useEffect(() => {
if (!open) setQuery('');
}, [open]);
const go = (path: string) => {
onOpenChange(false);
navigate(path);
};
const run = (fn: () => void) => {
onOpenChange(false);
setTimeout(fn, 0);
};
const userMatches = useMemo(
() =>
query.trim().length === 0
? []
: users
.filter(
(u) =>
u.displayName.toLowerCase().includes(query.toLowerCase()) ||
u.username.toLowerCase().includes(query.toLowerCase()),
)
.slice(0, 6),
[users, query],
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="p-0 gap-0 overflow-hidden max-w-xl">
<Command shouldFilter={false}>
<CommandInput
placeholder="Search tickets, jump to pages, run commands…"
value={query}
onValueChange={setQuery}
autoFocus
/>
<CommandList>
<CommandEmpty>
{query.trim().length < 2 ? 'Keep typing to search…' : 'No results.'}
</CommandEmpty>
{tickets.length > 0 && (
<CommandGroup heading="Tickets">
{tickets.map((t) => (
<CommandItem
key={t.id}
value={`ticket-${t.id}`}
onSelect={() => go(`/${t.displayId}`)}
>
<TicketIcon size={14} className="opacity-60" />
<span className="font-mono text-xs text-muted-foreground mr-1">
{t.displayId}
</span>
<span className="truncate">{t.title}</span>
</CommandItem>
))}
</CommandGroup>
)}
{userMatches.length > 0 && (
<>
{tickets.length > 0 && <CommandSeparator />}
<CommandGroup heading="People">
{userMatches.map((u) => (
<CommandItem
key={u.id}
value={`user-${u.id}`}
onSelect={() =>
go(`/tickets?assigneeId=${encodeURIComponent(u.id)}`)
}
>
<UserIcon size={14} className="opacity-60" />
<span>{u.displayName}</span>
<span className="ml-auto text-xs text-muted-foreground">
tickets assigned
</span>
</CommandItem>
))}
</CommandGroup>
</>
)}
{(tickets.length > 0 || userMatches.length > 0) && <CommandSeparator />}
<CommandGroup heading="Actions">
{onNewTicket && (
<CommandItem
value="new-ticket"
onSelect={() => run(() => onNewTicket())}
>
<Plus size={14} className="opacity-60" />
New ticket
<span className="ml-auto text-xs text-muted-foreground">c</span>
</CommandItem>
)}
<CommandItem value="nav-dashboard" onSelect={() => go('/dashboard')}>
<LayoutDashboard size={14} className="opacity-60" />
Go to dashboard
<span className="ml-auto text-xs text-muted-foreground">g d</span>
</CommandItem>
<CommandItem value="nav-tickets" onSelect={() => go('/tickets')}>
<ListChecks size={14} className="opacity-60" />
Go to tickets
<span className="ml-auto text-xs text-muted-foreground">g t</span>
</CommandItem>
<CommandItem value="nav-my" onSelect={() => go('/my-tickets')}>
<UserIcon size={14} className="opacity-60" />
My tickets
<span className="ml-auto text-xs text-muted-foreground">g m</span>
</CommandItem>
<CommandItem value="nav-notifications" onSelect={() => go('/notifications')}>
<Bell size={14} className="opacity-60" />
Notifications
<span className="ml-auto text-xs text-muted-foreground">g n</span>
</CommandItem>
<CommandItem value="nav-settings" onSelect={() => go('/settings')}>
<SettingsIcon size={14} className="opacity-60" />
Settings
</CommandItem>
</CommandGroup>
{isAdmin && (
<>
<CommandSeparator />
<CommandGroup heading="Admin">
<CommandItem value="admin-users" onSelect={() => go('/admin/users')}>
<Shield size={14} className="opacity-60" />
Manage users
</CommandItem>
<CommandItem value="admin-cti" onSelect={() => go('/admin/cti')}>
<Shield size={14} className="opacity-60" />
Manage CTI
</CommandItem>
<CommandItem value="admin-webhooks" onSelect={() => go('/admin/webhooks')}>
<Shield size={14} className="opacity-60" />
Manage webhooks
</CommandItem>
</CommandGroup>
</>
)}
<CommandSeparator />
<CommandGroup heading="System">
{onShowShortcuts && (
<CommandItem
value="show-shortcuts"
onSelect={() => run(() => onShowShortcuts())}
>
<Keyboard size={14} className="opacity-60" />
Keyboard shortcuts
<span className="ml-auto text-xs text-muted-foreground">?</span>
</CommandItem>
)}
<CommandItem value="logout" onSelect={() => run(logout)}>
<LogOut size={14} className="opacity-60" />
Log out
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</DialogContent>
</Dialog>
);
}
+104
View File
@@ -0,0 +1,104 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search } from 'lucide-react';
import api from '../api/client';
import type { Ticket } from '../types';
export default function GlobalSearch() {
const navigate = useNavigate();
const [q, setQ] = useState('');
const [open, setOpen] = useState(false);
const [results, setResults] = useState<Ticket[]>([]);
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
useEffect(() => {
if (!q.trim()) {
setResults([]);
return;
}
setLoading(true);
const t = setTimeout(async () => {
try {
const res = await api.get<{ tickets: Ticket[] }>('/search', {
params: { q, limit: 8 },
});
setResults(res.data.tickets ?? []);
} catch {
setResults([]);
} finally {
setLoading(false);
}
}, 200);
return () => clearTimeout(t);
}, [q]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!q.trim()) return;
setOpen(false);
navigate(`/tickets?search=${encodeURIComponent(q)}`);
};
return (
<div ref={wrapperRef} className="relative flex-1 max-w-md">
<form onSubmit={handleSubmit}>
<Search
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none"
/>
<input
ref={inputRef}
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
onFocus={() => setOpen(true)}
placeholder="Search tickets"
className="w-full pl-9 pr-3 py-1.5 rounded-md text-sm bg-background border border-input text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
</form>
{open && q.trim() && (
<div className="absolute top-full mt-1 left-0 right-0 z-40 bg-popover border rounded-md shadow-lg overflow-hidden">
{loading ? (
<p className="px-3 py-4 text-xs text-muted-foreground text-center">Searching</p>
) : results.length === 0 ? (
<p className="px-3 py-4 text-xs text-muted-foreground text-center">No matches</p>
) : (
<ul className="max-h-80 overflow-auto">
{results.map((t) => (
<li key={t.id}>
<button
type="button"
onClick={() => {
setOpen(false);
setQ('');
navigate(`/${t.displayId}`);
}}
className="w-full text-left px-3 py-2 hover:bg-accent flex flex-col gap-0.5"
>
<span className="text-sm text-foreground line-clamp-1">{t.title}</span>
<span className="text-xs text-muted-foreground font-mono">
{t.displayId} · {t.status}
</span>
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const GROUPS: { title: string; items: [string, string][] }[] = [
{
title: 'Global',
items: [
['⌘/Ctrl K', 'Open command palette'],
['?', 'Show this help'],
['c', 'Create new ticket'],
],
},
{
title: 'Navigate',
items: [
['g d', 'Dashboard'],
['g t', 'Tickets'],
['g m', 'My tickets'],
['g n', 'Notifications'],
['g s', 'Settings'],
],
},
{
title: 'Tickets list',
items: [
['j', 'Next ticket'],
['k', 'Previous ticket'],
['Enter', 'Open selected'],
['x', 'Toggle selected'],
],
},
{
title: 'Ticket detail',
items: [
['e', 'Edit title & overview'],
['r', 'Reply / new comment'],
['⌘/Ctrl Enter', 'Submit comment'],
],
},
];
export default function KeyboardHelp({ open, onOpenChange }: Props) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>Keyboard shortcuts</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{GROUPS.map((g) => (
<div key={g.title}>
<p className="text-xs uppercase tracking-wide text-muted-foreground mb-2">
{g.title}
</p>
<dl className="space-y-1.5">
{g.items.map(([keys, label]) => (
<div key={keys} className="flex items-center justify-between gap-4 text-sm">
<dt className="text-foreground">{label}</dt>
<dd>
<kbd className="font-mono text-[11px] bg-muted border border-border rounded px-1.5 py-0.5 text-muted-foreground">
{keys}
</kbd>
</dd>
</div>
))}
</dl>
</div>
))}
</div>
</DialogContent>
</Dialog>
);
}
+241 -66
View File
@@ -1,88 +1,263 @@
import { ReactNode } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { LayoutDashboard, Users, Settings, LogOut, Plus } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { ReactNode, useState } from 'react';
import { Link, NavLink, useNavigate } from 'react-router-dom';
import {
Plus,
LogOut,
Settings as SettingsIcon,
Users as UsersIcon,
Webhook,
Menu,
X,
SlidersHorizontal,
Keyboard,
} from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import NewTicketModal from '../pages/NewTicket';
import NotificationsBell from './NotificationsBell';
import GlobalSearch from './GlobalSearch';
import Avatar from './Avatar';
import CommandPalette from './CommandPalette';
import KeyboardHelp from './KeyboardHelp';
import { useShortcut, useLeaderShortcut } from '../hooks/useShortcuts';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
interface LayoutProps {
children: ReactNode
title?: string
action?: ReactNode
children: ReactNode;
title?: string;
action?: ReactNode;
subheader?: ReactNode;
wide?: boolean;
}
export default function Layout({ children, title, action }: LayoutProps) {
const { user, logout } = useAuth()
const location = useLocation()
const navigate = useNavigate()
const navBase =
'px-3 py-1.5 rounded-md text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground';
const navItems = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
...(user?.role === 'ADMIN'
? [
{ to: '/admin/users', icon: Users, label: 'Users' },
{ to: '/admin/cti', icon: Settings, label: 'CTI Config' },
]
: []),
]
export default function Layout({ children, title, action, subheader, wide }: LayoutProps) {
const { user, logout } = useAuth();
const navigate = useNavigate();
const [showNewTicket, setShowNewTicket] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const canCreateTicket = user?.role !== 'USER';
const isAdmin = user?.role === 'ADMIN';
const handleLogout = () => {
logout()
navigate('/login')
logout();
navigate('/login');
};
useShortcut('mod+k', (e) => {
e.preventDefault();
setPaletteOpen((v) => !v);
});
useShortcut('shift+/', (e) => {
// ? key — open help overlay
e.preventDefault();
setHelpOpen(true);
});
useShortcut('c', (e) => {
if (!canCreateTicket) return;
e.preventDefault();
setShowNewTicket(true);
}, [canCreateTicket]);
useLeaderShortcut('g', {
d: () => navigate('/dashboard'),
t: () => navigate('/tickets'),
m: () => navigate('/my-tickets'),
n: () => navigate('/notifications'),
s: () => navigate('/settings'),
});
const primaryNav = (
<>
<NavLink
to="/dashboard"
className={({ isActive }) =>
`${navBase} ${isActive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'}`
}
>
Dashboard
</NavLink>
<NavLink
to="/tickets"
className={({ isActive }) =>
`${navBase} ${isActive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'}`
}
>
Tickets
</NavLink>
<NavLink
to="/my-tickets"
className={({ isActive }) =>
`${navBase} ${isActive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'}`
}
>
My tickets
</NavLink>
</>
);
return (
<div className="flex h-screen bg-gray-100 overflow-hidden">
{/* Sidebar */}
<aside className="w-60 bg-gray-900 text-white flex flex-col flex-shrink-0">
<div className="p-5 border-b border-gray-700">
<h1 className="text-base font-bold text-white tracking-wide">Ticketing</h1>
<p className="text-xs text-gray-400 mt-0.5">{user?.displayName}</p>
<div className="min-h-screen bg-background text-foreground flex flex-col">
{/* Top nav */}
<header className="sticky top-0 z-30 flex-shrink-0 border-b border-border bg-card/95 backdrop-blur">
<div className="mx-auto max-w-[1400px] px-4 h-12 flex items-center gap-4">
<Link
to="/dashboard"
className="flex items-center gap-2 font-semibold text-sm whitespace-nowrap"
>
<span className="w-6 h-6 rounded bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
T
</span>
<span className="hidden sm:inline">Ticketing</span>
</Link>
<nav className="hidden md:flex items-center gap-1">{primaryNav}</nav>
<div className="flex-1 hidden md:block">
<GlobalSearch />
</div>
<nav className="flex-1 p-3 space-y-0.5">
{navItems.map(({ to, icon: Icon, label }) => (
<Link
key={to}
to={to}
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
location.pathname === to
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
}`}
>
<Icon size={15} />
{label}
</Link>
))}
</nav>
<div className="p-3 border-t border-gray-700 space-y-0.5">
<Link
to="/tickets/new"
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-gray-300 hover:bg-gray-800 hover:text-white transition-colors"
>
<Plus size={15} />
New Ticket
</Link>
<div className="flex items-center gap-1 ml-auto">
{canCreateTicket && (
<button
onClick={handleLogout}
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-gray-300 hover:bg-gray-800 hover:text-white w-full transition-colors"
onClick={() => setShowNewTicket(true)}
className="hidden sm:flex items-center gap-1.5 bg-primary text-primary-foreground px-3 py-1.5 rounded-md text-sm font-medium hover:opacity-90 transition-opacity"
>
<LogOut size={15} />
<Plus size={14} />
New
</button>
)}
<NotificationsBell />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="User menu"
className="p-1 rounded-md hover:bg-accent transition-colors"
>
<Avatar name={user?.displayName ?? '?'} size="sm" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<div className="px-2 py-2">
<p className="text-sm font-semibold">{user?.displayName}</p>
<p className="text-xs text-muted-foreground">{user?.email}</p>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to="/settings" className="flex items-center gap-2 w-full">
<SettingsIcon size={14} />
Settings
</Link>
</DropdownMenuItem>
{isAdmin && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to="/admin/users" className="flex items-center gap-2 w-full">
<UsersIcon size={14} />
Users
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/admin/cti" className="flex items-center gap-2 w-full">
<SlidersHorizontal size={14} />
CTI config
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/admin/webhooks" className="flex items-center gap-2 w-full">
<Webhook size={14} />
Webhooks
</Link>
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => setHelpOpen(true)}>
<Keyboard size={14} />
Keyboard shortcuts
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleLogout} className="text-destructive">
<LogOut size={14} />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<button
type="button"
onClick={() => setMobileOpen((v) => !v)}
className="md:hidden p-2 rounded-md text-muted-foreground hover:bg-accent"
aria-label="Menu"
>
{mobileOpen ? <X size={18} /> : <Menu size={18} />}
</button>
</div>
</aside>
</div>
{/* Main */}
<div className="flex-1 flex flex-col overflow-hidden">
{(title || action) && (
<header className="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between flex-shrink-0">
{title && <h2 className="text-lg font-semibold text-gray-900">{title}</h2>}
{action && <div>{action}</div>}
</header>
{mobileOpen && (
<div className="md:hidden border-t border-border bg-card px-4 py-3 space-y-2">
<div className="flex flex-col gap-1">{primaryNav}</div>
<GlobalSearch />
{canCreateTicket && (
<button
onClick={() => {
setShowNewTicket(true);
setMobileOpen(false);
}}
className="w-full flex items-center justify-center gap-1.5 bg-primary text-primary-foreground px-3 py-2 rounded-md text-sm font-medium"
>
<Plus size={14} />
New ticket
</button>
)}
<main className="flex-1 overflow-auto p-6">{children}</main>
</div>
)}
</header>
{/* Optional sub-header */}
{(title || action || subheader) && (
<div className="border-b border-border bg-card/50">
<div
className={`mx-auto px-4 py-3 ${wide ? 'max-w-[1400px]' : 'max-w-6xl'} flex items-center justify-between gap-3`}
>
<div className="min-w-0 flex-1">
{title && (
<h1 className="text-lg font-semibold text-foreground truncate">{title}</h1>
)}
{subheader}
</div>
{action && <div className="flex items-center gap-2">{action}</div>}
</div>
</div>
)
)}
<main className={`flex-1 mx-auto w-full px-4 py-6 ${wide ? 'max-w-[1400px]' : 'max-w-6xl'}`}>
{children}
</main>
{showNewTicket && <NewTicketModal onClose={() => setShowNewTicket(false)} />}
<CommandPalette
open={paletteOpen}
onOpenChange={setPaletteOpen}
onNewTicket={canCreateTicket ? () => setShowNewTicket(true) : undefined}
onShowShortcuts={() => setHelpOpen(true)}
/>
<KeyboardHelp open={helpOpen} onOpenChange={setHelpOpen} />
</div>
);
}
+158
View File
@@ -0,0 +1,158 @@
import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import type { User } from '../types';
interface Props extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'onChange'> {
value: string;
onChange: (value: string) => void;
users: Pick<User, 'id' | 'username' | 'displayName'>[];
/** Called when user hits ⌘/Ctrl+Enter with no menu open. */
onSubmit?: () => void;
}
interface Trigger {
start: number;
query: string;
}
const MENTION_RE = /(^|[^\w-])@([\w-]{0,30})$/;
function detectTrigger(text: string, caret: number): Trigger | null {
const prefix = text.slice(0, caret);
const m = MENTION_RE.exec(prefix);
if (!m) return null;
// position of '@' is caret - matched[2].length - 1
return { start: caret - m[2].length - 1, query: m[2] };
}
const MentionTextarea = forwardRef<HTMLTextAreaElement, Props>(function MentionTextarea(
{ value, onChange, users, onSubmit, onKeyDown, ...rest },
ref,
) {
const innerRef = useRef<HTMLTextAreaElement | null>(null);
useImperativeHandle(ref, () => innerRef.current as HTMLTextAreaElement);
const [trigger, setTrigger] = useState<Trigger | null>(null);
const [cursor, setCursor] = useState(0);
const matches = useMemo(() => {
if (!trigger) return [];
const q = trigger.query.toLowerCase();
const filter = (u: (typeof users)[number]) =>
u.username.toLowerCase().includes(q) || u.displayName.toLowerCase().includes(q);
return (q ? users.filter(filter) : users).slice(0, 6);
}, [trigger, users]);
useEffect(() => {
setCursor(0);
}, [trigger?.query]);
const handleSelect = () => {
const el = innerRef.current;
if (!el) return;
const t = detectTrigger(value, el.selectionStart ?? 0);
setTrigger(t);
};
const insertMention = (username: string) => {
const el = innerRef.current;
if (!el || !trigger) return;
const before = value.slice(0, trigger.start);
const after = value.slice(el.selectionStart ?? trigger.start);
const insertion = `@${username} `;
const next = before + insertion + after;
onChange(next);
setTrigger(null);
// move caret after inserted mention
const newCaret = before.length + insertion.length;
requestAnimationFrame(() => {
if (!innerRef.current) return;
innerRef.current.focus();
innerRef.current.setSelectionRange(newCaret, newCaret);
});
};
const keyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (trigger && matches.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setCursor((c) => (c + 1) % matches.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setCursor((c) => (c - 1 + matches.length) % matches.length);
return;
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
insertMention(matches[cursor].username);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
setTrigger(null);
return;
}
}
if (onSubmit && e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
onSubmit();
return;
}
onKeyDown?.(e);
};
return (
<div className="relative">
<textarea
{...rest}
ref={innerRef}
value={value}
onChange={(e) => {
onChange(e.target.value);
// trigger recalc via select event after React updates
requestAnimationFrame(handleSelect);
}}
onKeyUp={handleSelect}
onClick={handleSelect}
onBlur={() => setTimeout(() => setTrigger(null), 120)}
onKeyDown={keyDown}
/>
{trigger && matches.length > 0 && (
<div
className="absolute left-0 top-full mt-1 z-20 w-56 rounded-md border border-border bg-popover text-popover-foreground shadow-lg overflow-hidden"
role="listbox"
>
{matches.map((u, i) => (
<button
type="button"
key={u.id}
role="option"
aria-selected={i === cursor}
onMouseDown={(e) => {
e.preventDefault();
insertMention(u.username);
}}
onMouseEnter={() => setCursor(i)}
className={`w-full text-left px-3 py-1.5 text-sm flex items-center gap-2 ${
i === cursor ? 'bg-accent text-accent-foreground' : ''
}`}
>
<span className="font-medium">{u.displayName}</span>
<span className="text-xs text-muted-foreground">@{u.username}</span>
</button>
))}
</div>
)}
</div>
);
});
export default MentionTextarea;
+23 -21
View File
@@ -1,38 +1,40 @@
import { ReactNode, useEffect } from 'react'
import { X } from 'lucide-react'
import { ReactNode, useEffect } from 'react';
import { X } from 'lucide-react';
interface ModalProps {
title: string
onClose: () => void
children: ReactNode
title: string;
onClose: () => void;
children: ReactNode;
size?: 'md' | 'lg';
}
export default function Modal({ title, onClose, children }: ModalProps) {
export default function Modal({ title, onClose, children, size = 'md' }: ModalProps) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [onClose])
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [onClose]);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h3 className="text-base font-semibold text-gray-900">{title}</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
<div
className={`bg-gray-900 border border-gray-800 rounded-xl shadow-2xl w-full mx-4 ${size === 'lg' ? 'max-w-2xl' : 'max-w-md'}`}
>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-800">
<h3 className="text-base font-semibold text-gray-100">{title}</h3>
<button onClick={onClose} className="text-gray-500 hover:text-gray-300 transition-colors">
<X size={18} />
</button>
</div>
<div className="px-6 py-4">{children}</div>
<div className="px-6 py-5">{children}</div>
</div>
</div>
)
);
}
+113
View File
@@ -0,0 +1,113 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Bell } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
useNotifications,
useUnreadCount,
useMarkNotificationsRead,
} from '../api/queries';
import type { Notification } from '../../../shared/types';
const KIND_LABELS: Record<string, string> = {
ASSIGNED: 'assigned to you',
MENTION: 'mentioned you',
RESOLVED: 'was resolved',
TICKET_CREATED: 'was created',
STATUS_CHANGED: 'status changed',
COMMENT: 'new comment',
};
interface NotifData {
displayId?: string;
title?: string;
byName?: string;
}
function renderSummary(n: Notification): { label: string; href: string } {
const data = (n.data ?? {}) as NotifData;
const action = KIND_LABELS[n.kind] ?? n.kind.toLowerCase();
const label = data.title
? `${data.title}${action}`
: `Ticket ${action}`;
const href = data.displayId ? `/${data.displayId}` : '/notifications';
return { label, href };
}
export default function NotificationsBell() {
const { data: unreadCount = 0 } = useUnreadCount();
const { data: notifications = [] } = useNotifications();
const markRead = useMarkNotificationsRead();
const latest = useMemo(() => notifications.slice(0, 8), [notifications]);
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-label="Notifications"
className="relative p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<Bell size={18} />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 min-w-[1rem] h-4 px-1 rounded-full bg-destructive text-destructive-foreground text-[10px] font-semibold flex items-center justify-center">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0">
<div className="flex items-center justify-between px-3 py-2 border-b">
<span className="text-sm font-semibold">Notifications</span>
{unreadCount > 0 && (
<button
onClick={() => markRead.mutate({ all: true })}
className="text-xs text-primary hover:underline"
>
Mark all read
</button>
)}
</div>
<div className="max-h-96 overflow-auto">
{latest.length === 0 ? (
<p className="px-3 py-6 text-center text-xs text-muted-foreground">
You&apos;re all caught up
</p>
) : (
latest.map((n) => {
const { label, href } = renderSummary(n);
const unread = !n.readAt;
return (
<Link
key={n.id}
to={href}
onClick={() => unread && markRead.mutate({ ids: [n.id] })}
className={`flex flex-col gap-1 px-3 py-2 text-sm border-b last:border-b-0 hover:bg-accent transition-colors ${
unread ? 'bg-primary/5' : ''
}`}
>
<span className="line-clamp-2">{label}</span>
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(n.createdAt), { addSuffix: true })}
</span>
</Link>
);
})
)}
</div>
<Link
to="/notifications"
className="block px-3 py-2 text-center text-xs text-primary hover:underline border-t"
>
View all notifications
</Link>
</PopoverContent>
</Popover>
);
}
+5 -5
View File
@@ -1,16 +1,16 @@
import { Navigate, Outlet } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
export default function PrivateRoute() {
const { user, loading } = useAuth()
const { user, loading } = useAuth();
if (loading) {
return (
<div className="flex h-screen items-center justify-center bg-gray-50">
<div className="text-gray-500">Loading...</div>
</div>
)
);
}
return user ? <Outlet /> : <Navigate to="/login" replace />
return user ? <Outlet /> : <Navigate to="/login" replace />;
}
@@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import SeverityBadge from './SeverityBadge';
describe('SeverityBadge', () => {
it('renders the severity label', () => {
render(<SeverityBadge severity={1} />);
expect(screen.getByText('SEV 1')).toBeInTheDocument();
});
it('falls back to SEV 5 styling for out-of-range values', () => {
render(<SeverityBadge severity={99 as number} />);
expect(screen.getByText('SEV 5')).toBeInTheDocument();
});
});
+11 -9
View File
@@ -1,16 +1,18 @@
const config: Record<number, { label: string; className: string }> = {
1: { label: 'SEV 1', className: 'bg-red-100 text-red-800 border-red-200' },
2: { label: 'SEV 2', className: 'bg-orange-100 text-orange-800 border-orange-200' },
3: { label: 'SEV 3', className: 'bg-yellow-100 text-yellow-800 border-yellow-200' },
4: { label: 'SEV 4', className: 'bg-blue-100 text-blue-800 border-blue-200' },
5: { label: 'SEV 5', className: 'bg-gray-100 text-gray-600 border-gray-200' },
}
1: { label: 'SEV 1', className: 'bg-red-500/20 text-red-400 border-red-500/30' },
2: { label: 'SEV 2', className: 'bg-orange-500/20 text-orange-400 border-orange-500/30' },
3: { label: 'SEV 3', className: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' },
4: { label: 'SEV 4', className: 'bg-blue-500/20 text-blue-400 border-blue-500/30' },
5: { label: 'SEV 5', className: 'bg-gray-500/20 text-gray-400 border-gray-500/30' },
};
export default function SeverityBadge({ severity }: { severity: number }) {
const { label, className } = config[severity] ?? config[5]
const { label, className } = config[severity] ?? config[5];
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold border ${className}`}>
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold border ${className}`}
>
{label}
</span>
)
);
}
@@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import StatusBadge from './StatusBadge';
describe('StatusBadge', () => {
it.each([
['OPEN', 'Open'],
['IN_PROGRESS', 'In Progress'],
['RESOLVED', 'Resolved'],
['CLOSED', 'Closed'],
] as const)('renders %s as "%s"', (status, label) => {
render(<StatusBadge status={status} />);
expect(screen.getByText(label)).toBeInTheDocument();
});
});
+14 -9
View File
@@ -1,17 +1,22 @@
import { TicketStatus } from '../types'
import { TicketStatus } from '../types';
const config: Record<TicketStatus, { label: string; className: string }> = {
OPEN: { label: 'Open', className: 'bg-blue-100 text-blue-800 border-blue-200' },
IN_PROGRESS: { label: 'In Progress', className: 'bg-yellow-100 text-yellow-800 border-yellow-200' },
RESOLVED: { label: 'Resolved', className: 'bg-green-100 text-green-800 border-green-200' },
CLOSED: { label: 'Closed', className: 'bg-gray-100 text-gray-500 border-gray-200' },
}
OPEN: { label: 'Open', className: 'bg-blue-500/20 text-blue-400 border-blue-500/30' },
IN_PROGRESS: {
label: 'In Progress',
className: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
},
RESOLVED: { label: 'Resolved', className: 'bg-green-500/20 text-green-400 border-green-500/30' },
CLOSED: { label: 'Closed', className: 'bg-gray-500/20 text-gray-400 border-gray-500/30' },
};
export default function StatusBadge({ status }: { status: TicketStatus }) {
const { label, className } = config[status]
const { label, className } = config[status];
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border ${className}`}>
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border ${className}`}
>
{label}
</span>
)
);
}
+122
View File
@@ -0,0 +1,122 @@
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col space-y-2 text-center sm:text-left', className)}
{...props}
/>
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className,
)}
{...props}
/>
);
AlertDialogFooter.displayName = 'AlertDialogFooter';
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold', className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};
+47
View File
@@ -0,0 +1,47 @@
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@/lib/utils';
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full',
className,
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn('aspect-square h-full w-full', className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
'flex h-full w-full items-center justify-center rounded-full bg-muted',
className,
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };
+32
View File
@@ -0,0 +1,32 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
+50
View File
@@ -0,0 +1,50 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
},
);
Button.displayName = 'Button';
export { Button, buttonVariants };
+113
View File
@@ -0,0 +1,113 @@
import * as React from 'react';
import { Command as CommandPrimitive } from 'cmdk';
import { Search } from 'lucide-react';
import { cn } from '@/lib/utils';
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b border-border px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-[320px] overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm text-muted-foreground"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('-mx-1 h-px bg-border', className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled='true']:pointer-events-none data-[disabled='true']:opacity-50",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
export {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandSeparator,
};
+98
View File
@@ -0,0 +1,98 @@
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
+176
View File
@@ -0,0 +1,176 @@
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean }
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};
+21
View File
@@ -0,0 +1,21 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = 'Input';
export { Input };
+18
View File
@@ -0,0 +1,18 @@
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
+28
View File
@@ -0,0 +1,28 @@
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '@/lib/utils';
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
+147
View File
@@ -0,0 +1,147 @@
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
+23
View File
@@ -0,0 +1,23 @@
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '@/lib/utils';
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className,
)}
{...props}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };
+7
View File
@@ -0,0 +1,7 @@
import { cn } from '@/lib/utils';
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('animate-pulse rounded-md bg-muted', className)} {...props} />;
}
export { Skeleton };
+24
View File
@@ -0,0 +1,24 @@
import { Toaster as Sonner } from 'sonner';
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
theme="dark"
className="toaster group"
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted-foreground',
actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
},
}}
{...props}
/>
);
};
export { Toaster };
+52
View File
@@ -0,0 +1,52 @@
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '@/lib/utils';
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow',
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };
+21
View File
@@ -0,0 +1,21 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
});
Textarea.displayName = 'Textarea';
export { Textarea };
+27
View File
@@ -0,0 +1,27 @@
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+30 -32
View File
@@ -1,59 +1,57 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
import api from '../api/client'
import { User } from '../types'
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import api from '../api/client';
import { User } from '../types';
interface AuthContextType {
user: User | null
loading: boolean
login: (username: string, password: string) => Promise<void>
logout: () => void
user: User | null;
loading: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextType>(null!)
const AuthContext = createContext<AuthContextType>(null!);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const token = localStorage.getItem('token')
const token = localStorage.getItem('token');
if (token) {
api.defaults.headers.common['Authorization'] = `Bearer ${token}`
api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
api
.get<User>('/auth/me')
.then((res) => setUser(res.data))
.catch(() => {
localStorage.removeItem('token')
delete api.defaults.headers.common['Authorization']
localStorage.removeItem('token');
delete api.defaults.headers.common['Authorization'];
})
.finally(() => setLoading(false))
.finally(() => setLoading(false));
} else {
setLoading(false)
setLoading(false);
}
}, [])
}, []);
const login = async (username: string, password: string) => {
const res = await api.post<{ token: string; user: User }>('/auth/login', {
username,
password,
})
const { token, user } = res.data
localStorage.setItem('token', token)
api.defaults.headers.common['Authorization'] = `Bearer ${token}`
setUser(user)
}
});
const { token, user } = res.data;
localStorage.setItem('token', token);
api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
setUser(user);
};
const logout = () => {
localStorage.removeItem('token')
delete api.defaults.headers.common['Authorization']
setUser(null)
}
localStorage.removeItem('token');
delete api.defaults.headers.common['Authorization'];
setUser(null);
};
return (
<AuthContext.Provider value={{ user, loading, login, logout }}>
{children}
</AuthContext.Provider>
)
<AuthContext.Provider value={{ user, loading, login, logout }}>{children}</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext)
export const useAuth = () => useContext(AuthContext);
+36
View File
@@ -0,0 +1,36 @@
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
type Theme = 'light' | 'dark';
interface ThemeContextType {
theme: Theme;
toggle: () => void;
}
const ThemeContext = createContext<ThemeContextType>(null!);
function initial(): Theme {
// v1.0 ships dark-only; light-mode pages migrate in a future pass.
// Theme context is kept so the toggle can be re-enabled later without wiring.
return 'dark';
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>(initial);
useEffect(() => {
const root = document.documentElement;
root.classList.toggle('dark', theme === 'dark');
localStorage.setItem('theme', theme);
}, [theme]);
return (
<ThemeContext.Provider
value={{ theme, toggle: () => setTheme((t) => (t === 'dark' ? 'light' : 'dark')) }}
>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);
+111
View File
@@ -0,0 +1,111 @@
import { useEffect, useRef } from 'react';
export function isTextField(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false;
const tag = el.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
if (el.isContentEditable) return true;
return false;
}
type Handler = (e: KeyboardEvent) => void;
/**
* Fires when the given key is pressed outside any text field.
* Pass modifiers as part of the key spec, e.g. `mod+k` for Ctrl/Meta+K.
* Single-char keys ignore shift unless explicitly `shift+x`.
*/
export function useShortcut(spec: string | string[], handler: Handler, deps: unknown[] = []) {
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
const specs = (Array.isArray(spec) ? spec : [spec]).map(parseSpec);
const onKey = (e: KeyboardEvent) => {
if (isTextField(e.target)) {
// Allow mod+key even inside text fields (e.g. ⌘K)
if (!specs.some((s) => s.mod && matches(s, e))) return;
}
for (const s of specs) {
if (matches(s, e)) {
handlerRef.current(e);
return;
}
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
}
interface ParsedSpec {
key: string;
mod: boolean;
shift: boolean;
}
function parseSpec(s: string): ParsedSpec {
const parts = s.toLowerCase().split('+');
const key = parts[parts.length - 1];
return {
key,
mod: parts.includes('mod'),
shift: parts.includes('shift'),
};
}
function matches(s: ParsedSpec, e: KeyboardEvent): boolean {
if (e.key.toLowerCase() !== s.key) return false;
if (s.mod !== (e.metaKey || e.ctrlKey)) return false;
if (s.shift && !e.shiftKey) return false;
return true;
}
/**
* Tracks a two-key "g" prefix and fires the matching handler when the second
* key is pressed within the timeout window. Any other key cancels.
*/
export function useLeaderShortcut(
leader: string,
mapping: Record<string, () => void>,
timeoutMs = 1000,
) {
const state = useRef<{ armed: boolean; timer: number | null }>({ armed: false, timer: null });
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (isTextField(e.target)) return;
if (e.metaKey || e.ctrlKey || e.altKey) {
state.current.armed = false;
return;
}
const key = e.key.toLowerCase();
if (!state.current.armed) {
if (key === leader) {
state.current.armed = true;
if (state.current.timer) window.clearTimeout(state.current.timer);
state.current.timer = window.setTimeout(() => {
state.current.armed = false;
}, timeoutMs);
e.preventDefault();
}
return;
}
// armed: expect second key
state.current.armed = false;
if (state.current.timer) {
window.clearTimeout(state.current.timer);
state.current.timer = null;
}
const fn = mapping[key];
if (fn) {
e.preventDefault();
fn();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [leader, timeoutMs, JSON.stringify(Object.keys(mapping))]);
}
+125 -19
View File
@@ -2,23 +2,129 @@
@tailwind components;
@tailwind utilities;
/* Markdown prose styles */
.prose p { @apply mb-3 last:mb-0 leading-relaxed; }
.prose h1 { @apply text-xl font-bold mb-3 mt-5; }
.prose h2 { @apply text-lg font-semibold mb-2 mt-4; }
.prose h3 { @apply text-base font-semibold mb-2 mt-3; }
.prose ul { @apply list-disc pl-5 mb-3 space-y-1; }
.prose ol { @apply list-decimal pl-5 mb-3 space-y-1; }
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
}
}
/* Native select dark option styling */
select option {
background-color: #1f2937;
color: #f3f4f6;
}
/* Markdown prose styles (dark) */
.prose p {
@apply mb-3 last:mb-0 leading-relaxed;
}
.prose h1 {
@apply text-xl font-bold mb-3 mt-5;
}
.prose h2 {
@apply text-lg font-semibold mb-2 mt-4;
}
.prose h3 {
@apply text-base font-semibold mb-2 mt-3;
}
.prose ul {
@apply list-disc pl-5 mb-3 space-y-1;
}
.prose ol {
@apply list-decimal pl-5 mb-3 space-y-1;
}
.prose li > ul,
.prose li > ol { @apply mt-1 mb-0; }
.prose a { @apply text-blue-600 underline hover:text-blue-800; }
.prose strong { @apply font-semibold; }
.prose em { @apply italic; }
.prose blockquote { @apply border-l-4 border-gray-200 pl-4 text-gray-500 my-3 italic; }
.prose code { @apply bg-gray-100 text-gray-800 px-1.5 py-0.5 rounded text-xs font-mono; }
.prose pre { @apply bg-gray-900 text-gray-100 p-4 rounded-lg my-3 overflow-x-auto text-sm; }
.prose pre code { @apply bg-transparent text-gray-100 p-0; }
.prose hr { @apply border-gray-200 my-4; }
.prose table { @apply w-full border-collapse text-sm my-3; }
.prose th { @apply bg-gray-50 border border-gray-200 px-3 py-2 text-left font-semibold; }
.prose td { @apply border border-gray-200 px-3 py-2; }
.prose li > ol {
@apply mt-1 mb-0;
}
.prose a {
@apply text-blue-400 underline hover:text-blue-300;
}
.prose strong {
@apply font-semibold;
}
.prose em {
@apply italic;
}
.prose blockquote {
@apply border-l-4 border-gray-600 pl-4 text-gray-400 my-3 italic;
}
.prose code {
@apply bg-gray-800 text-gray-300 px-1.5 py-0.5 rounded text-xs font-mono;
}
.prose pre {
@apply bg-gray-950 text-gray-300 p-4 rounded-lg my-3 overflow-x-auto text-sm;
}
.prose pre code {
@apply bg-transparent text-gray-300 p-0;
}
.prose hr {
@apply border-gray-700 my-4;
}
.prose table {
@apply w-full border-collapse text-sm my-3;
}
.prose th {
@apply bg-gray-800 border border-gray-700 px-3 py-2 text-left font-semibold text-gray-300;
}
.prose td {
@apply border border-gray-700 px-3 py-2 text-gray-400;
}
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import { injectMentionLinks } from './mentions';
const users = [
{ id: 'u1', username: 'alice', displayName: 'Alice' },
{ id: 'u2', username: 'bob-smith', displayName: 'Bob Smith' },
];
describe('injectMentionLinks', () => {
it('rewrites known mentions to markdown links with assignee filter', () => {
const out = injectMentionLinks('hey @alice check this', users);
expect(out).toBe('hey [@alice](/tickets?assigneeId=u1) check this');
});
it('leaves unknown mentions alone', () => {
expect(injectMentionLinks('ping @ghost', users)).toBe('ping @ghost');
});
it('ignores email-like strings', () => {
expect(injectMentionLinks('mail alice@example.com', users)).toBe(
'mail alice@example.com',
);
});
it('matches at start of string', () => {
expect(injectMentionLinks('@alice hi', users)).toBe(
'[@alice](/tickets?assigneeId=u1) hi',
);
});
it('handles hyphenated usernames', () => {
expect(injectMentionLinks('cc @bob-smith', users)).toBe(
'cc [@bob-smith](/tickets?assigneeId=u2)',
);
});
it('skips processing when no @ in input', () => {
expect(injectMentionLinks('no mentions here', users)).toBe('no mentions here');
});
});
+17
View File
@@ -0,0 +1,17 @@
import type { User } from '../types';
type UserLite = Pick<User, 'id' | 'username' | 'displayName'>;
/**
* Rewrites `@username` tokens (outside word chars) into markdown links pointing
* to that user's ticket queue. Unknown usernames are left as plain text.
*/
export function injectMentionLinks(body: string, users: UserLite[]): string {
if (!body.includes('@') || users.length === 0) return body;
const byName = new Map(users.map((u) => [u.username.toLowerCase(), u]));
return body.replace(/(^|[^\w-])@([a-zA-Z0-9_-]+)/g, (_m, pre: string, name: string) => {
const user = byName.get(name.toLowerCase());
if (!user) return `${pre}@${name}`;
return `${pre}[@${user.username}](/tickets?assigneeId=${user.id})`;
});
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+23 -6
View File
@@ -1,10 +1,27 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from './api/queryClient';
import { ThemeProvider } from './contexts/ThemeContext';
import { Toaster } from '@/components/ui/sonner';
import './index.css';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider>
<QueryClientProvider client={queryClient}>
<App />
</StrictMode>
)
<Toaster />
</QueryClientProvider>
</ThemeProvider>
</StrictMode>,
);
if ('serviceWorker' in navigator && import.meta.env.PROD) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {
// Installability is a progressive enhancement; ignore failures
});
});
}
+172 -144
View File
@@ -1,163 +1,191 @@
import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { Plus, Search } from 'lucide-react'
import { formatDistanceToNow } from 'date-fns'
import api from '../api/client'
import Layout from '../components/Layout'
import SeverityBadge from '../components/SeverityBadge'
import StatusBadge from '../components/StatusBadge'
import Avatar from '../components/Avatar'
import { Ticket, TicketStatus } from '../types'
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { AlertTriangle, Clock, TrendingUp, Users } from 'lucide-react';
import Layout from '../components/Layout';
import { useAnalytics, useUsers } from '../api/queries';
const STATUSES: { value: TicketStatus | ''; label: string }[] = [
{ value: '', label: 'All Statuses' },
{ value: 'OPEN', label: 'Open' },
{ value: 'IN_PROGRESS', label: 'In Progress' },
{ value: 'RESOLVED', label: 'Resolved' },
{ value: 'CLOSED', label: 'Closed' },
]
const SEV_NAMES: Record<number, string> = {
1: 'SEV 1 — Critical',
2: 'SEV 2 — High',
3: 'SEV 3 — Medium',
4: 'SEV 4 — Low',
5: 'SEV 5 — Minimal',
};
const SEV_COLORS: Record<number, string> = {
1: 'bg-red-500',
2: 'bg-orange-400',
3: 'bg-yellow-400',
4: 'bg-blue-400',
5: 'bg-gray-500',
};
function fmtHours(hours: number | null) {
if (hours == null) return '—';
if (hours < 1) return `${Math.round(hours * 60)} min`;
if (hours < 48) return `${hours.toFixed(1)} h`;
return `${(hours / 24).toFixed(1)} d`;
}
export default function Dashboard() {
const [tickets, setTickets] = useState<Ticket[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [status, setStatus] = useState<TicketStatus | ''>('')
const [severity, setSeverity] = useState('')
const { data: a } = useAnalytics(30);
const { data: users = [] } = useUsers();
const fetchTickets = useCallback(() => {
setLoading(true)
const params: Record<string, string> = {}
if (status) params.status = status
if (severity) params.severity = severity
if (search) params.search = search
api
.get<Ticket[]>('/tickets', { params })
.then((r) => setTickets(r.data))
.finally(() => setLoading(false))
}, [status, severity, search])
const userById = useMemo(
() => new Map(users.map((u) => [u.id, u.displayName])),
[users],
);
useEffect(() => {
const t = setTimeout(fetchTickets, 300)
return () => clearTimeout(t)
}, [fetchTickets])
const totalOpen =
a?.openBySeverity.reduce((sum, row) => sum + row.count, 0) ?? 0;
const maxBucket = Math.max(
...Object.values(a?.ageBuckets ?? { d1: 0, d7: 0, d14: 0, older: 0 }),
1,
);
const maxSeverity = Math.max(...(a?.openBySeverity.map((r) => r.count) ?? [1]));
return (
<Layout
title="Tickets"
action={
<Link
to="/tickets/new"
className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg text-sm hover:bg-blue-700 transition-colors"
>
<Plus size={15} />
New Ticket
</Link>
}
>
{/* Filters */}
<div className="flex gap-3 mb-5">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={14} />
<input
type="text"
placeholder="Search tickets..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 pr-4 py-2 border border-gray-300 rounded-lg w-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<select
value={status}
onChange={(e) => setStatus(e.target.value as TicketStatus | '')}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{STATUSES.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
<select
value={severity}
onChange={(e) => setSeverity(e.target.value)}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All Severities</option>
{[1, 2, 3, 4, 5].map((s) => (
<option key={s} value={s}>
SEV {s}
</option>
))}
</select>
</div>
{/* Ticket list */}
{loading ? (
<div className="text-center py-16 text-gray-400 text-sm">Loading...</div>
) : tickets.length === 0 ? (
<div className="text-center py-16 text-gray-400 text-sm">No tickets found</div>
<Layout title="Dashboard" subheader={<p className="text-xs text-muted-foreground">Last 30 days</p>}>
{!a ? (
<p className="py-16 text-center text-sm text-muted-foreground">Loading analytics</p>
) : (
<div className="space-y-2">
{tickets.map((ticket) => (
<Link
key={ticket.id}
to={`/tickets/${ticket.displayId}`}
className="flex items-center gap-4 bg-white border border-gray-200 rounded-lg px-4 py-3 hover:border-blue-400 hover:shadow-sm transition-all group"
>
{/* Severity stripe */}
<div
className={`w-1 self-stretch rounded-full flex-shrink-0 ${
ticket.severity === 1
? 'bg-red-500'
: ticket.severity === 2
? 'bg-orange-400'
: ticket.severity === 3
? 'bg-yellow-400'
: ticket.severity === 4
? 'bg-blue-400'
: 'bg-gray-300'
}`}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card
icon={<AlertTriangle size={14} />}
label="Open tickets"
value={totalOpen.toString()}
/>
<Card
icon={<Clock size={14} />}
label="Aging >7d"
value={((a.ageBuckets.d14 ?? 0) + (a.ageBuckets.older ?? 0)).toString()}
/>
<Card
icon={<TrendingUp size={14} />}
label="Median resolution"
value={fmtHours(a.medianResolutionHours)}
/>
<Card
icon={<Users size={14} />}
label="Assignees loaded"
value={a.queueByAssignee.filter((q) => q.assigneeId).length.toString()}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-xs font-mono font-medium text-gray-400">
{ticket.displayId}
{/* Open by severity */}
<section className="md:col-span-2 rounded-md border border-border p-4">
<h2 className="text-sm font-semibold mb-3">Open by severity</h2>
<div className="space-y-2">
{[1, 2, 3, 4, 5].map((sev) => {
const row = a.openBySeverity.find((r) => r.severity === sev);
const count = row?.count ?? 0;
const pct = maxSeverity > 0 ? (count / maxSeverity) * 100 : 0;
return (
<div key={sev} className="flex items-center gap-3 text-sm">
<span className="w-32 text-xs text-muted-foreground">
{SEV_NAMES[sev]}
</span>
<SeverityBadge severity={ticket.severity} />
<StatusBadge status={ticket.status} />
<span className="text-xs text-gray-400">
{ticket.category.name} {ticket.type.name} {ticket.item.name}
<div className="flex-1 h-5 rounded-sm bg-muted overflow-hidden">
<div
className={`h-full ${SEV_COLORS[sev]}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className="w-10 text-right text-xs font-mono tabular-nums">
{count}
</span>
</div>
<p className="text-sm font-medium text-gray-900 truncate group-hover:text-blue-700">
{ticket.title}
</p>
</div>
<div className="flex items-center gap-3 flex-shrink-0">
{ticket.assignee && (
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<Avatar name={ticket.assignee.displayName} size="sm" />
<span>{ticket.assignee.displayName}</span>
</div>
)}
{!ticket.assignee && (
<span className="text-xs text-gray-400">Unassigned</span>
)}
<span className="text-xs text-gray-400">
{ticket._count?.comments ?? 0} comments
</span>
<span className="text-xs text-gray-400">
{formatDistanceToNow(new Date(ticket.createdAt), { addSuffix: true })}
</span>
);
})}
</div>
<div className="mt-3">
<Link
to="/tickets?status=OPEN"
className="text-xs text-primary hover:underline"
>
Go to open tickets
</Link>
))}
</div>
</section>
{/* Age buckets */}
<section className="md:col-span-2 rounded-md border border-border p-4">
<h2 className="text-sm font-semibold mb-3">Age of open tickets</h2>
<div className="space-y-2">
{[
{ key: 'd1', label: '≤ 1 day' },
{ key: 'd7', label: '≤ 7 days' },
{ key: 'd14', label: '≤ 14 days' },
{ key: 'older', label: '> 14 days' },
].map(({ key, label }) => {
const count = a.ageBuckets[key as keyof typeof a.ageBuckets] ?? 0;
const pct = (count / maxBucket) * 100;
return (
<div key={key} className="flex items-center gap-3 text-sm">
<span className="w-24 text-xs text-muted-foreground">{label}</span>
<div className="flex-1 h-5 rounded-sm bg-muted overflow-hidden">
<div
className={`h-full ${key === 'older' ? 'bg-red-500' : 'bg-primary'}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className="w-10 text-right text-xs font-mono tabular-nums">
{count}
</span>
</div>
);
})}
</div>
</section>
{/* Queue by assignee */}
<section className="md:col-span-2 lg:col-span-4 rounded-md border border-border p-4">
<h2 className="text-sm font-semibold mb-3">Queue load by assignee</h2>
{a.queueByAssignee.length === 0 ? (
<p className="text-xs text-muted-foreground">No open tickets right now.</p>
) : (
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-3">
{a.queueByAssignee
.slice()
.sort((x, y) => y.count - x.count)
.map((row) => {
const name = row.assigneeId
? userById.get(row.assigneeId) ?? 'Unknown'
: 'Unassigned';
return (
<div
key={row.assigneeId ?? 'none'}
className="flex items-center justify-between px-3 py-2 rounded-md bg-muted/40"
>
<span className="text-sm truncate">{name}</span>
<span className="text-xs font-mono tabular-nums">{row.count}</span>
</div>
);
})}
</div>
)}
</section>
</div>
)}
</Layout>
)
);
}
function Card({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="rounded-md border border-border p-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
{icon}
<span>{label}</span>
</div>
<p className="text-2xl font-semibold tabular-nums">{value}</p>
</div>
);
}
+46 -48
View File
@@ -1,83 +1,81 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { loginSchema, type LoginInput } from '../../../shared/schemas/auth';
import { useAuth } from '../contexts/AuthContext';
export default function Login() {
const { login } = useAuth()
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { login } = useAuth();
const navigate = useNavigate();
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginInput>({
resolver: zodResolver(loginSchema),
defaultValues: { username: '', password: '' },
});
const onSubmit = async (data: LoginInput) => {
setError('');
try {
await login(username, password)
navigate('/')
await login(data.username, data.password);
navigate('/');
} catch {
setError('Invalid username or password')
} finally {
setLoading(false)
}
setError('Invalid username or password');
}
};
const inputClass =
'w-full bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent';
return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center px-4">
<div className="min-h-screen bg-gray-950 flex items-center justify-center px-4">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-white">Ticketing System</h1>
<p className="text-gray-400 text-sm mt-1">Sign in to your account</p>
<p className="text-gray-500 text-sm mt-1">Sign in to your account</p>
</div>
<form
onSubmit={handleSubmit}
className="bg-white rounded-xl shadow-xl p-8 space-y-4"
onSubmit={handleSubmit(onSubmit)}
className="bg-gray-900 border border-gray-800 rounded-xl shadow-2xl p-8 space-y-4"
noValidate
>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm px-4 py-3 rounded-lg">
<div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm px-4 py-3 rounded-lg">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Username
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<label className="block text-sm font-medium text-gray-300 mb-1">Username</label>
<input type="text" autoFocus className={inputClass} {...register('username')} />
{errors.username && (
<p className="mt-1 text-xs text-red-400">{errors.username.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<label className="block text-sm font-medium text-gray-300 mb-1">Password</label>
<input type="password" className={inputClass} {...register('password')} />
{errors.password && (
<p className="mt-1 text-xs text-red-400">{errors.password.message}</p>
)}
</div>
<button
type="submit"
disabled={loading}
disabled={isSubmitting}
className="w-full bg-blue-600 text-white py-2 rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{loading ? 'Signing in...' : 'Sign in'}
{isSubmitting ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
</div>
)
);
}
+88
View File
@@ -0,0 +1,88 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { formatDistanceToNow } from 'date-fns';
import Layout from '../components/Layout';
import SeverityBadge from '../components/SeverityBadge';
import StatusBadge from '../components/StatusBadge';
import { useTickets } from '../api/queries';
import { useAuth } from '../contexts/AuthContext';
export default function MyTickets() {
const { user } = useAuth();
const openQ = useTickets(user ? { assigneeId: user.id, status: 'OPEN' } : {});
const inProgressQ = useTickets(user ? { assigneeId: user.id, status: 'IN_PROGRESS' } : {});
const loading = openQ.isLoading || inProgressQ.isLoading;
const tickets = useMemo(() => {
if (!user) return [];
const combined = [...(openQ.data ?? []), ...(inProgressQ.data ?? [])];
combined.sort(
(a, b) =>
a.severity - b.severity ||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
return combined;
}, [user, openQ.data, inProgressQ.data]);
return (
<Layout title="My Tickets">
{loading ? (
<div className="text-center py-16 text-gray-600 text-sm">Loading...</div>
) : tickets.length === 0 ? (
<div className="text-center py-16 text-gray-600 text-sm">
No active tickets assigned to you
</div>
) : (
<div className="space-y-1.5">
{tickets.map((ticket) => (
<Link
key={ticket.id}
to={`/${ticket.displayId}`}
className="flex items-center gap-4 bg-gray-900 border border-gray-800 rounded-lg px-4 py-3 hover:border-blue-500/50 transition-all group"
>
<div
className={`w-1 self-stretch rounded-full flex-shrink-0 ${
ticket.severity === 1
? 'bg-red-500'
: ticket.severity === 2
? 'bg-orange-400'
: ticket.severity === 3
? 'bg-yellow-400'
: ticket.severity === 4
? 'bg-blue-400'
: 'bg-gray-600'
}`}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5 flex-wrap">
<span className="text-xs font-mono font-medium text-gray-600">
{ticket.displayId}
</span>
<SeverityBadge severity={ticket.severity} />
<StatusBadge status={ticket.status} />
<span className="text-xs text-gray-600">
{ticket.category.name} {ticket.type.name} {ticket.item.name}
</span>
</div>
<p className="text-sm font-medium text-gray-200 truncate group-hover:text-blue-400">
{ticket.title}
</p>
</div>
<div className="flex items-center gap-3 flex-shrink-0">
<span className="text-xs text-gray-600">
{ticket._count?.comments ?? 0} comments
</span>
<span className="text-xs text-gray-600">
{formatDistanceToNow(new Date(ticket.createdAt), { addSuffix: true })}
</span>
</div>
</Link>
))}
</div>
)}
</Layout>
);
}
+91 -77
View File
@@ -1,72 +1,63 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import api from '../api/client'
import Layout from '../components/Layout'
import CTISelect from '../components/CTISelect'
import { User } from '../types'
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { createTicketSchema, type CreateTicketInput } from '../../../shared/schemas/ticket';
import Modal from '../components/Modal';
import CTISelect from '../components/CTISelect';
import { useUsers, useCreateTicket } from '../api/queries';
export default function NewTicket() {
const navigate = useNavigate()
const [users, setUsers] = useState<User[]>([])
const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false)
interface NewTicketModalProps {
onClose: () => void;
}
const [form, setForm] = useState({
export default function NewTicketModal({ onClose }: NewTicketModalProps) {
const navigate = useNavigate();
const { data: users = [] } = useUsers();
const createTicket = useCreateTicket();
const [error, setError] = useState('');
const {
register,
handleSubmit,
control,
formState: { errors, isSubmitting },
} = useForm<CreateTicketInput>({
resolver: zodResolver(createTicketSchema),
defaultValues: {
title: '',
overview: '',
severity: 3,
assigneeId: '',
categoryId: '',
typeId: '',
itemId: '',
})
assigneeId: undefined,
},
});
useEffect(() => {
api.get<User[]>('/users').then((r) => setUsers(r.data))
}, [])
const handleCTI = (cti: { categoryId: string; typeId: string; itemId: string }) => {
setForm((f) => ({ ...f, ...cti }))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.categoryId || !form.typeId || !form.itemId) {
setError('Please select a Category, Type, and Item')
return
}
setError('')
setSubmitting(true)
const onSubmit = async (data: CreateTicketInput) => {
setError('');
try {
const payload: Record<string, unknown> = {
title: form.title,
overview: form.overview,
severity: form.severity,
categoryId: form.categoryId,
typeId: form.typeId,
itemId: form.itemId,
}
if (form.assigneeId) payload.assigneeId = form.assigneeId
const res = await api.post('/tickets', payload)
navigate(`/tickets/${res.data.displayId}`)
const payload: Record<string, unknown> = { ...data };
if (!data.assigneeId) delete payload.assigneeId;
const created = await createTicket.mutateAsync(payload);
onClose();
navigate(`/${created.displayId}`);
} catch {
setError('Failed to create ticket')
} finally {
setSubmitting(false)
}
setError('Failed to create ticket');
}
};
const inputClass =
'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
const labelClass = 'block text-sm font-medium text-gray-700 mb-1'
'w-full bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent';
const labelClass = 'block text-sm font-medium text-gray-300 mb-1';
const errorClass = 'mt-1 text-xs text-red-400';
return (
<Layout title="New Ticket">
<div className="max-w-2xl">
<form onSubmit={handleSubmit} className="bg-white border border-gray-200 rounded-xl p-6 space-y-5">
<Modal title="New Ticket" onClose={onClose} size="lg">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm px-4 py-3 rounded-lg">
<div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm px-4 py-3 rounded-lg">
{error}
</div>
)}
@@ -75,33 +66,31 @@ export default function NewTicket() {
<label className={labelClass}>Title</label>
<input
type="text"
value={form.title}
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
required
className={inputClass}
placeholder="Brief description of the issue"
autoFocus
{...register('title')}
/>
{errors.title && <p className={errorClass}>{errors.title.message}</p>}
</div>
<div>
<label className={labelClass}>Overview</label>
<textarea
value={form.overview}
onChange={(e) => setForm((f) => ({ ...f, overview: e.target.value }))}
required
rows={4}
className={inputClass}
placeholder="Detailed description..."
placeholder="Detailed description... Markdown supported"
{...register('overview')}
/>
{errors.overview && <p className={errorClass}>{errors.overview.message}</p>}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className={labelClass}>Severity</label>
<select
value={form.severity}
onChange={(e) => setForm((f) => ({ ...f, severity: Number(e.target.value) }))}
className={inputClass}
{...register('severity', { valueAsNumber: true })}
>
<option value={1}>SEV 1 Critical</option>
<option value={2}>SEV 2 High</option>
@@ -109,15 +98,12 @@ export default function NewTicket() {
<option value={4}>SEV 4 Low</option>
<option value={5}>SEV 5 Minimal</option>
</select>
{errors.severity && <p className={errorClass}>{errors.severity.message}</p>}
</div>
<div>
<label className={labelClass}>Assignee</label>
<select
value={form.assigneeId}
onChange={(e) => setForm((f) => ({ ...f, assigneeId: e.target.value }))}
className={inputClass}
>
<select className={inputClass} {...register('assigneeId')}>
<option value="">Unassigned</option>
{users
.filter((u) => u.role !== 'SERVICE')
@@ -132,30 +118,58 @@ export default function NewTicket() {
<div>
<label className={labelClass}>Routing (CTI)</label>
<Controller
control={control}
name="categoryId"
render={({ field: categoryField }) => (
<Controller
control={control}
name="typeId"
render={({ field: typeField }) => (
<Controller
control={control}
name="itemId"
render={({ field: itemField }) => (
<CTISelect
value={{ categoryId: form.categoryId, typeId: form.typeId, itemId: form.itemId }}
onChange={handleCTI}
value={{
categoryId: categoryField.value,
typeId: typeField.value,
itemId: itemField.value,
}}
onChange={(cti) => {
categoryField.onChange(cti.categoryId);
typeField.onChange(cti.typeId);
itemField.onChange(cti.itemId);
}}
/>
)}
/>
)}
/>
)}
/>
{(errors.categoryId || errors.typeId || errors.itemId) && (
<p className={errorClass}>Please select a Category, Type, and Item</p>
)}
</div>
<div className="flex justify-end gap-3 pt-2">
<div className="flex justify-end gap-3 pt-1">
<button
type="button"
onClick={() => navigate(-1)}
className="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
onClick={onClose}
className="px-4 py-2 text-sm text-gray-400 border border-gray-700 rounded-lg hover:bg-gray-800 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={submitting}
disabled={isSubmitting}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{submitting ? 'Creating...' : 'Create Ticket'}
{isSubmitting ? 'Creating...' : 'Create Ticket'}
</button>
</div>
</form>
</div>
</Layout>
)
</Modal>
);
}
+90
View File
@@ -0,0 +1,90 @@
import { Link } from 'react-router-dom';
import { formatDistanceToNow } from 'date-fns';
import Layout from '../components/Layout';
import { useNotifications, useMarkNotificationsRead } from '../api/queries';
import type { Notification } from '../../../shared/types';
const KIND_LABELS: Record<string, string> = {
ASSIGNED: 'assigned to you',
MENTION: 'mentioned you',
RESOLVED: 'was resolved',
TICKET_CREATED: 'was created',
STATUS_CHANGED: 'status changed',
COMMENT: 'new comment',
};
interface NotifData {
displayId?: string;
title?: string;
byName?: string;
}
function summary(n: Notification): { label: string; href: string } {
const data = (n.data ?? {}) as NotifData;
const action = KIND_LABELS[n.kind] ?? n.kind.toLowerCase();
const label = data.title ? `${data.title}${action}` : `Ticket ${action}`;
const href = data.displayId ? `/${data.displayId}` : '/notifications';
return { label, href };
}
export default function Notifications() {
const { data: notifications = [], isLoading } = useNotifications();
const markRead = useMarkNotificationsRead();
const unread = notifications.filter((n) => !n.readAt);
return (
<Layout
title="Notifications"
action={
unread.length > 0 ? (
<button
onClick={() => markRead.mutate({ all: true })}
className="text-sm px-3 py-1.5 rounded-md border border-input hover:bg-accent"
>
Mark all read
</button>
) : null
}
>
{isLoading ? (
<p className="py-16 text-center text-sm text-muted-foreground">Loading</p>
) : notifications.length === 0 ? (
<p className="py-16 text-center text-sm text-muted-foreground">
You&apos;re all caught up
</p>
) : (
<ul className="rounded-md border border-border divide-y divide-border overflow-hidden">
{notifications.map((n) => {
const { label, href } = summary(n);
const isUnread = !n.readAt;
return (
<li
key={n.id}
className={isUnread ? 'bg-primary/5' : ''}
>
<Link
to={href}
onClick={() => isUnread && markRead.mutate({ ids: [n.id] })}
className="flex items-center gap-3 px-4 py-3 hover:bg-accent/30 transition-colors"
>
<span
className={`w-2 h-2 rounded-full flex-shrink-0 ${
isUnread ? 'bg-primary' : 'bg-transparent'
}`}
/>
<div className="flex-1 min-w-0">
<p className="text-sm text-foreground line-clamp-1">{label}</p>
<p className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(n.createdAt), { addSuffix: true })}
</p>
</div>
</Link>
</li>
);
})}
</ul>
)}
</Layout>
);
}
+151
View File
@@ -0,0 +1,151 @@
import { useEffect, useState } from 'react';
import { Copy } from 'lucide-react';
import { toast } from 'sonner';
import Layout from '../components/Layout';
import { useAuth } from '../contexts/AuthContext';
import {
useNotificationPrefs,
useUpdateNotificationPrefs,
type NotificationPrefs,
} from '../api/queries';
const CHANNELS = [
{ key: 'assignment', label: 'Ticket assigned to me' },
{ key: 'mention', label: 'I am mentioned in a comment' },
{ key: 'resolved', label: 'A ticket I created is resolved' },
] as const;
export default function Settings() {
const { user } = useAuth();
const prefsQ = useNotificationPrefs();
const updatePrefs = useUpdateNotificationPrefs();
const [local, setLocal] = useState<NotificationPrefs | null>(null);
useEffect(() => {
if (prefsQ.data && !local) setLocal(prefsQ.data);
}, [prefsQ.data, local]);
const handleToggle = (channel: 'email' | 'inApp', key: keyof NotificationPrefs['email']) => {
setLocal((l) =>
l
? {
...l,
[channel]: { ...l[channel], [key]: !l[channel][key] },
}
: l,
);
};
const save = async () => {
if (!local) return;
try {
await updatePrefs.mutateAsync(local);
toast.success('Preferences saved');
} catch (e) {
toast.error((e as Error).message || 'Failed to save');
}
};
const copyKey = async () => {
if (!user?.apiKey) return;
await navigator.clipboard.writeText(user.apiKey);
toast.success('API key copied');
};
return (
<Layout title="Settings">
<div className="space-y-6">
{/* Profile */}
<section className="rounded-md border border-border p-4">
<h2 className="text-sm font-semibold mb-3">Profile</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
<Field label="Display name" value={user?.displayName ?? ''} />
<Field label="Username" value={user?.username ?? ''} mono />
<Field label="Email" value={user?.email ?? ''} />
<Field label="Role" value={user?.role ?? ''} />
</div>
</section>
{/* Notifications */}
<section className="rounded-md border border-border p-4">
<h2 className="text-sm font-semibold mb-3">Notification preferences</h2>
{local ? (
<div className="space-y-3">
<div className="grid grid-cols-[1fr_80px_80px] gap-2 text-xs uppercase text-muted-foreground pb-1 border-b border-border">
<span />
<span className="text-center">Email</span>
<span className="text-center">In app</span>
</div>
{CHANNELS.map(({ key, label }) => (
<div
key={key}
className="grid grid-cols-[1fr_80px_80px] gap-2 items-center text-sm"
>
<span>{label}</span>
<div className="text-center">
<input
type="checkbox"
checked={local.email[key]}
onChange={() => handleToggle('email', key)}
/>
</div>
<div className="text-center">
<input
type="checkbox"
checked={local.inApp[key]}
onChange={() => handleToggle('inApp', key)}
/>
</div>
</div>
))}
<div className="flex justify-end pt-2">
<button
onClick={save}
disabled={updatePrefs.isPending}
className="px-3 py-1.5 bg-primary text-primary-foreground rounded-md text-sm disabled:opacity-50"
>
{updatePrefs.isPending ? 'Saving…' : 'Save preferences'}
</button>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">Loading</p>
)}
<p className="mt-3 text-xs text-muted-foreground">
Email notifications require the server&apos;s SMTP config. If SMTP is unset, only
in-app delivery happens regardless of these settings.
</p>
</section>
{/* API key (service accounts only) */}
{user?.role === 'SERVICE' && user?.apiKey && (
<section className="rounded-md border border-border p-4">
<h2 className="text-sm font-semibold mb-3">API key</h2>
<div className="flex items-center gap-2 bg-muted rounded-md px-3 py-2 font-mono text-xs break-all">
<span className="flex-1">{user.apiKey}</span>
<button
onClick={copyKey}
className="text-muted-foreground hover:text-foreground"
>
<Copy size={14} />
</button>
</div>
<p className="mt-2 text-xs text-muted-foreground">
Pass as <code>x-api-key</code> header on any server-to-server request.
</p>
</section>
)}
</div>
</Layout>
);
}
function Field({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div>
<p className="text-xs text-muted-foreground mb-0.5">{label}</p>
<p className={`text-sm ${mono ? 'font-mono' : ''}`}>{value || '—'}</p>
</div>
);
}
File diff suppressed because it is too large Load Diff
+641
View File
@@ -0,0 +1,641 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useShortcut } from '../hooks/useShortcuts';
import { ChevronLeft, ChevronRight, Trash2, Save } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { toast } from 'sonner';
import Layout from '../components/Layout';
import SeverityBadge from '../components/SeverityBadge';
import StatusBadge from '../components/StatusBadge';
import Avatar from '../components/Avatar';
import CTISelect from '../components/CTISelect';
import { TicketStatus } from '../types';
import { useAuth } from '../contexts/AuthContext';
import {
useTicketsPaged,
useBulkTickets,
useSavedViews,
useCreateSavedView,
useDeleteSavedView,
useUsers,
} from '../api/queries';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
const STATUS_TABS: { value: TicketStatus | ''; label: string }[] = [
{ value: '', label: 'All' },
{ value: 'OPEN', label: 'Open' },
{ value: 'IN_PROGRESS', label: 'In progress' },
{ value: 'RESOLVED', label: 'Resolved' },
{ value: 'CLOSED', label: 'Closed' },
];
const PAGE_SIZE = 25;
const BULK_LIMIT = 500;
function sevColor(severity: number) {
return severity === 1
? 'bg-red-500'
: severity === 2
? 'bg-orange-400'
: severity === 3
? 'bg-yellow-400'
: severity === 4
? 'bg-blue-400'
: 'bg-gray-600';
}
export default function Tickets() {
const [params, setParams] = useSearchParams();
const navigate = useNavigate();
const { user: authUser } = useAuth();
const { data: users = [] } = useUsers();
const status = (params.get('status') ?? '') as TicketStatus | '';
const severity = params.get('severity') ?? '';
const assigneeId = params.get('assigneeId') ?? '';
const categoryId = params.get('categoryId') ?? '';
const typeId = params.get('typeId') ?? '';
const itemId = params.get('itemId') ?? '';
const search = params.get('search') ?? '';
const page = Math.max(1, Number(params.get('page') ?? '1'));
const [searchInput, setSearchInput] = useState(search);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [cursor, setCursor] = useState<number>(-1);
const [saveOpen, setSaveOpen] = useState(false);
const [newViewName, setNewViewName] = useState('');
const [confirmBulk, setConfirmBulk] = useState<
| { kind: 'close' | 'setSeverity' | 'reassign'; value?: unknown; label: string }
| null
>(null);
useEffect(() => {
const t = setTimeout(() => {
if (search !== searchInput) {
updateParam('search', searchInput);
updateParam('page', '1');
}
}, 250);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchInput]);
const updateParam = (key: string, value: string | null) => {
setParams(
(prev) => {
const next = new URLSearchParams(prev);
if (value === null || value === '') next.delete(key);
else next.set(key, value);
if (key !== 'page') next.delete('page'); // reset page on filter change
return next;
},
{ replace: true },
);
};
const queryParams = {
status: status || undefined,
severity: severity ? Number(severity) : undefined,
assigneeId: assigneeId || undefined,
categoryId: categoryId || undefined,
typeId: typeId || undefined,
itemId: itemId || undefined,
search: search || undefined,
page,
pageSize: PAGE_SIZE,
};
const { data, isLoading, isFetching } = useTicketsPaged(queryParams);
const tickets = data?.data ?? [];
const total = data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const savedViewsQ = useSavedViews();
const createView = useCreateSavedView();
const deleteView = useDeleteSavedView();
const bulk = useBulkTickets();
const allVisible = tickets.length > 0 && tickets.every((t) => selected.has(t.id));
const someVisible = tickets.some((t) => selected.has(t.id));
const toggleAll = () => {
setSelected((prev) => {
const next = new Set(prev);
if (allVisible) tickets.forEach((t) => next.delete(t.id));
else tickets.forEach((t) => next.add(t.id));
return next;
});
};
const toggleOne = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const clearSelected = () => setSelected(new Set());
const currentFilters = useMemo(
() => ({
status: status || undefined,
severity: severity || undefined,
assigneeId: assigneeId || undefined,
categoryId: categoryId || undefined,
typeId: typeId || undefined,
itemId: itemId || undefined,
search: search || undefined,
}),
[status, severity, assigneeId, categoryId, typeId, itemId, search],
);
const runBulk = async (payload: { action: string; value?: unknown }) => {
const ids = Array.from(selected).slice(0, BULK_LIMIT);
try {
const res = (await bulk.mutateAsync({ ids, ...payload })) as { updated?: number };
toast.success(`Updated ${res.updated ?? ids.length} ticket${ids.length === 1 ? '' : 's'}`);
clearSelected();
} catch (e) {
toast.error((e as Error).message || 'Bulk action failed');
} finally {
setConfirmBulk(null);
}
};
const activeCount = Object.values(currentFilters).filter(Boolean).length;
const handleSaveView = async () => {
if (!newViewName.trim()) return;
try {
await createView.mutateAsync({
name: newViewName.trim(),
filters: currentFilters,
});
toast.success('Saved view');
setNewViewName('');
setSaveOpen(false);
} catch (e) {
toast.error((e as Error).message || 'Failed to save view');
}
};
const applyView = (filters: Record<string, unknown>) => {
const next = new URLSearchParams();
Object.entries(filters).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') next.set(k, String(v));
});
setParams(next, { replace: true });
setSearchInput(String(filters.search ?? ''));
};
const agentUsers = users.filter((u) => u.role !== 'SERVICE');
// Keyboard navigation
useEffect(() => {
setCursor((c) => (c >= tickets.length ? tickets.length - 1 : c));
}, [tickets.length]);
useShortcut('j', (e) => {
if (tickets.length === 0) return;
e.preventDefault();
setCursor((c) => Math.min(tickets.length - 1, c < 0 ? 0 : c + 1));
}, [tickets.length]);
useShortcut('k', (e) => {
if (tickets.length === 0) return;
e.preventDefault();
setCursor((c) => Math.max(0, c < 0 ? 0 : c - 1));
}, [tickets.length]);
useShortcut('enter', (e) => {
if (cursor < 0 || cursor >= tickets.length) return;
e.preventDefault();
navigate(`/${tickets[cursor].displayId}`);
}, [cursor, tickets]);
useShortcut('x', (e) => {
if (cursor < 0 || cursor >= tickets.length) return;
e.preventDefault();
toggleOne(tickets[cursor].id);
}, [cursor, tickets]);
return (
<Layout wide>
{/* Status tabs */}
<div className="flex items-center border-b border-border mb-4 -mx-4 px-4 overflow-x-auto">
{STATUS_TABS.map((tab) => (
<button
key={tab.value}
onClick={() => updateParam('status', tab.value || null)}
className={`px-3 py-2 text-sm whitespace-nowrap border-b-2 -mb-px transition-colors ${
status === tab.value
? 'border-primary text-foreground font-medium'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Filter bar */}
<div className="flex gap-2 mb-4 flex-wrap items-center">
<input
type="search"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search title, overview, ID…"
className="flex-1 min-w-48 max-w-xs px-3 py-1.5 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
<select
value={severity}
onChange={(e) => updateParam('severity', e.target.value || null)}
className="px-3 py-1.5 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">All severities</option>
{[1, 2, 3, 4, 5].map((s) => (
<option key={s} value={s}>
SEV {s}
</option>
))}
</select>
<select
value={assigneeId}
onChange={(e) => updateParam('assigneeId', e.target.value || null)}
className="px-3 py-1.5 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">All assignees</option>
{authUser && (
<option value={authUser.id}>Me ({authUser.displayName})</option>
)}
{agentUsers
.filter((u) => u.id !== authUser?.id)
.map((u) => (
<option key={u.id} value={u.id}>
{u.displayName}
</option>
))}
</select>
<div className="min-w-56">
<CTISelect
value={{ categoryId, typeId, itemId }}
onChange={(cti) => {
setParams(
(prev) => {
const next = new URLSearchParams(prev);
next.delete('page');
if (cti.categoryId) next.set('categoryId', cti.categoryId);
else next.delete('categoryId');
if (cti.typeId) next.set('typeId', cti.typeId);
else next.delete('typeId');
if (cti.itemId) next.set('itemId', cti.itemId);
else next.delete('itemId');
return next;
},
{ replace: true },
);
}}
/>
</div>
{/* Saved views */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="px-3 py-1.5 rounded-md border border-input bg-background text-sm hover:bg-accent">
Saved views
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel>Saved views</DropdownMenuLabel>
<DropdownMenuSeparator />
{savedViewsQ.data && savedViewsQ.data.length > 0 ? (
savedViewsQ.data.map((v) => (
<div
key={v.id}
className="flex items-center gap-1 px-2 py-1 hover:bg-accent rounded-sm"
>
<button
onClick={() => applyView(v.filters)}
className="flex-1 text-left text-sm truncate"
>
{v.name}
</button>
<button
onClick={() => deleteView.mutate(v.id)}
aria-label="Delete view"
className="text-muted-foreground hover:text-destructive"
>
<Trash2 size={13} />
</button>
</div>
))
) : (
<p className="px-2 py-1 text-xs text-muted-foreground">No saved views yet</p>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={activeCount === 0}
onSelect={() => setSaveOpen(true)}
className="gap-2"
>
<Save size={13} />
Save current filters
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="ml-auto text-xs text-muted-foreground">
{isFetching ? 'Loading…' : `${total} result${total === 1 ? '' : 's'}`}
</div>
</div>
{/* Bulk bar */}
{selected.size > 0 && (
<div className="flex items-center gap-3 mb-3 px-3 py-2 rounded-md bg-accent border border-border">
<span className="text-sm font-medium">
{selected.size} selected
</span>
<button
onClick={clearSelected}
className="text-xs text-muted-foreground hover:text-foreground"
>
Clear
</button>
<div className="h-4 w-px bg-border mx-1" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="px-2 py-1 rounded-md text-sm hover:bg-background">
Reassign
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onSelect={() =>
setConfirmBulk({
kind: 'reassign',
value: null,
label: 'Unassign',
})
}
>
Unassigned
</DropdownMenuItem>
{agentUsers.map((u) => (
<DropdownMenuItem
key={u.id}
onSelect={() =>
setConfirmBulk({
kind: 'reassign',
value: u.id,
label: `Assign to ${u.displayName}`,
})
}
>
{u.displayName}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="px-2 py-1 rounded-md text-sm hover:bg-background">
Severity
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{[1, 2, 3, 4, 5].map((s) => (
<DropdownMenuItem
key={s}
onSelect={() =>
setConfirmBulk({
kind: 'setSeverity',
value: s,
label: `Set severity SEV ${s}`,
})
}
>
SEV {s}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<button
onClick={() =>
setConfirmBulk({ kind: 'close', label: 'Close selected tickets' })
}
className="px-2 py-1 rounded-md text-sm hover:bg-background"
>
Close
</button>
</div>
)}
{/* Ticket list */}
{isLoading ? (
<div className="text-center py-16 text-muted-foreground text-sm">Loading</div>
) : tickets.length === 0 ? (
<div className="text-center py-16 text-muted-foreground text-sm">No tickets found</div>
) : (
<div className="rounded-md border border-border overflow-hidden">
<div className="flex items-center gap-3 px-4 py-2 bg-card text-xs text-muted-foreground border-b border-border">
<input
type="checkbox"
checked={allVisible}
aria-label="Select all on page"
ref={(el) => {
if (el) el.indeterminate = !allVisible && someVisible;
}}
onChange={toggleAll}
className="cursor-pointer"
/>
<span>
{tickets.length} of {total}
</span>
</div>
<ul className="divide-y divide-border">
{tickets.map((ticket, idx) => (
<li
key={ticket.id}
className={`flex items-center gap-3 px-4 py-3 transition-colors ${
idx === cursor
? 'bg-accent/50 ring-1 ring-inset ring-primary'
: 'hover:bg-accent/30'
}`}
>
<input
type="checkbox"
checked={selected.has(ticket.id)}
onChange={() => toggleOne(ticket.id)}
aria-label={`Select ${ticket.displayId}`}
className="cursor-pointer flex-shrink-0"
/>
<div
className={`w-1 self-stretch rounded-full flex-shrink-0 ${sevColor(ticket.severity)}`}
/>
<Link
to={`/${ticket.displayId}`}
className="flex-1 min-w-0 flex items-center gap-3 group"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5 flex-wrap">
<span className="text-sm font-medium text-foreground group-hover:text-primary truncate">
{ticket.title}
</span>
<span className="text-xs font-mono text-muted-foreground">
{ticket.displayId}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground flex-wrap">
<SeverityBadge severity={ticket.severity} />
<StatusBadge status={ticket.status} />
<span>
opened {formatDistanceToNow(new Date(ticket.createdAt), {
addSuffix: true,
})}{' '}
by {ticket.createdBy.displayName}
</span>
<span className="hidden md:inline">
· {ticket.category.name} {ticket.type.name} {ticket.item.name}
</span>
{ticket.assignee && (
<span className="hidden md:inline">· assigned {ticket.assignee.displayName}</span>
)}
<span>· {ticket._count?.comments ?? 0} comments</span>
</div>
</div>
<div className="hidden sm:flex items-center flex-shrink-0">
{ticket.assignee ? (
<Avatar name={ticket.assignee.displayName} size="sm" />
) : null}
</div>
</Link>
</li>
))}
</ul>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4 text-sm">
<div className="text-muted-foreground">
Page {page} of {totalPages}
</div>
<div className="flex items-center gap-1">
<button
disabled={page <= 1}
onClick={() => updateParam('page', String(page - 1))}
className="p-1.5 rounded-md hover:bg-accent disabled:opacity-40 disabled:cursor-not-allowed"
>
<ChevronLeft size={16} />
</button>
<button
disabled={page >= totalPages}
onClick={() => updateParam('page', String(page + 1))}
className="p-1.5 rounded-md hover:bg-accent disabled:opacity-40 disabled:cursor-not-allowed"
>
<ChevronRight size={16} />
</button>
</div>
</div>
)}
{/* Save view dialog */}
<Dialog open={saveOpen} onOpenChange={setSaveOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Save current filters</DialogTitle>
</DialogHeader>
<div className="py-2">
<input
autoFocus
type="text"
value={newViewName}
onChange={(e) => setNewViewName(e.target.value)}
placeholder="View name"
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveView();
}}
/>
</div>
<DialogFooter>
<button
onClick={() => setSaveOpen(false)}
className="px-3 py-1.5 rounded-md border border-input text-sm hover:bg-accent"
>
Cancel
</button>
<button
onClick={handleSaveView}
disabled={!newViewName.trim() || createView.isPending}
className="px-3 py-1.5 rounded-md bg-primary text-primary-foreground text-sm disabled:opacity-50"
>
Save
</button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Confirm bulk action */}
<AlertDialog open={!!confirmBulk} onOpenChange={(o) => !o && setConfirmBulk(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{confirmBulk?.label}</AlertDialogTitle>
<AlertDialogDescription>
This will affect {selected.size} ticket{selected.size === 1 ? '' : 's'}.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (!confirmBulk) return;
if (confirmBulk.kind === 'close') runBulk({ action: 'close' });
else
runBulk({
action: confirmBulk.kind,
value: confirmBulk.value,
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Layout>
);
}
+150 -129
View File
@@ -1,154 +1,157 @@
import { useState, useEffect } from 'react'
import { Plus, Pencil, Trash2, ChevronRight } from 'lucide-react'
import api from '../../api/client'
import Layout from '../../components/Layout'
import Modal from '../../components/Modal'
import { Category, CTIType, Item } from '../../types'
import { useState } from 'react';
import { Plus, Pencil, Trash2, ChevronRight } from 'lucide-react';
import Layout from '../../components/Layout';
import Modal from '../../components/Modal';
import { Category, CTIType } from '../../types';
import {
useCategories,
useTypes,
useItems,
useCreateCategory,
useUpdateCategory,
useDeleteCategory,
useCreateType,
useUpdateType,
useDeleteType,
useCreateItem,
useUpdateItem,
useDeleteItem,
} from '../../api/queries';
type PanelItem = { id: string; name: string }
type PanelItem = { id: string; name: string };
type Panel = 'category' | 'type' | 'item';
interface NameModalState {
open: boolean
mode: 'add' | 'edit'
panel: 'category' | 'type' | 'item'
item?: PanelItem
open: boolean;
mode: 'add' | 'edit';
panel: Panel;
item?: PanelItem;
}
export default function AdminCTI() {
const [categories, setCategories] = useState<Category[]>([])
const [types, setTypes] = useState<CTIType[]>([])
const [items, setItems] = useState<Item[]>([])
const [selectedCategory, setSelectedCategory] = useState<Category | null>(null);
const [selectedType, setSelectedType] = useState<CTIType | null>(null);
const [selectedCategory, setSelectedCategory] = useState<Category | null>(null)
const [selectedType, setSelectedType] = useState<CTIType | null>(null)
const { data: categories = [] } = useCategories();
const { data: types = [] } = useTypes(selectedCategory?.id);
const { data: items = [] } = useItems(selectedType?.id);
const [nameModal, setNameModal] = useState<NameModalState>({ open: false, mode: 'add', panel: 'category' })
const [nameValue, setNameValue] = useState('')
const [submitting, setSubmitting] = useState(false)
const createCategory = useCreateCategory();
const updateCategory = useUpdateCategory();
const deleteCategory = useDeleteCategory();
const createType = useCreateType();
const updateType = useUpdateType();
const deleteType = useDeleteType();
const createItem = useCreateItem();
const updateItem = useUpdateItem();
const deleteItem = useDeleteItem();
const fetchCategories = () =>
api.get<Category[]>('/cti/categories').then((r) => setCategories(r.data))
const [nameModal, setNameModal] = useState<NameModalState>({
open: false,
mode: 'add',
panel: 'category',
});
const [nameValue, setNameValue] = useState('');
const fetchTypes = (categoryId: string) =>
api
.get<CTIType[]>('/cti/types', { params: { categoryId } })
.then((r) => setTypes(r.data))
const fetchItems = (typeId: string) =>
api.get<Item[]>('/cti/items', { params: { typeId } }).then((r) => setItems(r.data))
useEffect(() => { fetchCategories() }, [])
const submitting =
createCategory.isPending ||
updateCategory.isPending ||
createType.isPending ||
updateType.isPending ||
createItem.isPending ||
updateItem.isPending;
const selectCategory = (cat: Category) => {
setSelectedCategory(cat)
setSelectedType(null)
setItems([])
fetchTypes(cat.id)
}
setSelectedCategory(cat);
setSelectedType(null);
};
const selectType = (type: CTIType) => {
setSelectedType(type)
fetchItems(type.id)
}
setSelectedType(type);
};
const openAdd = (panel: 'category' | 'type' | 'item') => {
setNameValue('')
setNameModal({ open: true, mode: 'add', panel })
}
const openAdd = (panel: Panel) => {
setNameValue('');
setNameModal({ open: true, mode: 'add', panel });
};
const openEdit = (panel: 'category' | 'type' | 'item', item: PanelItem) => {
setNameValue(item.name)
setNameModal({ open: true, mode: 'edit', panel, item })
}
const openEdit = (panel: Panel, item: PanelItem) => {
setNameValue(item.name);
setNameModal({ open: true, mode: 'edit', panel, item });
};
const closeModal = () => setNameModal((m) => ({ ...m, open: false }))
const closeModal = () => setNameModal((m) => ({ ...m, open: false }));
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
if (!nameValue.trim()) return
setSubmitting(true)
try {
const { mode, panel, item } = nameModal
e.preventDefault();
const name = nameValue.trim();
if (!name) return;
const { mode, panel, item } = nameModal;
if (mode === 'add') {
if (panel === 'category') {
await api.post('/cti/categories', { name: nameValue.trim() })
await fetchCategories()
await createCategory.mutateAsync({ name });
} else if (panel === 'type' && selectedCategory) {
await api.post('/cti/types', { name: nameValue.trim(), categoryId: selectedCategory.id })
await fetchTypes(selectedCategory.id)
await createType.mutateAsync({ name, categoryId: selectedCategory.id });
} else if (panel === 'item' && selectedType) {
await api.post('/cti/items', { name: nameValue.trim(), typeId: selectedType.id })
await fetchItems(selectedType.id)
await createItem.mutateAsync({ name, typeId: selectedType.id });
}
} else {
if (!item) return
} else if (item) {
if (panel === 'category') {
await api.put(`/cti/categories/${item.id}`, { name: nameValue.trim() })
await fetchCategories()
if (selectedCategory?.id === item.id) setSelectedCategory((c) => c ? { ...c, name: nameValue.trim() } : c)
await updateCategory.mutateAsync({ id: item.id, name });
if (selectedCategory?.id === item.id)
setSelectedCategory((c) => (c ? { ...c, name } : c));
} else if (panel === 'type') {
await api.put(`/cti/types/${item.id}`, { name: nameValue.trim() })
if (selectedCategory) await fetchTypes(selectedCategory.id)
if (selectedType?.id === item.id) setSelectedType((t) => t ? { ...t, name: nameValue.trim() } : t)
await updateType.mutateAsync({ id: item.id, name });
if (selectedType?.id === item.id) setSelectedType((t) => (t ? { ...t, name } : t));
} else if (panel === 'item') {
await api.put(`/cti/items/${item.id}`, { name: nameValue.trim() })
if (selectedType) await fetchItems(selectedType.id)
}
}
closeModal()
} finally {
setSubmitting(false)
await updateItem.mutateAsync({ id: item.id, name });
}
}
closeModal();
};
const handleDelete = async (panel: 'category' | 'type' | 'item', item: PanelItem) => {
if (!confirm(`Delete "${item.name}"? This will also delete all child records.`)) return
const handleDelete = async (panel: Panel, item: PanelItem) => {
if (!confirm(`Delete "${item.name}"? This will also delete all child records.`)) return;
if (panel === 'category') {
await api.delete(`/cti/categories/${item.id}`)
await deleteCategory.mutateAsync(item.id);
if (selectedCategory?.id === item.id) {
setSelectedCategory(null)
setSelectedType(null)
setTypes([])
setItems([])
setSelectedCategory(null);
setSelectedType(null);
}
await fetchCategories()
} else if (panel === 'type') {
await api.delete(`/cti/types/${item.id}`)
if (selectedType?.id === item.id) {
setSelectedType(null)
setItems([])
}
if (selectedCategory) await fetchTypes(selectedCategory.id)
await deleteType.mutateAsync(item.id);
if (selectedType?.id === item.id) setSelectedType(null);
} else {
await api.delete(`/cti/items/${item.id}`)
if (selectedType) await fetchItems(selectedType.id)
}
await deleteItem.mutateAsync(item.id);
}
};
const panelClass = 'bg-white border border-gray-200 rounded-xl overflow-hidden flex flex-col'
const panelHeaderClass = 'flex items-center justify-between px-4 py-3 border-b border-gray-100 bg-gray-50'
const panelClass = 'bg-gray-900 border border-gray-800 rounded-xl overflow-hidden flex flex-col';
const panelHeaderClass = 'flex items-center justify-between px-4 py-3 border-b border-gray-800';
const itemClass = (active: boolean) =>
`flex items-center justify-between px-4 py-2.5 cursor-pointer group transition-colors ${
active ? 'bg-blue-50 border-l-2 border-blue-500' : 'hover:bg-gray-50 border-l-2 border-transparent'
}`
active
? 'bg-blue-600/20 border-l-2 border-blue-500'
: 'hover:bg-gray-800 border-l-2 border-transparent'
}`;
return (
<Layout title="CTI Configuration">
<div className="grid grid-cols-3 gap-4 h-[calc(100vh-10rem)]">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:h-[calc(100vh-10rem)]">
{/* Categories */}
<div className={panelClass}>
<div className={panelHeaderClass}>
<h3 className="text-sm font-semibold text-gray-700">Categories</h3>
<h3 className="text-sm font-semibold text-gray-300">Categories</h3>
<button
onClick={() => openAdd('category')}
className="text-blue-600 hover:text-blue-800 transition-colors"
className="text-blue-400 hover:text-blue-300 transition-colors"
>
<Plus size={16} />
</button>
</div>
<div className="flex-1 overflow-auto">
{categories.length === 0 ? (
<p className="text-xs text-gray-400 text-center py-8">No categories</p>
<p className="text-xs text-gray-600 text-center py-8">No categories</p>
) : (
categories.map((cat) => (
<div
@@ -156,21 +159,27 @@ export default function AdminCTI() {
className={itemClass(selectedCategory?.id === cat.id)}
onClick={() => selectCategory(cat)}
>
<span className="text-sm text-gray-800">{cat.name}</span>
<span className="text-sm text-gray-300">{cat.name}</span>
<div className="flex items-center gap-1">
<button
onClick={(e) => { e.stopPropagation(); openEdit('category', cat) }}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-gray-700 transition-all"
onClick={(e) => {
e.stopPropagation();
openEdit('category', cat);
}}
className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-gray-300 transition-all"
>
<Pencil size={13} />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDelete('category', cat) }}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-600 transition-all"
onClick={(e) => {
e.stopPropagation();
handleDelete('category', cat);
}}
className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 transition-all"
>
<Trash2 size={13} />
</button>
<ChevronRight size={14} className="text-gray-300" />
<ChevronRight size={14} className="text-gray-700" />
</div>
</div>
))
@@ -181,16 +190,16 @@ export default function AdminCTI() {
{/* Types */}
<div className={panelClass}>
<div className={panelHeaderClass}>
<h3 className="text-sm font-semibold text-gray-700">
<h3 className="text-sm font-semibold text-gray-300">
Types
{selectedCategory && (
<span className="ml-1 font-normal text-gray-400"> {selectedCategory.name}</span>
<span className="ml-1 font-normal text-gray-500"> {selectedCategory.name}</span>
)}
</h3>
{selectedCategory && (
<button
onClick={() => openAdd('type')}
className="text-blue-600 hover:text-blue-800 transition-colors"
className="text-blue-400 hover:text-blue-300 transition-colors"
>
<Plus size={16} />
</button>
@@ -198,9 +207,9 @@ export default function AdminCTI() {
</div>
<div className="flex-1 overflow-auto">
{!selectedCategory ? (
<p className="text-xs text-gray-400 text-center py-8">Select a category</p>
<p className="text-xs text-gray-600 text-center py-8">Select a category</p>
) : types.length === 0 ? (
<p className="text-xs text-gray-400 text-center py-8">No types</p>
<p className="text-xs text-gray-600 text-center py-8">No types</p>
) : (
types.map((type) => (
<div
@@ -208,21 +217,27 @@ export default function AdminCTI() {
className={itemClass(selectedType?.id === type.id)}
onClick={() => selectType(type)}
>
<span className="text-sm text-gray-800">{type.name}</span>
<span className="text-sm text-gray-300">{type.name}</span>
<div className="flex items-center gap-1">
<button
onClick={(e) => { e.stopPropagation(); openEdit('type', type) }}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-gray-700 transition-all"
onClick={(e) => {
e.stopPropagation();
openEdit('type', type);
}}
className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-gray-300 transition-all"
>
<Pencil size={13} />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDelete('type', type) }}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-600 transition-all"
onClick={(e) => {
e.stopPropagation();
handleDelete('type', type);
}}
className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 transition-all"
>
<Trash2 size={13} />
</button>
<ChevronRight size={14} className="text-gray-300" />
<ChevronRight size={14} className="text-gray-700" />
</div>
</div>
))
@@ -233,16 +248,16 @@ export default function AdminCTI() {
{/* Items */}
<div className={panelClass}>
<div className={panelHeaderClass}>
<h3 className="text-sm font-semibold text-gray-700">
<h3 className="text-sm font-semibold text-gray-300">
Items
{selectedType && (
<span className="ml-1 font-normal text-gray-400"> {selectedType.name}</span>
<span className="ml-1 font-normal text-gray-500"> {selectedType.name}</span>
)}
</h3>
{selectedType && (
<button
onClick={() => openAdd('item')}
className="text-blue-600 hover:text-blue-800 transition-colors"
className="text-blue-400 hover:text-blue-300 transition-colors"
>
<Plus size={16} />
</button>
@@ -250,23 +265,29 @@ export default function AdminCTI() {
</div>
<div className="flex-1 overflow-auto">
{!selectedType ? (
<p className="text-xs text-gray-400 text-center py-8">Select a type</p>
<p className="text-xs text-gray-600 text-center py-8">Select a type</p>
) : items.length === 0 ? (
<p className="text-xs text-gray-400 text-center py-8">No items</p>
<p className="text-xs text-gray-600 text-center py-8">No items</p>
) : (
items.map((item) => (
<div key={item.id} className={itemClass(false)} onClick={() => {}}>
<span className="text-sm text-gray-800">{item.name}</span>
<span className="text-sm text-gray-300">{item.name}</span>
<div className="flex items-center gap-1">
<button
onClick={(e) => { e.stopPropagation(); openEdit('item', item) }}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-gray-700 transition-all"
onClick={(e) => {
e.stopPropagation();
openEdit('item', item);
}}
className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-gray-300 transition-all"
>
<Pencil size={13} />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDelete('item', item) }}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-600 transition-all"
onClick={(e) => {
e.stopPropagation();
handleDelete('item', item);
}}
className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 transition-all"
>
<Trash2 size={13} />
</button>
@@ -286,21 +307,21 @@ export default function AdminCTI() {
>
<form onSubmit={handleSave} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<label className="block text-sm font-medium text-gray-300 mb-1">Name</label>
<input
type="text"
value={nameValue}
onChange={(e) => setNameValue(e.target.value)}
required
autoFocus
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full bg-gray-800 border border-gray-700 text-gray-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
className="px-4 py-2 text-sm text-gray-400 border border-gray-700 rounded-lg hover:bg-gray-800 transition-colors"
>
Cancel
</button>
@@ -316,5 +337,5 @@ export default function AdminCTI() {
</Modal>
)}
</Layout>
)
);
}
+193 -114
View File
@@ -1,17 +1,33 @@
import { useState, useEffect } from 'react'
import { Plus, Pencil, Trash2, RefreshCw, Copy, Check } from 'lucide-react'
import api from '../../api/client'
import Layout from '../../components/Layout'
import Modal from '../../components/Modal'
import { User, Role } from '../../types'
import { useAuth } from '../../contexts/AuthContext'
import { useState } from 'react';
import { Plus, Pencil, Trash2, RefreshCw, Copy, Check } from 'lucide-react';
import { toast } from 'sonner';
import Layout from '../../components/Layout';
import Modal from '../../components/Modal';
import { User, Role } from '../../types';
import { useAuth } from '../../contexts/AuthContext';
import {
useUsers,
useCreateUser,
useUpdateUser,
useDeleteUser,
} from '../../api/queries';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
interface UserForm {
username: string
email: string
displayName: string
password: string
role: Role
username: string;
email: string;
displayName: string;
password: string;
role: Role;
}
const BLANK_FORM: UserForm = {
@@ -20,121 +36,149 @@ const BLANK_FORM: UserForm = {
displayName: '',
password: '',
role: 'AGENT',
}
};
const ROLE_LABELS: Record<Role, string> = {
ADMIN: 'Admin',
AGENT: 'Agent',
USER: 'User',
SERVICE: 'Service',
}
};
const ROLE_BADGE: Record<Role, string> = {
ADMIN: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
AGENT: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
USER: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
SERVICE: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
};
const ROLE_DESCRIPTIONS: Record<Role, string> = {
ADMIN: 'Full access — manage users, CTI config, close and delete tickets',
AGENT: 'Manage tickets — create, update, assign, comment, change status',
USER: 'Basic access — view tickets and add comments only',
SERVICE: 'Automation account — authenticates via API key, no password login',
};
export default function AdminUsers() {
const { user: authUser } = useAuth()
const [users, setUsers] = useState<User[]>([])
const [modal, setModal] = useState<'add' | 'edit' | null>(null)
const [selected, setSelected] = useState<User | null>(null)
const [form, setForm] = useState<UserForm>(BLANK_FORM)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
const [copiedKey, setCopiedKey] = useState<string | null>(null)
const [newApiKey, setNewApiKey] = useState<string | null>(null)
const { user: authUser } = useAuth();
const { data: users = [] } = useUsers();
const createUser = useCreateUser();
const updateUser = useUpdateUser();
const deleteUser = useDeleteUser();
const fetchUsers = () => {
api.get<User[]>('/users').then((r) => setUsers(r.data))
}
const [modal, setModal] = useState<'add' | 'edit' | null>(null);
const [selected, setSelected] = useState<User | null>(null);
const [form, setForm] = useState<UserForm>(BLANK_FORM);
const [error, setError] = useState('');
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const [newApiKey, setNewApiKey] = useState<string | null>(null);
const [deleting, setDeleting] = useState<User | null>(null);
const [rotating, setRotating] = useState<User | null>(null);
useEffect(() => { fetchUsers() }, [])
const submitting = createUser.isPending || updateUser.isPending;
const openAdd = () => {
setForm(BLANK_FORM)
setError('')
setNewApiKey(null)
setModal('add')
}
setForm(BLANK_FORM);
setError('');
setNewApiKey(null);
setModal('add');
};
const openEdit = (u: User) => {
setSelected(u)
setForm({ username: u.username, email: u.email, displayName: u.displayName, password: '', role: u.role })
setError('')
setNewApiKey(null)
setModal('edit')
}
setSelected(u);
setForm({
username: u.username,
email: u.email,
displayName: u.displayName,
password: '',
role: u.role,
});
setError('');
setNewApiKey(null);
setModal('edit');
};
const closeModal = () => {
setModal(null)
setSelected(null)
setNewApiKey(null)
fetchUsers()
}
setModal(null);
setSelected(null);
setNewApiKey(null);
};
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitting(true)
setError('')
e.preventDefault();
setError('');
try {
const payload: Record<string, string> = {
username: form.username,
email: form.email,
displayName: form.displayName,
role: form.role,
}
if (form.password) payload.password = form.password
const res = await api.post<User>('/users', payload)
if (res.data.apiKey) setNewApiKey(res.data.apiKey)
else closeModal()
};
if (form.password) payload.password = form.password;
const created = await createUser.mutateAsync(payload);
if (created.apiKey) setNewApiKey(created.apiKey);
else closeModal();
} catch (err: unknown) {
const e = err as { response?: { data?: { error?: string } } }
setError(e.response?.data?.error ?? 'Failed to create user')
} finally {
setSubmitting(false)
}
const e = err as { response?: { data?: { error?: string } } };
setError(e.response?.data?.error ?? 'Failed to create user');
}
};
const handleEdit = async (e: React.FormEvent) => {
e.preventDefault()
if (!selected) return
setSubmitting(true)
setError('')
e.preventDefault();
if (!selected) return;
setError('');
try {
const payload: Record<string, string> = {
email: form.email,
displayName: form.displayName,
role: form.role,
}
if (form.password) payload.password = form.password
await api.patch(`/users/${selected.id}`, payload)
closeModal()
};
if (form.password) payload.password = form.password;
await updateUser.mutateAsync({ id: selected.id, data: payload });
closeModal();
} catch (err: unknown) {
const e = err as { response?: { data?: { error?: string } } }
setError(e.response?.data?.error ?? 'Failed to update user')
} finally {
setSubmitting(false)
}
const e = err as { response?: { data?: { error?: string } } };
setError(e.response?.data?.error ?? 'Failed to update user');
}
};
const handleDelete = async (u: User) => {
if (!confirm(`Delete user "${u.displayName}"?`)) return
await api.delete(`/users/${u.id}`)
fetchUsers()
const confirmDelete = async () => {
if (!deleting) return;
try {
await deleteUser.mutateAsync(deleting.id);
toast.success(`Deleted ${deleting.displayName}`);
} catch (e) {
toast.error((e as Error).message || 'Failed to delete user');
}
setDeleting(null);
};
const handleRegenerateKey = async (u: User) => {
if (!confirm(`Regenerate API key for "${u.displayName}"? The old key will stop working immediately.`)) return
const res = await api.patch<User>(`/users/${u.id}`, { regenerateApiKey: true })
setNewApiKey(res.data.apiKey ?? null)
setSelected(u)
setModal('edit')
const confirmRegenerate = async () => {
if (!rotating) return;
try {
const updated = await updateUser.mutateAsync({
id: rotating.id,
data: { regenerateApiKey: true },
});
setNewApiKey(updated.apiKey ?? null);
setSelected(rotating);
setModal('edit');
} catch (e) {
toast.error((e as Error).message || 'Failed to rotate key');
}
setRotating(null);
};
const copyToClipboard = (key: string) => {
navigator.clipboard.writeText(key)
setCopiedKey(key)
setTimeout(() => setCopiedKey(null), 2000)
}
navigator.clipboard.writeText(key);
setCopiedKey(key);
setTimeout(() => setCopiedKey(null), 2000);
};
const inputClass =
'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
const labelClass = 'block text-sm font-medium text-gray-700 mb-1'
'w-full bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent';
const labelClass = 'block text-sm font-medium text-gray-300 mb-1';
return (
<Layout
@@ -149,9 +193,9 @@ export default function AdminUsers() {
</button>
}
>
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-x-auto">
<table className="w-full text-sm min-w-[640px]">
<thead className="border-b border-gray-800">
<tr>
<th className="text-left px-5 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">
User
@@ -168,31 +212,25 @@ export default function AdminUsers() {
<th className="px-5 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
<tbody className="divide-y divide-gray-800">
{users.map((u) => (
<tr key={u.id} className="hover:bg-gray-50">
<td className="px-5 py-3 font-medium text-gray-900">{u.displayName}</td>
<tr key={u.id} className="hover:bg-gray-800/50">
<td className="px-5 py-3 font-medium text-gray-100">{u.displayName}</td>
<td className="px-5 py-3 text-gray-500 font-mono text-xs">{u.username}</td>
<td className="px-5 py-3">
<span
className={`inline-flex px-2 py-0.5 rounded text-xs font-medium ${
u.role === 'ADMIN'
? 'bg-purple-100 text-purple-700'
: u.role === 'SERVICE'
? 'bg-orange-100 text-orange-700'
: 'bg-gray-100 text-gray-600'
}`}
className={`inline-flex px-2 py-0.5 rounded text-xs font-medium border ${ROLE_BADGE[u.role]}`}
>
{ROLE_LABELS[u.role]}
</span>
</td>
<td className="px-5 py-3 text-gray-500">{u.email}</td>
<td className="px-5 py-3 text-gray-400">{u.email}</td>
<td className="px-5 py-3">
<div className="flex items-center justify-end gap-2">
{u.role === 'SERVICE' && (
<button
onClick={() => handleRegenerateKey(u)}
className="text-gray-400 hover:text-gray-700 transition-colors"
onClick={() => setRotating(u)}
className="text-gray-600 hover:text-gray-300 transition-colors"
title="Regenerate API key"
>
<RefreshCw size={14} />
@@ -200,14 +238,14 @@ export default function AdminUsers() {
)}
<button
onClick={() => openEdit(u)}
className="text-gray-400 hover:text-gray-700 transition-colors"
className="text-gray-600 hover:text-gray-300 transition-colors"
>
<Pencil size={14} />
</button>
{u.id !== authUser?.id && (
<button
onClick={() => handleDelete(u)}
className="text-gray-400 hover:text-red-600 transition-colors"
onClick={() => setDeleting(u)}
className="text-gray-600 hover:text-red-400 transition-colors"
>
<Trash2 size={14} />
</button>
@@ -228,17 +266,17 @@ export default function AdminUsers() {
>
{newApiKey ? (
<div className="space-y-4">
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<p className="text-sm font-medium text-amber-800 mb-2">
API Key copy it now, it won't be shown again
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg p-4">
<p className="text-sm font-medium text-amber-400 mb-2">
API Key copy it now, it won&apos;t be shown again
</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs bg-white border border-amber-200 rounded px-3 py-2 font-mono break-all">
<code className="flex-1 text-xs bg-gray-800 border border-gray-700 text-gray-300 rounded px-3 py-2 font-mono break-all">
{newApiKey}
</code>
<button
onClick={() => copyToClipboard(newApiKey)}
className="flex-shrink-0 text-amber-700 hover:text-amber-900 transition-colors"
className="flex-shrink-0 text-amber-400 hover:text-amber-300 transition-colors"
>
{copiedKey === newApiKey ? <Check size={16} /> : <Copy size={16} />}
</button>
@@ -254,7 +292,7 @@ export default function AdminUsers() {
) : (
<form onSubmit={modal === 'add' ? handleAdd : handleEdit} className="space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm px-3 py-2 rounded-lg">
<div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm px-3 py-2 rounded-lg">
{error}
</div>
)}
@@ -296,7 +334,10 @@ export default function AdminUsers() {
<div>
<label className={labelClass}>
Password {modal === 'edit' && <span className="text-gray-400 font-normal">(leave blank to keep current)</span>}
Password{' '}
{modal === 'edit' && (
<span className="text-gray-500 font-normal">(leave blank to keep current)</span>
)}
</label>
<input
type="password"
@@ -316,16 +357,18 @@ export default function AdminUsers() {
className={inputClass}
>
<option value="AGENT">Agent</option>
<option value="USER">User</option>
<option value="ADMIN">Admin</option>
<option value="SERVICE">Service (API key auth)</option>
<option value="SERVICE">Service</option>
</select>
<p className="mt-1.5 text-xs text-gray-500">{ROLE_DESCRIPTIONS[form.role]}</p>
</div>
<div className="flex justify-end gap-3 pt-1">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
className="px-4 py-2 text-sm text-gray-400 border border-gray-700 rounded-lg hover:bg-gray-800 transition-colors"
>
Cancel
</button>
@@ -341,6 +384,42 @@ export default function AdminUsers() {
)}
</Modal>
)}
<AlertDialog open={!!deleting} onOpenChange={(o) => !o && setDeleting(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {deleting?.displayName}?</AlertDialogTitle>
<AlertDialogDescription>
This user will be permanently removed. Their tickets and comments are preserved.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={!!rotating} onOpenChange={(o) => !o && setRotating(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Regenerate API key for {rotating?.displayName}?</AlertDialogTitle>
<AlertDialogDescription>
The old key will stop working immediately. You&apos;ll see the new key once make
sure whatever uses it can be updated.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmRegenerate}>Rotate key</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Layout>
)
);
}
+304
View File
@@ -0,0 +1,304 @@
import { useState } from 'react';
import { Copy, Plus, RefreshCw, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import Layout from '../../components/Layout';
import { WEBHOOK_EVENTS } from '../../../../shared/schemas/notification';
import type { Webhook } from '../../../../shared/types';
import {
useWebhooks,
useCreateWebhook,
useDeleteWebhook,
useUpdateWebhook,
useRotateWebhookSecret,
} from '../../api/queries';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
const BLANK = {
name: '',
url: '',
events: [...WEBHOOK_EVENTS] as string[],
};
export default function AdminWebhooks() {
const { data: hooks = [] } = useWebhooks();
const create = useCreateWebhook();
const update = useUpdateWebhook();
const del = useDeleteWebhook();
const rotate = useRotateWebhookSecret();
const [addOpen, setAddOpen] = useState(false);
const [form, setForm] = useState(BLANK);
const [revealedSecret, setRevealedSecret] = useState<{ name: string; secret: string } | null>(
null,
);
const [deleting, setDeleting] = useState<Webhook | null>(null);
const [rotating, setRotating] = useState<Webhook | null>(null);
const copy = async (text: string) => {
await navigator.clipboard.writeText(text);
toast.success('Copied to clipboard');
};
const toggleEvent = (event: string) => {
setForm((f) => ({
...f,
events: f.events.includes(event)
? f.events.filter((e) => e !== event)
: [...f.events, event],
}));
};
const submitCreate = async () => {
try {
const hook = await create.mutateAsync(form);
if (hook.secret) setRevealedSecret({ name: hook.name, secret: hook.secret });
setForm(BLANK);
setAddOpen(false);
toast.success('Webhook created');
} catch (e) {
toast.error((e as Error).message || 'Failed to create webhook');
}
};
const confirmDelete = async () => {
if (!deleting) return;
try {
await del.mutateAsync(deleting.id);
toast.success('Webhook deleted');
} catch (e) {
toast.error((e as Error).message || 'Failed to delete');
}
setDeleting(null);
};
const confirmRotate = async () => {
if (!rotating) return;
try {
const hook = await rotate.mutateAsync(rotating.id);
if (hook.secret) setRevealedSecret({ name: hook.name, secret: hook.secret });
} catch (e) {
toast.error((e as Error).message || 'Failed to rotate');
}
setRotating(null);
};
return (
<Layout
title="Webhooks"
action={
<button
onClick={() => setAddOpen(true)}
className="flex items-center gap-1.5 bg-primary text-primary-foreground px-3 py-1.5 rounded-md text-sm"
>
<Plus size={14} />
Add webhook
</button>
}
>
{hooks.length === 0 ? (
<div className="py-16 text-center text-sm text-muted-foreground border border-border rounded-md">
No webhooks configured. Add one to push events to n8n, Slack, or any HTTP receiver.
</div>
) : (
<div className="rounded-md border border-border overflow-x-auto">
<table className="w-full text-sm min-w-[640px]">
<thead className="border-b border-border bg-card text-xs uppercase text-muted-foreground">
<tr>
<th className="text-left px-4 py-2">Name</th>
<th className="text-left px-4 py-2">URL</th>
<th className="text-left px-4 py-2">Events</th>
<th className="text-left px-4 py-2">Active</th>
<th className="px-4 py-2" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{hooks.map((h) => (
<tr key={h.id}>
<td className="px-4 py-2 font-medium">{h.name}</td>
<td className="px-4 py-2 font-mono text-xs text-muted-foreground truncate max-w-xs">
{h.url}
</td>
<td className="px-4 py-2 text-xs text-muted-foreground">
{h.events.length} event{h.events.length === 1 ? '' : 's'}
</td>
<td className="px-4 py-2">
<button
onClick={() => update.mutate({ id: h.id, data: { active: !h.active } })}
className={`text-xs px-2 py-0.5 rounded-full ${
h.active
? 'bg-green-500/20 text-green-400'
: 'bg-muted text-muted-foreground'
}`}
>
{h.active ? 'Active' : 'Disabled'}
</button>
</td>
<td className="px-4 py-2">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => setRotating(h)}
title="Rotate secret"
className="text-muted-foreground hover:text-foreground"
>
<RefreshCw size={14} />
</button>
<button
onClick={() => setDeleting(h)}
title="Delete"
className="text-muted-foreground hover:text-destructive"
>
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add webhook</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">Name</label>
<input
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
className="w-full px-3 py-1.5 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">
URL
</label>
<input
value={form.url}
onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))}
placeholder="https://n8n.example.com/webhook/abc"
className="w-full px-3 py-1.5 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">
Events
</label>
<div className="grid grid-cols-2 gap-1">
{WEBHOOK_EVENTS.map((ev) => (
<label key={ev} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.events.includes(ev)}
onChange={() => toggleEvent(ev)}
/>
<span className="font-mono text-xs">{ev}</span>
</label>
))}
</div>
</div>
</div>
<DialogFooter>
<button
onClick={() => setAddOpen(false)}
className="px-3 py-1.5 rounded-md border border-input text-sm hover:bg-accent"
>
Cancel
</button>
<button
onClick={submitCreate}
disabled={!form.name || !form.url || form.events.length === 0 || create.isPending}
className="px-3 py-1.5 rounded-md bg-primary text-primary-foreground text-sm disabled:opacity-50"
>
Create
</button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={!!revealedSecret} onOpenChange={(o) => !o && setRevealedSecret(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Secret for {revealedSecret?.name}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Copy it now it won&apos;t be shown again. Use it to verify the{' '}
<code className="text-xs">X-Ticketing-Signature</code> HMAC-SHA256 header.
</p>
<div className="flex items-center gap-2 bg-muted rounded-md px-3 py-2 font-mono text-xs break-all">
<span className="flex-1">{revealedSecret?.secret}</span>
<button
onClick={() => revealedSecret && copy(revealedSecret.secret)}
className="text-muted-foreground hover:text-foreground"
>
<Copy size={14} />
</button>
</div>
<DialogFooter>
<button
onClick={() => setRevealedSecret(null)}
className="px-3 py-1.5 rounded-md bg-primary text-primary-foreground text-sm"
>
Done
</button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={!!deleting} onOpenChange={(o) => !o && setDeleting(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete webhook {deleting?.name}?</AlertDialogTitle>
<AlertDialogDescription>
No events will be dispatched to this URL anymore.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={!!rotating} onOpenChange={(o) => !o && setRotating(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Rotate secret for {rotating?.name}?</AlertDialogTitle>
<AlertDialogDescription>
The existing secret stops signing new events immediately. You&apos;ll see the new
secret once.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmRotate}>Rotate</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Layout>
);
}
+7
View File
@@ -0,0 +1,7 @@
import '@testing-library/jest-dom/vitest';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(() => {
cleanup();
});
+53 -53
View File
@@ -1,74 +1,74 @@
export type Role = 'ADMIN' | 'AGENT' | 'SERVICE'
export type TicketStatus = 'OPEN' | 'IN_PROGRESS' | 'RESOLVED' | 'CLOSED'
export type Role = 'ADMIN' | 'AGENT' | 'USER' | 'SERVICE';
export type TicketStatus = 'OPEN' | 'IN_PROGRESS' | 'RESOLVED' | 'CLOSED';
export interface User {
id: string
username: string
displayName: string
email: string
role: Role
apiKey?: string
createdAt?: string
id: string;
username: string;
displayName: string;
email: string;
role: Role;
apiKey?: string;
createdAt?: string;
}
export interface Category {
id: string
name: string
id: string;
name: string;
}
export interface CTIType {
id: string
name: string
categoryId: string
category?: Category
id: string;
name: string;
categoryId: string;
category?: Category;
}
export interface Item {
id: string
name: string
typeId: string
type?: CTIType & { category?: Category }
id: string;
name: string;
typeId: string;
type?: CTIType & { category?: Category };
}
export interface Comment {
id: string
body: string
ticketId: string
authorId: string
author: Pick<User, 'id' | 'username' | 'displayName'>
createdAt: string
id: string;
body: string;
ticketId: string;
authorId: string;
author: Pick<User, 'id' | 'username' | 'displayName'>;
createdAt: string;
}
export interface AuditLog {
id: string
ticketId: string
userId: string
action: string
detail: string | null
createdAt: string
user: Pick<User, 'id' | 'username' | 'displayName'>
id: string;
ticketId: string;
userId: string;
action: string;
detail: string | null;
createdAt: string;
user: Pick<User, 'id' | 'username' | 'displayName'>;
}
export interface Ticket {
id: string
displayId: string
title: string
overview: string
severity: number
status: TicketStatus
categoryId: string
typeId: string
itemId: string
assigneeId: string | null
createdById: string
resolvedAt: string | null
createdAt: string
updatedAt: string
category: Category
type: CTIType
item: Item
assignee: Pick<User, 'id' | 'username' | 'displayName'> | null
createdBy: Pick<User, 'id' | 'username' | 'displayName'>
comments?: Comment[]
_count?: { comments: number }
id: string;
displayId: string;
title: string;
overview: string;
severity: number;
status: TicketStatus;
categoryId: string;
typeId: string;
itemId: string;
assigneeId: string | null;
createdById: string;
resolvedAt: string | null;
createdAt: string;
updatedAt: string;
category: Category;
type: CTIType;
item: Item;
assignee: Pick<User, 'id' | 'username' | 'displayName'> | null;
createdBy: Pick<User, 'id' | 'username' | 'displayName'>;
comments?: Comment[];
_count?: { comments: number };
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+68 -3
View File
@@ -1,8 +1,73 @@
import tailwindcssAnimate from 'tailwindcss-animate';
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ['class'],
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px',
},
plugins: [],
}
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
plugins: [tailwindcssAnimate],
};
+6 -3
View File
@@ -14,7 +14,10 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src", "../shared/**/*"]
}
+9 -3
View File
@@ -1,11 +1,17 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
proxy: {
'/api': 'http://localhost:3000',
},
},
})
});
+14
View File
@@ -0,0 +1,14 @@
/// <reference types="vitest" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
css: false,
},
});
+18 -2
View File
@@ -15,7 +15,7 @@ services:
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
test: ['CMD-SHELL', 'pg_isready -U postgres']
interval: 5s
timeout: 5s
retries: 5
@@ -30,9 +30,24 @@ services:
JWT_SECRET: ${JWT_SECRET}
CLIENT_URL: ${CLIENT_URL}
PORT: 3000
UPLOADS_DIR: /data/uploads
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASS: ${SMTP_PASS:-}
SMTP_FROM: ${SMTP_FROM:-noreply@localhost}
SMTP_SECURE: ${SMTP_SECURE:-false}
volumes:
- uploads:/data/uploads
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ['CMD-SHELL', 'wget -qO- http://localhost:3000/healthz || exit 1']
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
networks:
- internal
@@ -40,7 +55,7 @@ services:
image: ${REGISTRY}/josh/ticketing-client:${TAG:-latest}
restart: unless-stopped
ports:
- "${PORT:-3080}:80"
- '${PORT:-3080}:80'
depends_on:
- server
networks:
@@ -52,3 +67,4 @@ networks:
volumes:
postgres_data:
uploads:
+78
View File
@@ -0,0 +1,78 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import react from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import globals from 'globals';
export default [
{
ignores: [
'**/dist/**',
'**/node_modules/**',
'**/.vite/**',
'**/coverage/**',
'server/prisma/migrations/**',
'client/src/components/ui/**',
],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['client/src/**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: { ...globals.browser },
parserOptions: {
ecmaFeatures: { jsx: true },
},
},
plugins: {
react,
'react-hooks': reactHooks,
},
settings: {
react: { version: 'detect' },
},
rules: {
...react.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'@typescript-eslint/no-explicit-any': 'warn',
},
},
{
files: ['server/src/**/*.ts', 'server/prisma/**/*.ts'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: { ...globals.node },
},
rules: {
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'@typescript-eslint/no-explicit-any': 'warn',
},
},
{
files: ['shared/**/*.ts'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
rules: {
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
},
},
];
+3518
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "ticketing-system",
"private": true,
"version": "1.0.0",
"description": "Internal ticketing system — monorepo root (tooling only)",
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"typecheck": "npm run typecheck --prefix server && npm run typecheck --prefix client",
"test": "npm test --prefix server && npm test --prefix client"
},
"dependencies": {
"zod": "^3.23.8"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"eslint": "^9.17.0",
"eslint-plugin-react": "^7.37.3",
"eslint-plugin-react-hooks": "^5.1.0",
"globals": "^15.14.0",
"prettier": "^3.4.2",
"typescript-eslint": "^8.18.2"
}
}
+10 -10
View File
@@ -1,16 +1,16 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx prisma generate
RUN npm run build
COPY server/package*.json ./server/
RUN cd server && npm ci
COPY server ./server
COPY shared ./shared
RUN cd server && npx prisma generate && npm run build
FROM node:22-alpine
RUN apk add --no-cache openssl
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/prisma ./prisma
COPY package*.json ./
WORKDIR /app/server
COPY --from=build /app/server/dist ./dist
COPY --from=build /app/server/node_modules ./node_modules
COPY --from=build /app/server/prisma ./prisma
COPY --from=build /app/server/package*.json ./
CMD ["npm", "run", "start:prod"]
+2220 -7
View File
File diff suppressed because it is too large Load Diff
+20 -6
View File
@@ -4,12 +4,14 @@
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"start:prod": "prisma db push && node dist/index.js",
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"start": "node dist/server/src/index.js",
"start:prod": "prisma db push && prisma db execute --file prisma/post-push.sql --schema prisma/schema.prisma && node dist/server/src/index.js",
"db:push": "prisma db push && prisma db execute --file prisma/post-push.sql --schema prisma/schema.prisma",
"db:generate": "prisma generate",
"db:seed": "tsx prisma/seed.ts"
"db:seed": "tsx prisma/seed.ts",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@prisma/client": "^5.22.0",
@@ -18,8 +20,14 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-async-errors": "^3.1.0",
"express-rate-limit": "^7.5.0",
"jsonwebtoken": "^9.0.2",
"multer": "^2.1.1",
"node-cron": "^3.0.3",
"nodemailer": "^8.0.5",
"pino": "^9.5.0",
"pino-http": "^10.3.0",
"pino-pretty": "^13.0.0",
"zod": "^3.23.8"
},
"devDependencies": {
@@ -27,10 +35,16 @@
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.7",
"@types/multer": "^2.1.0",
"@types/node": "^22.10.0",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^8.0.0",
"@types/supertest": "^6.0.2",
"prisma": "^5.22.0",
"supertest": "^7.0.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
"typescript": "^5.7.2",
"vitest": "^2.1.8",
"vitest-mock-extended": "^4.0.0"
}
}
+52
View File
@@ -0,0 +1,52 @@
-- Idempotent SQL applied after `prisma db push`.
-- Adds Postgres full-text-search columns + triggers + GIN indexes for Ticket and Comment.
-- Prisma can't express tsvector/triggers, so we manage them here.
-- Ticket.searchVector
ALTER TABLE "Ticket" ADD COLUMN IF NOT EXISTS "searchVector" tsvector;
CREATE INDEX IF NOT EXISTS ticket_search_idx ON "Ticket" USING GIN ("searchVector");
CREATE OR REPLACE FUNCTION ticket_search_trigger() RETURNS trigger AS $$
BEGIN
NEW."searchVector" :=
setweight(to_tsvector('english', coalesce(NEW."displayId", '')), 'A') ||
setweight(to_tsvector('english', coalesce(NEW."title", '')), 'A') ||
setweight(to_tsvector('english', coalesce(NEW."overview", '')), 'B');
RETURN NEW;
END
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS ticket_search_update ON "Ticket";
CREATE TRIGGER ticket_search_update
BEFORE INSERT OR UPDATE OF "title", "overview", "displayId" ON "Ticket"
FOR EACH ROW EXECUTE FUNCTION ticket_search_trigger();
-- Backfill any rows missing the vector (first run or new columns)
UPDATE "Ticket"
SET "searchVector" =
setweight(to_tsvector('english', coalesce("displayId", '')), 'A') ||
setweight(to_tsvector('english', coalesce("title", '')), 'A') ||
setweight(to_tsvector('english', coalesce("overview", '')), 'B')
WHERE "searchVector" IS NULL;
-- Comment.searchVector
ALTER TABLE "Comment" ADD COLUMN IF NOT EXISTS "searchVector" tsvector;
CREATE INDEX IF NOT EXISTS comment_search_idx ON "Comment" USING GIN ("searchVector");
CREATE OR REPLACE FUNCTION comment_search_trigger() RETURNS trigger AS $$
BEGIN
NEW."searchVector" := to_tsvector('english', coalesce(NEW."body", ''));
RETURN NEW;
END
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS comment_search_update ON "Comment";
CREATE TRIGGER comment_search_update
BEFORE INSERT OR UPDATE OF "body" ON "Comment"
FOR EACH ROW EXECUTE FUNCTION comment_search_trigger();
UPDATE "Comment"
SET "searchVector" = to_tsvector('english', coalesce("body", ''))
WHERE "searchVector" IS NULL;
+79 -1
View File
@@ -10,6 +10,7 @@ datasource db {
enum Role {
ADMIN
AGENT
USER
SERVICE
}
@@ -28,6 +29,7 @@ model User {
displayName String
role Role @default(AGENT)
apiKey String? @unique
notificationPrefs Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -35,6 +37,9 @@ model User {
createdTickets Ticket[] @relation("CreatedTickets")
comments Comment[]
auditLogs AuditLog[]
attachments Attachment[]
notifications Notification[]
savedViews SavedView[]
}
model Category {
@@ -67,7 +72,7 @@ model Item {
model Ticket {
id String @id @default(cuid())
displayId String @unique
displayId String @unique @default(dbgenerated("concat('V', (floor(random() * 900000000) + 100000000)::bigint::text)"))
title String
overview String
severity Int
@@ -89,6 +94,14 @@ model Ticket {
createdBy User @relation("CreatedTickets", fields: [createdById], references: [id])
comments Comment[]
auditLogs AuditLog[]
attachments Attachment[]
notifications Notification[]
@@index([status])
@@index([severity])
@@index([assigneeId])
@@index([createdById])
@@index([createdAt])
}
model Comment {
@@ -100,6 +113,71 @@ model Comment {
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
author User @relation(fields: [authorId], references: [id])
attachments Attachment[]
notifications Notification[]
@@index([ticketId])
}
model Attachment {
id String @id @default(cuid())
filename String
mimetype String
size Int
storagePath String
ticketId String?
commentId String?
uploadedById String
createdAt DateTime @default(now())
ticket Ticket? @relation(fields: [ticketId], references: [id], onDelete: Cascade)
comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade)
uploadedBy User @relation(fields: [uploadedById], references: [id])
@@index([ticketId])
@@index([commentId])
}
model Webhook {
id String @id @default(cuid())
name String
url String
events String[]
secret String
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Notification {
id String @id @default(cuid())
userId String
kind String
ticketId String?
commentId String?
data Json?
readAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
ticket Ticket? @relation(fields: [ticketId], references: [id], onDelete: Cascade)
comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade)
@@index([userId, readAt])
@@index([userId, createdAt])
}
model SavedView {
id String @id @default(cuid())
userId String
name String
filters Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, name])
}
model AuditLog {
+27 -24
View File
@@ -1,11 +1,11 @@
import { PrismaClient } from '@prisma/client'
import bcrypt from 'bcryptjs'
import crypto from 'crypto'
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
import crypto from 'crypto';
const prisma = new PrismaClient()
const prisma = new PrismaClient();
async function main() {
console.log('Seeding database...')
console.log('Seeding database...');
// Admin user
await prisma.user.upsert({
@@ -18,11 +18,11 @@ async function main() {
passwordHash: await bcrypt.hash('admin123', 12),
role: 'ADMIN',
},
})
});
// Goddard — n8n service account
const apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`
const goddard = await prisma.user.upsert({
const apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`;
await prisma.user.upsert({
where: { username: 'goddard' },
update: {},
create: {
@@ -33,70 +33,73 @@ async function main() {
role: 'SERVICE',
apiKey,
},
})
});
const existingGoddard = await prisma.user.findUnique({ where: { username: 'goddard' } })
console.log(`\nGoddard API key: ${existingGoddard?.apiKey ?? apiKey}`)
console.log('(This key is only displayed once on first seed — copy it now)\n')
const existingGoddard = await prisma.user.findUnique({ where: { username: 'goddard' } });
console.log(`\nGoddard API key: ${existingGoddard?.apiKey ?? apiKey}`);
console.log('(This key is only displayed once on first seed — copy it now)\n');
// Sample CTI structure
const theWrightServer = await prisma.category.upsert({
where: { name: 'TheWrightServer' },
update: {},
create: { name: 'TheWrightServer' },
})
});
const homelab = await prisma.category.upsert({
where: { name: 'Homelab' },
update: {},
create: { name: 'Homelab' },
})
});
const automation = await prisma.type.upsert({
where: { categoryId_name: { categoryId: theWrightServer.id, name: 'Automation' } },
update: {},
create: { name: 'Automation', categoryId: theWrightServer.id },
})
});
const media = await prisma.type.upsert({
where: { categoryId_name: { categoryId: theWrightServer.id, name: 'Media' } },
update: {},
create: { name: 'Media', categoryId: theWrightServer.id },
})
});
const infrastructure = await prisma.type.upsert({
where: { categoryId_name: { categoryId: homelab.id, name: 'Infrastructure' } },
update: {},
create: { name: 'Infrastructure', categoryId: homelab.id },
})
});
await prisma.item.upsert({
where: { typeId_name: { typeId: automation.id, name: 'Backup' } },
update: {},
create: { name: 'Backup', typeId: automation.id },
})
});
await prisma.item.upsert({
where: { typeId_name: { typeId: automation.id, name: 'Sync' } },
update: {},
create: { name: 'Sync', typeId: automation.id },
})
});
await prisma.item.upsert({
where: { typeId_name: { typeId: media.id, name: 'Plex' } },
update: {},
create: { name: 'Plex', typeId: media.id },
})
});
await prisma.item.upsert({
where: { typeId_name: { typeId: infrastructure.id, name: 'Proxmox' } },
update: {},
create: { name: 'Proxmox', typeId: infrastructure.id },
})
});
console.log('Seed complete.')
console.log('Seed complete.');
}
main()
.catch((e) => { console.error(e); process.exit(1) })
.finally(() => prisma.$disconnect())
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+57 -26
View File
@@ -1,41 +1,72 @@
import 'express-async-errors'
import express from 'express'
import cors from 'cors'
import dotenv from 'dotenv'
import 'express-async-errors';
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import pinoHttp from 'pino-http';
import rateLimit from 'express-rate-limit';
import authRoutes from './routes/auth'
import ticketRoutes from './routes/tickets'
import ctiRoutes from './routes/cti'
import userRoutes from './routes/users'
import { authenticate } from './middleware/auth'
import { errorHandler } from './middleware/errorHandler'
import { startAutoCloseJob } from './jobs/autoClose'
import authRoutes from './routes/auth';
import ticketRoutes from './routes/tickets';
import ctiRoutes from './routes/cti';
import userRoutes from './routes/users';
import attachmentRoutes from './routes/attachments';
import searchRoutes from './routes/search';
import analyticsRoutes from './routes/analytics';
import exportRoutes from './routes/export';
import savedViewRoutes from './routes/savedViews';
import notificationRoutes from './routes/notifications';
import webhookRoutes from './routes/webhooks';
import { authenticate } from './middleware/auth';
import { errorHandler } from './middleware/errorHandler';
import { startAutoCloseJob } from './jobs/autoClose';
import { logger } from './lib/logger';
dotenv.config()
dotenv.config();
if (!process.env.JWT_SECRET) {
console.error('FATAL: JWT_SECRET is not set')
process.exit(1)
logger.fatal('JWT_SECRET is not set');
process.exit(1);
}
const app = express()
const app = express();
app.use(cors({ origin: process.env.CLIENT_URL || 'http://localhost:5173' }))
app.use(express.json())
app.use(pinoHttp({ logger }));
app.use(cors({ origin: process.env.CLIENT_URL || 'http://localhost:5173' }));
app.use(express.json());
app.get('/healthz', (_req, res) => {
res.json({ status: 'ok' });
});
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many login attempts. Try again in 15 minutes.' },
});
// Public
app.use('/api/auth', authRoutes)
app.use('/api/auth/login', loginLimiter);
app.use('/api/auth', authRoutes);
// Protected
app.use('/api/tickets', authenticate, ticketRoutes)
app.use('/api/cti', authenticate, ctiRoutes)
app.use('/api/users', authenticate, userRoutes)
app.use('/api/tickets', authenticate, ticketRoutes);
app.use('/api/cti', authenticate, ctiRoutes);
app.use('/api/users', authenticate, userRoutes);
app.use('/api', attachmentRoutes); // self-mounts authenticate inside
app.use('/api/search', authenticate, searchRoutes);
app.use('/api/analytics', authenticate, analyticsRoutes);
app.use('/api/export', authenticate, exportRoutes);
app.use('/api/saved-views', authenticate, savedViewRoutes);
app.use('/api/notifications', authenticate, notificationRoutes);
app.use('/api/webhooks', authenticate, webhookRoutes);
app.use(errorHandler)
app.use(errorHandler);
startAutoCloseJob()
startAutoCloseJob();
const PORT = Number(process.env.PORT) || 3000
const PORT = Number(process.env.PORT) || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`)
})
logger.info({ port: PORT }, 'Server started');
});
+8 -17
View File
@@ -1,24 +1,15 @@
import cron from 'node-cron'
import prisma from '../lib/prisma'
import cron from 'node-cron';
import { closeStale } from '../services/ticketService';
import { logger } from '../lib/logger';
export function startAutoCloseJob() {
// Run every hour — closes RESOLVED tickets that have been resolved for 14+ days
cron.schedule('0 * * * *', async () => {
const twoWeeksAgo = new Date()
twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14)
const result = await prisma.ticket.updateMany({
where: {
status: 'RESOLVED',
resolvedAt: { lte: twoWeeksAgo },
},
data: { status: 'CLOSED' },
})
if (result.count > 0) {
console.log(`[AutoClose] Closed ${result.count} ticket(s) after 2-week resolution period`)
const count = await closeStale(14);
if (count > 0) {
logger.info({ count }, 'AutoClose: closed stale resolved tickets');
}
})
});
console.log('[AutoClose] Job scheduled — runs every hour')
logger.info('AutoClose: job scheduled — runs every hour');
}
+9
View File
@@ -0,0 +1,9 @@
export class HttpError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
this.name = 'HttpError';
}
}
+17
View File
@@ -0,0 +1,17 @@
import pino from 'pino';
const isDev = process.env.NODE_ENV !== 'production';
export const logger = pino({
level: process.env.LOG_LEVEL ?? (isDev ? 'debug' : 'info'),
transport: isDev
? {
target: 'pino-pretty',
options: { colorize: true, translateTime: 'SYS:HH:MM:ss.l', ignore: 'pid,hostname' },
}
: undefined,
redact: {
paths: ['req.headers.authorization', 'req.headers["x-api-key"]', '*.password', '*.passwordHash'],
remove: true,
},
});
+3 -3
View File
@@ -1,5 +1,5 @@
import { PrismaClient } from '@prisma/client'
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient()
const prisma = new PrismaClient();
export default prisma
export default prisma;
+35 -35
View File
@@ -1,57 +1,57 @@
import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
import prisma from '../lib/prisma'
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import prisma from '../lib/prisma';
export interface AuthRequest extends Request {
user?: {
id: string
role: string
username: string
}
id: string;
role: string;
username: string;
};
}
export const authenticate = async (
req: AuthRequest,
res: Response,
next: NextFunction
) => {
const apiKey = req.headers['x-api-key'] as string | undefined
export const authenticate = async (req: AuthRequest, res: Response, next: NextFunction) => {
const apiKey = req.headers['x-api-key'] as string | undefined;
if (apiKey) {
const user = await prisma.user.findUnique({ where: { apiKey } })
const user = await prisma.user.findUnique({ where: { apiKey } });
if (!user || user.role !== 'SERVICE') {
return res.status(401).json({ error: 'Invalid API key' })
return res.status(401).json({ error: 'Invalid API key' });
}
req.user = { id: user.id, role: user.role, username: user.username }
return next()
req.user = { id: user.id, role: user.role, username: user.username };
return next();
}
const authHeader = req.headers.authorization
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Unauthorized' })
return res.status(401).json({ error: 'Unauthorized' });
}
const token = authHeader.split(' ')[1]
const token = authHeader.split(' ')[1];
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as {
id: string
role: string
username: string
}
req.user = payload
next()
id: string;
role: string;
username: string;
};
req.user = payload;
next();
} catch {
return res.status(401).json({ error: 'Invalid or expired token' })
}
return res.status(401).json({ error: 'Invalid or expired token' });
}
};
export const requireAdmin = (
req: AuthRequest,
res: Response,
next: NextFunction
) => {
export const requireAdmin = (req: AuthRequest, res: Response, next: NextFunction) => {
if (req.user?.role !== 'ADMIN') {
return res.status(403).json({ error: 'Admin access required' })
return res.status(403).json({ error: 'Admin access required' });
}
next()
next();
};
// Blocks USER role — allows ADMIN, AGENT, SERVICE
export const requireAgent = (req: AuthRequest, res: Response, next: NextFunction) => {
if (req.user?.role === 'USER') {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
+22 -19
View File
@@ -1,29 +1,32 @@
import { Request, Response, NextFunction } from 'express'
import { ZodError } from 'zod'
import { Request, Response, NextFunction } from 'express';
import { ZodError } from 'zod';
import { logger } from '../lib/logger';
export function errorHandler(
err: any,
req: Request,
res: Response,
next: NextFunction
) {
console.error(err)
type ErrorLike = {
code?: string;
status?: number;
statusCode?: number;
message?: string;
};
export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction) {
logger.error({ err }, 'Request failed');
if (err instanceof ZodError) {
return res.status(400).json({ error: 'Validation error', details: err.flatten() })
return res.status(400).json({ error: 'Validation error', details: err.flatten() });
}
// Prisma unique constraint violation
if (err.code === 'P2002') {
return res.status(409).json({ error: 'A record with that value already exists' })
const e = (err ?? {}) as ErrorLike;
if (e.code === 'P2002') {
return res.status(409).json({ error: 'A record with that value already exists' });
}
// Prisma record not found
if (err.code === 'P2025') {
return res.status(404).json({ error: 'Record not found' })
if (e.code === 'P2025') {
return res.status(404).json({ error: 'Record not found' });
}
const status = err.status || err.statusCode || 500
const message = err.message || 'Internal server error'
res.status(status).json({ error: message })
const status = e.status || e.statusCode || 500;
const message = e.message || 'Internal server error';
res.status(status).json({ error: message });
}
+14
View File
@@ -0,0 +1,14 @@
import { Router } from 'express';
import * as analyticsService from '../services/analyticsService';
const router = Router();
router.get('/summary', async (req, res) => {
const raw = Number(req.query.window);
const window: analyticsService.AnalyticsWindow =
raw === 14 || raw === 30 || raw === 90 ? raw : 30;
const summary = await analyticsService.summarize(window);
res.json(summary);
});
export default router;
+190
View File
@@ -0,0 +1,190 @@
import { Router, Request } from 'express';
import multer from 'multer';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { AuthRequest, authenticate } from '../middleware/auth';
import { HttpError } from '../lib/httpError';
import * as attachmentService from '../services/attachmentService';
import * as ticketService from '../services/ticketService';
import prisma from '../lib/prisma';
import { ATTACHMENT_MAX_BYTES } from '../../../shared/schemas/attachment';
const storage = multer.diskStorage({
destination: (_req, _file, cb) => {
attachmentService
.ensureUploadsDir()
.then(() => cb(null, attachmentService.UPLOADS_DIR))
.catch((err: Error) => cb(err, ''));
},
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname).slice(0, 20);
const safeBase = crypto.randomBytes(16).toString('hex');
cb(null, `${safeBase}${ext}`);
},
});
const upload = multer({
storage,
limits: { fileSize: ATTACHMENT_MAX_BYTES },
});
async function cleanup(file?: Express.Multer.File) {
if (!file) return;
await import('node:fs/promises').then((fsp) => fsp.unlink(file.path).catch(() => undefined));
}
const router = Router();
router.use(authenticate);
router.post(
'/tickets/:ticketId/attachments',
upload.single('file'),
async (req: AuthRequest, res) => {
const file = req.file;
if (!file) throw new HttpError(400, 'No file provided');
try {
attachmentService.validateUpload(file);
const ticket = await ticketService.findByIdOrDisplay(req.params.ticketId);
if (!ticket) throw new HttpError(404, 'Ticket not found');
const attachment = await prisma.$transaction(async (tx) => {
const created = await tx.attachment.create({
data: {
filename: file.originalname,
mimetype: file.mimetype,
size: file.size,
storagePath: path.relative(attachmentService.UPLOADS_DIR, file.path),
uploadedById: req.user!.id,
ticketId: ticket.id,
},
include: {
uploadedBy: { select: { id: true, username: true, displayName: true } },
},
});
await tx.auditLog.create({
data: {
ticketId: ticket.id,
userId: req.user!.id,
action: 'ATTACHMENT_ADDED',
detail: file.originalname,
},
});
return created;
});
res.status(201).json(attachment);
} catch (err) {
await cleanup(file);
throw err;
}
},
);
router.post(
'/comments/:commentId/attachments',
upload.single('file'),
async (req: AuthRequest, res) => {
const file = req.file;
if (!file) throw new HttpError(400, 'No file provided');
try {
attachmentService.validateUpload(file);
const comment = await prisma.comment.findUnique({
where: { id: req.params.commentId },
select: { id: true, ticketId: true },
});
if (!comment) throw new HttpError(404, 'Comment not found');
const attachment = await prisma.$transaction(async (tx) => {
const created = await tx.attachment.create({
data: {
filename: file.originalname,
mimetype: file.mimetype,
size: file.size,
storagePath: path.relative(attachmentService.UPLOADS_DIR, file.path),
uploadedById: req.user!.id,
commentId: comment.id,
},
include: {
uploadedBy: { select: { id: true, username: true, displayName: true } },
},
});
await tx.auditLog.create({
data: {
ticketId: comment.ticketId,
userId: req.user!.id,
action: 'ATTACHMENT_ADDED',
detail: file.originalname,
},
});
return created;
});
res.status(201).json(attachment);
} catch (err) {
await cleanup(file);
throw err;
}
},
);
router.get('/tickets/:ticketId/attachments', async (req, res) => {
const ticket = await ticketService.findByIdOrDisplay(req.params.ticketId);
if (!ticket) throw new HttpError(404, 'Ticket not found');
const attachments = await attachmentService.listForTicket(ticket.id);
res.json(attachments);
});
router.get('/attachments/:id', async (req: Request, res) => {
const attachment = await attachmentService.getAttachment(req.params.id);
const abs = attachmentService.absolutePathFor(attachment.storagePath);
res.setHeader('Content-Type', attachment.mimetype);
res.setHeader('Content-Length', String(attachment.size));
res.setHeader(
'Content-Disposition',
`inline; filename="${encodeURIComponent(attachment.filename)}"`,
);
const stream = fs.createReadStream(abs);
stream.on('error', (err) => {
if (!res.headersSent) res.status(404).json({ error: 'File not found on disk' });
else res.destroy(err);
});
stream.pipe(res);
});
router.delete('/attachments/:id', async (req: AuthRequest, res) => {
const attachment = await attachmentService.deleteAttachment(req.params.id, {
id: req.user!.id,
role: req.user!.role,
});
const ticketId =
attachment.ticketId ??
(attachment.commentId
? (
await prisma.comment.findUnique({
where: { id: attachment.commentId },
select: { ticketId: true },
})
)?.ticketId
: null);
if (ticketId) {
await prisma.auditLog.create({
data: {
ticketId,
userId: req.user!.id,
action: 'ATTACHMENT_REMOVED',
detail: attachment.filename,
},
});
}
res.status(204).send();
});
export default router;
+14 -48
View File
@@ -1,54 +1,20 @@
import { Router } from 'express'
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import { z } from 'zod'
import prisma from '../lib/prisma'
import { authenticate, AuthRequest } from '../middleware/auth'
import { Router } from 'express';
import { authenticate, AuthRequest } from '../middleware/auth';
import { loginSchema } from '../../../shared/schemas/auth';
import * as authService from '../services/authService';
import * as userService from '../services/userService';
const router = Router()
const loginSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
})
const router = Router();
router.post('/login', async (req, res) => {
const { username, password } = loginSchema.parse(req.body)
const user = await prisma.user.findUnique({ where: { username } })
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return res.status(401).json({ error: 'Invalid credentials' })
}
if (user.role === 'SERVICE') {
return res.status(401).json({ error: 'Service accounts must authenticate via API key' })
}
const token = jwt.sign(
{ id: user.id, role: user.role, username: user.username },
process.env.JWT_SECRET!,
{ expiresIn: '24h' }
)
res.json({
token,
user: {
id: user.id,
username: user.username,
displayName: user.displayName,
email: user.email,
role: user.role,
},
})
})
const input = loginSchema.parse(req.body);
const result = await authService.login(input);
res.json(result);
});
router.get('/me', authenticate, async (req: AuthRequest, res) => {
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
select: { id: true, username: true, displayName: true, email: true, role: true },
})
if (!user) return res.status(404).json({ error: 'User not found' })
res.json(user)
})
const user = await userService.getCurrentUser(req.user!.id);
res.json(user);
});
export default router
export default router;
+17 -48
View File
@@ -1,54 +1,23 @@
import { Router } from 'express'
import { z } from 'zod'
import prisma from '../lib/prisma'
import { AuthRequest } from '../middleware/auth'
import { Router } from 'express';
import { AuthRequest } from '../middleware/auth';
import { commentSchema } from '../../../shared/schemas/comment';
import * as commentService from '../services/commentService';
const router = Router({ mergeParams: true })
const commentSchema = z.object({
body: z.string().min(1),
})
const router = Router({ mergeParams: true });
router.post('/', async (req: AuthRequest, res) => {
const { body } = commentSchema.parse(req.body)
const ticketId = (req.params as Record<string, string>).ticketId
const ticket = await prisma.ticket.findFirst({
where: { OR: [{ id: ticketId }, { displayId: ticketId }] },
})
if (!ticket) return res.status(404).json({ error: 'Ticket not found' })
const [comment] = await prisma.$transaction([
prisma.comment.create({
data: { body, ticketId: ticket.id, authorId: req.user!.id },
include: { author: { select: { id: true, username: true, displayName: true } } },
}),
prisma.auditLog.create({
data: { ticketId: ticket.id, userId: req.user!.id, action: 'COMMENT_ADDED' },
}),
])
res.status(201).json(comment)
})
const { body } = commentSchema.parse(req.body);
const ticketId = (req.params as Record<string, string>).ticketId;
const comment = await commentService.addComment(ticketId, body, req.user!.id);
res.status(201).json(comment);
});
router.delete('/:commentId', async (req: AuthRequest, res) => {
const comment = await prisma.comment.findUnique({
where: { id: req.params.commentId },
})
if (!comment) return res.status(404).json({ error: 'Comment not found' })
await commentService.deleteComment(req.params.commentId, {
id: req.user!.id,
role: req.user!.role,
});
res.status(204).send();
});
if (comment.authorId !== req.user!.id && req.user!.role !== 'ADMIN') {
return res.status(403).json({ error: 'Not allowed' })
}
await prisma.$transaction([
prisma.comment.delete({ where: { id: req.params.commentId } }),
prisma.auditLog.create({
data: { ticketId: comment.ticketId, userId: req.user!.id, action: 'COMMENT_DELETED' },
}),
])
res.status(204).send()
})
export default router
export default router;
+46 -84
View File
@@ -1,113 +1,75 @@
import { Router } from 'express'
import { z } from 'zod'
import prisma from '../lib/prisma'
import { requireAdmin } from '../middleware/auth'
import { Router } from 'express';
import { requireAdmin } from '../middleware/auth';
import {
ctiNameSchema as nameSchema,
createTypeSchema,
createItemSchema,
} from '../../../shared/schemas/cti';
import * as ctiService from '../services/ctiService';
const router = Router()
const router = Router();
const nameSchema = z.object({ name: z.string().min(1).max(100) })
// ── Categories ────────────────────────────────────────────────────────────────
// ── Categories ───────────────────────────────────────────────────────────────
router.get('/categories', async (_req, res) => {
const categories = await prisma.category.findMany({ orderBy: { name: 'asc' } })
res.json(categories)
})
res.json(await ctiService.listCategories());
});
router.post('/categories', requireAdmin, async (req, res) => {
const { name } = nameSchema.parse(req.body)
const category = await prisma.category.create({ data: { name } })
res.status(201).json(category)
})
const { name } = nameSchema.parse(req.body);
res.status(201).json(await ctiService.createCategory(name));
});
router.put('/categories/:id', requireAdmin, async (req, res) => {
const { name } = nameSchema.parse(req.body)
const category = await prisma.category.update({
where: { id: req.params.id },
data: { name },
})
res.json(category)
})
const { name } = nameSchema.parse(req.body);
res.json(await ctiService.updateCategory(req.params.id, name));
});
router.delete('/categories/:id', requireAdmin, async (req, res) => {
await prisma.category.delete({ where: { id: req.params.id } })
res.status(204).send()
})
await ctiService.deleteCategory(req.params.id);
res.status(204).send();
});
// ── Types ────────────────────────────────────────────────────────────────────
// ── Types ────────────────────────────────────────────────────────────────────
router.get('/types', async (req, res) => {
const { categoryId } = req.query
const types = await prisma.type.findMany({
where: categoryId ? { categoryId: categoryId as string } : undefined,
include: { category: true },
orderBy: { name: 'asc' },
})
res.json(types)
})
res.json(await ctiService.listTypes(req.query.categoryId as string | undefined));
});
router.post('/types', requireAdmin, async (req, res) => {
const { name, categoryId } = z
.object({ name: z.string().min(1).max(100), categoryId: z.string().min(1) })
.parse(req.body)
const type = await prisma.type.create({
data: { name, categoryId },
include: { category: true },
})
res.status(201).json(type)
})
const { name, categoryId } = createTypeSchema.parse(req.body);
res.status(201).json(await ctiService.createType(name, categoryId));
});
router.put('/types/:id', requireAdmin, async (req, res) => {
const { name } = nameSchema.parse(req.body)
const type = await prisma.type.update({
where: { id: req.params.id },
data: { name },
include: { category: true },
})
res.json(type)
})
const { name } = nameSchema.parse(req.body);
res.json(await ctiService.updateType(req.params.id, name));
});
router.delete('/types/:id', requireAdmin, async (req, res) => {
await prisma.type.delete({ where: { id: req.params.id } })
res.status(204).send()
})
await ctiService.deleteType(req.params.id);
res.status(204).send();
});
// ── Items ────────────────────────────────────────────────────────────────────
// ── Items ────────────────────────────────────────────────────────────────────
router.get('/items', async (req, res) => {
const { typeId } = req.query
const items = await prisma.item.findMany({
where: typeId ? { typeId: typeId as string } : undefined,
include: { type: { include: { category: true } } },
orderBy: { name: 'asc' },
})
res.json(items)
})
res.json(await ctiService.listItems(req.query.typeId as string | undefined));
});
router.post('/items', requireAdmin, async (req, res) => {
const { name, typeId } = z
.object({ name: z.string().min(1).max(100), typeId: z.string().min(1) })
.parse(req.body)
const item = await prisma.item.create({
data: { name, typeId },
include: { type: { include: { category: true } } },
})
res.status(201).json(item)
})
const { name, typeId } = createItemSchema.parse(req.body);
res.status(201).json(await ctiService.createItem(name, typeId));
});
router.put('/items/:id', requireAdmin, async (req, res) => {
const { name } = nameSchema.parse(req.body)
const item = await prisma.item.update({
where: { id: req.params.id },
data: { name },
include: { type: { include: { category: true } } },
})
res.json(item)
})
const { name } = nameSchema.parse(req.body);
res.json(await ctiService.updateItem(req.params.id, name));
});
router.delete('/items/:id', requireAdmin, async (req, res) => {
await prisma.item.delete({ where: { id: req.params.id } })
res.status(204).send()
})
await ctiService.deleteItem(req.params.id);
res.status(204).send();
});
export default router
export default router;

Some files were not shown because too many files have changed in this diff Show More