Compare commits

..

16 Commits

Author SHA1 Message Date
Josh Wright
2a6090e473 Redesign comments to GitHub/Gitea style
All checks were successful
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
All checks were successful
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
All checks were successful
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
All checks were successful
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
Some checks failed
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
Some checks failed
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
All checks were successful
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
Some checks failed
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
All checks were successful
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
2b76ad27b1 Reroute ticket via modal instead of inline sidebar expansion
All checks were successful
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
d751e36ae8 Fix My Tickets and queue filter
All checks were successful
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
725f91578d Dark theme, roles overhaul, modal New Ticket, My Tickets page, and more
All checks were successful
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
d8dc5b3ded Full-page ticket layout: two-column with sticky sidebar
All checks were successful
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
64477529ea Add dbgenerated default for displayId
All checks were successful
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
a19a9a6ccd Merge branch 'main' of https://gitea.thewrightserver.net/josh/TicketingSystem
All checks were successful
Build & Push / Build Server (push) Successful in 48s
Build & Push / Build Client (push) Successful in 40s
2026-03-30 20:53:51 -04:00
f65c259a71 Ticket IDs, audit log, markdown comments, tabbed detail page
- Tickets get a random display ID (V + 9 digits, e.g. V325813929)
- Ticket detail page has Overview / Comments / Audit Log tabs
- Audit log records every action (create, status, assignee, severity,
  reroute, title/overview edit, comment add/delete) with who and when
- Comments redesigned: avatar (initials + color), markdown rendering
  via react-markdown + remark-gfm, Write/Preview toggle
- Dashboard shows displayId and assignee avatar
- URLs now use displayId (/tickets/V325813929)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 20:53:37 -04:00
25 changed files with 3226 additions and 614 deletions

View File

@@ -10,6 +10,26 @@ env:
OWNER: ${{ github.repository_owner }} OWNER: ${{ github.repository_owner }}
jobs: jobs:
typecheck-client:
name: TypeScript Check (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: npx tsc --noEmit
working-directory: ./client
build-server: build-server:
name: Build Server name: Build Server
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -38,6 +58,7 @@ jobs:
build-client: build-client:
name: Build Client name: Build Client
needs: typecheck-client
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout

306
README.md
View File

@@ -1,19 +1,35 @@
# TicketingSystem # 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 ## 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 - **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 - **Status lifecycle** — Open → In Progress → Resolved → Closed; resolved tickets auto-close after 14 days
- **Comments** — threaded comments per ticket with author attribution - **Queue filter** — filter dashboard by category (queue)
- **Roles** — Admin, Agent, Service (API key auth for automation accounts) - **My Tickets** — dedicated view of tickets assigned to you
- **Comments** — threaded markdown comments per ticket with author avatars
- **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 and the full CTI hierarchy via UI - **Admin panel** — manage users and the full CTI hierarchy via UI
- **n8n ready** — service accounts authenticate via `X-Api-Key` header - **n8n ready** — service accounts authenticate via `X-Api-Key` header
--- ---
## Roles
| Role | Access |
|---|---|
| **Admin** | Full access — manage users, CTI config, 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 ## Production Deployment
### Prerequisites ### Prerequisites
@@ -55,7 +71,7 @@ docker compose pull
docker compose up -d docker compose up -d
``` ```
### 5. Seed (first deploy only) ### 4. Seed (first deploy only)
```bash ```bash
docker compose exec server npm run db:seed docker compose exec server npm run db:seed
@@ -92,7 +108,7 @@ docker run -d \
cd server cd server
cp .env.example .env # set DATABASE_URL and JWT_SECRET cp .env.example .env # set DATABASE_URL and JWT_SECRET
npm install npm install
npm run db:migrate # creates tables + migration files npm run db:migrate # creates tables
npm run db:seed # seeds admin + Goddard + sample CTI npm run db:seed # seeds admin + Goddard + sample CTI
npm run dev # http://localhost:3000 npm run dev # http://localhost:3000
``` ```
@@ -107,6 +123,251 @@ npm run dev # http://localhost:5173 (proxies /api to :3000)
--- ---
## API Reference
All endpoints (except `/api/auth/*`) 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`
---
### 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 all 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 |
| `search` | string | Full-text search on title, overview, and display ID |
**Response:** Array of ticket objects with nested `category`, `type`, `item`, `assignee`, `createdBy`, and `_count.comments`.
---
#### `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.
**Body (all fields optional):**
```json
{
"title": "string",
"overview": "string (markdown)",
"severity": 3,
"status": "IN_PROGRESS",
"assigneeId": "string | null",
"categoryId": "string",
"typeId": "string",
"itemId": "string"
}
```
All changes are recorded in the audit log automatically.
**Response:** Updated ticket object.
---
#### `DELETE /api/tickets/:id`
Delete a ticket and all associated comments and audit logs. **Admin only.**
**Response:** 204 No Content.
---
### Comments
#### `POST /api/tickets/:id/comments`
Add a comment to a ticket. Supports markdown. All authenticated roles may comment.
**Body:**
```json
{ "body": "string (markdown)" }
```
**Response:** Created comment object (201).
---
#### `DELETE /api/tickets/:id/comments/:commentId`
Delete a comment. Authors may delete their own comments; Admins may delete any.
**Response:** 204 No Content.
---
### Audit Log
#### `GET /api/tickets/:id/audit`
Retrieve the full audit log for a ticket, ordered newest first.
**Response:** Array of audit log entries:
```json
[
{
"id": "...",
"action": "COMMENT_ADDED",
"detail": "Comment body text here",
"createdAt": "2026-03-30T10:00:00Z",
"user": { "id": "...", "username": "admin", "displayName": "Admin" }
}
]
```
**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 |
---
### 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.
```json
{
"displayName": "string",
"email": "string",
"password": "string",
"role": "ADMIN | AGENT | USER | SERVICE",
"regenerateApiKey": true
}
```
#### `DELETE /api/users/:id`
Delete a user. Cannot delete your own account.
---
## n8n Integration (Goddard) ## n8n Integration (Goddard)
The `goddard` service account authenticates via API key — no login flow needed. The `goddard` service account authenticates via API key — no login flow needed.
@@ -114,7 +375,7 @@ The `goddard` service account authenticates via API key — no login flow needed
**Create a ticket from n8n:** **Create a ticket from n8n:**
``` ```
POST https://tickets.thewrightserver.net/api/tickets POST /api/tickets
X-Api-Key: sk_<goddard api key> X-Api-Key: sk_<goddard api key>
Content-Type: application/json Content-Type: application/json
@@ -138,32 +399,6 @@ 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 ## Environment Variables
| Variable | Required | Description | | Variable | Required | Description |
@@ -174,7 +409,6 @@ TAG=<sha> docker compose up -d
| `PORT` | No | Server port (default: `3000`) | | `PORT` | No | Server port (default: `3000`) |
| `REGISTRY` | Deploy | Container registry hostname | | `REGISTRY` | Deploy | Container registry hostname |
| `POSTGRES_PASSWORD` | Deploy | Postgres password | | `POSTGRES_PASSWORD` | Deploy | Postgres password |
| `DOMAIN` | Deploy | Public domain for Traefik routing |
| `TAG` | Deploy | Image tag to deploy (default: `latest`) | | `TAG` | Deploy | Image tag to deploy (default: `latest`) |
--- ---
@@ -189,7 +423,7 @@ TAG=<sha> docker compose up -d
| 4 | SEV 4 | Low — minor issue | | 4 | SEV 4 | Low — minor issue |
| 5 | SEV 5 | Minimal — informational / automated | | 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 +435,5 @@ OPEN → IN_PROGRESS → RESOLVED ──(14 days)──→ CLOSED
re-opens reset re-opens reset
the 14-day timer the 14-day timer
``` ```
> CLOSED status can only be set manually by an **Admin**. The auto-close job runs hourly.

1475
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,9 @@
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"react-markdown": "^9.0.1",
"react-router-dom": "^6.28.0", "react-router-dom": "^6.28.0",
"remark-gfm": "^4.0.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -4,8 +4,8 @@ import PrivateRoute from './components/PrivateRoute'
import AdminRoute from './components/AdminRoute' import AdminRoute from './components/AdminRoute'
import Login from './pages/Login' import Login from './pages/Login'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
import MyTickets from './pages/MyTickets'
import TicketDetail from './pages/TicketDetail' import TicketDetail from './pages/TicketDetail'
import NewTicket from './pages/NewTicket'
import AdminUsers from './pages/admin/Users' import AdminUsers from './pages/admin/Users'
import AdminCTI from './pages/admin/CTI' import AdminCTI from './pages/admin/CTI'
@@ -17,8 +17,8 @@ export default function App() {
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route element={<PrivateRoute />}> <Route element={<PrivateRoute />}>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/tickets/new" element={<NewTicket />} /> <Route path="/my-tickets" element={<MyTickets />} />
<Route path="/tickets/:id" element={<TicketDetail />} /> <Route path="/:id" element={<TicketDetail />} />
<Route element={<AdminRoute />}> <Route element={<AdminRoute />}>
<Route path="/admin/users" element={<AdminUsers />} /> <Route path="/admin/users" element={<AdminUsers />} />
<Route path="/admin/cti" element={<AdminCTI />} /> <Route path="/admin/cti" element={<AdminCTI />} />

View File

@@ -0,0 +1,44 @@
const PALETTE = [
'#ef4444', '#f97316', '#f59e0b', '#10b981',
'#06b6d4', '#3b82f6', '#8b5cf6', '#ec4899',
]
function nameToColor(name: string): string {
let hash = 0
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash)
}
return PALETTE[Math.abs(hash) % PALETTE.length]
}
function initials(name: string): string {
return name
.split(' ')
.slice(0, 2)
.map((n) => n[0])
.join('')
.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
}
export default function Avatar({ name, size = 'md' }: AvatarProps) {
return (
<div
className={`${SIZES[size]} rounded-full flex items-center justify-center font-semibold text-white flex-shrink-0 select-none`}
style={{ backgroundColor: nameToColor(name) }}
title={name}
>
{initials(name)}
</div>
)
}

View File

@@ -51,12 +51,12 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
} }
const selectClass = 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 ( return (
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<div> <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 <select
value={value.categoryId} value={value.categoryId}
onChange={(e) => handleCategory(e.target.value)} onChange={(e) => handleCategory(e.target.value)}
@@ -73,7 +73,7 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
</div> </div>
<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 <select
value={value.typeId} value={value.typeId}
onChange={(e) => handleType(e.target.value)} onChange={(e) => handleType(e.target.value)}
@@ -90,7 +90,7 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
</div> </div>
<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 <select
value={value.itemId} value={value.itemId}
onChange={(e) => handleItem(e.target.value)} onChange={(e) => handleItem(e.target.value)}

View File

@@ -1,7 +1,8 @@
import { ReactNode } from 'react' import { ReactNode, useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom' import { Link, useLocation, useNavigate } from 'react-router-dom'
import { LayoutDashboard, Users, Settings, LogOut, Plus } from 'lucide-react' import { LayoutDashboard, Users, Settings, LogOut, Plus, Ticket } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import NewTicketModal from '../pages/NewTicket'
interface LayoutProps { interface LayoutProps {
children: ReactNode children: ReactNode
@@ -13,9 +14,13 @@ export default function Layout({ children, title, action }: LayoutProps) {
const { user, logout } = useAuth() const { user, logout } = useAuth()
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const [showNewTicket, setShowNewTicket] = useState(false)
const canCreateTicket = user?.role !== 'USER'
const navItems = [ const navItems = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' }, { to: '/', icon: LayoutDashboard, label: 'All Tickets' },
{ to: '/my-tickets', icon: Ticket, label: 'My Tickets' },
...(user?.role === 'ADMIN' ...(user?.role === 'ADMIN'
? [ ? [
{ to: '/admin/users', icon: Users, label: 'Users' }, { to: '/admin/users', icon: Users, label: 'Users' },
@@ -29,24 +34,27 @@ export default function Layout({ children, title, action }: LayoutProps) {
navigate('/login') navigate('/login')
} }
const isActive = (to: string) =>
to === '/' ? location.pathname === '/' : location.pathname.startsWith(to)
return ( return (
<div className="flex h-screen bg-gray-100 overflow-hidden"> <div className="flex h-screen bg-gray-950 overflow-hidden">
{/* Sidebar */} {/* Sidebar */}
<aside className="w-60 bg-gray-900 text-white flex flex-col flex-shrink-0"> <aside className="w-60 bg-gray-900 border-r border-gray-800 flex flex-col flex-shrink-0">
<div className="p-5 border-b border-gray-700"> <div className="px-5 py-4 border-b border-gray-800">
<h1 className="text-base font-bold text-white tracking-wide">Ticketing</h1> <h1 className="text-sm font-bold text-white tracking-wide">Ticketing</h1>
<p className="text-xs text-gray-400 mt-0.5">{user?.displayName}</p> <p className="text-xs text-gray-500 mt-0.5">{user?.displayName}</p>
</div> </div>
<nav className="flex-1 p-3 space-y-0.5"> <nav className="flex-1 p-2 space-y-0.5">
{navItems.map(({ to, icon: Icon, label }) => ( {navItems.map(({ to, icon: Icon, label }) => (
<Link <Link
key={to} key={to}
to={to} to={to}
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${ className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
location.pathname === to isActive(to)
? 'bg-blue-600 text-white' ? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-800 hover:text-white' : 'text-gray-400 hover:bg-gray-800 hover:text-gray-100'
}`} }`}
> >
<Icon size={15} /> <Icon size={15} />
@@ -55,17 +63,19 @@ export default function Layout({ children, title, action }: LayoutProps) {
))} ))}
</nav> </nav>
<div className="p-3 border-t border-gray-700 space-y-0.5"> <div className="p-2 border-t border-gray-800 space-y-0.5">
<Link {canCreateTicket && (
to="/tickets/new" <button
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" onClick={() => setShowNewTicket(true)}
> className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-gray-400 hover:bg-gray-800 hover:text-gray-100 w-full transition-colors"
<Plus size={15} /> >
New Ticket <Plus size={15} />
</Link> New Ticket
</button>
)}
<button <button
onClick={handleLogout} 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" className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-gray-400 hover:bg-gray-800 hover:text-gray-100 w-full transition-colors"
> >
<LogOut size={15} /> <LogOut size={15} />
Logout Logout
@@ -76,13 +86,15 @@ export default function Layout({ children, title, action }: LayoutProps) {
{/* Main */} {/* Main */}
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden">
{(title || action) && ( {(title || action) && (
<header className="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between flex-shrink-0"> <header className="bg-gray-900 border-b border-gray-800 px-6 py-4 flex items-center justify-between flex-shrink-0">
{title && <h2 className="text-lg font-semibold text-gray-900">{title}</h2>} {title && <h2 className="text-base font-semibold text-gray-100">{title}</h2>}
{action && <div>{action}</div>} {action && <div>{action}</div>}
</header> </header>
)} )}
<main className="flex-1 overflow-auto p-6">{children}</main> <main className="flex-1 overflow-auto p-6">{children}</main>
</div> </div>
{showNewTicket && <NewTicketModal onClose={() => setShowNewTicket(false)} />}
</div> </div>
) )
} }

View File

@@ -5,9 +5,10 @@ interface ModalProps {
title: string title: string
onClose: () => void onClose: () => void
children: ReactNode children: ReactNode
size?: 'md' | 'lg'
} }
export default function Modal({ title, onClose, children }: ModalProps) { export default function Modal({ title, onClose, children, size = 'md' }: ModalProps) {
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose() if (e.key === 'Escape') onClose()
@@ -18,20 +19,20 @@ export default function Modal({ title, onClose, children }: ModalProps) {
return ( return (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" 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() }} 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={`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-200"> <div className="flex items-center justify-between px-6 py-4 border-b border-gray-800">
<h3 className="text-base font-semibold text-gray-900">{title}</h3> <h3 className="text-base font-semibold text-gray-100">{title}</h3>
<button <button
onClick={onClose} onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors" className="text-gray-500 hover:text-gray-300 transition-colors"
> >
<X size={18} /> <X size={18} />
</button> </button>
</div> </div>
<div className="px-6 py-4">{children}</div> <div className="px-6 py-5">{children}</div>
</div> </div>
</div> </div>
) )

View File

@@ -1,9 +1,9 @@
const config: Record<number, { label: string; className: string }> = { const config: Record<number, { label: string; className: string }> = {
1: { label: 'SEV 1', className: 'bg-red-100 text-red-800 border-red-200' }, 1: { label: 'SEV 1', className: 'bg-red-500/20 text-red-400 border-red-500/30' },
2: { label: 'SEV 2', className: 'bg-orange-100 text-orange-800 border-orange-200' }, 2: { label: 'SEV 2', className: 'bg-orange-500/20 text-orange-400 border-orange-500/30' },
3: { label: 'SEV 3', className: 'bg-yellow-100 text-yellow-800 border-yellow-200' }, 3: { label: 'SEV 3', className: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' },
4: { label: 'SEV 4', className: 'bg-blue-100 text-blue-800 border-blue-200' }, 4: { label: 'SEV 4', className: 'bg-blue-500/20 text-blue-400 border-blue-500/30' },
5: { label: 'SEV 5', className: 'bg-gray-100 text-gray-600 border-gray-200' }, 5: { label: 'SEV 5', className: 'bg-gray-500/20 text-gray-400 border-gray-500/30' },
} }
export default function SeverityBadge({ severity }: { severity: number }) { export default function SeverityBadge({ severity }: { severity: number }) {

View File

@@ -1,10 +1,10 @@
import { TicketStatus } from '../types' import { TicketStatus } from '../types'
const config: Record<TicketStatus, { label: string; className: string }> = { const config: Record<TicketStatus, { label: string; className: string }> = {
OPEN: { label: 'Open', className: 'bg-blue-100 text-blue-800 border-blue-200' }, OPEN: { label: 'Open', className: 'bg-blue-500/20 text-blue-400 border-blue-500/30' },
IN_PROGRESS: { label: 'In Progress', className: 'bg-yellow-100 text-yellow-800 border-yellow-200' }, IN_PROGRESS: { label: 'In Progress', className: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' },
RESOLVED: { label: 'Resolved', className: 'bg-green-100 text-green-800 border-green-200' }, RESOLVED: { label: 'Resolved', className: 'bg-green-500/20 text-green-400 border-green-500/30' },
CLOSED: { label: 'Closed', className: 'bg-gray-100 text-gray-500 border-gray-200' }, CLOSED: { label: 'Closed', className: 'bg-gray-500/20 text-gray-400 border-gray-500/30' },
} }
export default function StatusBadge({ status }: { status: TicketStatus }) { export default function StatusBadge({ status }: { status: TicketStatus }) {

View File

@@ -1,3 +1,30 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/* 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-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; }

View File

@@ -1,12 +1,13 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { Plus, Search } from 'lucide-react' import { Search, ChevronRight, X } from 'lucide-react'
import { formatDistanceToNow } from 'date-fns' import { formatDistanceToNow } from 'date-fns'
import api from '../api/client' import api from '../api/client'
import Layout from '../components/Layout' import Layout from '../components/Layout'
import SeverityBadge from '../components/SeverityBadge' import SeverityBadge from '../components/SeverityBadge'
import StatusBadge from '../components/StatusBadge' import StatusBadge from '../components/StatusBadge'
import { Ticket, TicketStatus } from '../types' import Avatar from '../components/Avatar'
import { Ticket, TicketStatus, Category, CTIType, Item } from '../types'
const STATUSES: { value: TicketStatus | ''; label: string }[] = [ const STATUSES: { value: TicketStatus | ''; label: string }[] = [
{ value: '', label: 'All Statuses' }, { value: '', label: 'All Statuses' },
@@ -16,6 +17,21 @@ const STATUSES: { value: TicketStatus | ''; label: string }[] = [
{ value: 'CLOSED', label: 'Closed' }, { value: 'CLOSED', label: 'Closed' },
] ]
const selectClass =
'bg-gray-800 border border-gray-700 text-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent'
// Queue label built from whatever CTI level is selected
function queueLabel(
category: Category | null,
type: CTIType | null,
item: Item | null,
): string {
if (item && type && category) return `${category.name} ${type.name} ${item.name}`
if (type && category) return `${category.name} ${type.name}`
if (category) return category.name
return ''
}
export default function Dashboard() { export default function Dashboard() {
const [tickets, setTickets] = useState<Ticket[]>([]) const [tickets, setTickets] = useState<Ticket[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -23,9 +39,57 @@ export default function Dashboard() {
const [status, setStatus] = useState<TicketStatus | ''>('') const [status, setStatus] = useState<TicketStatus | ''>('')
const [severity, setSeverity] = useState('') const [severity, setSeverity] = useState('')
// CTI queue filter state
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 [selectedItem, setSelectedItem] = useState<Item | null>(null)
const [showQueueFilter, setShowQueueFilter] = useState(false)
useEffect(() => {
api.get<Category[]>('/cti/categories').then((r) => setCategories(r.data))
}, [])
const handleCategorySelect = (cat: Category) => {
setSelectedCategory(cat)
setSelectedType(null)
setSelectedItem(null)
setTypes([])
setItems([])
api.get<CTIType[]>('/cti/types', { params: { categoryId: cat.id } }).then((r) => setTypes(r.data))
}
const handleTypeSelect = (type: CTIType) => {
setSelectedType(type)
setSelectedItem(null)
setItems([])
api.get<Item[]>('/cti/items', { params: { typeId: type.id } }).then((r) => setItems(r.data))
}
const handleItemSelect = (item: Item) => {
setSelectedItem(item)
setShowQueueFilter(false)
}
const clearQueue = () => {
setSelectedCategory(null)
setSelectedType(null)
setSelectedItem(null)
setTypes([])
setItems([])
}
// Derive the most specific filter param
const queueParams: Record<string, string> = {}
if (selectedItem) queueParams.itemId = selectedItem.id
else if (selectedType) queueParams.typeId = selectedType.id
else if (selectedCategory) queueParams.categoryId = selectedCategory.id
const fetchTickets = useCallback(() => { const fetchTickets = useCallback(() => {
setLoading(true) setLoading(true)
const params: Record<string, string> = {} const params: Record<string, string> = { ...queueParams }
if (status) params.status = status if (status) params.status = status
if (severity) params.severity = severity if (severity) params.severity = severity
if (search) params.search = search if (search) params.search = search
@@ -33,43 +97,35 @@ export default function Dashboard() {
.get<Ticket[]>('/tickets', { params }) .get<Ticket[]>('/tickets', { params })
.then((r) => setTickets(r.data)) .then((r) => setTickets(r.data))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, [status, severity, search]) // eslint-disable-next-line react-hooks/exhaustive-deps
}, [status, severity, search, selectedCategory, selectedType, selectedItem])
useEffect(() => { useEffect(() => {
const t = setTimeout(fetchTickets, 300) const t = setTimeout(fetchTickets, 300)
return () => clearTimeout(t) return () => clearTimeout(t)
}, [fetchTickets]) }, [fetchTickets])
const activeQueue = queueLabel(selectedCategory, selectedType, selectedItem)
return ( return (
<Layout <Layout title="All Tickets">
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 */} {/* Filters */}
<div className="flex gap-3 mb-5"> <div className="flex gap-3 mb-5 flex-wrap items-start">
<div className="relative flex-1 max-w-sm"> <div className="relative flex-1 min-w-48 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={14} /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" size={14} />
<input <input
type="text" type="text"
placeholder="Search tickets..." placeholder="Search tickets..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} 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" className="pl-9 pr-4 py-2 bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 rounded-lg w-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/> />
</div> </div>
<select <select
value={status} value={status}
onChange={(e) => setStatus(e.target.value as TicketStatus | '')} 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" className={selectClass}
> >
{STATUSES.map((s) => ( {STATUSES.map((s) => (
<option key={s.value} value={s.value}> <option key={s.value} value={s.value}>
@@ -81,7 +137,7 @@ export default function Dashboard() {
<select <select
value={severity} value={severity}
onChange={(e) => setSeverity(e.target.value)} 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" className={selectClass}
> >
<option value="">All Severities</option> <option value="">All Severities</option>
{[1, 2, 3, 4, 5].map((s) => ( {[1, 2, 3, 4, 5].map((s) => (
@@ -90,22 +146,133 @@ export default function Dashboard() {
</option> </option>
))} ))}
</select> </select>
{/* Queue picker */}
<div className="relative">
<button
onClick={() => setShowQueueFilter((v) => !v)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm border transition-colors ${
activeQueue
? 'bg-blue-600/20 border-blue-500/40 text-blue-400'
: 'bg-gray-800 border-gray-700 text-gray-300 hover:border-gray-600'
}`}
>
{activeQueue ? (
<>
<span className="max-w-48 truncate">{activeQueue}</span>
<span
onClick={(e) => { e.stopPropagation(); clearQueue() }}
className="text-blue-400 hover:text-white transition-colors cursor-pointer"
>
<X size={13} />
</span>
</>
) : (
'All Queues'
)}
</button>
{showQueueFilter && (
<div className="absolute z-20 top-full mt-1 left-0 bg-gray-900 border border-gray-700 rounded-xl shadow-2xl overflow-hidden flex"
style={{ minWidth: '520px' }}
>
{/* Categories */}
<div className="w-44 border-r border-gray-800">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide px-3 py-2 border-b border-gray-800">
Category
</p>
<div className="overflow-auto max-h-64">
{categories.map((cat) => (
<button
key={cat.id}
onClick={() => handleCategorySelect(cat)}
className={`w-full flex items-center justify-between px-3 py-2 text-sm text-left transition-colors ${
selectedCategory?.id === cat.id
? 'bg-blue-600/20 text-blue-400'
: 'text-gray-300 hover:bg-gray-800'
}`}
>
{cat.name}
<ChevronRight size={12} className="text-gray-600" />
</button>
))}
</div>
</div>
{/* Types */}
<div className="w-44 border-r border-gray-800">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide px-3 py-2 border-b border-gray-800">
Type
</p>
<div className="overflow-auto max-h-64">
{!selectedCategory ? (
<p className="text-xs text-gray-600 px-3 py-4">Select category</p>
) : types.length === 0 ? (
<p className="text-xs text-gray-600 px-3 py-4">No types</p>
) : (
types.map((type) => (
<button
key={type.id}
onClick={() => handleTypeSelect(type)}
className={`w-full flex items-center justify-between px-3 py-2 text-sm text-left transition-colors ${
selectedType?.id === type.id
? 'bg-blue-600/20 text-blue-400'
: 'text-gray-300 hover:bg-gray-800'
}`}
>
{type.name}
<ChevronRight size={12} className="text-gray-600" />
</button>
))
)}
</div>
</div>
{/* Items */}
<div className="w-44">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide px-3 py-2 border-b border-gray-800">
Item
</p>
<div className="overflow-auto max-h-64">
{!selectedType ? (
<p className="text-xs text-gray-600 px-3 py-4">Select type</p>
) : items.length === 0 ? (
<p className="text-xs text-gray-600 px-3 py-4">No items</p>
) : (
items.map((item) => (
<button
key={item.id}
onClick={() => handleItemSelect(item)}
className={`w-full px-3 py-2 text-sm text-left transition-colors ${
selectedItem?.id === item.id
? 'bg-blue-600/20 text-blue-400'
: 'text-gray-300 hover:bg-gray-800'
}`}
>
{item.name}
</button>
))
)}
</div>
</div>
</div>
)}
</div>
</div> </div>
{/* Ticket list */} {/* Ticket list */}
{loading ? ( {loading ? (
<div className="text-center py-16 text-gray-400 text-sm">Loading...</div> <div className="text-center py-16 text-gray-600 text-sm">Loading...</div>
) : tickets.length === 0 ? ( ) : tickets.length === 0 ? (
<div className="text-center py-16 text-gray-400 text-sm">No tickets found</div> <div className="text-center py-16 text-gray-600 text-sm">No tickets found</div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-1.5">
{tickets.map((ticket) => ( {tickets.map((ticket) => (
<Link <Link
key={ticket.id} key={ticket.id}
to={`/tickets/${ticket.id}`} to={`/${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" className="flex items-center gap-4 bg-gray-900 border border-gray-800 rounded-lg px-4 py-3 hover:border-blue-500/50 hover:bg-gray-900/80 transition-all group"
> >
{/* Severity stripe */}
<div <div
className={`w-1 self-stretch rounded-full flex-shrink-0 ${ className={`w-1 self-stretch rounded-full flex-shrink-0 ${
ticket.severity === 1 ticket.severity === 1
@@ -116,30 +283,41 @@ export default function Dashboard() {
? 'bg-yellow-400' ? 'bg-yellow-400'
: ticket.severity === 4 : ticket.severity === 4
? 'bg-blue-400' ? 'bg-blue-400'
: 'bg-gray-300' : 'bg-gray-600'
}`} }`}
/> />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5"> <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} /> <SeverityBadge severity={ticket.severity} />
<StatusBadge status={ticket.status} /> <StatusBadge status={ticket.status} />
<span className="text-xs text-gray-400"> <span className="text-xs text-gray-600">
{ticket.category.name} {ticket.type.name} {ticket.item.name} {ticket.category.name} {ticket.type.name} {ticket.item.name}
</span> </span>
</div> </div>
<p className="text-sm font-medium text-gray-900 truncate group-hover:text-blue-700"> <p className="text-sm font-medium text-gray-200 truncate group-hover:text-blue-400">
{ticket.title} {ticket.title}
</p> </p>
<p className="text-xs text-gray-400 truncate mt-0.5">{ticket.overview}</p>
</div> </div>
<div className="text-right text-xs text-gray-400 flex-shrink-0 space-y-0.5"> <div className="flex items-center gap-3 flex-shrink-0">
<div className="font-medium text-gray-600"> {ticket.assignee ? (
{ticket.assignee?.displayName ?? 'Unassigned'} <div className="flex items-center gap-1.5 text-xs text-gray-500">
</div> <Avatar name={ticket.assignee.displayName} size="sm" />
<div>{ticket._count?.comments ?? 0} comments</div> <span>{ticket.assignee.displayName}</span>
<div>{formatDistanceToNow(new Date(ticket.createdAt), { addSuffix: true })}</div> </div>
) : (
<span className="text-xs text-gray-600">Unassigned</span>
)}
<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> </div>
</Link> </Link>
))} ))}

View File

@@ -25,25 +25,25 @@ export default function Login() {
} }
return ( 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="w-full max-w-sm">
<div className="text-center mb-8"> <div className="text-center mb-8">
<h1 className="text-2xl font-bold text-white">Ticketing System</h1> <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> </div>
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="bg-white rounded-xl shadow-xl p-8 space-y-4" className="bg-gray-900 border border-gray-800 rounded-xl shadow-2xl p-8 space-y-4"
> >
{error && ( {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} {error}
</div> </div>
)} )}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-300 mb-1">
Username Username
</label> </label>
<input <input
@@ -52,12 +52,12 @@ export default function Login() {
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
required required
autoFocus 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 placeholder-gray-500 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-300 mb-1">
Password Password
</label> </label>
<input <input
@@ -65,7 +65,7 @@ export default function Login() {
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required 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" className="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"
/> />
</div> </div>

View File

@@ -0,0 +1,91 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
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 { Ticket } from '../types'
import { useAuth } from '../contexts/AuthContext'
export default function MyTickets() {
const { user } = useAuth()
const [tickets, setTickets] = useState<Ticket[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!user) return
// Only show active tickets — OPEN and IN_PROGRESS
Promise.all([
api.get<Ticket[]>('/tickets', { params: { assigneeId: user.id, status: 'OPEN' } }),
api.get<Ticket[]>('/tickets', { params: { assigneeId: user.id, status: 'IN_PROGRESS' } }),
])
.then(([openRes, inProgressRes]) => {
const combined = [...openRes.data, ...inProgressRes.data]
combined.sort((a, b) => a.severity - b.severity || new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
setTickets(combined)
})
.finally(() => setLoading(false))
}, [user])
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>
)
}

View File

@@ -1,11 +1,15 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import api from '../api/client' import api from '../api/client'
import Layout from '../components/Layout' import Modal from '../components/Modal'
import CTISelect from '../components/CTISelect' import CTISelect from '../components/CTISelect'
import { User } from '../types' import { User } from '../types'
export default function NewTicket() { interface NewTicketModalProps {
onClose: () => void
}
export default function NewTicketModal({ onClose }: NewTicketModalProps) {
const navigate = useNavigate() const navigate = useNavigate()
const [users, setUsers] = useState<User[]>([]) const [users, setUsers] = useState<User[]>([])
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -49,7 +53,8 @@ export default function NewTicket() {
if (form.assigneeId) payload.assigneeId = form.assigneeId if (form.assigneeId) payload.assigneeId = form.assigneeId
const res = await api.post('/tickets', payload) const res = await api.post('/tickets', payload)
navigate(`/tickets/${res.data.id}`) onClose()
navigate(`/${res.data.displayId}`)
} catch { } catch {
setError('Failed to create ticket') setError('Failed to create ticket')
} finally { } finally {
@@ -58,104 +63,103 @@ export default function NewTicket() {
} }
const inputClass = 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' '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-700 mb-1' const labelClass = 'block text-sm font-medium text-gray-300 mb-1'
return ( return (
<Layout title="New Ticket"> <Modal title="New Ticket" onClose={onClose} size="lg">
<div className="max-w-2xl"> <form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} className="bg-white border border-gray-200 rounded-xl p-6 space-y-5"> {error && (
{error && ( <div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm px-4 py-3 rounded-lg">
<div className="bg-red-50 border border-red-200 text-red-700 text-sm px-4 py-3 rounded-lg"> {error}
{error} </div>
</div> )}
)}
<div>
<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
/>
</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... Markdown supported"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div> <div>
<label className={labelClass}>Title</label> <label className={labelClass}>Severity</label>
<input <select
type="text" value={form.severity}
value={form.title} onChange={(e) => setForm((f) => ({ ...f, severity: Number(e.target.value) }))}
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
required
className={inputClass} className={inputClass}
placeholder="Brief description of the issue" >
/> <option value={1}>SEV 1 Critical</option>
<option value={2}>SEV 2 High</option>
<option value={3}>SEV 3 Medium</option>
<option value={4}>SEV 4 Low</option>
<option value={5}>SEV 5 Minimal</option>
</select>
</div> </div>
<div> <div>
<label className={labelClass}>Overview</label> <label className={labelClass}>Assignee</label>
<textarea <select
value={form.overview} value={form.assigneeId}
onChange={(e) => setForm((f) => ({ ...f, overview: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, assigneeId: e.target.value }))}
required
rows={4}
className={inputClass} className={inputClass}
placeholder="Detailed description..."
/>
</div>
<div className="grid 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}
>
<option value={1}>SEV 1 Critical</option>
<option value={2}>SEV 2 High</option>
<option value={3}>SEV 3 Medium</option>
<option value={4}>SEV 4 Low</option>
<option value={5}>SEV 5 Minimal</option>
</select>
</div>
<div>
<label className={labelClass}>Assignee</label>
<select
value={form.assigneeId}
onChange={(e) => setForm((f) => ({ ...f, assigneeId: e.target.value }))}
className={inputClass}
>
<option value="">Unassigned</option>
{users
.filter((u) => u.role !== 'SERVICE')
.map((u) => (
<option key={u.id} value={u.id}>
{u.displayName}
</option>
))}
</select>
</div>
</div>
<div>
<label className={labelClass}>Routing (CTI)</label>
<CTISelect
value={{ categoryId: form.categoryId, typeId: form.typeId, itemId: form.itemId }}
onChange={handleCTI}
/>
</div>
<div className="flex justify-end gap-3 pt-2">
<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"
> >
Cancel <option value="">Unassigned</option>
</button> {users
<button .filter((u) => u.role !== 'SERVICE')
type="submit" .map((u) => (
disabled={submitting} <option key={u.id} value={u.id}>
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors" {u.displayName}
> </option>
{submitting ? 'Creating...' : 'Create Ticket'} ))}
</button> </select>
</div> </div>
</form> </div>
</div>
</Layout> <div>
<label className={labelClass}>Routing (CTI)</label>
<CTISelect
value={{ categoryId: form.categoryId, typeId: form.typeId, itemId: form.itemId }}
onChange={handleCTI}
/>
</div>
<div className="flex justify-end gap-3 pt-1">
<button
type="button"
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}
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'}
</button>
</div>
</form>
</Modal>
) )
} }

View File

@@ -1,22 +1,60 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { format } from 'date-fns' import { format, formatDistanceToNow } from 'date-fns'
import { Pencil, Trash2, Send, X, Check } from 'lucide-react' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import {
Pencil, Trash2, Send, X, Check,
MessageSquare, ClipboardList, FileText,
ArrowLeft, ChevronDown, ChevronRight,
} from 'lucide-react'
import api from '../api/client' import api from '../api/client'
import Layout from '../components/Layout' import Layout from '../components/Layout'
import Modal from '../components/Modal'
import SeverityBadge from '../components/SeverityBadge' import SeverityBadge from '../components/SeverityBadge'
import StatusBadge from '../components/StatusBadge' import StatusBadge from '../components/StatusBadge'
import CTISelect from '../components/CTISelect' import CTISelect from '../components/CTISelect'
import { Ticket, TicketStatus, User, Comment } from '../types' import Avatar from '../components/Avatar'
import { Ticket, TicketStatus, User, Comment, AuditLog } from '../types'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
const STATUS_OPTIONS: { value: TicketStatus; label: string }[] = [ type Tab = 'overview' | 'comments' | 'audit'
{ value: 'OPEN', label: 'Open' },
{ value: 'IN_PROGRESS', label: 'In Progress' }, const SEVERITY_OPTIONS = [
{ value: 'RESOLVED', label: 'Resolved' }, { value: 1, label: 'SEV 1 — Critical' },
{ value: 'CLOSED', label: 'Closed' }, { value: 2, label: 'SEV 2 — High' },
{ value: 3, label: 'SEV 3 — Medium' },
{ value: 4, label: 'SEV 4 — Low' },
{ value: 5, label: 'SEV 5 — Minimal' },
] ]
const AUDIT_LABELS: Record<string, string> = {
CREATED: 'created this ticket',
STATUS_CHANGED: 'changed status',
ASSIGNEE_CHANGED: 'changed assignee',
SEVERITY_CHANGED: 'changed severity',
REROUTED: 'rerouted ticket',
TITLE_CHANGED: 'updated title',
OVERVIEW_CHANGED: 'updated overview',
COMMENT_ADDED: 'added a comment',
COMMENT_DELETED: 'deleted a comment',
}
const AUDIT_COLORS: Record<string, string> = {
CREATED: 'bg-green-500',
STATUS_CHANGED: 'bg-blue-500',
ASSIGNEE_CHANGED: 'bg-purple-500',
SEVERITY_CHANGED: 'bg-orange-500',
REROUTED: 'bg-cyan-500',
TITLE_CHANGED: 'bg-gray-500',
OVERVIEW_CHANGED: 'bg-gray-500',
COMMENT_ADDED: 'bg-gray-500',
COMMENT_DELETED: 'bg-red-500',
}
const COMMENT_ACTIONS = new Set(['COMMENT_ADDED', 'COMMENT_DELETED'])
export default function TicketDetail() { export default function TicketDetail() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
@@ -24,21 +62,39 @@ export default function TicketDetail() {
const [ticket, setTicket] = useState<Ticket | null>(null) const [ticket, setTicket] = useState<Ticket | null>(null)
const [users, setUsers] = useState<User[]>([]) const [users, setUsers] = useState<User[]>([])
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [tab, setTab] = useState<Tab>('overview')
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
const [reroutingCTI, setReroutingCTI] = useState(false)
const [commentBody, setCommentBody] = useState('') const [commentBody, setCommentBody] = useState('')
const [submittingComment, setSubmittingComment] = useState(false) const [submittingComment, setSubmittingComment] = useState(false)
const commentRef = useRef<HTMLTextAreaElement>(null) const [preview, setPreview] = useState(false)
const [expandedLogs, setExpandedLogs] = useState<Set<string>>(new Set())
const [editForm, setEditForm] = useState({ const [editForm, setEditForm] = useState({ title: '', overview: '' })
title: '', const [pendingCTI, setPendingCTI] = useState({ categoryId: '', typeId: '', itemId: '' })
overview: '', const [editingStatus, setEditingStatus] = useState(false)
severity: 3, const [editingSeverity, setEditingSeverity] = useState(false)
assigneeId: '', const [editingAssignee, setEditingAssignee] = useState(false)
categoryId: '', const [expandedDates, setExpandedDates] = useState<Set<string>>(new Set())
typeId: '', const [expandedCommentDates, setExpandedCommentDates] = useState<Set<string>>(new Set())
itemId: '',
}) const toggleDate = (key: string) =>
setExpandedDates((prev) => {
const next = new Set(prev)
next.has(key) ? next.delete(key) : next.add(key)
return next
})
const toggleCommentDate = (id: string) =>
setExpandedCommentDates((prev) => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
const isAdmin = authUser?.role === 'ADMIN'
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([
@@ -50,53 +106,45 @@ export default function TicketDetail() {
}).finally(() => setLoading(false)) }).finally(() => setLoading(false))
}, [id]) }, [id])
useEffect(() => {
if (tab === 'audit' && ticket) {
api.get<AuditLog[]>(`/tickets/${id}/audit`).then((r) => setAuditLogs(r.data))
}
}, [tab, ticket, id])
const patch = async (payload: Record<string, unknown>) => {
if (!ticket) return
const res = await api.patch<Ticket>(`/tickets/${ticket.displayId}`, payload)
setTicket(res.data)
return res.data
}
const startEdit = () => { const startEdit = () => {
if (!ticket) return if (!ticket) return
setEditForm({ setEditForm({ title: ticket.title, overview: ticket.overview })
title: ticket.title,
overview: ticket.overview,
severity: ticket.severity,
assigneeId: ticket.assigneeId ?? '',
categoryId: ticket.categoryId,
typeId: ticket.typeId,
itemId: ticket.itemId,
})
setEditing(true) setEditing(true)
setTab('overview')
} }
const saveEdit = async () => { const saveEdit = async () => {
if (!ticket) return await patch({ title: editForm.title, overview: editForm.overview })
const payload: Record<string, unknown> = {
title: editForm.title,
overview: editForm.overview,
severity: editForm.severity,
categoryId: editForm.categoryId,
typeId: editForm.typeId,
itemId: editForm.itemId,
assigneeId: editForm.assigneeId || null,
}
const res = await api.patch<Ticket>(`/tickets/${ticket.id}`, payload)
setTicket(res.data)
setEditing(false) setEditing(false)
} }
const updateStatus = async (status: TicketStatus) => { const startReroute = () => {
if (!ticket) return if (!ticket) return
const res = await api.patch<Ticket>(`/tickets/${ticket.id}`, { status }) setPendingCTI({ categoryId: ticket.categoryId, typeId: ticket.typeId, itemId: ticket.itemId })
setTicket(res.data) setReroutingCTI(true)
} }
const updateAssignee = async (assigneeId: string) => { const saveReroute = async () => {
if (!ticket) return await patch(pendingCTI)
const res = await api.patch<Ticket>(`/tickets/${ticket.id}`, { setReroutingCTI(false)
assigneeId: assigneeId || null,
})
setTicket(res.data)
} }
const deleteTicket = async () => { const deleteTicket = async () => {
if (!ticket || !confirm('Delete this ticket?')) return if (!ticket || !confirm('Delete this ticket? This cannot be undone.')) return
await api.delete(`/tickets/${ticket.id}`) await api.delete(`/tickets/${ticket.displayId}`)
navigate('/') navigate('/')
} }
@@ -105,13 +153,12 @@ export default function TicketDetail() {
if (!ticket || !commentBody.trim()) return if (!ticket || !commentBody.trim()) return
setSubmittingComment(true) setSubmittingComment(true)
try { try {
const res = await api.post<Comment>(`/tickets/${ticket.id}/comments`, { const res = await api.post<Comment>(`/tickets/${ticket.displayId}/comments`, {
body: commentBody.trim(), body: commentBody.trim(),
}) })
setTicket((t) => setTicket((t) => t ? { ...t, comments: [...(t.comments ?? []), res.data] } : t)
t ? { ...t, comments: [...(t.comments ?? []), res.data] } : t
)
setCommentBody('') setCommentBody('')
setPreview(false)
} finally { } finally {
setSubmittingComment(false) setSubmittingComment(false)
} }
@@ -119,19 +166,25 @@ export default function TicketDetail() {
const deleteComment = async (commentId: string) => { const deleteComment = async (commentId: string) => {
if (!ticket) return if (!ticket) return
await api.delete(`/tickets/${ticket.id}/comments/${commentId}`) await api.delete(`/tickets/${ticket.displayId}/comments/${commentId}`)
setTicket((t) => setTicket((t) => t ? { ...t, comments: t.comments?.filter((c) => c.id !== commentId) } : t)
t ? { ...t, comments: t.comments?.filter((c) => c.id !== commentId) } : t
)
} }
const inputClass = const toggleLog = (logId: string) => {
'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500' setExpandedLogs((prev) => {
const next = new Set(prev)
if (next.has(logId)) next.delete(logId)
else next.add(logId)
return next
})
}
if (loading) { if (loading) {
return ( return (
<Layout> <Layout>
<div className="text-center py-16 text-gray-400 text-sm">Loading...</div> <div className="flex items-center justify-center h-full text-gray-600 text-sm">
Loading...
</div>
</Layout> </Layout>
) )
} }
@@ -139,253 +192,538 @@ export default function TicketDetail() {
if (!ticket) { if (!ticket) {
return ( return (
<Layout> <Layout>
<div className="text-center py-16 text-gray-400 text-sm">Ticket not found</div> <div className="flex items-center justify-center h-full text-gray-600 text-sm">
Ticket not found
</div>
</Layout> </Layout>
) )
} }
const commentCount = ticket.comments?.length ?? 0
const agentUsers = users.filter((u) => u.role !== 'SERVICE')
// Status options: CLOSED only for admins
const statusOptions: { value: TicketStatus; label: string }[] = [
{ value: 'OPEN', label: 'Open' },
{ value: 'IN_PROGRESS', label: 'In Progress' },
{ value: 'RESOLVED', label: 'Resolved' },
...(isAdmin ? [{ value: 'CLOSED' as TicketStatus, label: 'Closed' }] : []),
]
return ( return (
<Layout <Layout>
title={ticket.title} {/* Back link */}
action={ <button
authUser?.role === 'ADMIN' ? ( onClick={() => navigate(-1)}
<button className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-300 mb-4 transition-colors"
onClick={deleteTicket} >
className="flex items-center gap-2 text-sm text-red-600 hover:text-red-800 border border-red-200 hover:border-red-400 px-3 py-1.5 rounded-lg transition-colors" <ArrowLeft size={14} />
> Back
<Trash2 size={14} /> </button>
Delete
</button> <div className="flex gap-6 items-start">
) : undefined {/* ── Main content ── */}
} <div className="flex-1 min-w-0">
> {/* Title card */}
<div className="max-w-3xl space-y-5"> <div className="bg-gray-900 border border-gray-800 rounded-xl px-6 py-5 mb-3">
{/* Ticket card */} <div className="flex items-center gap-2 mb-3 flex-wrap">
<div className="bg-white border border-gray-200 rounded-xl"> <span className="font-mono text-xs font-semibold text-gray-500 bg-gray-800 px-2 py-0.5 rounded">
{/* Header bar */} {ticket.displayId}
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-100"> </span>
<div className="flex items-center gap-2">
<SeverityBadge severity={ticket.severity} /> <SeverityBadge severity={ticket.severity} />
<StatusBadge status={ticket.status} /> <StatusBadge status={ticket.status} />
{!editing && ( <span className="text-xs text-gray-500 ml-1">
<span className="text-xs text-gray-400"> {ticket.category.name} {ticket.type.name} {ticket.item.name}
{ticket.category.name} {ticket.type.name} {ticket.item.name} </span>
</span>
)}
</div>
{!editing ? (
<button <button
onClick={startEdit} onClick={startEdit}
className="flex items-center gap-1.5 text-xs text-gray-500 hover:text-gray-800 border border-gray-200 hover:border-gray-400 px-2.5 py-1 rounded-lg transition-colors" className="ml-auto text-gray-500 hover:text-gray-300 transition-colors"
title="Edit title & overview"
> >
<Pencil size={12} /> <Pencil size={14} />
Edit
</button> </button>
</div>
{editing ? (
<input
type="text"
value={editForm.title}
onChange={(e) => setEditForm((f) => ({ ...f, title: e.target.value }))}
className="w-full text-2xl font-bold text-gray-100 bg-transparent border-0 border-b-2 border-blue-500 focus:outline-none pb-1"
autoFocus
/>
) : ( ) : (
<div className="flex items-center gap-2"> <h1 className="text-2xl font-bold text-gray-100">{ticket.title}</h1>
)}
</div>
{/* Tabs + content */}
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
{/* Tab bar */}
<div className="flex border-b border-gray-800 px-2">
{(
[
{ key: 'overview', icon: FileText, label: 'Overview' },
{ key: 'comments', icon: MessageSquare, label: `Comments${commentCount > 0 ? ` (${commentCount})` : ''}` },
{ key: 'audit', icon: ClipboardList, label: 'Audit Log' },
] as const
).map(({ key, icon: Icon, label }) => (
<button <button
onClick={() => setEditing(false)} key={key}
className="flex items-center gap-1.5 text-xs text-gray-500 hover:text-gray-700 border border-gray-200 px-2.5 py-1 rounded-lg transition-colors" onClick={() => setTab(key)}
className={`flex items-center gap-2 px-4 py-3.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
tab === key
? 'border-blue-500 text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-300'
}`}
> >
<X size={12} /> <Icon size={14} />
Cancel {label}
</button>
<button
onClick={saveEdit}
className="flex items-center gap-1.5 text-xs bg-blue-600 text-white px-2.5 py-1 rounded-lg hover:bg-blue-700 transition-colors"
>
<Check size={12} />
Save
</button> </button>
))}
</div>
{/* ── Overview ── */}
{tab === 'overview' && (
<div className="p-6">
{editing ? (
<div className="space-y-3">
<textarea
value={editForm.overview}
onChange={(e) => setEditForm((f) => ({ ...f, overview: e.target.value }))}
rows={12}
className="w-full bg-gray-800 border border-gray-700 text-gray-100 rounded-lg px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-y font-mono"
/>
<div className="flex justify-end gap-2">
<button
onClick={() => setEditing(false)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 border border-gray-700 rounded-lg hover:bg-gray-800 transition-colors"
>
<X size={13} /> Cancel
</button>
<button
onClick={saveEdit}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Check size={13} /> Save changes
</button>
</div>
</div>
) : (
<div className="prose text-sm text-gray-300">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{ticket.overview}
</ReactMarkdown>
</div>
)}
</div>
)}
{/* ── Comments ── */}
{tab === 'comments' && (
<div className="p-6 space-y-4">
{ticket.comments && ticket.comments.length > 0 ? (
ticket.comments.map((comment, i) => (
<div key={comment.id} className="flex gap-3 group">
{/* Avatar + spine */}
<div className="flex flex-col items-center">
<Avatar name={comment.author.displayName} size="md" />
{i < (ticket.comments!.length - 1) && (
<div className="flex-1 w-px bg-gray-800 mt-2" />
)}
</div>
{/* Card */}
<div className="flex-1 min-w-0 border border-gray-700 rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 bg-gray-800/60 border-b border-gray-700">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-200">
{comment.author.displayName}
</span>
<button
onClick={() => toggleCommentDate(comment.id)}
className="text-xs text-gray-500 hover:text-gray-300 transition-colors"
>
{expandedCommentDates.has(comment.id)
? format(new Date(comment.createdAt), 'MMM d, yyyy HH:mm')
: formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })}
</button>
</div>
{(comment.authorId === authUser?.id || isAdmin) && (
<button
onClick={() => deleteComment(comment.id)}
className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 transition-all"
>
<Trash2 size={13} />
</button>
)}
</div>
<div className="px-4 py-3 prose prose-sm prose-invert text-gray-300 text-sm max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{comment.body}
</ReactMarkdown>
</div>
</div>
</div>
))
) : (
<div className="py-12 text-center text-sm text-gray-600">
No comments yet
</div>
)}
{/* Composer */}
<div className="flex gap-3">
<Avatar name={authUser?.displayName ?? '?'} size="md" />
<div className="flex-1 border border-gray-700 rounded-lg overflow-hidden">
<div className="flex gap-4 px-4 bg-gray-800/60 border-b border-gray-700">
{(['Write', 'Preview'] as const).map((label) => (
<button
key={label}
onClick={() => setPreview(label === 'Preview')}
className={`text-xs py-2 border-b-2 -mb-px transition-colors ${
(label === 'Preview') === preview
? 'border-blue-500 text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-300'
}`}
>
{label}
</button>
))}
</div>
<form onSubmit={submitComment} className="p-3">
{preview ? (
<div className="prose prose-sm prose-invert text-gray-300 min-h-[80px] mb-3 px-1 max-w-none">
{commentBody.trim()
? <ReactMarkdown remarkPlugins={[remarkGfm]}>{commentBody}</ReactMarkdown>
: <span className="text-gray-600 italic">Nothing to preview</span>
}
</div>
) : (
<textarea
value={commentBody}
onChange={(e) => setCommentBody(e.target.value)}
placeholder="Leave a comment… Markdown supported"
rows={4}
className="w-full bg-transparent text-gray-100 placeholder-gray-600 text-sm focus:outline-none resize-none mb-3"
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
submitComment(e as unknown as React.FormEvent)
}
}}
/>
)}
<div className="flex justify-between items-center border-t border-gray-700 pt-2">
<span className="text-xs text-gray-600">Markdown · Ctrl+Enter</span>
<button
type="submit"
disabled={submittingComment || !commentBody.trim()}
className="flex items-center gap-2 px-4 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
<Send size={13} />
Comment
</button>
</div>
</form>
</div>
</div>
</div>
)}
{/* ── Audit Log ── */}
{tab === 'audit' && (
<div className="p-6">
{auditLogs.length === 0 ? (
<div className="py-10 text-center text-sm text-gray-600">No activity yet</div>
) : (
<div>
{auditLogs.map((log, i) => {
const hasDetail = !!log.detail
const isExpanded = expandedLogs.has(log.id)
const isComment = COMMENT_ACTIONS.has(log.action)
return (
<div key={log.id} className="flex gap-4">
{/* Timeline */}
<div className="flex flex-col items-center w-5 flex-shrink-0">
<div className={`w-2.5 h-2.5 rounded-full mt-1 flex-shrink-0 ${AUDIT_COLORS[log.action] ?? 'bg-gray-500'}`} />
{i < auditLogs.length - 1 && (
<div className="w-px flex-1 bg-gray-800 my-1" />
)}
</div>
{/* Entry */}
<div className="flex-1 pb-4">
<div
className={`flex items-baseline justify-between gap-4 ${hasDetail ? 'cursor-pointer select-none' : ''}`}
onClick={() => hasDetail && toggleLog(log.id)}
>
<p className="text-sm text-gray-300">
<span className="font-medium text-gray-100">{log.user.displayName}</span>
{' '}{AUDIT_LABELS[log.action] ?? log.action.toLowerCase()}
{hasDetail && (
<span className="ml-1 inline-flex items-center text-gray-600">
{isExpanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
</span>
)}
</p>
<span
className="text-xs text-gray-600 flex-shrink-0"
title={format(new Date(log.createdAt), 'MMM d, yyyy HH:mm:ss')}
>
{formatDistanceToNow(new Date(log.createdAt), { addSuffix: true })}
</span>
</div>
{hasDetail && isExpanded && (
<div className="mt-2 ml-0 bg-gray-800 border border-gray-700 rounded-lg px-4 py-3">
{isComment ? (
<div className="prose text-sm text-gray-300">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{log.detail!}
</ReactMarkdown>
</div>
) : (
<p className="text-sm text-gray-400">{log.detail}</p>
)}
</div>
)}
</div>
</div>
)
})}
</div>
)}
</div> </div>
)} )}
</div> </div>
<div className="p-5 space-y-4">
{editing ? (
<>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Title</label>
<input
type="text"
value={editForm.title}
onChange={(e) => setEditForm((f) => ({ ...f, title: e.target.value }))}
className={inputClass}
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Overview</label>
<textarea
value={editForm.overview}
onChange={(e) => setEditForm((f) => ({ ...f, overview: e.target.value }))}
rows={4}
className={inputClass}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Severity</label>
<select
value={editForm.severity}
onChange={(e) => setEditForm((f) => ({ ...f, severity: Number(e.target.value) }))}
className={inputClass}
>
<option value={1}>SEV 1 Critical</option>
<option value={2}>SEV 2 High</option>
<option value={3}>SEV 3 Medium</option>
<option value={4}>SEV 4 Low</option>
<option value={5}>SEV 5 Minimal</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Assignee</label>
<select
value={editForm.assigneeId}
onChange={(e) => setEditForm((f) => ({ ...f, assigneeId: e.target.value }))}
className={inputClass}
>
<option value="">Unassigned</option>
{users
.filter((u) => u.role !== 'SERVICE')
.map((u) => (
<option key={u.id} value={u.id}>
{u.displayName}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Routing (CTI)</label>
<CTISelect
value={{ categoryId: editForm.categoryId, typeId: editForm.typeId, itemId: editForm.itemId }}
onChange={(cti) => setEditForm((f) => ({ ...f, ...cti }))}
/>
</div>
</>
) : (
<>
<p className="text-sm text-gray-700 whitespace-pre-wrap">{ticket.overview}</p>
{/* Quick controls */}
<div className="grid grid-cols-2 gap-4 pt-2">
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Status</label>
<select
value={ticket.status}
onChange={(e) => updateStatus(e.target.value as TicketStatus)}
className={inputClass}
>
{STATUS_OPTIONS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Assignee</label>
<select
value={ticket.assigneeId ?? ''}
onChange={(e) => updateAssignee(e.target.value)}
className={inputClass}
>
<option value="">Unassigned</option>
{users
.filter((u) => u.role !== 'SERVICE')
.map((u) => (
<option key={u.id} value={u.id}>
{u.displayName}
</option>
))}
</select>
</div>
</div>
</>
)}
</div>
{/* Metadata footer */}
<div className="px-5 py-3 bg-gray-50 rounded-b-xl border-t border-gray-100 flex items-center gap-6 text-xs text-gray-400">
<span>Created by <strong className="text-gray-600">{ticket.createdBy.displayName}</strong></span>
<span>{format(new Date(ticket.createdAt), 'MMM d, yyyy HH:mm')}</span>
{ticket.resolvedAt && (
<span>Resolved {format(new Date(ticket.resolvedAt), 'MMM d, yyyy')}</span>
)}
<span>Updated {format(new Date(ticket.updatedAt), 'MMM d, yyyy HH:mm')}</span>
</div>
</div> </div>
{/* Comments */} {/* ── Sidebar ── */}
<div className="bg-white border border-gray-200 rounded-xl"> <div className="w-64 flex-shrink-0 sticky top-0 space-y-3">
<div className="px-5 py-3 border-b border-gray-100">
<h3 className="text-sm font-semibold text-gray-700">
Comments ({ticket.comments?.length ?? 0})
</h3>
</div>
{ticket.comments && ticket.comments.length > 0 ? ( {/* Ticket Summary */}
<div className="divide-y divide-gray-100"> <div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
{ticket.comments.map((comment) => ( <div className="px-4 py-3">
<div key={comment.id} className="px-5 py-4 group"> <p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Ticket Summary</p>
<div className="flex items-start justify-between gap-3"> </div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1"> {/* Status */}
<span className="text-xs font-medium text-gray-700"> <button
{comment.author.displayName} onClick={() => setEditingStatus(true)}
</span> className="w-full px-4 py-3 text-left hover:bg-gray-800/50 transition-colors"
<span className="text-xs text-gray-400"> >
{format(new Date(comment.createdAt), 'MMM d, yyyy HH:mm')} <p className="text-xs font-medium text-gray-500 mb-1.5">Status</p>
</span> <StatusBadge status={ticket.status} />
</div> </button>
<p className="text-sm text-gray-700 whitespace-pre-wrap">{comment.body}</p>
</div> {/* Severity */}
{(comment.authorId === authUser?.id || authUser?.role === 'ADMIN') && ( <button
<button onClick={() => setEditingSeverity(true)}
onClick={() => deleteComment(comment.id)} className="w-full px-4 py-3 text-left hover:bg-gray-800/50 transition-colors"
className="opacity-0 group-hover:opacity-100 text-gray-300 hover:text-red-500 transition-all flex-shrink-0" >
> <p className="text-xs font-medium text-gray-500 mb-1.5">Severity</p>
<Trash2 size={13} /> <SeverityBadge severity={ticket.severity} />
</button> </button>
)}
</div> {/* CTI — one clickable unit */}
<button
onClick={startReroute}
className="w-full px-4 py-3 text-left space-y-3 hover:bg-gray-800/50 transition-colors"
>
<div>
<p className="text-xs font-medium text-gray-500 mb-1">Category</p>
<p className="text-sm text-gray-300">{ticket.category.name}</p>
</div>
<div>
<p className="text-xs font-medium text-gray-500 mb-1">Type</p>
<p className="text-sm text-gray-300">{ticket.type.name}</p>
</div>
<div>
<p className="text-xs font-medium text-gray-500 mb-1">Issue</p>
<p className="text-sm text-gray-300">{ticket.item.name}</p>
</div>
</button>
{/* Dates */}
<div className="px-4 py-3 space-y-2.5">
{[
{ key: 'created', label: 'Created', date: ticket.createdAt },
{ key: 'modified', label: 'Modified', date: ticket.updatedAt },
...(ticket.resolvedAt ? [{ key: 'resolved', label: 'Resolved', date: ticket.resolvedAt }] : []),
].map(({ key, label, date }) => (
<div key={key}>
<p className="text-xs font-medium text-gray-500 mb-1">{label}</p>
<button
onClick={() => toggleDate(key)}
className="text-xs text-gray-300 hover:text-gray-100 transition-colors"
>
{expandedDates.has(key)
? format(new Date(date), 'MMM d, yyyy HH:mm')
: formatDistanceToNow(new Date(date), { addSuffix: true })}
</button>
</div> </div>
))} ))}
</div> </div>
) : (
<div className="px-5 py-8 text-center text-xs text-gray-400">No comments yet</div>
)}
{/* Comment form */} {/* Assignee */}
<form onSubmit={submitComment} className="px-5 py-4 border-t border-gray-100"> <button
<textarea onClick={() => setEditingAssignee(true)}
ref={commentRef} className="w-full px-4 py-3 text-left hover:bg-gray-800/50 transition-colors"
value={commentBody} >
onChange={(e) => setCommentBody(e.target.value)} <p className="text-xs font-medium text-gray-500 mb-1.5">Assignee</p>
placeholder="Add a comment..." {ticket.assignee ? (
rows={3} <div className="flex items-center gap-1.5">
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 resize-none" <Avatar name={ticket.assignee.displayName} size="sm" />
onKeyDown={(e) => { <span className="text-sm text-gray-300">{ticket.assignee.displayName}</span>
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { </div>
e.preventDefault() ) : (
submitComment(e as unknown as React.FormEvent) <p className="text-sm text-gray-500">Unassigned</p>
} )}
}} </button>
/>
<div className="flex justify-between items-center mt-2"> {/* Requester */}
<span className="text-xs text-gray-400">Ctrl+Enter to submit</span> <div className="px-4 py-3">
<p className="text-xs font-medium text-gray-500 mb-1.5">Requester</p>
<div className="flex items-center gap-1.5">
<Avatar name={ticket.createdBy.displayName} size="sm" />
<span className="text-sm text-gray-300">{ticket.createdBy.displayName}</span>
</div>
</div>
</div>
{isAdmin && (
<div className="bg-gray-900 border border-gray-800 rounded-xl px-4 py-3">
<button <button
type="submit" onClick={deleteTicket}
disabled={submittingComment || !commentBody.trim()} className="w-full flex items-center justify-center gap-2 py-2 text-sm text-red-400 border border-red-500/30 rounded-lg hover:bg-red-500/10 transition-colors"
className="flex items-center gap-2 px-3 py-1.5 bg-blue-600 text-white text-xs rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
> >
<Send size={12} /> <Trash2 size={13} />
Comment Delete ticket
</button> </button>
</div> </div>
</form> )}
</div> </div>
</div> </div>
{editingStatus && (
<Modal title="Change Status" onClose={() => setEditingStatus(false)}>
<div className="space-y-2">
{statusOptions.map((s) => (
<button
key={s.value}
onClick={async () => {
await patch({ status: s.value })
setEditingStatus(false)
}}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors ${
ticket.status === s.value
? 'border-blue-500/50 bg-blue-500/10'
: 'border-gray-700 hover:bg-gray-800'
}`}
>
<StatusBadge status={s.value} />
{ticket.status === s.value && <Check size={14} className="ml-auto text-blue-400" />}
</button>
))}
{!isAdmin && (
<p className="text-xs text-gray-500 pt-1">Closing a ticket requires admin access</p>
)}
</div>
</Modal>
)}
{editingSeverity && (
<Modal title="Change Severity" onClose={() => setEditingSeverity(false)}>
<div className="space-y-2">
{SEVERITY_OPTIONS.map((s) => (
<button
key={s.value}
onClick={async () => {
await patch({ severity: s.value })
setEditingSeverity(false)
}}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors ${
ticket.severity === s.value
? 'border-blue-500/50 bg-blue-500/10'
: 'border-gray-700 hover:bg-gray-800'
}`}
>
<SeverityBadge severity={s.value} />
<span className="text-sm text-gray-400">{s.label.split(' — ')[1]}</span>
{ticket.severity === s.value && <Check size={14} className="ml-auto text-blue-400" />}
</button>
))}
</div>
</Modal>
)}
{editingAssignee && (
<Modal title="Change Assignee" onClose={() => setEditingAssignee(false)}>
<div className="space-y-2">
<button
onClick={async () => {
await patch({ assigneeId: null })
setEditingAssignee(false)
}}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors ${
!ticket.assigneeId
? 'border-blue-500/50 bg-blue-500/10'
: 'border-gray-700 hover:bg-gray-800'
}`}
>
<span className="text-sm text-gray-400">Unassigned</span>
{!ticket.assigneeId && <Check size={14} className="ml-auto text-blue-400" />}
</button>
{agentUsers.map((u) => (
<button
key={u.id}
onClick={async () => {
await patch({ assigneeId: u.id })
setEditingAssignee(false)
}}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors ${
ticket.assigneeId === u.id
? 'border-blue-500/50 bg-blue-500/10'
: 'border-gray-700 hover:bg-gray-800'
}`}
>
<Avatar name={u.displayName} size="sm" />
<span className="text-sm text-gray-300">{u.displayName}</span>
{ticket.assigneeId === u.id && <Check size={14} className="ml-auto text-blue-400" />}
</button>
))}
</div>
</Modal>
)}
{reroutingCTI && (
<Modal title="Change Routing" onClose={() => setReroutingCTI(false)} size="lg">
<div className="space-y-5">
<p className="text-sm text-gray-400">
Current:{' '}
<span className="text-gray-200">
{ticket.category.name} {ticket.type.name} {ticket.item.name}
</span>
</p>
<CTISelect value={pendingCTI} onChange={setPendingCTI} />
<div className="flex justify-end gap-3 pt-1">
<button
onClick={() => setReroutingCTI(false)}
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
onClick={saveReroute}
disabled={!pendingCTI.itemId}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
Save routing
</button>
</div>
</div>
</Modal>
)}
</Layout> </Layout>
) )
} }

View File

@@ -125,11 +125,11 @@ export default function AdminCTI() {
} }
} }
const panelClass = 'bg-white border border-gray-200 rounded-xl overflow-hidden flex flex-col' 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-100 bg-gray-50' const panelHeaderClass = 'flex items-center justify-between px-4 py-3 border-b border-gray-800'
const itemClass = (active: boolean) => const itemClass = (active: boolean) =>
`flex items-center justify-between px-4 py-2.5 cursor-pointer group transition-colors ${ `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 ( return (
@@ -138,17 +138,17 @@ export default function AdminCTI() {
{/* Categories */} {/* Categories */}
<div className={panelClass}> <div className={panelClass}>
<div className={panelHeaderClass}> <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 <button
onClick={() => openAdd('category')} 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} /> <Plus size={16} />
</button> </button>
</div> </div>
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
{categories.length === 0 ? ( {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) => ( categories.map((cat) => (
<div <div
@@ -156,21 +156,21 @@ export default function AdminCTI() {
className={itemClass(selectedCategory?.id === cat.id)} className={itemClass(selectedCategory?.id === cat.id)}
onClick={() => selectCategory(cat)} 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"> <div className="flex items-center gap-1">
<button <button
onClick={(e) => { e.stopPropagation(); openEdit('category', cat) }} onClick={(e) => { e.stopPropagation(); openEdit('category', cat) }}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-gray-700 transition-all" className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-gray-300 transition-all"
> >
<Pencil size={13} /> <Pencil size={13} />
</button> </button>
<button <button
onClick={(e) => { e.stopPropagation(); handleDelete('category', cat) }} onClick={(e) => { e.stopPropagation(); handleDelete('category', cat) }}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-600 transition-all" className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 transition-all"
> >
<Trash2 size={13} /> <Trash2 size={13} />
</button> </button>
<ChevronRight size={14} className="text-gray-300" /> <ChevronRight size={14} className="text-gray-700" />
</div> </div>
</div> </div>
)) ))
@@ -181,16 +181,16 @@ export default function AdminCTI() {
{/* Types */} {/* Types */}
<div className={panelClass}> <div className={panelClass}>
<div className={panelHeaderClass}> <div className={panelHeaderClass}>
<h3 className="text-sm font-semibold text-gray-700"> <h3 className="text-sm font-semibold text-gray-300">
Types Types
{selectedCategory && ( {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> </h3>
{selectedCategory && ( {selectedCategory && (
<button <button
onClick={() => openAdd('type')} 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} /> <Plus size={16} />
</button> </button>
@@ -198,9 +198,9 @@ export default function AdminCTI() {
</div> </div>
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
{!selectedCategory ? ( {!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 ? ( ) : 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) => ( types.map((type) => (
<div <div
@@ -208,21 +208,21 @@ export default function AdminCTI() {
className={itemClass(selectedType?.id === type.id)} className={itemClass(selectedType?.id === type.id)}
onClick={() => selectType(type)} 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"> <div className="flex items-center gap-1">
<button <button
onClick={(e) => { e.stopPropagation(); openEdit('type', type) }} onClick={(e) => { e.stopPropagation(); openEdit('type', type) }}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-gray-700 transition-all" className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-gray-300 transition-all"
> >
<Pencil size={13} /> <Pencil size={13} />
</button> </button>
<button <button
onClick={(e) => { e.stopPropagation(); handleDelete('type', type) }} onClick={(e) => { e.stopPropagation(); handleDelete('type', type) }}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-600 transition-all" className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 transition-all"
> >
<Trash2 size={13} /> <Trash2 size={13} />
</button> </button>
<ChevronRight size={14} className="text-gray-300" /> <ChevronRight size={14} className="text-gray-700" />
</div> </div>
</div> </div>
)) ))
@@ -233,16 +233,16 @@ export default function AdminCTI() {
{/* Items */} {/* Items */}
<div className={panelClass}> <div className={panelClass}>
<div className={panelHeaderClass}> <div className={panelHeaderClass}>
<h3 className="text-sm font-semibold text-gray-700"> <h3 className="text-sm font-semibold text-gray-300">
Items Items
{selectedType && ( {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> </h3>
{selectedType && ( {selectedType && (
<button <button
onClick={() => openAdd('item')} 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} /> <Plus size={16} />
</button> </button>
@@ -250,23 +250,23 @@ export default function AdminCTI() {
</div> </div>
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
{!selectedType ? ( {!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 ? ( ) : 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) => ( items.map((item) => (
<div key={item.id} className={itemClass(false)} onClick={() => {}}> <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"> <div className="flex items-center gap-1">
<button <button
onClick={(e) => { e.stopPropagation(); openEdit('item', item) }} onClick={(e) => { e.stopPropagation(); openEdit('item', item) }}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-gray-700 transition-all" className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-gray-300 transition-all"
> >
<Pencil size={13} /> <Pencil size={13} />
</button> </button>
<button <button
onClick={(e) => { e.stopPropagation(); handleDelete('item', item) }} onClick={(e) => { e.stopPropagation(); handleDelete('item', item) }}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-600 transition-all" className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 transition-all"
> >
<Trash2 size={13} /> <Trash2 size={13} />
</button> </button>
@@ -286,21 +286,21 @@ export default function AdminCTI() {
> >
<form onSubmit={handleSave} className="space-y-4"> <form onSubmit={handleSave} className="space-y-4">
<div> <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 <input
type="text" type="text"
value={nameValue} value={nameValue}
onChange={(e) => setNameValue(e.target.value)} onChange={(e) => setNameValue(e.target.value)}
required required
autoFocus 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>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button <button
type="button" type="button"
onClick={closeModal} 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 Cancel
</button> </button>

View File

@@ -25,9 +25,24 @@ const BLANK_FORM: UserForm = {
const ROLE_LABELS: Record<Role, string> = { const ROLE_LABELS: Record<Role, string> = {
ADMIN: 'Admin', ADMIN: 'Admin',
AGENT: 'Agent', AGENT: 'Agent',
USER: 'User',
SERVICE: 'Service', 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() { export default function AdminUsers() {
const { user: authUser } = useAuth() const { user: authUser } = useAuth()
const [users, setUsers] = useState<User[]>([]) const [users, setUsers] = useState<User[]>([])
@@ -133,8 +148,8 @@ export default function AdminUsers() {
} }
const inputClass = 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' '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-700 mb-1' const labelClass = 'block text-sm font-medium text-gray-300 mb-1'
return ( return (
<Layout <Layout
@@ -149,9 +164,9 @@ export default function AdminUsers() {
</button> </button>
} }
> >
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden"> <div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200"> <thead className="border-b border-gray-800">
<tr> <tr>
<th className="text-left px-5 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide"> <th className="text-left px-5 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">
User User
@@ -168,31 +183,23 @@ export default function AdminUsers() {
<th className="px-5 py-3" /> <th className="px-5 py-3" />
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-100"> <tbody className="divide-y divide-gray-800">
{users.map((u) => ( {users.map((u) => (
<tr key={u.id} className="hover:bg-gray-50"> <tr key={u.id} className="hover:bg-gray-800/50">
<td className="px-5 py-3 font-medium text-gray-900">{u.displayName}</td> <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 text-gray-500 font-mono text-xs">{u.username}</td>
<td className="px-5 py-3"> <td className="px-5 py-3">
<span <span className={`inline-flex px-2 py-0.5 rounded text-xs font-medium border ${ROLE_BADGE[u.role]}`}>
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'
}`}
>
{ROLE_LABELS[u.role]} {ROLE_LABELS[u.role]}
</span> </span>
</td> </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"> <td className="px-5 py-3">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
{u.role === 'SERVICE' && ( {u.role === 'SERVICE' && (
<button <button
onClick={() => handleRegenerateKey(u)} onClick={() => handleRegenerateKey(u)}
className="text-gray-400 hover:text-gray-700 transition-colors" className="text-gray-600 hover:text-gray-300 transition-colors"
title="Regenerate API key" title="Regenerate API key"
> >
<RefreshCw size={14} /> <RefreshCw size={14} />
@@ -200,14 +207,14 @@ export default function AdminUsers() {
)} )}
<button <button
onClick={() => openEdit(u)} 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} /> <Pencil size={14} />
</button> </button>
{u.id !== authUser?.id && ( {u.id !== authUser?.id && (
<button <button
onClick={() => handleDelete(u)} onClick={() => handleDelete(u)}
className="text-gray-400 hover:text-red-600 transition-colors" className="text-gray-600 hover:text-red-400 transition-colors"
> >
<Trash2 size={14} /> <Trash2 size={14} />
</button> </button>
@@ -228,17 +235,17 @@ export default function AdminUsers() {
> >
{newApiKey ? ( {newApiKey ? (
<div className="space-y-4"> <div className="space-y-4">
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4"> <div className="bg-amber-500/10 border border-amber-500/30 rounded-lg p-4">
<p className="text-sm font-medium text-amber-800 mb-2"> <p className="text-sm font-medium text-amber-400 mb-2">
API Key copy it now, it won't be shown again API Key copy it now, it won't be shown again
</p> </p>
<div className="flex items-center gap-2"> <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} {newApiKey}
</code> </code>
<button <button
onClick={() => copyToClipboard(newApiKey)} 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} />} {copiedKey === newApiKey ? <Check size={16} /> : <Copy size={16} />}
</button> </button>
@@ -254,7 +261,7 @@ export default function AdminUsers() {
) : ( ) : (
<form onSubmit={modal === 'add' ? handleAdd : handleEdit} className="space-y-4"> <form onSubmit={modal === 'add' ? handleAdd : handleEdit} className="space-y-4">
{error && ( {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} {error}
</div> </div>
)} )}
@@ -296,7 +303,10 @@ export default function AdminUsers() {
<div> <div>
<label className={labelClass}> <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> </label>
<input <input
type="password" type="password"
@@ -316,16 +326,18 @@ export default function AdminUsers() {
className={inputClass} className={inputClass}
> >
<option value="AGENT">Agent</option> <option value="AGENT">Agent</option>
<option value="USER">User</option>
<option value="ADMIN">Admin</option> <option value="ADMIN">Admin</option>
<option value="SERVICE">Service (API key auth)</option> <option value="SERVICE">Service</option>
</select> </select>
<p className="mt-1.5 text-xs text-gray-500">{ROLE_DESCRIPTIONS[form.role]}</p>
</div> </div>
<div className="flex justify-end gap-3 pt-1"> <div className="flex justify-end gap-3 pt-1">
<button <button
type="button" type="button"
onClick={closeModal} 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 Cancel
</button> </button>

View File

@@ -1,4 +1,4 @@
export type Role = 'ADMIN' | 'AGENT' | 'SERVICE' export type Role = 'ADMIN' | 'AGENT' | 'USER' | 'SERVICE'
export type TicketStatus = 'OPEN' | 'IN_PROGRESS' | 'RESOLVED' | 'CLOSED' export type TicketStatus = 'OPEN' | 'IN_PROGRESS' | 'RESOLVED' | 'CLOSED'
export interface User { export interface User {
@@ -39,8 +39,19 @@ export interface Comment {
createdAt: string createdAt: string
} }
export interface AuditLog {
id: string
ticketId: string
userId: string
action: string
detail: string | null
createdAt: string
user: Pick<User, 'id' | 'username' | 'displayName'>
}
export interface Ticket { export interface Ticket {
id: string id: string
displayId: string
title: string title: string
overview: string overview: string
severity: number severity: number

View File

@@ -10,6 +10,7 @@ datasource db {
enum Role { enum Role {
ADMIN ADMIN
AGENT AGENT
USER
SERVICE SERVICE
} }
@@ -31,9 +32,10 @@ model User {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
assignedTickets Ticket[] @relation("AssignedTickets") assignedTickets Ticket[] @relation("AssignedTickets")
createdTickets Ticket[] @relation("CreatedTickets") createdTickets Ticket[] @relation("CreatedTickets")
comments Comment[] comments Comment[]
auditLogs AuditLog[]
} }
model Category { model Category {
@@ -65,27 +67,29 @@ model Item {
} }
model Ticket { model Ticket {
id String @id @default(cuid()) id String @id @default(cuid())
title String displayId String @unique @default(dbgenerated("concat('V', (floor(random() * 900000000) + 100000000)::bigint::text)"))
overview String title String
severity Int overview String
status TicketStatus @default(OPEN) severity Int
categoryId String status TicketStatus @default(OPEN)
typeId String categoryId String
itemId String typeId String
assigneeId String? itemId String
assigneeId String?
createdById String createdById String
resolvedAt DateTime? resolvedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
category Category @relation(fields: [categoryId], references: [id]) category Category @relation(fields: [categoryId], references: [id])
type Type @relation(fields: [typeId], references: [id]) type Type @relation(fields: [typeId], references: [id])
item Item @relation(fields: [itemId], references: [id]) item Item @relation(fields: [itemId], references: [id])
assignee User? @relation("AssignedTickets", fields: [assigneeId], references: [id]) assignee User? @relation("AssignedTickets", fields: [assigneeId], references: [id])
createdBy User @relation("CreatedTickets", fields: [createdById], references: [id]) createdBy User @relation("CreatedTickets", fields: [createdById], references: [id])
comments Comment[] comments Comment[]
auditLogs AuditLog[]
} }
model Comment { model Comment {
@@ -98,3 +102,15 @@ model Comment {
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
author User @relation(fields: [authorId], references: [id]) author User @relation(fields: [authorId], references: [id])
} }
model AuditLog {
id String @id @default(cuid())
ticketId String
userId String
action String
detail String?
createdAt DateTime @default(now())
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id])
}

View File

@@ -55,3 +55,15 @@ export const requireAdmin = (
} }
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()
}

View File

@@ -3,7 +3,6 @@ import { z } from 'zod'
import prisma from '../lib/prisma' import prisma from '../lib/prisma'
import { AuthRequest } from '../middleware/auth' import { AuthRequest } from '../middleware/auth'
// mergeParams: true so req.params.ticketId is accessible from the parent router
const router = Router({ mergeParams: true }) const router = Router({ mergeParams: true })
const commentSchema = z.object({ const commentSchema = z.object({
@@ -12,22 +11,22 @@ const commentSchema = z.object({
router.post('/', async (req: AuthRequest, res) => { router.post('/', async (req: AuthRequest, res) => {
const { body } = commentSchema.parse(req.body) const { body } = commentSchema.parse(req.body)
const ticketId = (req.params as Record<string, string>).ticketId
const ticket = await prisma.ticket.findUnique({ const ticket = await prisma.ticket.findFirst({
where: { id: (req.params as any).ticketId }, where: { OR: [{ id: ticketId }, { displayId: ticketId }] },
}) })
if (!ticket) return res.status(404).json({ error: 'Ticket not found' }) if (!ticket) return res.status(404).json({ error: 'Ticket not found' })
const comment = await prisma.comment.create({ const [comment] = await prisma.$transaction([
data: { prisma.comment.create({
body, data: { body, ticketId: ticket.id, authorId: req.user!.id },
ticketId: (req.params as any).ticketId, include: { author: { select: { id: true, username: true, displayName: true } } },
authorId: req.user!.id, }),
}, prisma.auditLog.create({
include: { data: { ticketId: ticket.id, userId: req.user!.id, action: 'COMMENT_ADDED', detail: body },
author: { select: { id: true, username: true, displayName: true } }, }),
}, ])
})
res.status(201).json(comment) res.status(201).json(comment)
}) })
@@ -42,7 +41,18 @@ router.delete('/:commentId', async (req: AuthRequest, res) => {
return res.status(403).json({ error: 'Not allowed' }) return res.status(403).json({ error: 'Not allowed' })
} }
await prisma.comment.delete({ where: { id: req.params.commentId } }) 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',
detail: comment.body,
},
}),
])
res.status(204).send() res.status(204).send()
}) })

View File

@@ -1,7 +1,7 @@
import { Router } from 'express' import { Router } from 'express'
import { z } from 'zod' import { z } from 'zod'
import prisma from '../lib/prisma' import prisma from '../lib/prisma'
import { requireAdmin, AuthRequest } from '../middleware/auth' import { requireAdmin, requireAgent, AuthRequest } from '../middleware/auth'
import commentRouter from './comments' import commentRouter from './comments'
const router = Router() const router = Router()
@@ -18,6 +18,29 @@ const ticketInclude = {
}, },
} as const } as const
const STATUS_LABELS: Record<string, string> = {
OPEN: 'Open',
IN_PROGRESS: 'In Progress',
RESOLVED: 'Resolved',
CLOSED: 'Closed',
}
async function generateDisplayId(): Promise<string> {
while (true) {
const num = Math.floor(Math.random() * 900_000_000) + 100_000_000
const displayId = `V${num}`
const exists = await prisma.ticket.findUnique({ where: { displayId } })
if (!exists) return displayId
}
}
// Look up ticket by internal id or displayId
function findByIdOrDisplay(idOrDisplay: string) {
return prisma.ticket.findFirst({
where: { OR: [{ id: idOrDisplay }, { displayId: idOrDisplay }] },
})
}
const createSchema = z.object({ const createSchema = z.object({
title: z.string().min(1).max(255), title: z.string().min(1).max(255),
overview: z.string().min(1), overview: z.string().min(1),
@@ -44,17 +67,20 @@ router.use('/:ticketId/comments', commentRouter)
// GET /api/tickets // GET /api/tickets
router.get('/', async (req: AuthRequest, res) => { router.get('/', async (req: AuthRequest, res) => {
const { status, severity, assigneeId, categoryId, search } = req.query const { status, severity, assigneeId, categoryId, typeId, itemId, search } = req.query
const where: Record<string, any> = {} const where: Record<string, unknown> = {}
if (status) where.status = status if (status) where.status = status
if (severity) where.severity = Number(severity) if (severity) where.severity = Number(severity)
if (assigneeId) where.assigneeId = assigneeId if (assigneeId) where.assigneeId = assigneeId
if (categoryId) where.categoryId = categoryId if (itemId) where.itemId = itemId
else if (typeId) where.typeId = typeId
else if (categoryId) where.categoryId = categoryId
if (search) { if (search) {
where.OR = [ where.OR = [
{ title: { contains: search as string, mode: 'insensitive' } }, { title: { contains: search as string, mode: 'insensitive' } },
{ overview: { contains: search as string, mode: 'insensitive' } }, { overview: { contains: search as string, mode: 'insensitive' } },
{ displayId: { contains: search as string, mode: 'insensitive' } },
] ]
} }
@@ -76,46 +102,152 @@ router.get('/', async (req: AuthRequest, res) => {
// GET /api/tickets/:id // GET /api/tickets/:id
router.get('/:id', async (req, res) => { router.get('/:id', async (req, res) => {
const ticket = await prisma.ticket.findUnique({ const ticket = await prisma.ticket.findFirst({
where: { id: req.params.id }, where: { OR: [{ id: req.params.id }, { displayId: req.params.id }] },
include: ticketInclude, include: ticketInclude,
}) })
if (!ticket) return res.status(404).json({ error: 'Ticket not found' }) if (!ticket) return res.status(404).json({ error: 'Ticket not found' })
res.json(ticket) res.json(ticket)
}) })
// POST /api/tickets // GET /api/tickets/:id/audit
router.post('/', async (req: AuthRequest, res) => { router.get('/:id/audit', async (req, res) => {
const data = createSchema.parse(req.body) const ticket = await findByIdOrDisplay(req.params.id)
if (!ticket) return res.status(404).json({ error: 'Ticket not found' })
const ticket = await prisma.ticket.create({ const logs = await prisma.auditLog.findMany({
data: { ...data, createdById: req.user!.id }, where: { ticketId: ticket.id },
include: ticketInclude, include: { user: { select: { id: true, username: true, displayName: true } } },
orderBy: { createdAt: 'desc' },
})
res.json(logs)
})
// POST /api/tickets
router.post('/', requireAgent, async (req: AuthRequest, res) => {
const data = createSchema.parse(req.body)
const displayId = await generateDisplayId()
const ticket = await prisma.$transaction(async (tx) => {
const created = await tx.ticket.create({
data: { displayId, ...data, createdById: req.user!.id },
})
await tx.auditLog.create({
data: { ticketId: created.id, userId: req.user!.id, action: 'CREATED' },
})
return tx.ticket.findUnique({ where: { id: created.id }, include: ticketInclude })
}) })
res.status(201).json(ticket) res.status(201).json(ticket)
}) })
// PATCH /api/tickets/:id // PATCH /api/tickets/:id
router.patch('/:id', async (req: AuthRequest, res) => { router.patch('/:id', requireAgent, async (req: AuthRequest, res) => {
const data = updateSchema.parse(req.body) const data = updateSchema.parse(req.body)
const existing = await prisma.ticket.findUnique({ where: { id: req.params.id } }) // Only admins can set status to CLOSED
if (data.status === 'CLOSED' && req.user?.role !== 'ADMIN') {
return res.status(403).json({ error: 'Only admins can close tickets' })
}
const existing = await prisma.ticket.findFirst({
where: { OR: [{ id: req.params.id }, { displayId: req.params.id }] },
include: {
category: true,
type: true,
item: true,
assignee: { select: { displayName: true } },
},
})
if (!existing) return res.status(404).json({ error: 'Ticket not found' }) if (!existing) return res.status(404).json({ error: 'Ticket not found' })
const update: Record<string, any> = { ...data } // Build audit entries
const auditEntries: { action: string; detail?: string }[] = []
if (data.status && data.status !== existing.status) {
auditEntries.push({
action: 'STATUS_CHANGED',
detail: `${STATUS_LABELS[existing.status]}${STATUS_LABELS[data.status]}`,
})
}
if ('assigneeId' in data && data.assigneeId !== existing.assigneeId) {
const newAssignee = data.assigneeId
? await prisma.user.findUnique({
where: { id: data.assigneeId },
select: { displayName: true },
})
: null
auditEntries.push({
action: 'ASSIGNEE_CHANGED',
detail: `${existing.assignee?.displayName ?? 'Unassigned'}${newAssignee?.displayName ?? 'Unassigned'}`,
})
}
if (data.severity && data.severity !== existing.severity) {
auditEntries.push({
action: 'SEVERITY_CHANGED',
detail: `SEV ${existing.severity} → SEV ${data.severity}`,
})
}
// CTI rerouting — only log if any CTI field actually changed
const ctiChanged =
(data.categoryId && data.categoryId !== existing.categoryId) ||
(data.typeId && data.typeId !== existing.typeId) ||
(data.itemId && data.itemId !== existing.itemId)
if (ctiChanged) {
const [newCat, newType, newItem] = await Promise.all([
data.categoryId && data.categoryId !== existing.categoryId
? prisma.category.findUnique({ where: { id: data.categoryId } })
: Promise.resolve(existing.category),
data.typeId && data.typeId !== existing.typeId
? prisma.type.findUnique({ where: { id: data.typeId } })
: Promise.resolve(existing.type),
data.itemId && data.itemId !== existing.itemId
? prisma.item.findUnique({ where: { id: data.itemId } })
: Promise.resolve(existing.item),
])
auditEntries.push({
action: 'REROUTED',
detail: `${existing.category.name} ${existing.type.name} ${existing.item.name}${newCat?.name} ${newType?.name} ${newItem?.name}`,
})
}
if (data.title && data.title !== existing.title) {
auditEntries.push({ action: 'TITLE_CHANGED', detail: data.title })
}
if (data.overview && data.overview !== existing.overview) {
auditEntries.push({ action: 'OVERVIEW_CHANGED' })
}
// Handle resolvedAt tracking
const update: Record<string, unknown> = { ...data }
if (data.status === 'RESOLVED' && existing.status !== 'RESOLVED') { if (data.status === 'RESOLVED' && existing.status !== 'RESOLVED') {
update.resolvedAt = new Date() update.resolvedAt = new Date()
} else if (data.status && data.status !== 'RESOLVED' && existing.status === 'RESOLVED') { } else if (data.status && data.status !== 'RESOLVED' && existing.status === 'RESOLVED') {
// Re-opening a resolved ticket resets the 2-week auto-close timer
update.resolvedAt = null update.resolvedAt = null
} }
const ticket = await prisma.ticket.update({ const ticket = await prisma.$transaction(async (tx) => {
where: { id: req.params.id }, const updated = await tx.ticket.update({
data: update, where: { id: existing.id },
include: ticketInclude, data: update,
})
if (auditEntries.length > 0) {
await tx.auditLog.createMany({
data: auditEntries.map((e) => ({
ticketId: existing.id,
userId: req.user!.id,
action: e.action,
detail: e.detail ?? null,
})),
})
}
return tx.ticket.findUnique({ where: { id: updated.id }, include: ticketInclude })
}) })
res.json(ticket) res.json(ticket)
@@ -123,7 +255,9 @@ router.patch('/:id', async (req: AuthRequest, res) => {
// DELETE /api/tickets/:id — admin only // DELETE /api/tickets/:id — admin only
router.delete('/:id', requireAdmin, async (req, res) => { router.delete('/:id', requireAdmin, async (req, res) => {
await prisma.ticket.delete({ where: { id: req.params.id } }) const ticket = await findByIdOrDisplay(req.params.id)
if (!ticket) return res.status(404).json({ error: 'Ticket not found' })
await prisma.ticket.delete({ where: { id: ticket.id } })
res.status(204).send() res.status(204).send()
}) })

View File

@@ -32,7 +32,7 @@ router.post('/', requireAdmin, async (req, res) => {
email: z.string().email(), email: z.string().email(),
displayName: z.string().min(1).max(100), displayName: z.string().min(1).max(100),
password: z.string().min(8).optional(), password: z.string().min(8).optional(),
role: z.enum(['ADMIN', 'AGENT', 'SERVICE']).default('AGENT'), role: z.enum(['ADMIN', 'AGENT', 'USER', 'SERVICE']).default('AGENT'),
}) })
.parse(req.body) .parse(req.body)
@@ -64,7 +64,7 @@ router.patch('/:id', requireAdmin, async (req, res) => {
displayName: z.string().min(1).max(100).optional(), displayName: z.string().min(1).max(100).optional(),
email: z.string().email().optional(), email: z.string().email().optional(),
password: z.string().min(8).optional(), password: z.string().min(8).optional(),
role: z.enum(['ADMIN', 'AGENT', 'SERVICE']).optional(), role: z.enum(['ADMIN', 'AGENT', 'USER', 'SERVICE']).optional(),
regenerateApiKey: z.boolean().optional(), regenerateApiKey: z.boolean().optional(),
}) })
.parse(req.body) .parse(req.body)