Initial commit: TicketingSystem
Some checks failed
Build & Push / Build Client (push) Failing after 9s
Build & Push / Build Server (push) Failing after 28s

Internal ticketing app with CTI routing, severity levels, and n8n integration.

Stack: Express + TypeScript + Prisma + PostgreSQL / React + Vite + Tailwind
- JWT auth for users, API key auth for service accounts (Goddard/n8n)
- CTI hierarchy (Category > Type > Item) for ticket routing
- Severity 1-5, auto-close resolved tickets after 14 days
- Gitea Actions CI/CD building separate server/client images
- Production docker-compose.yml with Traefik integration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-30 19:38:32 -04:00
commit 21894fad7a
50 changed files with 3293 additions and 0 deletions

4
client/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
.env
*.log

12
client/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

12
client/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ticketing System</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

18
client/nginx.conf Normal file
View File

@@ -0,0 +1,18 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# React Router — serve index.html for all non-asset paths
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API calls to the backend
location /api {
proxy_pass http://server:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}

32
client/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "ticketing-client",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"axios": "^1.7.9",
"date-fns": "^3.6.0",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.54.2",
"react-router-dom": "^6.28.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"typescript": "^5.7.2",
"vite": "^6.0.5"
}
}

6
client/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

32
client/src/App.tsx Normal file
View File

@@ -0,0 +1,32 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider } from './contexts/AuthContext'
import PrivateRoute from './components/PrivateRoute'
import AdminRoute from './components/AdminRoute'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import TicketDetail from './pages/TicketDetail'
import NewTicket from './pages/NewTicket'
import AdminUsers from './pages/admin/Users'
import AdminCTI from './pages/admin/CTI'
export default function App() {
return (
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route element={<PrivateRoute />}>
<Route path="/" element={<Dashboard />} />
<Route path="/tickets/new" element={<NewTicket />} />
<Route path="/tickets/:id" element={<TicketDetail />} />
<Route element={<AdminRoute />}>
<Route path="/admin/users" element={<AdminUsers />} />
<Route path="/admin/cti" element={<AdminCTI />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</AuthProvider>
)
}

7
client/src/api/client.ts Normal file
View File

@@ -0,0 +1,7 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api',
})
export default api

View File

@@ -0,0 +1,7 @@
import { Navigate, Outlet } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
export default function AdminRoute() {
const { user } = useAuth()
return user?.role === 'ADMIN' ? <Outlet /> : <Navigate to="/" replace />
}

View File

@@ -0,0 +1,110 @@
import { useEffect, useState } from 'react'
import api from '../api/client'
import { Category, CTIType, Item } from '../types'
interface CTISelectProps {
value: { categoryId: string; typeId: string; itemId: string }
onChange: (value: { categoryId: string; typeId: string; itemId: string }) => void
disabled?: boolean
}
export default function CTISelect({ value, onChange, disabled }: CTISelectProps) {
const [categories, setCategories] = useState<Category[]>([])
const [types, setTypes] = useState<CTIType[]>([])
const [items, setItems] = useState<Item[]>([])
useEffect(() => {
api.get<Category[]>('/cti/categories').then((r) => setCategories(r.data))
}, [])
useEffect(() => {
if (!value.categoryId) {
setTypes([])
setItems([])
return
}
api
.get<CTIType[]>('/cti/types', { params: { categoryId: value.categoryId } })
.then((r) => setTypes(r.data))
}, [value.categoryId])
useEffect(() => {
if (!value.typeId) {
setItems([])
return
}
api
.get<Item[]>('/cti/items', { params: { typeId: value.typeId } })
.then((r) => setItems(r.data))
}, [value.typeId])
const handleCategory = (categoryId: string) => {
onChange({ categoryId, typeId: '', itemId: '' })
}
const handleType = (typeId: string) => {
onChange({ ...value, typeId, itemId: '' })
}
const handleItem = (itemId: string) => {
onChange({ ...value, itemId })
}
const selectClass =
'block w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-50 disabled:text-gray-400'
return (
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Category</label>
<select
value={value.categoryId}
onChange={(e) => handleCategory(e.target.value)}
disabled={disabled}
className={selectClass}
>
<option value="">Select...</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Type</label>
<select
value={value.typeId}
onChange={(e) => handleType(e.target.value)}
disabled={disabled || !value.categoryId}
className={selectClass}
>
<option value="">Select...</option>
{types.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Item</label>
<select
value={value.itemId}
onChange={(e) => handleItem(e.target.value)}
disabled={disabled || !value.typeId}
className={selectClass}
>
<option value="">Select...</option>
{items.map((i) => (
<option key={i.id} value={i.id}>
{i.name}
</option>
))}
</select>
</div>
</div>
)
}

View File

@@ -0,0 +1,88 @@
import { ReactNode } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { LayoutDashboard, Users, Settings, LogOut, Plus } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
interface LayoutProps {
children: ReactNode
title?: string
action?: ReactNode
}
export default function Layout({ children, title, action }: LayoutProps) {
const { user, logout } = useAuth()
const location = useLocation()
const navigate = useNavigate()
const navItems = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
...(user?.role === 'ADMIN'
? [
{ to: '/admin/users', icon: Users, label: 'Users' },
{ to: '/admin/cti', icon: Settings, label: 'CTI Config' },
]
: []),
]
const handleLogout = () => {
logout()
navigate('/login')
}
return (
<div className="flex h-screen bg-gray-100 overflow-hidden">
{/* Sidebar */}
<aside className="w-60 bg-gray-900 text-white flex flex-col flex-shrink-0">
<div className="p-5 border-b border-gray-700">
<h1 className="text-base font-bold text-white tracking-wide">Ticketing</h1>
<p className="text-xs text-gray-400 mt-0.5">{user?.displayName}</p>
</div>
<nav className="flex-1 p-3 space-y-0.5">
{navItems.map(({ to, icon: Icon, label }) => (
<Link
key={to}
to={to}
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
location.pathname === to
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
}`}
>
<Icon size={15} />
{label}
</Link>
))}
</nav>
<div className="p-3 border-t border-gray-700 space-y-0.5">
<Link
to="/tickets/new"
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-gray-300 hover:bg-gray-800 hover:text-white transition-colors"
>
<Plus size={15} />
New Ticket
</Link>
<button
onClick={handleLogout}
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-gray-300 hover:bg-gray-800 hover:text-white w-full transition-colors"
>
<LogOut size={15} />
Logout
</button>
</div>
</aside>
{/* Main */}
<div className="flex-1 flex flex-col overflow-hidden">
{(title || action) && (
<header className="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between flex-shrink-0">
{title && <h2 className="text-lg font-semibold text-gray-900">{title}</h2>}
{action && <div>{action}</div>}
</header>
)}
<main className="flex-1 overflow-auto p-6">{children}</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,38 @@
import { ReactNode, useEffect } from 'react'
import { X } from 'lucide-react'
interface ModalProps {
title: string
onClose: () => void
children: ReactNode
}
export default function Modal({ title, onClose, children }: ModalProps) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [onClose])
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h3 className="text-base font-semibold text-gray-900">{title}</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<X size={18} />
</button>
</div>
<div className="px-6 py-4">{children}</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,16 @@
import { Navigate, Outlet } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
export default function PrivateRoute() {
const { user, loading } = useAuth()
if (loading) {
return (
<div className="flex h-screen items-center justify-center bg-gray-50">
<div className="text-gray-500">Loading...</div>
</div>
)
}
return user ? <Outlet /> : <Navigate to="/login" replace />
}

View File

@@ -0,0 +1,16 @@
const config: Record<number, { label: string; className: string }> = {
1: { label: 'SEV 1', className: 'bg-red-100 text-red-800 border-red-200' },
2: { label: 'SEV 2', className: 'bg-orange-100 text-orange-800 border-orange-200' },
3: { label: 'SEV 3', className: 'bg-yellow-100 text-yellow-800 border-yellow-200' },
4: { label: 'SEV 4', className: 'bg-blue-100 text-blue-800 border-blue-200' },
5: { label: 'SEV 5', className: 'bg-gray-100 text-gray-600 border-gray-200' },
}
export default function SeverityBadge({ severity }: { severity: number }) {
const { label, className } = config[severity] ?? config[5]
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold border ${className}`}>
{label}
</span>
)
}

View File

@@ -0,0 +1,17 @@
import { TicketStatus } from '../types'
const config: Record<TicketStatus, { label: string; className: string }> = {
OPEN: { label: 'Open', className: 'bg-blue-100 text-blue-800 border-blue-200' },
IN_PROGRESS: { label: 'In Progress', className: 'bg-yellow-100 text-yellow-800 border-yellow-200' },
RESOLVED: { label: 'Resolved', className: 'bg-green-100 text-green-800 border-green-200' },
CLOSED: { label: 'Closed', className: 'bg-gray-100 text-gray-500 border-gray-200' },
}
export default function StatusBadge({ status }: { status: TicketStatus }) {
const { label, className } = config[status]
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border ${className}`}>
{label}
</span>
)
}

View File

@@ -0,0 +1,59 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
import api from '../api/client'
import { User } from '../types'
interface AuthContextType {
user: User | null
loading: boolean
login: (username: string, password: string) => Promise<void>
logout: () => void
}
const AuthContext = createContext<AuthContextType>(null!)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const token = localStorage.getItem('token')
if (token) {
api.defaults.headers.common['Authorization'] = `Bearer ${token}`
api
.get<User>('/auth/me')
.then((res) => setUser(res.data))
.catch(() => {
localStorage.removeItem('token')
delete api.defaults.headers.common['Authorization']
})
.finally(() => setLoading(false))
} else {
setLoading(false)
}
}, [])
const login = async (username: string, password: string) => {
const res = await api.post<{ token: string; user: User }>('/auth/login', {
username,
password,
})
const { token, user } = res.data
localStorage.setItem('token', token)
api.defaults.headers.common['Authorization'] = `Bearer ${token}`
setUser(user)
}
const logout = () => {
localStorage.removeItem('token')
delete api.defaults.headers.common['Authorization']
setUser(null)
}
return (
<AuthContext.Provider value={{ user, loading, login, logout }}>
{children}
</AuthContext.Provider>
)
}
export const useAuth = () => useContext(AuthContext)

3
client/src/index.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

10
client/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
)

View File

@@ -0,0 +1,150 @@
import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { Plus, Search } from 'lucide-react'
import { formatDistanceToNow } from 'date-fns'
import api from '../api/client'
import Layout from '../components/Layout'
import SeverityBadge from '../components/SeverityBadge'
import StatusBadge from '../components/StatusBadge'
import { Ticket, TicketStatus } from '../types'
const STATUSES: { value: TicketStatus | ''; label: string }[] = [
{ value: '', label: 'All Statuses' },
{ value: 'OPEN', label: 'Open' },
{ value: 'IN_PROGRESS', label: 'In Progress' },
{ value: 'RESOLVED', label: 'Resolved' },
{ value: 'CLOSED', label: 'Closed' },
]
export default function Dashboard() {
const [tickets, setTickets] = useState<Ticket[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [status, setStatus] = useState<TicketStatus | ''>('')
const [severity, setSeverity] = useState('')
const fetchTickets = useCallback(() => {
setLoading(true)
const params: Record<string, string> = {}
if (status) params.status = status
if (severity) params.severity = severity
if (search) params.search = search
api
.get<Ticket[]>('/tickets', { params })
.then((r) => setTickets(r.data))
.finally(() => setLoading(false))
}, [status, severity, search])
useEffect(() => {
const t = setTimeout(fetchTickets, 300)
return () => clearTimeout(t)
}, [fetchTickets])
return (
<Layout
title="Tickets"
action={
<Link
to="/tickets/new"
className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg text-sm hover:bg-blue-700 transition-colors"
>
<Plus size={15} />
New Ticket
</Link>
}
>
{/* Filters */}
<div className="flex gap-3 mb-5">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={14} />
<input
type="text"
placeholder="Search tickets..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 pr-4 py-2 border border-gray-300 rounded-lg w-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<select
value={status}
onChange={(e) => setStatus(e.target.value as TicketStatus | '')}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{STATUSES.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
<select
value={severity}
onChange={(e) => setSeverity(e.target.value)}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All Severities</option>
{[1, 2, 3, 4, 5].map((s) => (
<option key={s} value={s}>
SEV {s}
</option>
))}
</select>
</div>
{/* Ticket list */}
{loading ? (
<div className="text-center py-16 text-gray-400 text-sm">Loading...</div>
) : tickets.length === 0 ? (
<div className="text-center py-16 text-gray-400 text-sm">No tickets found</div>
) : (
<div className="space-y-2">
{tickets.map((ticket) => (
<Link
key={ticket.id}
to={`/tickets/${ticket.id}`}
className="flex items-center gap-4 bg-white border border-gray-200 rounded-lg px-4 py-3 hover:border-blue-400 hover:shadow-sm transition-all group"
>
{/* Severity stripe */}
<div
className={`w-1 self-stretch rounded-full flex-shrink-0 ${
ticket.severity === 1
? 'bg-red-500'
: ticket.severity === 2
? 'bg-orange-400'
: ticket.severity === 3
? 'bg-yellow-400'
: ticket.severity === 4
? 'bg-blue-400'
: 'bg-gray-300'
}`}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<SeverityBadge severity={ticket.severity} />
<StatusBadge status={ticket.status} />
<span className="text-xs text-gray-400">
{ticket.category.name} {ticket.type.name} {ticket.item.name}
</span>
</div>
<p className="text-sm font-medium text-gray-900 truncate group-hover:text-blue-700">
{ticket.title}
</p>
<p className="text-xs text-gray-400 truncate mt-0.5">{ticket.overview}</p>
</div>
<div className="text-right text-xs text-gray-400 flex-shrink-0 space-y-0.5">
<div className="font-medium text-gray-600">
{ticket.assignee?.displayName ?? 'Unassigned'}
</div>
<div>{ticket._count?.comments ?? 0} comments</div>
<div>{formatDistanceToNow(new Date(ticket.createdAt), { addSuffix: true })}</div>
</div>
</Link>
))}
</div>
)}
</Layout>
)
}

View File

@@ -0,0 +1,83 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
export default function Login() {
const { login } = useAuth()
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await login(username, password)
navigate('/')
} catch {
setError('Invalid username or password')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center px-4">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-white">Ticketing System</h1>
<p className="text-gray-400 text-sm mt-1">Sign in to your account</p>
</div>
<form
onSubmit={handleSubmit}
className="bg-white rounded-xl shadow-xl p-8 space-y-4"
>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm px-4 py-3 rounded-lg">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Username
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white py-2 rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,161 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import api from '../api/client'
import Layout from '../components/Layout'
import CTISelect from '../components/CTISelect'
import { User } from '../types'
export default function NewTicket() {
const navigate = useNavigate()
const [users, setUsers] = useState<User[]>([])
const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false)
const [form, setForm] = useState({
title: '',
overview: '',
severity: 3,
assigneeId: '',
categoryId: '',
typeId: '',
itemId: '',
})
useEffect(() => {
api.get<User[]>('/users').then((r) => setUsers(r.data))
}, [])
const handleCTI = (cti: { categoryId: string; typeId: string; itemId: string }) => {
setForm((f) => ({ ...f, ...cti }))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.categoryId || !form.typeId || !form.itemId) {
setError('Please select a Category, Type, and Item')
return
}
setError('')
setSubmitting(true)
try {
const payload: Record<string, unknown> = {
title: form.title,
overview: form.overview,
severity: form.severity,
categoryId: form.categoryId,
typeId: form.typeId,
itemId: form.itemId,
}
if (form.assigneeId) payload.assigneeId = form.assigneeId
const res = await api.post('/tickets', payload)
navigate(`/tickets/${res.data.id}`)
} catch {
setError('Failed to create ticket')
} finally {
setSubmitting(false)
}
}
const inputClass =
'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
const labelClass = 'block text-sm font-medium text-gray-700 mb-1'
return (
<Layout title="New Ticket">
<div className="max-w-2xl">
<form onSubmit={handleSubmit} className="bg-white border border-gray-200 rounded-xl p-6 space-y-5">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm px-4 py-3 rounded-lg">
{error}
</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"
/>
</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..."
/>
</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
</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>
</div>
</Layout>
)
}

View File

@@ -0,0 +1,391 @@
import { useState, useEffect, useRef } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { format } from 'date-fns'
import { Pencil, Trash2, Send, X, Check } from 'lucide-react'
import api from '../api/client'
import Layout from '../components/Layout'
import SeverityBadge from '../components/SeverityBadge'
import StatusBadge from '../components/StatusBadge'
import CTISelect from '../components/CTISelect'
import { Ticket, TicketStatus, User, Comment } from '../types'
import { useAuth } from '../contexts/AuthContext'
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' },
]
export default function TicketDetail() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { user: authUser } = useAuth()
const [ticket, setTicket] = useState<Ticket | null>(null)
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
const [editing, setEditing] = useState(false)
const [commentBody, setCommentBody] = useState('')
const [submittingComment, setSubmittingComment] = useState(false)
const commentRef = useRef<HTMLTextAreaElement>(null)
const [editForm, setEditForm] = useState({
title: '',
overview: '',
severity: 3,
assigneeId: '',
categoryId: '',
typeId: '',
itemId: '',
})
useEffect(() => {
Promise.all([
api.get<Ticket>(`/tickets/${id}`),
api.get<User[]>('/users'),
]).then(([tRes, uRes]) => {
setTicket(tRes.data)
setUsers(uRes.data)
}).finally(() => setLoading(false))
}, [id])
const startEdit = () => {
if (!ticket) return
setEditForm({
title: ticket.title,
overview: ticket.overview,
severity: ticket.severity,
assigneeId: ticket.assigneeId ?? '',
categoryId: ticket.categoryId,
typeId: ticket.typeId,
itemId: ticket.itemId,
})
setEditing(true)
}
const saveEdit = async () => {
if (!ticket) return
const payload: Record<string, unknown> = {
title: editForm.title,
overview: editForm.overview,
severity: editForm.severity,
categoryId: editForm.categoryId,
typeId: editForm.typeId,
itemId: editForm.itemId,
assigneeId: editForm.assigneeId || null,
}
const res = await api.patch<Ticket>(`/tickets/${ticket.id}`, payload)
setTicket(res.data)
setEditing(false)
}
const updateStatus = async (status: TicketStatus) => {
if (!ticket) return
const res = await api.patch<Ticket>(`/tickets/${ticket.id}`, { status })
setTicket(res.data)
}
const updateAssignee = async (assigneeId: string) => {
if (!ticket) return
const res = await api.patch<Ticket>(`/tickets/${ticket.id}`, {
assigneeId: assigneeId || null,
})
setTicket(res.data)
}
const deleteTicket = async () => {
if (!ticket || !confirm('Delete this ticket?')) return
await api.delete(`/tickets/${ticket.id}`)
navigate('/')
}
const submitComment = async (e: React.FormEvent) => {
e.preventDefault()
if (!ticket || !commentBody.trim()) return
setSubmittingComment(true)
try {
const res = await api.post<Comment>(`/tickets/${ticket.id}/comments`, {
body: commentBody.trim(),
})
setTicket((t) =>
t ? { ...t, comments: [...(t.comments ?? []), res.data] } : t
)
setCommentBody('')
} finally {
setSubmittingComment(false)
}
}
const deleteComment = async (commentId: string) => {
if (!ticket) return
await api.delete(`/tickets/${ticket.id}/comments/${commentId}`)
setTicket((t) =>
t ? { ...t, comments: t.comments?.filter((c) => c.id !== commentId) } : t
)
}
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'
if (loading) {
return (
<Layout>
<div className="text-center py-16 text-gray-400 text-sm">Loading...</div>
</Layout>
)
}
if (!ticket) {
return (
<Layout>
<div className="text-center py-16 text-gray-400 text-sm">Ticket not found</div>
</Layout>
)
}
return (
<Layout
title={ticket.title}
action={
authUser?.role === 'ADMIN' ? (
<button
onClick={deleteTicket}
className="flex items-center gap-2 text-sm text-red-600 hover:text-red-800 border border-red-200 hover:border-red-400 px-3 py-1.5 rounded-lg transition-colors"
>
<Trash2 size={14} />
Delete
</button>
) : undefined
}
>
<div className="max-w-3xl space-y-5">
{/* Ticket card */}
<div className="bg-white border border-gray-200 rounded-xl">
{/* Header bar */}
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-100">
<div className="flex items-center gap-2">
<SeverityBadge severity={ticket.severity} />
<StatusBadge status={ticket.status} />
{!editing && (
<span className="text-xs text-gray-400">
{ticket.category.name} {ticket.type.name} {ticket.item.name}
</span>
)}
</div>
{!editing ? (
<button
onClick={startEdit}
className="flex items-center gap-1.5 text-xs text-gray-500 hover:text-gray-800 border border-gray-200 hover:border-gray-400 px-2.5 py-1 rounded-lg transition-colors"
>
<Pencil size={12} />
Edit
</button>
) : (
<div className="flex items-center gap-2">
<button
onClick={() => setEditing(false)}
className="flex items-center gap-1.5 text-xs text-gray-500 hover:text-gray-700 border border-gray-200 px-2.5 py-1 rounded-lg transition-colors"
>
<X size={12} />
Cancel
</button>
<button
onClick={saveEdit}
className="flex items-center gap-1.5 text-xs bg-blue-600 text-white px-2.5 py-1 rounded-lg hover:bg-blue-700 transition-colors"
>
<Check size={12} />
Save
</button>
</div>
)}
</div>
<div className="p-5 space-y-4">
{editing ? (
<>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Title</label>
<input
type="text"
value={editForm.title}
onChange={(e) => setEditForm((f) => ({ ...f, title: e.target.value }))}
className={inputClass}
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Overview</label>
<textarea
value={editForm.overview}
onChange={(e) => setEditForm((f) => ({ ...f, overview: e.target.value }))}
rows={4}
className={inputClass}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Severity</label>
<select
value={editForm.severity}
onChange={(e) => setEditForm((f) => ({ ...f, severity: Number(e.target.value) }))}
className={inputClass}
>
<option value={1}>SEV 1 Critical</option>
<option value={2}>SEV 2 High</option>
<option value={3}>SEV 3 Medium</option>
<option value={4}>SEV 4 Low</option>
<option value={5}>SEV 5 Minimal</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Assignee</label>
<select
value={editForm.assigneeId}
onChange={(e) => setEditForm((f) => ({ ...f, assigneeId: e.target.value }))}
className={inputClass}
>
<option value="">Unassigned</option>
{users
.filter((u) => u.role !== 'SERVICE')
.map((u) => (
<option key={u.id} value={u.id}>
{u.displayName}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Routing (CTI)</label>
<CTISelect
value={{ categoryId: editForm.categoryId, typeId: editForm.typeId, itemId: editForm.itemId }}
onChange={(cti) => setEditForm((f) => ({ ...f, ...cti }))}
/>
</div>
</>
) : (
<>
<p className="text-sm text-gray-700 whitespace-pre-wrap">{ticket.overview}</p>
{/* Quick controls */}
<div className="grid grid-cols-2 gap-4 pt-2">
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Status</label>
<select
value={ticket.status}
onChange={(e) => updateStatus(e.target.value as TicketStatus)}
className={inputClass}
>
{STATUS_OPTIONS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Assignee</label>
<select
value={ticket.assigneeId ?? ''}
onChange={(e) => updateAssignee(e.target.value)}
className={inputClass}
>
<option value="">Unassigned</option>
{users
.filter((u) => u.role !== 'SERVICE')
.map((u) => (
<option key={u.id} value={u.id}>
{u.displayName}
</option>
))}
</select>
</div>
</div>
</>
)}
</div>
{/* Metadata footer */}
<div className="px-5 py-3 bg-gray-50 rounded-b-xl border-t border-gray-100 flex items-center gap-6 text-xs text-gray-400">
<span>Created by <strong className="text-gray-600">{ticket.createdBy.displayName}</strong></span>
<span>{format(new Date(ticket.createdAt), 'MMM d, yyyy HH:mm')}</span>
{ticket.resolvedAt && (
<span>Resolved {format(new Date(ticket.resolvedAt), 'MMM d, yyyy')}</span>
)}
<span>Updated {format(new Date(ticket.updatedAt), 'MMM d, yyyy HH:mm')}</span>
</div>
</div>
{/* Comments */}
<div className="bg-white border border-gray-200 rounded-xl">
<div className="px-5 py-3 border-b border-gray-100">
<h3 className="text-sm font-semibold text-gray-700">
Comments ({ticket.comments?.length ?? 0})
</h3>
</div>
{ticket.comments && ticket.comments.length > 0 ? (
<div className="divide-y divide-gray-100">
{ticket.comments.map((comment) => (
<div key={comment.id} className="px-5 py-4 group">
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-gray-700">
{comment.author.displayName}
</span>
<span className="text-xs text-gray-400">
{format(new Date(comment.createdAt), 'MMM d, yyyy HH:mm')}
</span>
</div>
<p className="text-sm text-gray-700 whitespace-pre-wrap">{comment.body}</p>
</div>
{(comment.authorId === authUser?.id || authUser?.role === 'ADMIN') && (
<button
onClick={() => deleteComment(comment.id)}
className="opacity-0 group-hover:opacity-100 text-gray-300 hover:text-red-500 transition-all flex-shrink-0"
>
<Trash2 size={13} />
</button>
)}
</div>
</div>
))}
</div>
) : (
<div className="px-5 py-8 text-center text-xs text-gray-400">No comments yet</div>
)}
{/* Comment form */}
<form onSubmit={submitComment} className="px-5 py-4 border-t border-gray-100">
<textarea
ref={commentRef}
value={commentBody}
onChange={(e) => setCommentBody(e.target.value)}
placeholder="Add a comment..."
rows={3}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
submitComment(e as unknown as React.FormEvent)
}
}}
/>
<div className="flex justify-between items-center mt-2">
<span className="text-xs text-gray-400">Ctrl+Enter to submit</span>
<button
type="submit"
disabled={submittingComment || !commentBody.trim()}
className="flex items-center gap-2 px-3 py-1.5 bg-blue-600 text-white text-xs rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
<Send size={12} />
Comment
</button>
</div>
</form>
</div>
</div>
</Layout>
)
}

View File

@@ -0,0 +1,320 @@
import { useState, useEffect } from 'react'
import { Plus, Pencil, Trash2, ChevronRight } from 'lucide-react'
import api from '../../api/client'
import Layout from '../../components/Layout'
import Modal from '../../components/Modal'
import { Category, CTIType, Item } from '../../types'
type PanelItem = { id: string; name: string }
interface NameModalState {
open: boolean
mode: 'add' | 'edit'
panel: 'category' | 'type' | 'item'
item?: PanelItem
}
export default function AdminCTI() {
const [categories, setCategories] = useState<Category[]>([])
const [types, setTypes] = useState<CTIType[]>([])
const [items, setItems] = useState<Item[]>([])
const [selectedCategory, setSelectedCategory] = useState<Category | null>(null)
const [selectedType, setSelectedType] = useState<CTIType | null>(null)
const [nameModal, setNameModal] = useState<NameModalState>({ open: false, mode: 'add', panel: 'category' })
const [nameValue, setNameValue] = useState('')
const [submitting, setSubmitting] = useState(false)
const fetchCategories = () =>
api.get<Category[]>('/cti/categories').then((r) => setCategories(r.data))
const fetchTypes = (categoryId: string) =>
api
.get<CTIType[]>('/cti/types', { params: { categoryId } })
.then((r) => setTypes(r.data))
const fetchItems = (typeId: string) =>
api.get<Item[]>('/cti/items', { params: { typeId } }).then((r) => setItems(r.data))
useEffect(() => { fetchCategories() }, [])
const selectCategory = (cat: Category) => {
setSelectedCategory(cat)
setSelectedType(null)
setItems([])
fetchTypes(cat.id)
}
const selectType = (type: CTIType) => {
setSelectedType(type)
fetchItems(type.id)
}
const openAdd = (panel: 'category' | 'type' | 'item') => {
setNameValue('')
setNameModal({ open: true, mode: 'add', panel })
}
const openEdit = (panel: 'category' | 'type' | 'item', item: PanelItem) => {
setNameValue(item.name)
setNameModal({ open: true, mode: 'edit', panel, item })
}
const closeModal = () => setNameModal((m) => ({ ...m, open: false }))
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
if (!nameValue.trim()) return
setSubmitting(true)
try {
const { mode, panel, item } = nameModal
if (mode === 'add') {
if (panel === 'category') {
await api.post('/cti/categories', { name: nameValue.trim() })
await fetchCategories()
} else if (panel === 'type' && selectedCategory) {
await api.post('/cti/types', { name: nameValue.trim(), categoryId: selectedCategory.id })
await fetchTypes(selectedCategory.id)
} else if (panel === 'item' && selectedType) {
await api.post('/cti/items', { name: nameValue.trim(), typeId: selectedType.id })
await fetchItems(selectedType.id)
}
} else {
if (!item) return
if (panel === 'category') {
await api.put(`/cti/categories/${item.id}`, { name: nameValue.trim() })
await fetchCategories()
if (selectedCategory?.id === item.id) setSelectedCategory((c) => c ? { ...c, name: nameValue.trim() } : c)
} else if (panel === 'type') {
await api.put(`/cti/types/${item.id}`, { name: nameValue.trim() })
if (selectedCategory) await fetchTypes(selectedCategory.id)
if (selectedType?.id === item.id) setSelectedType((t) => t ? { ...t, name: nameValue.trim() } : t)
} else if (panel === 'item') {
await api.put(`/cti/items/${item.id}`, { name: nameValue.trim() })
if (selectedType) await fetchItems(selectedType.id)
}
}
closeModal()
} finally {
setSubmitting(false)
}
}
const handleDelete = async (panel: 'category' | 'type' | 'item', item: PanelItem) => {
if (!confirm(`Delete "${item.name}"? This will also delete all child records.`)) return
if (panel === 'category') {
await api.delete(`/cti/categories/${item.id}`)
if (selectedCategory?.id === item.id) {
setSelectedCategory(null)
setSelectedType(null)
setTypes([])
setItems([])
}
await fetchCategories()
} else if (panel === 'type') {
await api.delete(`/cti/types/${item.id}`)
if (selectedType?.id === item.id) {
setSelectedType(null)
setItems([])
}
if (selectedCategory) await fetchTypes(selectedCategory.id)
} else {
await api.delete(`/cti/items/${item.id}`)
if (selectedType) await fetchItems(selectedType.id)
}
}
const panelClass = 'bg-white border border-gray-200 rounded-xl overflow-hidden flex flex-col'
const panelHeaderClass = 'flex items-center justify-between px-4 py-3 border-b border-gray-100 bg-gray-50'
const itemClass = (active: boolean) =>
`flex items-center justify-between px-4 py-2.5 cursor-pointer group transition-colors ${
active ? 'bg-blue-50 border-l-2 border-blue-500' : 'hover:bg-gray-50 border-l-2 border-transparent'
}`
return (
<Layout title="CTI Configuration">
<div className="grid grid-cols-3 gap-4 h-[calc(100vh-10rem)]">
{/* Categories */}
<div className={panelClass}>
<div className={panelHeaderClass}>
<h3 className="text-sm font-semibold text-gray-700">Categories</h3>
<button
onClick={() => openAdd('category')}
className="text-blue-600 hover:text-blue-800 transition-colors"
>
<Plus size={16} />
</button>
</div>
<div className="flex-1 overflow-auto">
{categories.length === 0 ? (
<p className="text-xs text-gray-400 text-center py-8">No categories</p>
) : (
categories.map((cat) => (
<div
key={cat.id}
className={itemClass(selectedCategory?.id === cat.id)}
onClick={() => selectCategory(cat)}
>
<span className="text-sm text-gray-800">{cat.name}</span>
<div className="flex items-center gap-1">
<button
onClick={(e) => { e.stopPropagation(); openEdit('category', cat) }}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-gray-700 transition-all"
>
<Pencil size={13} />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDelete('category', cat) }}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-600 transition-all"
>
<Trash2 size={13} />
</button>
<ChevronRight size={14} className="text-gray-300" />
</div>
</div>
))
)}
</div>
</div>
{/* Types */}
<div className={panelClass}>
<div className={panelHeaderClass}>
<h3 className="text-sm font-semibold text-gray-700">
Types
{selectedCategory && (
<span className="ml-1 font-normal text-gray-400"> {selectedCategory.name}</span>
)}
</h3>
{selectedCategory && (
<button
onClick={() => openAdd('type')}
className="text-blue-600 hover:text-blue-800 transition-colors"
>
<Plus size={16} />
</button>
)}
</div>
<div className="flex-1 overflow-auto">
{!selectedCategory ? (
<p className="text-xs text-gray-400 text-center py-8">Select a category</p>
) : types.length === 0 ? (
<p className="text-xs text-gray-400 text-center py-8">No types</p>
) : (
types.map((type) => (
<div
key={type.id}
className={itemClass(selectedType?.id === type.id)}
onClick={() => selectType(type)}
>
<span className="text-sm text-gray-800">{type.name}</span>
<div className="flex items-center gap-1">
<button
onClick={(e) => { e.stopPropagation(); openEdit('type', type) }}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-gray-700 transition-all"
>
<Pencil size={13} />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDelete('type', type) }}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-600 transition-all"
>
<Trash2 size={13} />
</button>
<ChevronRight size={14} className="text-gray-300" />
</div>
</div>
))
)}
</div>
</div>
{/* Items */}
<div className={panelClass}>
<div className={panelHeaderClass}>
<h3 className="text-sm font-semibold text-gray-700">
Items
{selectedType && (
<span className="ml-1 font-normal text-gray-400"> {selectedType.name}</span>
)}
</h3>
{selectedType && (
<button
onClick={() => openAdd('item')}
className="text-blue-600 hover:text-blue-800 transition-colors"
>
<Plus size={16} />
</button>
)}
</div>
<div className="flex-1 overflow-auto">
{!selectedType ? (
<p className="text-xs text-gray-400 text-center py-8">Select a type</p>
) : items.length === 0 ? (
<p className="text-xs text-gray-400 text-center py-8">No items</p>
) : (
items.map((item) => (
<div key={item.id} className={itemClass(false)} onClick={() => {}}>
<span className="text-sm text-gray-800">{item.name}</span>
<div className="flex items-center gap-1">
<button
onClick={(e) => { e.stopPropagation(); openEdit('item', item) }}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-gray-700 transition-all"
>
<Pencil size={13} />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDelete('item', item) }}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-600 transition-all"
>
<Trash2 size={13} />
</button>
</div>
</div>
))
)}
</div>
</div>
</div>
{/* Name modal */}
{nameModal.open && (
<Modal
title={`${nameModal.mode === 'add' ? 'Add' : 'Rename'} ${nameModal.panel.charAt(0).toUpperCase() + nameModal.panel.slice(1)}`}
onClose={closeModal}
>
<form onSubmit={handleSave} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input
type="text"
value={nameValue}
onChange={(e) => setNameValue(e.target.value)}
required
autoFocus
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
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 ? 'Saving...' : 'Save'}
</button>
</div>
</form>
</Modal>
)}
</Layout>
)
}

View File

@@ -0,0 +1,346 @@
import { useState, useEffect } from 'react'
import { Plus, Pencil, Trash2, RefreshCw, Copy, Check } from 'lucide-react'
import api from '../../api/client'
import Layout from '../../components/Layout'
import Modal from '../../components/Modal'
import { User, Role } from '../../types'
import { useAuth } from '../../contexts/AuthContext'
interface UserForm {
username: string
email: string
displayName: string
password: string
role: Role
}
const BLANK_FORM: UserForm = {
username: '',
email: '',
displayName: '',
password: '',
role: 'AGENT',
}
const ROLE_LABELS: Record<Role, string> = {
ADMIN: 'Admin',
AGENT: 'Agent',
SERVICE: 'Service',
}
export default function AdminUsers() {
const { user: authUser } = useAuth()
const [users, setUsers] = useState<User[]>([])
const [modal, setModal] = useState<'add' | 'edit' | null>(null)
const [selected, setSelected] = useState<User | null>(null)
const [form, setForm] = useState<UserForm>(BLANK_FORM)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
const [copiedKey, setCopiedKey] = useState<string | null>(null)
const [newApiKey, setNewApiKey] = useState<string | null>(null)
const fetchUsers = () => {
api.get<User[]>('/users').then((r) => setUsers(r.data))
}
useEffect(() => { fetchUsers() }, [])
const openAdd = () => {
setForm(BLANK_FORM)
setError('')
setNewApiKey(null)
setModal('add')
}
const openEdit = (u: User) => {
setSelected(u)
setForm({ username: u.username, email: u.email, displayName: u.displayName, password: '', role: u.role })
setError('')
setNewApiKey(null)
setModal('edit')
}
const closeModal = () => {
setModal(null)
setSelected(null)
setNewApiKey(null)
fetchUsers()
}
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitting(true)
setError('')
try {
const payload: Record<string, string> = {
username: form.username,
email: form.email,
displayName: form.displayName,
role: form.role,
}
if (form.password) payload.password = form.password
const res = await api.post<User>('/users', payload)
if (res.data.apiKey) setNewApiKey(res.data.apiKey)
else closeModal()
} catch (err: unknown) {
const e = err as { response?: { data?: { error?: string } } }
setError(e.response?.data?.error ?? 'Failed to create user')
} finally {
setSubmitting(false)
}
}
const handleEdit = async (e: React.FormEvent) => {
e.preventDefault()
if (!selected) return
setSubmitting(true)
setError('')
try {
const payload: Record<string, string> = {
email: form.email,
displayName: form.displayName,
role: form.role,
}
if (form.password) payload.password = form.password
await api.patch(`/users/${selected.id}`, payload)
closeModal()
} catch (err: unknown) {
const e = err as { response?: { data?: { error?: string } } }
setError(e.response?.data?.error ?? 'Failed to update user')
} finally {
setSubmitting(false)
}
}
const handleDelete = async (u: User) => {
if (!confirm(`Delete user "${u.displayName}"?`)) return
await api.delete(`/users/${u.id}`)
fetchUsers()
}
const handleRegenerateKey = async (u: User) => {
if (!confirm(`Regenerate API key for "${u.displayName}"? The old key will stop working immediately.`)) return
const res = await api.patch<User>(`/users/${u.id}`, { regenerateApiKey: true })
setNewApiKey(res.data.apiKey ?? null)
setSelected(u)
setModal('edit')
}
const copyToClipboard = (key: string) => {
navigator.clipboard.writeText(key)
setCopiedKey(key)
setTimeout(() => setCopiedKey(null), 2000)
}
const inputClass =
'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
const labelClass = 'block text-sm font-medium text-gray-700 mb-1'
return (
<Layout
title="Users"
action={
<button
onClick={openAdd}
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={14} />
Add User
</button>
}
>
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-5 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">
User
</th>
<th className="text-left px-5 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">
Username
</th>
<th className="text-left px-5 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">
Role
</th>
<th className="text-left px-5 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">
Email
</th>
<th className="px-5 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{users.map((u) => (
<tr key={u.id} className="hover:bg-gray-50">
<td className="px-5 py-3 font-medium text-gray-900">{u.displayName}</td>
<td className="px-5 py-3 text-gray-500 font-mono text-xs">{u.username}</td>
<td className="px-5 py-3">
<span
className={`inline-flex px-2 py-0.5 rounded text-xs font-medium ${
u.role === 'ADMIN'
? 'bg-purple-100 text-purple-700'
: u.role === 'SERVICE'
? 'bg-orange-100 text-orange-700'
: 'bg-gray-100 text-gray-600'
}`}
>
{ROLE_LABELS[u.role]}
</span>
</td>
<td className="px-5 py-3 text-gray-500">{u.email}</td>
<td className="px-5 py-3">
<div className="flex items-center justify-end gap-2">
{u.role === 'SERVICE' && (
<button
onClick={() => handleRegenerateKey(u)}
className="text-gray-400 hover:text-gray-700 transition-colors"
title="Regenerate API key"
>
<RefreshCw size={14} />
</button>
)}
<button
onClick={() => openEdit(u)}
className="text-gray-400 hover:text-gray-700 transition-colors"
>
<Pencil size={14} />
</button>
{u.id !== authUser?.id && (
<button
onClick={() => handleDelete(u)}
className="text-gray-400 hover:text-red-600 transition-colors"
>
<Trash2 size={14} />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Add / Edit Modal */}
{modal && (
<Modal
title={modal === 'add' ? 'Add User' : `Edit ${selected?.displayName}`}
onClose={closeModal}
>
{newApiKey ? (
<div className="space-y-4">
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<p className="text-sm font-medium text-amber-800 mb-2">
API Key copy it now, it won't be shown again
</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs bg-white border border-amber-200 rounded px-3 py-2 font-mono break-all">
{newApiKey}
</code>
<button
onClick={() => copyToClipboard(newApiKey)}
className="flex-shrink-0 text-amber-700 hover:text-amber-900 transition-colors"
>
{copiedKey === newApiKey ? <Check size={16} /> : <Copy size={16} />}
</button>
</div>
</div>
<button
onClick={closeModal}
className="w-full bg-blue-600 text-white py-2 rounded-lg text-sm hover:bg-blue-700 transition-colors"
>
Done
</button>
</div>
) : (
<form onSubmit={modal === 'add' ? handleAdd : handleEdit} className="space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm px-3 py-2 rounded-lg">
{error}
</div>
)}
{modal === 'add' && (
<div>
<label className={labelClass}>Username</label>
<input
type="text"
value={form.username}
onChange={(e) => setForm((f) => ({ ...f, username: e.target.value }))}
required
className={inputClass}
/>
</div>
)}
<div>
<label className={labelClass}>Display Name</label>
<input
type="text"
value={form.displayName}
onChange={(e) => setForm((f) => ({ ...f, displayName: e.target.value }))}
required
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Email</label>
<input
type="email"
value={form.email}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
required
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>
Password {modal === 'edit' && <span className="text-gray-400 font-normal">(leave blank to keep current)</span>}
</label>
<input
type="password"
value={form.password}
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
required={modal === 'add' && form.role !== 'SERVICE'}
className={inputClass}
placeholder={modal === 'edit' ? '' : ''}
/>
</div>
<div>
<label className={labelClass}>Role</label>
<select
value={form.role}
onChange={(e) => setForm((f) => ({ ...f, role: e.target.value as Role }))}
className={inputClass}
>
<option value="AGENT">Agent</option>
<option value="ADMIN">Admin</option>
<option value="SERVICE">Service (API key auth)</option>
</select>
</div>
<div className="flex justify-end gap-3 pt-1">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
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 ? 'Saving...' : modal === 'add' ? 'Create User' : 'Save Changes'}
</button>
</div>
</form>
)}
</Modal>
)}
</Layout>
)
}

63
client/src/types/index.ts Normal file
View File

@@ -0,0 +1,63 @@
export type Role = 'ADMIN' | 'AGENT' | 'SERVICE'
export type TicketStatus = 'OPEN' | 'IN_PROGRESS' | 'RESOLVED' | 'CLOSED'
export interface User {
id: string
username: string
displayName: string
email: string
role: Role
apiKey?: string
createdAt?: string
}
export interface Category {
id: string
name: string
}
export interface CTIType {
id: string
name: string
categoryId: string
category?: Category
}
export interface Item {
id: string
name: string
typeId: string
type?: CTIType & { category?: Category }
}
export interface Comment {
id: string
body: string
ticketId: string
authorId: string
author: Pick<User, 'id' | 'username' | 'displayName'>
createdAt: string
}
export interface Ticket {
id: string
title: string
overview: string
severity: number
status: TicketStatus
categoryId: string
typeId: string
itemId: string
assigneeId: string | null
createdById: string
resolvedAt: string | null
createdAt: string
updatedAt: string
category: Category
type: CTIType
item: Item
assignee: Pick<User, 'id' | 'username' | 'displayName'> | null
createdBy: Pick<User, 'id' | 'username' | 'displayName'>
comments?: Comment[]
_count?: { comments: number }
}

View File

@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
}

20
client/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

11
client/vite.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:3000',
},
},
})