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>
This commit is contained in:
2026-03-30 23:17:14 -04:00
parent d8dc5b3ded
commit 725f91578d
21 changed files with 821 additions and 388 deletions

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,7 +17,7 @@ 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="/tickets/:id" element={<TicketDetail />} />
<Route element={<AdminRoute />}> <Route element={<AdminRoute />}>
<Route path="/admin/users" element={<AdminUsers />} /> <Route path="/admin/users" element={<AdminUsers />} />

View File

@@ -51,12 +51,12 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
} }
const selectClass = const selectClass =
'block w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-50 disabled:text-gray-400' 'block w-full bg-gray-800 border border-gray-700 text-gray-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed'
return ( return (
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<div> <div>
<label className="block text-xs font-medium text-gray-500 mb-1">Category</label> <label className="block text-xs font-medium text-gray-400 mb-1">Category</label>
<select <select
value={value.categoryId} value={value.categoryId}
onChange={(e) => handleCategory(e.target.value)} onChange={(e) => handleCategory(e.target.value)}
@@ -73,7 +73,7 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-gray-500 mb-1">Type</label> <label className="block text-xs font-medium text-gray-400 mb-1">Type</label>
<select <select
value={value.typeId} value={value.typeId}
onChange={(e) => handleType(e.target.value)} onChange={(e) => handleType(e.target.value)}
@@ -90,7 +90,7 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-gray-500 mb-1">Item</label> <label className="block text-xs font-medium text-gray-400 mb-1">Item</label>
<select <select
value={value.itemId} value={value.itemId}
onChange={(e) => handleItem(e.target.value)} onChange={(e) => handleItem(e.target.value)}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 } 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 } from '../types'
const STATUSES: { value: TicketStatus | ''; label: string }[] = [ const STATUSES: { value: TicketStatus | ''; label: string }[] = [
{ value: '', label: 'All Statuses' }, { value: '', label: 'All Statuses' },
@@ -17,24 +17,34 @@ 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'
export default function Dashboard() { export default function Dashboard() {
const [tickets, setTickets] = useState<Ticket[]>([]) const [tickets, setTickets] = useState<Ticket[]>([])
const [categories, setCategories] = useState<Category[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [status, setStatus] = useState<TicketStatus | ''>('') const [status, setStatus] = useState<TicketStatus | ''>('')
const [severity, setSeverity] = useState('') const [severity, setSeverity] = useState('')
const [categoryId, setCategoryId] = useState('')
useEffect(() => {
api.get<Category[]>('/cti/categories').then((r) => setCategories(r.data))
}, [])
const fetchTickets = useCallback(() => { const fetchTickets = useCallback(() => {
setLoading(true) setLoading(true)
const params: Record<string, string> = {} const params: Record<string, string> = {}
if (status) params.status = status if (status) params.status = status
if (severity) params.severity = severity if (severity) params.severity = severity
if (categoryId) params.categoryId = categoryId
if (search) params.search = search if (search) params.search = search
api api
.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]) }, [status, severity, categoryId, search])
useEffect(() => { useEffect(() => {
const t = setTimeout(fetchTickets, 300) const t = setTimeout(fetchTickets, 300)
@@ -42,35 +52,24 @@ export default function Dashboard() {
}, [fetchTickets]) }, [fetchTickets])
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">
<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 +81,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,20 +90,33 @@ export default function Dashboard() {
</option> </option>
))} ))}
</select> </select>
<select
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
className={selectClass}
>
<option value="">All Queues</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</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={`/tickets/${ticket.displayId}`}
className="flex items-center gap-4 bg-white border border-gray-200 rounded-lg px-4 py-3 hover:border-blue-400 hover:shadow-sm transition-all group" 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 */} {/* Severity stripe */}
<div <div
@@ -117,40 +129,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,85 @@
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
api
.get<Ticket[]>('/tickets', { params: { assigneeId: user.id } })
.then((r) => setTickets(r.data))
.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 tickets assigned to you
</div>
) : (
<div className="space-y-1.5">
{tickets.map((ticket) => (
<Link
key={ticket.id}
to={`/tickets/${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"
>
{/* Severity stripe */}
<div
className={`w-1 self-stretch rounded-full flex-shrink-0 ${
ticket.severity === 1
? 'bg-red-500'
: ticket.severity === 2
? 'bg-orange-400'
: ticket.severity === 3
? 'bg-yellow-400'
: ticket.severity === 4
? 'bg-blue-400'
: 'bg-gray-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,6 +53,7 @@ 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)
onClose()
navigate(`/tickets/${res.data.displayId}`) navigate(`/tickets/${res.data.displayId}`)
} catch { } catch {
setError('Failed to create ticket') setError('Failed to create ticket')
@@ -58,104 +63,103 @@ export default function NewTicket() {
} }
const inputClass = const inputClass =
'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500' 'w-full bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent'
const labelClass = 'block text-sm font-medium text-gray-700 mb-1' const labelClass = 'block text-sm font-medium text-gray-300 mb-1'
return ( return (
<Layout title="New Ticket"> <Modal title="New Ticket" onClose={onClose} size="lg">
<div className="max-w-2xl"> <form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} className="bg-white border border-gray-200 rounded-xl p-6 space-y-5"> {error && (
{error && ( <div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm px-4 py-3 rounded-lg">
<div className="bg-red-50 border border-red-200 text-red-700 text-sm px-4 py-3 rounded-lg"> {error}
{error} </div>
</div> )}
)}
<div>
<label className={labelClass}>Title</label>
<input
type="text"
value={form.title}
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
required
className={inputClass}
placeholder="Brief description of the issue"
autoFocus
/>
</div>
<div>
<label className={labelClass}>Overview</label>
<textarea
value={form.overview}
onChange={(e) => setForm((f) => ({ ...f, overview: e.target.value }))}
required
rows={4}
className={inputClass}
placeholder="Detailed description... Markdown supported"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div> <div>
<label className={labelClass}>Title</label> <label className={labelClass}>Severity</label>
<input <select
type="text" value={form.severity}
value={form.title} onChange={(e) => setForm((f) => ({ ...f, severity: Number(e.target.value) }))}
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
required
className={inputClass} className={inputClass}
placeholder="Brief description of the issue" >
/> <option value={1}>SEV 1 Critical</option>
<option value={2}>SEV 2 High</option>
<option value={3}>SEV 3 Medium</option>
<option value={4}>SEV 4 Low</option>
<option value={5}>SEV 5 Minimal</option>
</select>
</div> </div>
<div> <div>
<label className={labelClass}>Overview</label> <label className={labelClass}>Assignee</label>
<textarea <select
value={form.overview} value={form.assigneeId}
onChange={(e) => setForm((f) => ({ ...f, overview: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, assigneeId: e.target.value }))}
required
rows={4}
className={inputClass} className={inputClass}
placeholder="Detailed description..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass}>Severity</label>
<select
value={form.severity}
onChange={(e) => setForm((f) => ({ ...f, severity: Number(e.target.value) }))}
className={inputClass}
>
<option value={1}>SEV 1 Critical</option>
<option value={2}>SEV 2 High</option>
<option value={3}>SEV 3 Medium</option>
<option value={4}>SEV 4 Low</option>
<option value={5}>SEV 5 Minimal</option>
</select>
</div>
<div>
<label className={labelClass}>Assignee</label>
<select
value={form.assigneeId}
onChange={(e) => setForm((f) => ({ ...f, assigneeId: e.target.value }))}
className={inputClass}
>
<option value="">Unassigned</option>
{users
.filter((u) => u.role !== 'SERVICE')
.map((u) => (
<option key={u.id} value={u.id}>
{u.displayName}
</option>
))}
</select>
</div>
</div>
<div>
<label className={labelClass}>Routing (CTI)</label>
<CTISelect
value={{ categoryId: form.categoryId, typeId: form.typeId, itemId: form.itemId }}
onChange={handleCTI}
/>
</div>
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={() => navigate(-1)}
className="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
> >
Cancel <option value="">Unassigned</option>
</button> {users
<button .filter((u) => u.role !== 'SERVICE')
type="submit" .map((u) => (
disabled={submitting} <option key={u.id} value={u.id}>
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors" {u.displayName}
> </option>
{submitting ? 'Creating...' : 'Create Ticket'} ))}
</button> </select>
</div> </div>
</form> </div>
</div>
</Layout> <div>
<label className={labelClass}>Routing (CTI)</label>
<CTISelect
value={{ categoryId: form.categoryId, typeId: form.typeId, itemId: form.itemId }}
onChange={handleCTI}
/>
</div>
<div className="flex justify-end gap-3 pt-1">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-gray-400 border border-gray-700 rounded-lg hover:bg-gray-800 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{submitting ? 'Creating...' : 'Create Ticket'}
</button>
</div>
</form>
</Modal>
) )
} }

View File

@@ -6,7 +6,7 @@ import remarkGfm from 'remark-gfm'
import { import {
Pencil, Trash2, Send, X, Check, Pencil, Trash2, Send, X, Check,
MessageSquare, ClipboardList, FileText, MessageSquare, ClipboardList, FileText,
ArrowLeft, 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'
@@ -19,13 +19,6 @@ import { useAuth } from '../contexts/AuthContext'
type Tab = 'overview' | 'comments' | 'audit' type Tab = 'overview' | 'comments' | 'audit'
const STATUS_OPTIONS: { value: TicketStatus; label: string }[] = [
{ value: 'OPEN', label: 'Open' },
{ value: 'IN_PROGRESS', label: 'In Progress' },
{ value: 'RESOLVED', label: 'Resolved' },
{ value: 'CLOSED', label: 'Closed' },
]
const SEVERITY_OPTIONS = [ const SEVERITY_OPTIONS = [
{ value: 1, label: 'SEV 1 — Critical' }, { value: 1, label: 'SEV 1 — Critical' },
{ value: 2, label: 'SEV 2 — High' }, { value: 2, label: 'SEV 2 — High' },
@@ -52,19 +45,21 @@ 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'])
const selectClass = const selectClass =
'w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white' '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'
function SidebarField({ label, children }: { label: string; children: React.ReactNode }) { function SidebarField({ label, children }: { label: string; children: React.ReactNode }) {
return ( return (
<div> <div>
<p className="text-xs font-medium text-gray-400 mb-1.5">{label}</p> <p className="text-xs font-medium text-gray-500 mb-1.5">{label}</p>
{children} {children}
</div> </div>
) )
@@ -85,10 +80,13 @@ export default function TicketDetail() {
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({ title: '', overview: '' }) const [editForm, setEditForm] = useState({ title: '', overview: '' })
const [pendingCTI, setPendingCTI] = useState({ categoryId: '', typeId: '', itemId: '' }) const [pendingCTI, setPendingCTI] = useState({ categoryId: '', typeId: '', itemId: '' })
const isAdmin = authUser?.role === 'ADMIN'
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([
api.get<Ticket>(`/tickets/${id}`), api.get<Ticket>(`/tickets/${id}`),
@@ -163,10 +161,19 @@ 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 toggleLog = (logId: string) => {
setExpandedLogs((prev) => {
const next = new Set(prev)
if (next.has(logId)) next.delete(logId)
else next.add(logId)
return next
})
}
if (loading) { if (loading) {
return ( return (
<Layout> <Layout>
<div className="flex items-center justify-center h-full text-gray-400 text-sm"> <div className="flex items-center justify-center h-full text-gray-600 text-sm">
Loading... Loading...
</div> </div>
</Layout> </Layout>
@@ -176,7 +183,7 @@ export default function TicketDetail() {
if (!ticket) { if (!ticket) {
return ( return (
<Layout> <Layout>
<div className="flex items-center justify-center h-full text-gray-400 text-sm"> <div className="flex items-center justify-center h-full text-gray-600 text-sm">
Ticket not found Ticket not found
</div> </div>
</Layout> </Layout>
@@ -186,29 +193,37 @@ export default function TicketDetail() {
const commentCount = ticket.comments?.length ?? 0 const commentCount = ticket.comments?.length ?? 0
const agentUsers = users.filter((u) => u.role !== 'SERVICE') 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>
{/* Back link */} {/* Back link */}
<button <button
onClick={() => navigate('/')} onClick={() => navigate(-1)}
className="flex items-center gap-1.5 text-sm text-gray-400 hover:text-gray-700 mb-4 transition-colors" className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-300 mb-4 transition-colors"
> >
<ArrowLeft size={14} /> <ArrowLeft size={14} />
All tickets Back
</button> </button>
<div className="flex gap-6 items-start"> <div className="flex gap-6 items-start">
{/* ── Main content ── */} {/* ── Main content ── */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{/* Title card */} {/* Title card */}
<div className="bg-white border border-gray-200 rounded-xl px-6 py-5 mb-4"> <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"> <div className="flex items-center gap-2 mb-3 flex-wrap">
<span className="font-mono text-xs font-semibold text-gray-400 bg-gray-100 px-2 py-0.5 rounded"> <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} />
<span className="text-xs text-gray-400 ml-1"> <span className="text-xs text-gray-500 ml-1">
{ticket.category.name} {ticket.type.name} {ticket.item.name} {ticket.category.name} {ticket.type.name} {ticket.item.name}
</span> </span>
</div> </div>
@@ -218,18 +233,18 @@ export default function TicketDetail() {
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="w-full text-2xl font-bold text-gray-900 border-0 border-b-2 border-blue-500 focus:outline-none pb-1 bg-transparent" 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 autoFocus
/> />
) : ( ) : (
<h1 className="text-2xl font-bold text-gray-900">{ticket.title}</h1> <h1 className="text-2xl font-bold text-gray-100">{ticket.title}</h1>
)} )}
</div> </div>
{/* Tabs + content */} {/* Tabs + content */}
<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">
{/* Tab bar */} {/* Tab bar */}
<div className="flex border-b border-gray-200 px-2"> <div className="flex border-b border-gray-800 px-2">
{( {(
[ [
{ key: 'overview', icon: FileText, label: 'Overview' }, { key: 'overview', icon: FileText, label: 'Overview' },
@@ -242,8 +257,8 @@ export default function TicketDetail() {
onClick={() => setTab(key)} onClick={() => setTab(key)}
className={`flex items-center gap-2 px-4 py-3.5 text-sm font-medium border-b-2 -mb-px transition-colors ${ 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} />
@@ -261,12 +276,12 @@ export default function TicketDetail() {
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={12} rows={12}
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-y font-mono" 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"> <div className="flex justify-end gap-2">
<button <button
onClick={() => setEditing(false)} onClick={() => setEditing(false)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-600 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors" 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 <X size={13} /> Cancel
</button> </button>
@@ -279,7 +294,7 @@ export default function TicketDetail() {
</div> </div>
</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>
@@ -292,7 +307,7 @@ export default function TicketDetail() {
{tab === 'comments' && ( {tab === 'comments' && (
<div> <div>
{ticket.comments && ticket.comments.length > 0 ? ( {ticket.comments && ticket.comments.length > 0 ? (
<div className="divide-y divide-gray-100"> <div className="divide-y divide-gray-800">
{ticket.comments.map((comment) => ( {ticket.comments.map((comment) => (
<div key={comment.id} className="p-6 group"> <div key={comment.id} className="p-6 group">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
@@ -300,23 +315,23 @@ export default function TicketDetail() {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<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"> <span className="text-xs text-gray-500">
{format(new Date(comment.createdAt), 'MMM d, yyyy · HH:mm')} {format(new Date(comment.createdAt), 'MMM d, yyyy · HH:mm')}
</span> </span>
</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="prose text-sm text-gray-300">
<ReactMarkdown remarkPlugins={[remarkGfm]}> <ReactMarkdown remarkPlugins={[remarkGfm]}>
{comment.body} {comment.body}
</ReactMarkdown> </ReactMarkdown>
@@ -327,26 +342,25 @@ export default function TicketDetail() {
))} ))}
</div> </div>
) : ( ) : (
<div className="py-16 text-center text-sm text-gray-400"> <div className="py-16 text-center text-sm text-gray-600">
No comments yet be the first No comments yet
</div> </div>
)} )}
{/* Comment composer */} {/* Comment composer */}
<div className="border-t border-gray-200 p-6"> <div className="border-t border-gray-800 p-6">
<div className="flex gap-3"> <div className="flex gap-3">
<Avatar name={authUser?.displayName ?? '?'} size="md" /> <Avatar name={authUser?.displayName ?? '?'} size="md" />
<div className="flex-1"> <div className="flex-1">
{/* Write / Preview toggle */} <div className="flex gap-4 mb-2 border-b border-gray-800">
<div className="flex gap-4 mb-2 border-b border-gray-100">
{(['Write', 'Preview'] as const).map((label) => ( {(['Write', 'Preview'] as const).map((label) => (
<button <button
key={label} key={label}
onClick={() => setPreview(label === 'Preview')} onClick={() => setPreview(label === 'Preview')}
className={`text-xs pb-2 border-b-2 -mb-px transition-colors ${ className={`text-xs pb-2 border-b-2 -mb-px transition-colors ${
(label === 'Preview') === preview (label === 'Preview') === preview
? 'border-blue-600 text-blue-600' ? 'border-blue-500 text-blue-400'
: 'border-transparent text-gray-400 hover:text-gray-600' : 'border-transparent text-gray-500 hover:text-gray-300'
}`} }`}
> >
{label} {label}
@@ -356,10 +370,10 @@ export default function TicketDetail() {
<form onSubmit={submitComment}> <form onSubmit={submitComment}>
{preview ? ( {preview ? (
<div className="prose text-sm text-gray-700 min-h-[80px] mb-3 px-1"> <div className="prose text-sm text-gray-300 min-h-[80px] mb-3 px-1">
{commentBody.trim() {commentBody.trim()
? <ReactMarkdown remarkPlugins={[remarkGfm]}>{commentBody}</ReactMarkdown> ? <ReactMarkdown remarkPlugins={[remarkGfm]}>{commentBody}</ReactMarkdown>
: <span className="text-gray-400 italic">Nothing to preview</span> : <span className="text-gray-600 italic">Nothing to preview</span>
} }
</div> </div>
) : ( ) : (
@@ -368,7 +382,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.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none mb-3" className="w-full bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-600 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 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()
@@ -378,7 +392,7 @@ export default function TicketDetail() {
/> />
)} )}
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-xs text-gray-400"> <span className="text-xs text-gray-600">
Markdown supported · Ctrl+Enter to submit Markdown supported · Ctrl+Enter to submit
</span> </span>
<button <button
@@ -401,38 +415,64 @@ export default function TicketDetail() {
{tab === 'audit' && ( {tab === 'audit' && (
<div className="p-6"> <div className="p-6">
{auditLogs.length === 0 ? ( {auditLogs.length === 0 ? (
<div className="py-10 text-center text-sm text-gray-400">No activity yet</div> <div className="py-10 text-center text-sm text-gray-600">No activity yet</div>
) : ( ) : (
<div className="space-y-0"> <div>
{auditLogs.map((log, i) => ( {auditLogs.map((log, i) => {
<div key={log.id} className="flex gap-4"> const hasDetail = !!log.detail
{/* Timeline */} const isExpanded = expandedLogs.has(log.id)
<div className="flex flex-col items-center w-5 flex-shrink-0"> const isComment = COMMENT_ACTIONS.has(log.action)
<div className={`w-2.5 h-2.5 rounded-full mt-1 flex-shrink-0 ${AUDIT_COLORS[log.action] ?? 'bg-gray-400'}`} />
{i < auditLogs.length - 1 && ( return (
<div className="w-px flex-1 bg-gray-100 my-1" /> <div key={log.id} className="flex gap-4">
)} {/* Timeline */}
</div> <div className="flex flex-col items-center w-5 flex-shrink-0">
{/* Entry */} <div className={`w-2.5 h-2.5 rounded-full mt-1 flex-shrink-0 ${AUDIT_COLORS[log.action] ?? 'bg-gray-500'}`} />
<div className="flex-1 pb-5"> {i < auditLogs.length - 1 && (
<div className="flex items-baseline justify-between gap-4"> <div className="w-px flex-1 bg-gray-800 my-1" />
<p className="text-sm text-gray-700"> )}
<span className="font-medium">{log.user.displayName}</span> </div>
{' '}{AUDIT_LABELS[log.action] ?? log.action.toLowerCase()}
{log.detail && ( {/* Entry */}
<span className="text-gray-500"> {log.detail}</span> <div className="flex-1 pb-4">
)} <div
</p> className={`flex items-baseline justify-between gap-4 ${hasDetail ? 'cursor-pointer select-none' : ''}`}
<span onClick={() => hasDetail && toggleLog(log.id)}
className="text-xs text-gray-400 flex-shrink-0"
title={format(new Date(log.createdAt), 'MMM d, yyyy HH:mm:ss')}
> >
{formatDistanceToNow(new Date(log.createdAt), { addSuffix: true })} <p className="text-sm text-gray-300">
</span> <span className="font-medium text-gray-100">{log.user.displayName}</span>
{' '}{AUDIT_LABELS[log.action] ?? log.action.toLowerCase()}
{hasDetail && (
<span className="ml-1 inline-flex items-center text-gray-600">
{isExpanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
</span>
)}
</p>
<span
className="text-xs text-gray-600 flex-shrink-0"
title={format(new Date(log.createdAt), 'MMM d, yyyy HH:mm:ss')}
>
{formatDistanceToNow(new Date(log.createdAt), { addSuffix: true })}
</span>
</div>
{hasDetail && isExpanded && (
<div className="mt-2 ml-0 bg-gray-800 border border-gray-700 rounded-lg px-4 py-3">
{isComment ? (
<div className="prose text-sm text-gray-300">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{log.detail!}
</ReactMarkdown>
</div>
) : (
<p className="text-sm text-gray-400">{log.detail}</p>
)}
</div>
)}
</div> </div>
</div> </div>
</div> )
))} })}
</div> </div>
)} )}
</div> </div>
@@ -444,9 +484,9 @@ export default function TicketDetail() {
<div className="w-64 flex-shrink-0 sticky top-0 space-y-3"> <div className="w-64 flex-shrink-0 sticky top-0 space-y-3">
{/* Details */} {/* Details */}
<div className="bg-white border border-gray-200 rounded-xl divide-y divide-gray-100"> <div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
<div className="px-4 py-3"> <div className="px-4 py-3">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide">Details</p> <p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Details</p>
</div> </div>
<div className="px-4 py-3 space-y-3"> <div className="px-4 py-3 space-y-3">
@@ -456,10 +496,13 @@ export default function TicketDetail() {
onChange={(e) => patch({ status: e.target.value })} onChange={(e) => patch({ status: e.target.value })}
className={selectClass} className={selectClass}
> >
{STATUS_OPTIONS.map((s) => ( {statusOptions.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option> <option key={s.value} value={s.value}>{s.label}</option>
))} ))}
</select> </select>
{!isAdmin && ticket.status !== 'CLOSED' && (
<p className="text-xs text-gray-600 mt-1">Closing requires admin</p>
)}
</SidebarField> </SidebarField>
<SidebarField label="Severity"> <SidebarField label="Severity">
@@ -488,7 +531,7 @@ export default function TicketDetail() {
{ticket.assignee && ( {ticket.assignee && (
<div className="flex items-center gap-1.5 mt-1.5"> <div className="flex items-center gap-1.5 mt-1.5">
<Avatar name={ticket.assignee.displayName} size="sm" /> <Avatar name={ticket.assignee.displayName} size="sm" />
<span className="text-xs text-gray-500">{ticket.assignee.displayName}</span> <span className="text-xs text-gray-400">{ticket.assignee.displayName}</span>
</div> </div>
)} )}
</SidebarField> </SidebarField>
@@ -496,14 +539,14 @@ export default function TicketDetail() {
{/* Routing */} {/* Routing */}
<div className="px-4 py-3"> <div className="px-4 py-3">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-2">Routing</p> <p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Routing</p>
{reroutingCTI ? ( {reroutingCTI ? (
<div className="space-y-2"> <div className="space-y-2">
<CTISelect value={pendingCTI} onChange={setPendingCTI} /> <CTISelect value={pendingCTI} onChange={setPendingCTI} />
<div className="flex gap-2 pt-1"> <div className="flex gap-2 pt-1">
<button <button
onClick={() => setReroutingCTI(false)} onClick={() => setReroutingCTI(false)}
className="flex-1 text-xs py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-gray-600" className="flex-1 text-xs py-1.5 border border-gray-700 rounded-lg hover:bg-gray-800 transition-colors text-gray-400"
> >
Cancel Cancel
</button> </button>
@@ -517,16 +560,16 @@ export default function TicketDetail() {
</div> </div>
) : ( ) : (
<div> <div>
<p className="text-xs text-gray-700 leading-relaxed"> <p className="text-xs text-gray-300 leading-relaxed">
{ticket.category.name} {ticket.category.name}
<span className="text-gray-400"> </span> <span className="text-gray-600"> </span>
{ticket.type.name} {ticket.type.name}
<span className="text-gray-400"> </span> <span className="text-gray-600"> </span>
{ticket.item.name} {ticket.item.name}
</p> </p>
<button <button
onClick={startReroute} onClick={startReroute}
className="mt-1.5 text-xs text-blue-600 hover:text-blue-800 transition-colors" className="mt-1.5 text-xs text-blue-500 hover:text-blue-400 transition-colors"
> >
Change routing Change routing
</button> </button>
@@ -535,44 +578,44 @@ export default function TicketDetail() {
</div> </div>
{/* Dates */} {/* Dates */}
<div className="px-4 py-3 space-y-2"> <div className="px-4 py-3 space-y-2.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Avatar name={ticket.createdBy.displayName} size="sm" /> <Avatar name={ticket.createdBy.displayName} size="sm" />
<div> <div>
<p className="text-xs text-gray-400">Opened by</p> <p className="text-xs text-gray-500">Opened by</p>
<p className="text-xs font-medium text-gray-700">{ticket.createdBy.displayName}</p> <p className="text-xs font-medium text-gray-300">{ticket.createdBy.displayName}</p>
</div> </div>
</div> </div>
<div> <div>
<p className="text-xs text-gray-400">Created</p> <p className="text-xs text-gray-500">Created</p>
<p className="text-xs text-gray-700">{format(new Date(ticket.createdAt), 'MMM d, yyyy HH:mm')}</p> <p className="text-xs text-gray-300">{format(new Date(ticket.createdAt), 'MMM d, yyyy HH:mm')}</p>
</div> </div>
{ticket.resolvedAt && ( {ticket.resolvedAt && (
<div> <div>
<p className="text-xs text-gray-400">Resolved</p> <p className="text-xs text-gray-500">Resolved</p>
<p className="text-xs text-gray-700">{format(new Date(ticket.resolvedAt), 'MMM d, yyyy')}</p> <p className="text-xs text-gray-300">{format(new Date(ticket.resolvedAt), 'MMM d, yyyy HH:mm')}</p>
</div> </div>
)} )}
<div> <div>
<p className="text-xs text-gray-400">Updated</p> <p className="text-xs text-gray-500">Updated</p>
<p className="text-xs text-gray-700">{formatDistanceToNow(new Date(ticket.updatedAt), { addSuffix: true })}</p> <p className="text-xs text-gray-300">{formatDistanceToNow(new Date(ticket.updatedAt), { addSuffix: true })}</p>
</div> </div>
</div> </div>
</div> </div>
{/* Actions */} {/* Actions */}
<div className="bg-white border border-gray-200 rounded-xl px-4 py-3 space-y-2"> <div className="bg-gray-900 border border-gray-800 rounded-xl px-4 py-3 space-y-2">
<button <button
onClick={startEdit} onClick={startEdit}
className="w-full flex items-center justify-center gap-2 py-2 text-sm text-gray-700 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors" className="w-full flex items-center justify-center gap-2 py-2 text-sm text-gray-300 border border-gray-700 rounded-lg hover:bg-gray-800 transition-colors"
> >
<Pencil size={13} /> <Pencil size={13} />
Edit title &amp; overview Edit title &amp; overview
</button> </button>
{authUser?.role === 'ADMIN' && ( {isAdmin && (
<button <button
onClick={deleteTicket} onClick={deleteTicket}
className="w-full flex items-center justify-center gap-2 py-2 text-sm text-red-600 border border-red-200 rounded-lg hover:bg-red-50 transition-colors" 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} /> <Trash2 size={13} />
Delete ticket Delete ticket

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
} }

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()
@@ -123,7 +123,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 +141,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)