Compare commits

..

14 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
22 changed files with 1457 additions and 693 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.

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

@@ -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} /> <Plus size={15} />
New Ticket New Ticket
</Link> </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

@@ -2,7 +2,13 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/* Markdown prose styles */ /* 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 p { @apply mb-3 last:mb-0 leading-relaxed; }
.prose h1 { @apply text-xl font-bold mb-3 mt-5; } .prose h1 { @apply text-xl font-bold mb-3 mt-5; }
.prose h2 { @apply text-lg font-semibold mb-2 mt-4; } .prose h2 { @apply text-lg font-semibold mb-2 mt-4; }
@@ -11,14 +17,14 @@
.prose ol { @apply list-decimal pl-5 mb-3 space-y-1; } .prose ol { @apply list-decimal pl-5 mb-3 space-y-1; }
.prose li > ul, .prose li > ul,
.prose li > ol { @apply mt-1 mb-0; } .prose li > ol { @apply mt-1 mb-0; }
.prose a { @apply text-blue-600 underline hover:text-blue-800; } .prose a { @apply text-blue-400 underline hover:text-blue-300; }
.prose strong { @apply font-semibold; } .prose strong { @apply font-semibold; }
.prose em { @apply italic; } .prose em { @apply italic; }
.prose blockquote { @apply border-l-4 border-gray-200 pl-4 text-gray-500 my-3 italic; } .prose blockquote { @apply border-l-4 border-gray-600 pl-4 text-gray-400 my-3 italic; }
.prose code { @apply bg-gray-100 text-gray-800 px-1.5 py-0.5 rounded text-xs font-mono; } .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-900 text-gray-100 p-4 rounded-lg my-3 overflow-x-auto text-sm; } .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-100 p-0; } .prose pre code { @apply bg-transparent text-gray-300 p-0; }
.prose hr { @apply border-gray-200 my-4; } .prose hr { @apply border-gray-700 my-4; }
.prose table { @apply w-full border-collapse text-sm my-3; } .prose table { @apply w-full border-collapse text-sm my-3; }
.prose th { @apply bg-gray-50 border border-gray-200 px-3 py-2 text-left font-semibold; } .prose 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-200 px-3 py-2; } .prose td { @apply border border-gray-700 px-3 py-2 text-gray-400; }

View File

@@ -1,13 +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 Avatar from '../components/Avatar' import Avatar from '../components/Avatar'
import { Ticket, TicketStatus } from '../types' 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' },
@@ -17,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)
@@ -24,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
@@ -34,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}>
@@ -82,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) => (
@@ -91,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.displayId}`} 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
@@ -117,40 +283,39 @@ 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-400"> <span className="text-xs font-mono font-medium text-gray-600">
{ticket.displayId} {ticket.displayId}
</span> </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>
</div> </div>
<div className="flex items-center gap-3 flex-shrink-0"> <div className="flex items-center gap-3 flex-shrink-0">
{ticket.assignee && ( {ticket.assignee ? (
<div className="flex items-center gap-1.5 text-xs text-gray-500"> <div className="flex items-center gap-1.5 text-xs text-gray-500">
<Avatar name={ticket.assignee.displayName} size="sm" /> <Avatar name={ticket.assignee.displayName} size="sm" />
<span>{ticket.assignee.displayName}</span> <span>{ticket.assignee.displayName}</span>
</div> </div>
) : (
<span className="text-xs text-gray-600">Unassigned</span>
)} )}
{!ticket.assignee && ( <span className="text-xs text-gray-600">
<span className="text-xs text-gray-400">Unassigned</span>
)}
<span className="text-xs text-gray-400">
{ticket._count?.comments ?? 0} comments {ticket._count?.comments ?? 0} comments
</span> </span>
<span className="text-xs text-gray-400"> <span className="text-xs text-gray-600">
{formatDistanceToNow(new Date(ticket.createdAt), { addSuffix: true })} {formatDistanceToNow(new Date(ticket.createdAt), { addSuffix: true })}
</span> </span>
</div> </div>

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.displayId}`) onClose()
navigate(`/${res.data.displayId}`)
} catch { } catch {
setError('Failed to create ticket') setError('Failed to create ticket')
} finally { } finally {
@@ -58,15 +63,14 @@ 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-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>
)} )}
@@ -80,6 +84,7 @@ export default function NewTicket() {
required required
className={inputClass} className={inputClass}
placeholder="Brief description of the issue" placeholder="Brief description of the issue"
autoFocus
/> />
</div> </div>
@@ -91,7 +96,7 @@ export default function NewTicket() {
required required
rows={4} rows={4}
className={inputClass} className={inputClass}
placeholder="Detailed description..." placeholder="Detailed description... Markdown supported"
/> />
</div> </div>
@@ -138,11 +143,11 @@ export default function NewTicket() {
/> />
</div> </div>
<div className="flex justify-end gap-3 pt-2"> <div className="flex justify-end gap-3 pt-1">
<button <button
type="button" type="button"
onClick={() => navigate(-1)} onClick={onClose}
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>
@@ -155,7 +160,6 @@ export default function NewTicket() {
</button> </button>
</div> </div>
</form> </form>
</div> </Modal>
</Layout>
) )
} }

View File

@@ -6,9 +6,11 @@ import remarkGfm from 'remark-gfm'
import { import {
Pencil, Trash2, Send, X, Check, Pencil, Trash2, Send, X, Check,
MessageSquare, ClipboardList, FileText, MessageSquare, ClipboardList, FileText,
ArrowLeft, ChevronDown, ChevronRight,
} from 'lucide-react' } 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'
@@ -18,23 +20,24 @@ import { useAuth } from '../contexts/AuthContext'
type Tab = 'overview' | 'comments' | 'audit' type Tab = 'overview' | 'comments' | 'audit'
const STATUS_OPTIONS: { value: TicketStatus; label: string }[] = [ const SEVERITY_OPTIONS = [
{ value: 'OPEN', label: 'Open' }, { value: 1, label: 'SEV 1 — Critical' },
{ value: 'IN_PROGRESS', label: 'In Progress' }, { value: 2, label: 'SEV 2 — High' },
{ value: 'RESOLVED', label: 'Resolved' }, { value: 3, label: 'SEV 3 — Medium' },
{ value: 'CLOSED', label: 'Closed' }, { value: 4, label: 'SEV 4 — Low' },
{ value: 5, label: 'SEV 5 — Minimal' },
] ]
const AUDIT_LABELS: Record<string, string> = { const AUDIT_LABELS: Record<string, string> = {
CREATED: 'Ticket created', CREATED: 'created this ticket',
STATUS_CHANGED: 'Status changed', STATUS_CHANGED: 'changed status',
ASSIGNEE_CHANGED: 'Assignee changed', ASSIGNEE_CHANGED: 'changed assignee',
SEVERITY_CHANGED: 'Severity changed', SEVERITY_CHANGED: 'changed severity',
REROUTED: 'Rerouted', REROUTED: 'rerouted ticket',
TITLE_CHANGED: 'Title updated', TITLE_CHANGED: 'updated title',
OVERVIEW_CHANGED: 'Overview updated', OVERVIEW_CHANGED: 'updated overview',
COMMENT_ADDED: 'Comment added', COMMENT_ADDED: 'added a comment',
COMMENT_DELETED: 'Comment deleted', COMMENT_DELETED: 'deleted a comment',
} }
const AUDIT_COLORS: Record<string, string> = { const AUDIT_COLORS: Record<string, string> = {
@@ -43,12 +46,15 @@ const AUDIT_COLORS: Record<string, string> = {
ASSIGNEE_CHANGED: 'bg-purple-500', ASSIGNEE_CHANGED: 'bg-purple-500',
SEVERITY_CHANGED: 'bg-orange-500', SEVERITY_CHANGED: 'bg-orange-500',
REROUTED: 'bg-cyan-500', REROUTED: 'bg-cyan-500',
TITLE_CHANGED: 'bg-gray-400', TITLE_CHANGED: 'bg-gray-500',
OVERVIEW_CHANGED: 'bg-gray-400', OVERVIEW_CHANGED: 'bg-gray-500',
COMMENT_ADDED: 'bg-gray-400', COMMENT_ADDED: 'bg-gray-500',
COMMENT_DELETED: 'bg-red-400', 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()
@@ -60,20 +66,36 @@ export default function TicketDetail() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [tab, setTab] = useState<Tab>('overview') 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 [preview, setPreview] = useState(false) 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([
api.get<Ticket>(`/tickets/${id}`), api.get<Ticket>(`/tickets/${id}`),
@@ -84,60 +106,44 @@ export default function TicketDetail() {
}).finally(() => setLoading(false)) }).finally(() => setLoading(false))
}, [id]) }, [id])
const fetchAudit = () => { useEffect(() => {
if (!ticket) return if (tab === 'audit' && ticket) {
api.get<AuditLog[]>(`/tickets/${id}/audit`).then((r) => setAuditLogs(r.data)) api.get<AuditLog[]>(`/tickets/${id}/audit`).then((r) => setAuditLogs(r.data))
} }
}, [tab, ticket, id])
useEffect(() => { const patch = async (payload: Record<string, unknown>) => {
if (tab === 'audit') fetchAudit() if (!ticket) return
}, [tab, ticket]) 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 res = await api.patch<Ticket>(`/tickets/${ticket.displayId}`, {
title: editForm.title,
overview: editForm.overview,
severity: editForm.severity,
categoryId: editForm.categoryId,
typeId: editForm.typeId,
itemId: editForm.itemId,
assigneeId: editForm.assigneeId || null,
})
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.displayId}`, { 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.displayId}`, { 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.displayId}`) await api.delete(`/tickets/${ticket.displayId}`)
navigate('/') navigate('/')
} }
@@ -164,180 +170,97 @@ export default function TicketDetail() {
setTicket((t) => t ? { ...t, comments: t.comments?.filter((c) => c.id !== commentId) } : t) setTicket((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 <Layout><div className="text-center py-16 text-gray-400 text-sm">Loading...</div></Layout> return (
<Layout>
<div className="flex items-center justify-center h-full text-gray-600 text-sm">
Loading...
</div>
</Layout>
)
} }
if (!ticket) { if (!ticket) {
return <Layout><div className="text-center py-16 text-gray-400 text-sm">Ticket not found</div></Layout> return (
<Layout>
<div className="flex items-center justify-center h-full text-gray-600 text-sm">
Ticket not found
</div>
</Layout>
)
} }
const commentCount = ticket.comments?.length ?? 0 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>
<div className="max-w-3xl"> {/* Back link */}
{/* Ticket header */} <button
<div className="bg-white border border-gray-200 rounded-xl mb-4"> onClick={() => navigate(-1)}
<div className="px-5 pt-5 pb-4"> className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-300 mb-4 transition-colors"
{/* ID + actions row */} >
<div className="flex items-start justify-between gap-4 mb-3"> <ArrowLeft size={14} />
<div className="flex items-center gap-2"> Back
<span className="font-mono text-xs font-semibold text-gray-400 bg-gray-100 px-2 py-0.5 rounded"> </button>
<div className="flex gap-6 items-start">
{/* ── Main content ── */}
<div className="flex-1 min-w-0">
{/* Title card */}
<div className="bg-gray-900 border border-gray-800 rounded-xl px-6 py-5 mb-3">
<div className="flex items-center gap-2 mb-3 flex-wrap">
<span className="font-mono text-xs font-semibold text-gray-500 bg-gray-800 px-2 py-0.5 rounded">
{ticket.displayId} {ticket.displayId}
</span> </span>
<SeverityBadge severity={ticket.severity} /> <SeverityBadge severity={ticket.severity} />
<StatusBadge status={ticket.status} /> <StatusBadge status={ticket.status} />
</div> <span className="text-xs text-gray-500 ml-1">
<div className="flex items-center gap-2"> {ticket.category.name} {ticket.type.name} {ticket.item.name}
{!editing ? ( </span>
<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>
) : (
<>
<button
onClick={() => setEditing(false)}
className="flex items-center gap-1.5 text-xs text-gray-500 border border-gray-200 px-2.5 py-1 rounded-lg hover:bg-gray-50 transition-colors"
>
<X size={12} /> Cancel
</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>
</>
)}
{authUser?.role === 'ADMIN' && (
<button
onClick={deleteTicket}
className="flex items-center gap-1.5 text-xs text-red-500 hover:text-red-700 border border-red-200 hover:border-red-400 px-2.5 py-1 rounded-lg transition-colors"
>
<Trash2 size={12} />
Delete
</button>
)}
</div>
</div> </div>
{/* Title */}
{editing ? ( {editing ? (
<input <input
type="text" type="text"
value={editForm.title} value={editForm.title}
onChange={(e) => setEditForm((f) => ({ ...f, title: e.target.value }))} onChange={(e) => setEditForm((f) => ({ ...f, title: e.target.value }))}
className={`${inputClass} text-lg font-semibold mb-3`} 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
/> />
) : ( ) : (
<h1 className="text-lg font-semibold text-gray-900 mb-3">{ticket.title}</h1> <h1 className="text-2xl font-bold text-gray-100">{ticket.title}</h1>
)}
{/* CTI breadcrumb / edit */}
{editing ? (
<div className="mb-3">
<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-xs text-gray-400 mb-3">
{ticket.category.name} {ticket.type.name} {ticket.item.name}
</p>
)}
{/* Status + Assignee quick controls (when not editing) */}
{!editing && (
<div className="grid grid-cols-2 gap-3">
<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>
)}
{/* Severity + Assignee edit controls */}
{editing && (
<div className="grid grid-cols-2 gap-3">
<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> </div>
{/* Meta footer */} {/* Tabs + content */}
<div className="px-5 py-2.5 bg-gray-50 rounded-b-xl border-t border-gray-100 flex items-center gap-5 text-xs text-gray-400"> <div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
<span>
Opened 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 className="ml-auto">
Updated {formatDistanceToNow(new Date(ticket.updatedAt), { addSuffix: true })}
</span>
</div>
</div>
{/* Tab bar */} {/* Tab bar */}
<div className="flex border-b border-gray-200 mb-4 bg-white rounded-t-xl border border-gray-200 border-b-0"> <div className="flex border-b border-gray-800 px-2">
{( {(
[ [
{ key: 'overview', icon: FileText, label: 'Overview' }, { key: 'overview', icon: FileText, label: 'Overview' },
@@ -348,10 +271,10 @@ export default function TicketDetail() {
<button <button
key={key} key={key}
onClick={() => setTab(key)} onClick={() => setTab(key)}
className={`flex items-center gap-2 px-5 py-3 text-sm font-medium border-b-2 transition-colors ${ className={`flex items-center gap-2 px-4 py-3.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
tab === key tab === key
? 'border-blue-600 text-blue-600' ? 'border-blue-500 text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-800' : 'border-transparent text-gray-500 hover:text-gray-300'
}`} }`}
> >
<Icon size={14} /> <Icon size={14} />
@@ -360,18 +283,34 @@ export default function TicketDetail() {
))} ))}
</div> </div>
{/* ── Overview tab ── */} {/* ── Overview ── */}
{tab === 'overview' && ( {tab === 'overview' && (
<div className="bg-white border border-gray-200 rounded-xl p-5"> <div className="p-6">
{editing ? ( {editing ? (
<div className="space-y-3">
<textarea <textarea
value={editForm.overview} value={editForm.overview}
onChange={(e) => setEditForm((f) => ({ ...f, overview: e.target.value }))} onChange={(e) => setEditForm((f) => ({ ...f, overview: e.target.value }))}
rows={8} rows={12}
className={inputClass} 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-700"> <div className="prose text-sm text-gray-300">
<ReactMarkdown remarkPlugins={[remarkGfm]}> <ReactMarkdown remarkPlugins={[remarkGfm]}>
{ticket.overview} {ticket.overview}
</ReactMarkdown> </ReactMarkdown>
@@ -380,86 +319,84 @@ export default function TicketDetail() {
</div> </div>
)} )}
{/* ── Comments tab ── */} {/* ── Comments ── */}
{tab === 'comments' && ( {tab === 'comments' && (
<div className="space-y-4"> <div className="p-6 space-y-4">
{ticket.comments && ticket.comments.length > 0 ? ( {ticket.comments && ticket.comments.length > 0 ? (
ticket.comments.map((comment) => ( ticket.comments.map((comment, i) => (
<div <div key={comment.id} className="flex gap-3 group">
key={comment.id} {/* Avatar + spine */}
className="bg-white border border-gray-200 rounded-xl p-5 group" <div className="flex flex-col items-center">
>
<div className="flex items-start gap-3">
<Avatar name={comment.author.displayName} size="md" /> <Avatar name={comment.author.displayName} size="md" />
<div className="flex-1 min-w-0"> {i < (ticket.comments!.length - 1) && (
<div className="flex items-center justify-between mb-2"> <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"> <div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-900"> <span className="text-sm font-semibold text-gray-200">
{comment.author.displayName} {comment.author.displayName}
</span> </span>
<span className="text-xs text-gray-400"> <button
{formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })} onClick={() => toggleCommentDate(comment.id)}
</span> className="text-xs text-gray-500 hover:text-gray-300 transition-colors"
<span className="text-xs text-gray-300" title={format(new Date(comment.createdAt), 'MMM d, yyyy HH:mm')}> >
· {format(new Date(comment.createdAt), 'MMM d, yyyy')} {expandedCommentDates.has(comment.id)
</span> ? format(new Date(comment.createdAt), 'MMM d, yyyy HH:mm')
: formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })}
</button>
</div> </div>
{(comment.authorId === authUser?.id || authUser?.role === 'ADMIN') && ( {(comment.authorId === authUser?.id || isAdmin) && (
<button <button
onClick={() => deleteComment(comment.id)} onClick={() => deleteComment(comment.id)}
className="opacity-0 group-hover:opacity-100 text-gray-300 hover:text-red-500 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>
)} )}
</div> </div>
<div className="prose text-sm text-gray-700"> <div className="px-4 py-3 prose prose-sm prose-invert text-gray-300 text-sm max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}> <ReactMarkdown remarkPlugins={[remarkGfm]}>
{comment.body} {comment.body}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
</div> </div>
</div> </div>
</div>
)) ))
) : ( ) : (
<div className="bg-white border border-gray-200 rounded-xl px-5 py-10 text-center text-sm text-gray-400"> <div className="py-12 text-center text-sm text-gray-600">
No comments yet No comments yet
</div> </div>
)} )}
{/* Comment composer */} {/* Composer */}
<div className="bg-white border border-gray-200 rounded-xl"> <div className="flex gap-3">
<div className="flex items-center gap-3 px-4 pt-4">
<Avatar name={authUser?.displayName ?? '?'} size="md" /> <Avatar name={authUser?.displayName ?? '?'} size="md" />
<div className="flex gap-3 border-b border-gray-100 pb-0 flex-1"> <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 <button
onClick={() => setPreview(false)} key={label}
className={`text-xs pb-2 border-b-2 transition-colors ${ onClick={() => setPreview(label === 'Preview')}
!preview ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-400 hover:text-gray-600' 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'
}`} }`}
> >
Write {label}
</button>
<button
onClick={() => setPreview(true)}
className={`text-xs pb-2 border-b-2 transition-colors ${
preview ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-400 hover:text-gray-600'
}`}
>
Preview
</button> </button>
))}
</div> </div>
</div> <form onSubmit={submitComment} className="p-3">
<form onSubmit={submitComment} className="p-4">
{preview ? ( {preview ? (
<div className="prose text-sm text-gray-700 min-h-[80px] px-1"> <div className="prose prose-sm prose-invert text-gray-300 min-h-[80px] mb-3 px-1 max-w-none">
{commentBody.trim() ? ( {commentBody.trim()
<ReactMarkdown remarkPlugins={[remarkGfm]}>{commentBody}</ReactMarkdown> ? <ReactMarkdown remarkPlugins={[remarkGfm]}>{commentBody}</ReactMarkdown>
) : ( : <span className="text-gray-600 italic">Nothing to preview</span>
<span className="text-gray-400 italic">Nothing to preview</span> }
)}
</div> </div>
) : ( ) : (
<textarea <textarea
@@ -467,7 +404,7 @@ export default function TicketDetail() {
onChange={(e) => setCommentBody(e.target.value)} onChange={(e) => setCommentBody(e.target.value)}
placeholder="Leave a comment… Markdown supported" placeholder="Leave a comment… Markdown supported"
rows={4} rows={4}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none" className="w-full bg-transparent text-gray-100 placeholder-gray-600 text-sm focus:outline-none resize-none mb-3"
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault() e.preventDefault()
@@ -476,63 +413,317 @@ export default function TicketDetail() {
}} }}
/> />
)} )}
<div className="flex justify-between items-center mt-3"> <div className="flex justify-between items-center border-t border-gray-700 pt-2">
<span className="text-xs text-gray-400">Markdown supported · Ctrl+Enter to submit</span> <span className="text-xs text-gray-600">Markdown · Ctrl+Enter</span>
<button <button
type="submit" type="submit"
disabled={submittingComment || !commentBody.trim()} disabled={submittingComment || !commentBody.trim()}
className="flex items-center gap-2 px-4 py-1.5 bg-blue-600 text-white text-xs rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors" 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={12} /> <Send size={13} />
Comment Comment
</button> </button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</div>
)} )}
{/* ── Audit Log tab ── */} {/* ── Audit Log ── */}
{tab === 'audit' && ( {tab === 'audit' && (
<div className="bg-white border border-gray-200 rounded-xl divide-y divide-gray-100"> <div className="p-6">
{auditLogs.length === 0 ? ( {auditLogs.length === 0 ? (
<div className="px-5 py-10 text-center text-sm text-gray-400">No audit entries</div> <div className="py-10 text-center text-sm text-gray-600">No activity yet</div>
) : ( ) : (
auditLogs.map((log, i) => ( <div>
<div key={log.id} className="flex items-start gap-4 px-5 py-4"> {auditLogs.map((log, i) => {
{/* Timeline dot */} const hasDetail = !!log.detail
<div className="flex flex-col items-center flex-shrink-0 pt-0.5"> const isExpanded = expandedLogs.has(log.id)
<div const isComment = COMMENT_ACTIONS.has(log.action)
className={`w-2.5 h-2.5 rounded-full ${AUDIT_COLORS[log.action] ?? 'bg-gray-400'}`}
/> 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 && ( {i < auditLogs.length - 1 && (
<div className="w-px flex-1 bg-gray-100 mt-1" style={{ minHeight: '16px' }} /> <div className="w-px flex-1 bg-gray-800 my-1" />
)} )}
</div> </div>
<div className="flex-1 min-w-0 pb-1"> {/* Entry */}
<div className="flex items-baseline justify-between gap-3"> <div className="flex-1 pb-4">
<span className="text-sm text-gray-800"> <div
<strong className="font-medium">{log.user.displayName}</strong> className={`flex items-baseline justify-between gap-4 ${hasDetail ? 'cursor-pointer select-none' : ''}`}
{' '}{AUDIT_LABELS[log.action]?.toLowerCase() ?? log.action.toLowerCase()} onClick={() => hasDetail && toggleLog(log.id)}
{log.detail && ( >
<span className="text-gray-500"> {log.detail}</span> <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> </span>
)}
</p>
<span <span
className="text-xs text-gray-400 flex-shrink-0" className="text-xs text-gray-600 flex-shrink-0"
title={format(new Date(log.createdAt), 'MMM d, yyyy HH:mm:ss')} title={format(new Date(log.createdAt), 'MMM d, yyyy HH:mm:ss')}
> >
{formatDistanceToNow(new Date(log.createdAt), { addSuffix: true })} {formatDistanceToNow(new Date(log.createdAt), { addSuffix: true })}
</span> </span>
</div> </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> </div>
</div> ) : (
)) <p className="text-sm text-gray-400">{log.detail}</p>
)} )}
</div> </div>
)} )}
</div> </div>
</div>
)
})}
</div>
)}
</div>
)}
</div>
</div>
{/* ── Sidebar ── */}
<div className="w-64 flex-shrink-0 sticky top-0 space-y-3">
{/* Ticket Summary */}
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
<div className="px-4 py-3">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Ticket Summary</p>
</div>
{/* Status */}
<button
onClick={() => setEditingStatus(true)}
className="w-full px-4 py-3 text-left hover:bg-gray-800/50 transition-colors"
>
<p className="text-xs font-medium text-gray-500 mb-1.5">Status</p>
<StatusBadge status={ticket.status} />
</button>
{/* Severity */}
<button
onClick={() => setEditingSeverity(true)}
className="w-full px-4 py-3 text-left hover:bg-gray-800/50 transition-colors"
>
<p className="text-xs font-medium text-gray-500 mb-1.5">Severity</p>
<SeverityBadge severity={ticket.severity} />
</button>
{/* 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>
{/* Assignee */}
<button
onClick={() => setEditingAssignee(true)}
className="w-full px-4 py-3 text-left hover:bg-gray-800/50 transition-colors"
>
<p className="text-xs font-medium text-gray-500 mb-1.5">Assignee</p>
{ticket.assignee ? (
<div className="flex items-center gap-1.5">
<Avatar name={ticket.assignee.displayName} size="sm" />
<span className="text-sm text-gray-300">{ticket.assignee.displayName}</span>
</div>
) : (
<p className="text-sm text-gray-500">Unassigned</p>
)}
</button>
{/* Requester */}
<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
onClick={deleteTicket}
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"
>
<Trash2 size={13} />
Delete ticket
</button>
</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 {

View File

@@ -10,6 +10,7 @@ datasource db {
enum Role { enum Role {
ADMIN ADMIN
AGENT AGENT
USER
SERVICE SERVICE
} }
@@ -67,7 +68,7 @@ model Item {
model Ticket { model Ticket {
id String @id @default(cuid()) id String @id @default(cuid())
displayId String @unique displayId String @unique @default(dbgenerated("concat('V', (floor(random() * 900000000) + 100000000)::bigint::text)"))
title String title String
overview String overview String
severity Int severity Int

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

@@ -24,7 +24,7 @@ router.post('/', async (req: AuthRequest, res) => {
include: { author: { select: { id: true, username: true, displayName: true } } }, include: { author: { select: { id: true, username: true, displayName: true } } },
}), }),
prisma.auditLog.create({ prisma.auditLog.create({
data: { ticketId: ticket.id, userId: req.user!.id, action: 'COMMENT_ADDED' }, data: { ticketId: ticket.id, userId: req.user!.id, action: 'COMMENT_ADDED', detail: body },
}), }),
]) ])
@@ -44,7 +44,12 @@ router.delete('/:commentId', async (req: AuthRequest, res) => {
await prisma.$transaction([ await prisma.$transaction([
prisma.comment.delete({ where: { id: req.params.commentId } }), prisma.comment.delete({ where: { id: req.params.commentId } }),
prisma.auditLog.create({ prisma.auditLog.create({
data: { ticketId: comment.ticketId, userId: req.user!.id, action: 'COMMENT_DELETED' }, data: {
ticketId: comment.ticketId,
userId: req.user!.id,
action: 'COMMENT_DELETED',
detail: comment.body,
},
}), }),
]) ])

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()
@@ -67,13 +67,15 @@ 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, unknown> = {} 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' } },
@@ -123,7 +125,7 @@ router.get('/:id/audit', async (req, res) => {
}) })
// POST /api/tickets // POST /api/tickets
router.post('/', async (req: AuthRequest, res) => { router.post('/', requireAgent, async (req: AuthRequest, res) => {
const data = createSchema.parse(req.body) const data = createSchema.parse(req.body)
const displayId = await generateDisplayId() const displayId = await generateDisplayId()
@@ -141,9 +143,14 @@ router.post('/', async (req: AuthRequest, res) => {
}) })
// 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)
// 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({ const existing = await prisma.ticket.findFirst({
where: { OR: [{ id: req.params.id }, { displayId: req.params.id }] }, where: { OR: [{ id: req.params.id }, { displayId: req.params.id }] },
include: { include: {

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)