Dark theme, roles overhaul, modal New Ticket, My Tickets page, and more
- 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:
@@ -4,8 +4,8 @@ import PrivateRoute from './components/PrivateRoute'
|
||||
import AdminRoute from './components/AdminRoute'
|
||||
import Login from './pages/Login'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import MyTickets from './pages/MyTickets'
|
||||
import TicketDetail from './pages/TicketDetail'
|
||||
import NewTicket from './pages/NewTicket'
|
||||
import AdminUsers from './pages/admin/Users'
|
||||
import AdminCTI from './pages/admin/CTI'
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function App() {
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route element={<PrivateRoute />}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/tickets/new" element={<NewTicket />} />
|
||||
<Route path="/my-tickets" element={<MyTickets />} />
|
||||
<Route path="/tickets/:id" element={<TicketDetail />} />
|
||||
<Route element={<AdminRoute />}>
|
||||
<Route path="/admin/users" element={<AdminUsers />} />
|
||||
|
||||
@@ -51,12 +51,12 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
|
||||
}
|
||||
|
||||
const selectClass =
|
||||
'block w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-50 disabled:text-gray-400'
|
||||
'block w-full bg-gray-800 border border-gray-700 text-gray-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Category</label>
|
||||
<label className="block text-xs font-medium text-gray-400 mb-1">Category</label>
|
||||
<select
|
||||
value={value.categoryId}
|
||||
onChange={(e) => handleCategory(e.target.value)}
|
||||
@@ -73,7 +73,7 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Type</label>
|
||||
<label className="block text-xs font-medium text-gray-400 mb-1">Type</label>
|
||||
<select
|
||||
value={value.typeId}
|
||||
onChange={(e) => handleType(e.target.value)}
|
||||
@@ -90,7 +90,7 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Item</label>
|
||||
<label className="block text-xs font-medium text-gray-400 mb-1">Item</label>
|
||||
<select
|
||||
value={value.itemId}
|
||||
onChange={(e) => handleItem(e.target.value)}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { ReactNode, useState } from 'react'
|
||||
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 NewTicketModal from '../pages/NewTicket'
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode
|
||||
@@ -13,9 +14,13 @@ export default function Layout({ children, title, action }: LayoutProps) {
|
||||
const { user, logout } = useAuth()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [showNewTicket, setShowNewTicket] = useState(false)
|
||||
|
||||
const canCreateTicket = user?.role !== 'USER'
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
|
||||
{ to: '/', icon: LayoutDashboard, label: 'All Tickets' },
|
||||
{ to: '/my-tickets', icon: Ticket, label: 'My Tickets' },
|
||||
...(user?.role === 'ADMIN'
|
||||
? [
|
||||
{ to: '/admin/users', icon: Users, label: 'Users' },
|
||||
@@ -29,24 +34,27 @@ export default function Layout({ children, title, action }: LayoutProps) {
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
const isActive = (to: string) =>
|
||||
to === '/' ? location.pathname === '/' : location.pathname.startsWith(to)
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-100 overflow-hidden">
|
||||
<div className="flex h-screen bg-gray-950 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>
|
||||
<aside className="w-60 bg-gray-900 border-r border-gray-800 flex flex-col flex-shrink-0">
|
||||
<div className="px-5 py-4 border-b border-gray-800">
|
||||
<h1 className="text-sm font-bold text-white tracking-wide">Ticketing</h1>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{user?.displayName}</p>
|
||||
</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 }) => (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
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'
|
||||
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
||||
: 'text-gray-400 hover:bg-gray-800 hover:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
<Icon size={15} />
|
||||
@@ -55,17 +63,19 @@ export default function Layout({ children, title, action }: LayoutProps) {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="p-3 border-t border-gray-700 space-y-0.5">
|
||||
<Link
|
||||
to="/tickets/new"
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-gray-300 hover:bg-gray-800 hover:text-white transition-colors"
|
||||
>
|
||||
<Plus size={15} />
|
||||
New Ticket
|
||||
</Link>
|
||||
<div className="p-2 border-t border-gray-800 space-y-0.5">
|
||||
{canCreateTicket && (
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
)}
|
||||
<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"
|
||||
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
|
||||
@@ -76,13 +86,15 @@ export default function Layout({ children, title, action }: LayoutProps) {
|
||||
{/* 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>}
|
||||
<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-base font-semibold text-gray-100">{title}</h2>}
|
||||
{action && <div>{action}</div>}
|
||||
</header>
|
||||
)}
|
||||
<main className="flex-1 overflow-auto p-6">{children}</main>
|
||||
</div>
|
||||
|
||||
{showNewTicket && <NewTicketModal onClose={() => setShowNewTicket(false)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,9 +5,10 @@ interface ModalProps {
|
||||
title: string
|
||||
onClose: () => void
|
||||
children: ReactNode
|
||||
size?: 'md' | 'lg'
|
||||
}
|
||||
|
||||
export default function Modal({ title, onClose, children }: ModalProps) {
|
||||
export default function Modal({ title, onClose, children, size = 'md' }: ModalProps) {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
@@ -18,20 +19,20 @@ export default function Modal({ title, onClose, children }: ModalProps) {
|
||||
|
||||
return (
|
||||
<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() }}
|
||||
>
|
||||
<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>
|
||||
<div className={`bg-gray-900 border border-gray-800 rounded-xl shadow-2xl w-full mx-4 ${size === 'lg' ? 'max-w-2xl' : 'max-w-md'}`}>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-800">
|
||||
<h3 className="text-base font-semibold text-gray-100">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
className="text-gray-500 hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-6 py-4">{children}</div>
|
||||
<div className="px-6 py-5">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
const config: Record<number, { label: string; className: string }> = {
|
||||
1: { label: 'SEV 1', className: 'bg-red-100 text-red-800 border-red-200' },
|
||||
2: { label: 'SEV 2', className: 'bg-orange-100 text-orange-800 border-orange-200' },
|
||||
3: { label: 'SEV 3', className: 'bg-yellow-100 text-yellow-800 border-yellow-200' },
|
||||
4: { label: 'SEV 4', className: 'bg-blue-100 text-blue-800 border-blue-200' },
|
||||
5: { label: 'SEV 5', className: 'bg-gray-100 text-gray-600 border-gray-200' },
|
||||
1: { label: 'SEV 1', className: 'bg-red-500/20 text-red-400 border-red-500/30' },
|
||||
2: { label: 'SEV 2', className: 'bg-orange-500/20 text-orange-400 border-orange-500/30' },
|
||||
3: { label: 'SEV 3', className: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' },
|
||||
4: { label: 'SEV 4', className: 'bg-blue-500/20 text-blue-400 border-blue-500/30' },
|
||||
5: { label: 'SEV 5', className: 'bg-gray-500/20 text-gray-400 border-gray-500/30' },
|
||||
}
|
||||
|
||||
export default function SeverityBadge({ severity }: { severity: number }) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { TicketStatus } from '../types'
|
||||
|
||||
const config: Record<TicketStatus, { label: string; className: string }> = {
|
||||
OPEN: { label: 'Open', className: 'bg-blue-100 text-blue-800 border-blue-200' },
|
||||
IN_PROGRESS: { label: 'In Progress', className: 'bg-yellow-100 text-yellow-800 border-yellow-200' },
|
||||
RESOLVED: { label: 'Resolved', className: 'bg-green-100 text-green-800 border-green-200' },
|
||||
CLOSED: { label: 'Closed', className: 'bg-gray-100 text-gray-500 border-gray-200' },
|
||||
OPEN: { label: 'Open', className: 'bg-blue-500/20 text-blue-400 border-blue-500/30' },
|
||||
IN_PROGRESS: { label: 'In Progress', className: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' },
|
||||
RESOLVED: { label: 'Resolved', className: 'bg-green-500/20 text-green-400 border-green-500/30' },
|
||||
CLOSED: { label: 'Closed', className: 'bg-gray-500/20 text-gray-400 border-gray-500/30' },
|
||||
}
|
||||
|
||||
export default function StatusBadge({ status }: { status: TicketStatus }) {
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
@tailwind components;
|
||||
@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 h1 { @apply text-xl font-bold mb-3 mt-5; }
|
||||
.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 li > ul,
|
||||
.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 em { @apply italic; }
|
||||
.prose blockquote { @apply border-l-4 border-gray-200 pl-4 text-gray-500 my-3 italic; }
|
||||
.prose code { @apply bg-gray-100 text-gray-800 px-1.5 py-0.5 rounded text-xs font-mono; }
|
||||
.prose pre { @apply bg-gray-900 text-gray-100 p-4 rounded-lg my-3 overflow-x-auto text-sm; }
|
||||
.prose pre code { @apply bg-transparent text-gray-100 p-0; }
|
||||
.prose hr { @apply border-gray-200 my-4; }
|
||||
.prose blockquote { @apply border-l-4 border-gray-600 pl-4 text-gray-400 my-3 italic; }
|
||||
.prose code { @apply bg-gray-800 text-gray-300 px-1.5 py-0.5 rounded text-xs font-mono; }
|
||||
.prose pre { @apply bg-gray-950 text-gray-300 p-4 rounded-lg my-3 overflow-x-auto text-sm; }
|
||||
.prose pre code { @apply bg-transparent text-gray-300 p-0; }
|
||||
.prose hr { @apply border-gray-700 my-4; }
|
||||
.prose table { @apply w-full border-collapse text-sm my-3; }
|
||||
.prose th { @apply bg-gray-50 border border-gray-200 px-3 py-2 text-left font-semibold; }
|
||||
.prose td { @apply border border-gray-200 px-3 py-2; }
|
||||
.prose th { @apply bg-gray-800 border border-gray-700 px-3 py-2 text-left font-semibold text-gray-300; }
|
||||
.prose td { @apply border border-gray-700 px-3 py-2 text-gray-400; }
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Plus, Search } from 'lucide-react'
|
||||
import { Search } from 'lucide-react'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import api from '../api/client'
|
||||
import Layout from '../components/Layout'
|
||||
import SeverityBadge from '../components/SeverityBadge'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import Avatar from '../components/Avatar'
|
||||
import { Ticket, TicketStatus } from '../types'
|
||||
import { Ticket, TicketStatus, Category } from '../types'
|
||||
|
||||
const STATUSES: { value: TicketStatus | ''; label: string }[] = [
|
||||
{ value: '', label: 'All Statuses' },
|
||||
@@ -17,24 +17,34 @@ const STATUSES: { value: TicketStatus | ''; label: string }[] = [
|
||||
{ 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() {
|
||||
const [tickets, setTickets] = useState<Ticket[]>([])
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [status, setStatus] = useState<TicketStatus | ''>('')
|
||||
const [severity, setSeverity] = useState('')
|
||||
const [categoryId, setCategoryId] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Category[]>('/cti/categories').then((r) => setCategories(r.data))
|
||||
}, [])
|
||||
|
||||
const fetchTickets = useCallback(() => {
|
||||
setLoading(true)
|
||||
const params: Record<string, string> = {}
|
||||
if (status) params.status = status
|
||||
if (severity) params.severity = severity
|
||||
if (categoryId) params.categoryId = categoryId
|
||||
if (search) params.search = search
|
||||
api
|
||||
.get<Ticket[]>('/tickets', { params })
|
||||
.then((r) => setTickets(r.data))
|
||||
.finally(() => setLoading(false))
|
||||
}, [status, severity, search])
|
||||
}, [status, severity, categoryId, search])
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(fetchTickets, 300)
|
||||
@@ -42,35 +52,24 @@ export default function Dashboard() {
|
||||
}, [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>
|
||||
}
|
||||
>
|
||||
<Layout title="All Tickets">
|
||||
{/* 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} />
|
||||
<div className="flex gap-3 mb-5 flex-wrap">
|
||||
<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-500" 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"
|
||||
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>
|
||||
|
||||
<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"
|
||||
className={selectClass}
|
||||
>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
@@ -82,7 +81,7 @@ export default function Dashboard() {
|
||||
<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"
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">All Severities</option>
|
||||
{[1, 2, 3, 4, 5].map((s) => (
|
||||
@@ -91,20 +90,33 @@ export default function Dashboard() {
|
||||
</option>
|
||||
))}
|
||||
</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>
|
||||
|
||||
{/* Ticket list */}
|
||||
{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 ? (
|
||||
<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) => (
|
||||
<Link
|
||||
key={ticket.id}
|
||||
to={`/tickets/${ticket.displayId}`}
|
||||
className="flex items-center gap-4 bg-white border border-gray-200 rounded-lg px-4 py-3 hover:border-blue-400 hover:shadow-sm transition-all group"
|
||||
className="flex items-center gap-4 bg-gray-900 border border-gray-800 rounded-lg px-4 py-3 hover:border-blue-500/50 hover:bg-gray-900/80 transition-all group"
|
||||
>
|
||||
{/* Severity stripe */}
|
||||
<div
|
||||
@@ -117,40 +129,39 @@ export default function Dashboard() {
|
||||
? 'bg-yellow-400'
|
||||
: ticket.severity === 4
|
||||
? 'bg-blue-400'
|
||||
: 'bg-gray-300'
|
||||
: 'bg-gray-600'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className="text-xs font-mono font-medium text-gray-400">
|
||||
<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-400">
|
||||
<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-900 truncate group-hover:text-blue-700">
|
||||
<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">
|
||||
{ticket.assignee && (
|
||||
{ticket.assignee ? (
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||
<Avatar name={ticket.assignee.displayName} size="sm" />
|
||||
<span>{ticket.assignee.displayName}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-gray-600">Unassigned</span>
|
||||
)}
|
||||
{!ticket.assignee && (
|
||||
<span className="text-xs text-gray-400">Unassigned</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-400">
|
||||
<span className="text-xs text-gray-600">
|
||||
{ticket._count?.comments ?? 0} comments
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
<span className="text-xs text-gray-600">
|
||||
{formatDistanceToNow(new Date(ticket.createdAt), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -25,25 +25,25 @@ export default function Login() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 flex items-center justify-center px-4">
|
||||
<div className="min-h-screen bg-gray-950 flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-white">Ticketing System</h1>
|
||||
<p className="text-gray-400 text-sm mt-1">Sign in to your account</p>
|
||||
<p className="text-gray-500 text-sm mt-1">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-white rounded-xl shadow-xl p-8 space-y-4"
|
||||
className="bg-gray-900 border border-gray-800 rounded-xl shadow-2xl 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">
|
||||
<div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm px-4 py-3 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
@@ -52,12 +52,12 @@ export default function Login() {
|
||||
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"
|
||||
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>
|
||||
<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
|
||||
</label>
|
||||
<input
|
||||
@@ -65,7 +65,7 @@ export default function Login() {
|
||||
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"
|
||||
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>
|
||||
|
||||
|
||||
85
client/src/pages/MyTickets.tsx
Normal file
85
client/src/pages/MyTickets.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
import Layout from '../components/Layout'
|
||||
import Modal from '../components/Modal'
|
||||
import CTISelect from '../components/CTISelect'
|
||||
import { User } from '../types'
|
||||
|
||||
export default function NewTicket() {
|
||||
interface NewTicketModalProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function NewTicketModal({ onClose }: NewTicketModalProps) {
|
||||
const navigate = useNavigate()
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [error, setError] = useState('')
|
||||
@@ -49,6 +53,7 @@ export default function NewTicket() {
|
||||
if (form.assigneeId) payload.assigneeId = form.assigneeId
|
||||
|
||||
const res = await api.post('/tickets', payload)
|
||||
onClose()
|
||||
navigate(`/tickets/${res.data.displayId}`)
|
||||
} catch {
|
||||
setError('Failed to create ticket')
|
||||
@@ -58,104 +63,103 @@ export default function NewTicket() {
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
const labelClass = 'block text-sm font-medium text-gray-700 mb-1'
|
||||
'w-full bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent'
|
||||
const labelClass = 'block text-sm font-medium text-gray-300 mb-1'
|
||||
|
||||
return (
|
||||
<Layout 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>
|
||||
)}
|
||||
<Modal title="New Ticket" onClose={onClose} size="lg">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm px-4 py-3 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className={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>
|
||||
<label className={labelClass}>Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.title}
|
||||
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
|
||||
required
|
||||
<label className={labelClass}>Severity</label>
|
||||
<select
|
||||
value={form.severity}
|
||||
onChange={(e) => setForm((f) => ({ ...f, severity: Number(e.target.value) }))}
|
||||
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>
|
||||
<label className={labelClass}>Overview</label>
|
||||
<textarea
|
||||
value={form.overview}
|
||||
onChange={(e) => setForm((f) => ({ ...f, overview: e.target.value }))}
|
||||
required
|
||||
rows={4}
|
||||
<label className={labelClass}>Assignee</label>
|
||||
<select
|
||||
value={form.assigneeId}
|
||||
onChange={(e) => setForm((f) => ({ ...f, assigneeId: e.target.value }))}
|
||||
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>
|
||||
<option value="">Unassigned</option>
|
||||
{users
|
||||
.filter((u) => u.role !== 'SERVICE')
|
||||
.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Layout>
|
||||
</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-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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import remarkGfm from 'remark-gfm'
|
||||
import {
|
||||
Pencil, Trash2, Send, X, Check,
|
||||
MessageSquare, ClipboardList, FileText,
|
||||
ArrowLeft,
|
||||
ArrowLeft, ChevronDown, ChevronRight,
|
||||
} from 'lucide-react'
|
||||
import api from '../api/client'
|
||||
import Layout from '../components/Layout'
|
||||
@@ -19,13 +19,6 @@ import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
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 = [
|
||||
{ value: 1, label: 'SEV 1 — Critical' },
|
||||
{ value: 2, label: 'SEV 2 — High' },
|
||||
@@ -52,19 +45,21 @@ const AUDIT_COLORS: Record<string, string> = {
|
||||
ASSIGNEE_CHANGED: 'bg-purple-500',
|
||||
SEVERITY_CHANGED: 'bg-orange-500',
|
||||
REROUTED: 'bg-cyan-500',
|
||||
TITLE_CHANGED: 'bg-gray-400',
|
||||
OVERVIEW_CHANGED: 'bg-gray-400',
|
||||
COMMENT_ADDED: 'bg-gray-400',
|
||||
COMMENT_DELETED: 'bg-red-400',
|
||||
TITLE_CHANGED: 'bg-gray-500',
|
||||
OVERVIEW_CHANGED: 'bg-gray-500',
|
||||
COMMENT_ADDED: 'bg-gray-500',
|
||||
COMMENT_DELETED: 'bg-red-500',
|
||||
}
|
||||
|
||||
const COMMENT_ACTIONS = new Set(['COMMENT_ADDED', 'COMMENT_DELETED'])
|
||||
|
||||
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 }) {
|
||||
return (
|
||||
<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}
|
||||
</div>
|
||||
)
|
||||
@@ -85,10 +80,13 @@ export default function TicketDetail() {
|
||||
const [commentBody, setCommentBody] = useState('')
|
||||
const [submittingComment, setSubmittingComment] = useState(false)
|
||||
const [preview, setPreview] = useState(false)
|
||||
const [expandedLogs, setExpandedLogs] = useState<Set<string>>(new Set())
|
||||
|
||||
const [editForm, setEditForm] = useState({ title: '', overview: '' })
|
||||
const [pendingCTI, setPendingCTI] = useState({ categoryId: '', typeId: '', itemId: '' })
|
||||
|
||||
const isAdmin = authUser?.role === 'ADMIN'
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
return (
|
||||
<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...
|
||||
</div>
|
||||
</Layout>
|
||||
@@ -176,7 +183,7 @@ export default function TicketDetail() {
|
||||
if (!ticket) {
|
||||
return (
|
||||
<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
|
||||
</div>
|
||||
</Layout>
|
||||
@@ -186,29 +193,37 @@ export default function TicketDetail() {
|
||||
const commentCount = ticket.comments?.length ?? 0
|
||||
const agentUsers = users.filter((u) => u.role !== 'SERVICE')
|
||||
|
||||
// Status options: CLOSED only for admins
|
||||
const statusOptions: { value: TicketStatus; label: string }[] = [
|
||||
{ value: 'OPEN', label: 'Open' },
|
||||
{ value: 'IN_PROGRESS', label: 'In Progress' },
|
||||
{ value: 'RESOLVED', label: 'Resolved' },
|
||||
...(isAdmin ? [{ value: 'CLOSED' as TicketStatus, label: 'Closed' }] : []),
|
||||
]
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
{/* Back link */}
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="flex items-center gap-1.5 text-sm text-gray-400 hover:text-gray-700 mb-4 transition-colors"
|
||||
onClick={() => navigate(-1)}
|
||||
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-300 mb-4 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
All tickets
|
||||
Back
|
||||
</button>
|
||||
|
||||
<div className="flex gap-6 items-start">
|
||||
{/* ── Main content ── */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Title card */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl px-6 py-5 mb-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="font-mono text-xs font-semibold text-gray-400 bg-gray-100 px-2 py-0.5 rounded">
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl px-6 py-5 mb-3">
|
||||
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||||
<span className="font-mono text-xs font-semibold text-gray-500 bg-gray-800 px-2 py-0.5 rounded">
|
||||
{ticket.displayId}
|
||||
</span>
|
||||
<SeverityBadge severity={ticket.severity} />
|
||||
<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}
|
||||
</span>
|
||||
</div>
|
||||
@@ -218,18 +233,18 @@ export default function TicketDetail() {
|
||||
type="text"
|
||||
value={editForm.title}
|
||||
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
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
{/* 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 */}
|
||||
<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' },
|
||||
@@ -242,8 +257,8 @@ export default function TicketDetail() {
|
||||
onClick={() => setTab(key)}
|
||||
className={`flex items-center gap-2 px-4 py-3.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
|
||||
tab === key
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-800'
|
||||
? 'border-blue-500 text-blue-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} />
|
||||
@@ -261,12 +276,12 @@ export default function TicketDetail() {
|
||||
value={editForm.overview}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, overview: e.target.value }))}
|
||||
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">
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
@@ -279,7 +294,7 @@ export default function TicketDetail() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="prose text-sm text-gray-700">
|
||||
<div className="prose text-sm text-gray-300">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{ticket.overview}
|
||||
</ReactMarkdown>
|
||||
@@ -292,7 +307,7 @@ export default function TicketDetail() {
|
||||
{tab === 'comments' && (
|
||||
<div>
|
||||
{ticket.comments && ticket.comments.length > 0 ? (
|
||||
<div className="divide-y divide-gray-100">
|
||||
<div className="divide-y divide-gray-800">
|
||||
{ticket.comments.map((comment) => (
|
||||
<div key={comment.id} className="p-6 group">
|
||||
<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 items-center justify-between mb-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}
|
||||
</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')}
|
||||
</span>
|
||||
</div>
|
||||
{(comment.authorId === authUser?.id || authUser?.role === 'ADMIN') && (
|
||||
{(comment.authorId === authUser?.id || isAdmin) && (
|
||||
<button
|
||||
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} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="prose text-sm text-gray-700">
|
||||
<div className="prose text-sm text-gray-300">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{comment.body}
|
||||
</ReactMarkdown>
|
||||
@@ -327,26 +342,25 @@ export default function TicketDetail() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-16 text-center text-sm text-gray-400">
|
||||
No comments yet — be the first
|
||||
<div className="py-16 text-center text-sm text-gray-600">
|
||||
No comments yet
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<Avatar name={authUser?.displayName ?? '?'} size="md" />
|
||||
<div className="flex-1">
|
||||
{/* Write / Preview toggle */}
|
||||
<div className="flex gap-4 mb-2 border-b border-gray-100">
|
||||
<div className="flex gap-4 mb-2 border-b border-gray-800">
|
||||
{(['Write', 'Preview'] as const).map((label) => (
|
||||
<button
|
||||
key={label}
|
||||
onClick={() => setPreview(label === 'Preview')}
|
||||
className={`text-xs pb-2 border-b-2 -mb-px transition-colors ${
|
||||
(label === 'Preview') === preview
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-400 hover:text-gray-600'
|
||||
? 'border-blue-500 text-blue-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
@@ -356,10 +370,10 @@ export default function TicketDetail() {
|
||||
|
||||
<form onSubmit={submitComment}>
|
||||
{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()
|
||||
? <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>
|
||||
) : (
|
||||
@@ -368,7 +382,7 @@ export default function TicketDetail() {
|
||||
onChange={(e) => setCommentBody(e.target.value)}
|
||||
placeholder="Leave a comment… Markdown supported"
|
||||
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) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
@@ -378,7 +392,7 @@ export default function TicketDetail() {
|
||||
/>
|
||||
)}
|
||||
<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
|
||||
</span>
|
||||
<button
|
||||
@@ -401,38 +415,64 @@ export default function TicketDetail() {
|
||||
{tab === 'audit' && (
|
||||
<div className="p-6">
|
||||
{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">
|
||||
{auditLogs.map((log, i) => (
|
||||
<div key={log.id} className="flex gap-4">
|
||||
{/* Timeline */}
|
||||
<div className="flex flex-col items-center w-5 flex-shrink-0">
|
||||
<div className={`w-2.5 h-2.5 rounded-full mt-1 flex-shrink-0 ${AUDIT_COLORS[log.action] ?? 'bg-gray-400'}`} />
|
||||
{i < auditLogs.length - 1 && (
|
||||
<div className="w-px flex-1 bg-gray-100 my-1" />
|
||||
)}
|
||||
</div>
|
||||
{/* Entry */}
|
||||
<div className="flex-1 pb-5">
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<p className="text-sm text-gray-700">
|
||||
<span className="font-medium">{log.user.displayName}</span>
|
||||
{' '}{AUDIT_LABELS[log.action] ?? log.action.toLowerCase()}
|
||||
{log.detail && (
|
||||
<span className="text-gray-500"> — {log.detail}</span>
|
||||
)}
|
||||
</p>
|
||||
<span
|
||||
className="text-xs text-gray-400 flex-shrink-0"
|
||||
title={format(new Date(log.createdAt), 'MMM d, yyyy HH:mm:ss')}
|
||||
<div>
|
||||
{auditLogs.map((log, i) => {
|
||||
const hasDetail = !!log.detail
|
||||
const isExpanded = expandedLogs.has(log.id)
|
||||
const isComment = COMMENT_ACTIONS.has(log.action)
|
||||
|
||||
return (
|
||||
<div key={log.id} className="flex gap-4">
|
||||
{/* Timeline */}
|
||||
<div className="flex flex-col items-center w-5 flex-shrink-0">
|
||||
<div className={`w-2.5 h-2.5 rounded-full mt-1 flex-shrink-0 ${AUDIT_COLORS[log.action] ?? 'bg-gray-500'}`} />
|
||||
{i < auditLogs.length - 1 && (
|
||||
<div className="w-px flex-1 bg-gray-800 my-1" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Entry */}
|
||||
<div className="flex-1 pb-4">
|
||||
<div
|
||||
className={`flex items-baseline justify-between gap-4 ${hasDetail ? 'cursor-pointer select-none' : ''}`}
|
||||
onClick={() => hasDetail && toggleLog(log.id)}
|
||||
>
|
||||
{formatDistanceToNow(new Date(log.createdAt), { addSuffix: true })}
|
||||
</span>
|
||||
<p className="text-sm text-gray-300">
|
||||
<span className="font-medium text-gray-100">{log.user.displayName}</span>
|
||||
{' '}{AUDIT_LABELS[log.action] ?? log.action.toLowerCase()}
|
||||
{hasDetail && (
|
||||
<span className="ml-1 inline-flex items-center text-gray-600">
|
||||
{isExpanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
@@ -444,9 +484,9 @@ export default function TicketDetail() {
|
||||
<div className="w-64 flex-shrink-0 sticky top-0 space-y-3">
|
||||
|
||||
{/* 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">
|
||||
<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 className="px-4 py-3 space-y-3">
|
||||
@@ -456,10 +496,13 @@ export default function TicketDetail() {
|
||||
onChange={(e) => patch({ status: e.target.value })}
|
||||
className={selectClass}
|
||||
>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
{statusOptions.map((s) => (
|
||||
<option key={s.value} value={s.value}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{!isAdmin && ticket.status !== 'CLOSED' && (
|
||||
<p className="text-xs text-gray-600 mt-1">Closing requires admin</p>
|
||||
)}
|
||||
</SidebarField>
|
||||
|
||||
<SidebarField label="Severity">
|
||||
@@ -488,7 +531,7 @@ export default function TicketDetail() {
|
||||
{ticket.assignee && (
|
||||
<div className="flex items-center gap-1.5 mt-1.5">
|
||||
<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>
|
||||
)}
|
||||
</SidebarField>
|
||||
@@ -496,14 +539,14 @@ export default function TicketDetail() {
|
||||
|
||||
{/* Routing */}
|
||||
<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 ? (
|
||||
<div className="space-y-2">
|
||||
<CTISelect value={pendingCTI} onChange={setPendingCTI} />
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
@@ -517,16 +560,16 @@ export default function TicketDetail() {
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-xs text-gray-700 leading-relaxed">
|
||||
<p className="text-xs text-gray-300 leading-relaxed">
|
||||
{ticket.category.name}
|
||||
<span className="text-gray-400"> › </span>
|
||||
<span className="text-gray-600"> › </span>
|
||||
{ticket.type.name}
|
||||
<span className="text-gray-400"> › </span>
|
||||
<span className="text-gray-600"> › </span>
|
||||
{ticket.item.name}
|
||||
</p>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
@@ -535,44 +578,44 @@ export default function TicketDetail() {
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<Avatar name={ticket.createdBy.displayName} size="sm" />
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">Opened by</p>
|
||||
<p className="text-xs font-medium text-gray-700">{ticket.createdBy.displayName}</p>
|
||||
<p className="text-xs text-gray-500">Opened by</p>
|
||||
<p className="text-xs font-medium text-gray-300">{ticket.createdBy.displayName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">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-500">Created</p>
|
||||
<p className="text-xs text-gray-300">{format(new Date(ticket.createdAt), 'MMM d, yyyy HH:mm')}</p>
|
||||
</div>
|
||||
{ticket.resolvedAt && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">Resolved</p>
|
||||
<p className="text-xs text-gray-700">{format(new Date(ticket.resolvedAt), 'MMM d, yyyy')}</p>
|
||||
<p className="text-xs text-gray-500">Resolved</p>
|
||||
<p className="text-xs text-gray-300">{format(new Date(ticket.resolvedAt), 'MMM d, yyyy HH:mm')}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">Updated</p>
|
||||
<p className="text-xs text-gray-700">{formatDistanceToNow(new Date(ticket.updatedAt), { addSuffix: true })}</p>
|
||||
<p className="text-xs text-gray-500">Updated</p>
|
||||
<p className="text-xs text-gray-300">{formatDistanceToNow(new Date(ticket.updatedAt), { addSuffix: true })}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
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} />
|
||||
Edit title & overview
|
||||
</button>
|
||||
{authUser?.role === 'ADMIN' && (
|
||||
{isAdmin && (
|
||||
<button
|
||||
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} />
|
||||
Delete ticket
|
||||
|
||||
@@ -125,11 +125,11 @@ export default function AdminCTI() {
|
||||
}
|
||||
}
|
||||
|
||||
const panelClass = 'bg-white border border-gray-200 rounded-xl overflow-hidden flex flex-col'
|
||||
const panelHeaderClass = 'flex items-center justify-between px-4 py-3 border-b border-gray-100 bg-gray-50'
|
||||
const panelClass = 'bg-gray-900 border border-gray-800 rounded-xl overflow-hidden flex flex-col'
|
||||
const panelHeaderClass = 'flex items-center justify-between px-4 py-3 border-b border-gray-800'
|
||||
const itemClass = (active: boolean) =>
|
||||
`flex items-center justify-between px-4 py-2.5 cursor-pointer group transition-colors ${
|
||||
active ? 'bg-blue-50 border-l-2 border-blue-500' : 'hover:bg-gray-50 border-l-2 border-transparent'
|
||||
active ? 'bg-blue-600/20 border-l-2 border-blue-500' : 'hover:bg-gray-800 border-l-2 border-transparent'
|
||||
}`
|
||||
|
||||
return (
|
||||
@@ -138,17 +138,17 @@ export default function AdminCTI() {
|
||||
{/* Categories */}
|
||||
<div className={panelClass}>
|
||||
<div className={panelHeaderClass}>
|
||||
<h3 className="text-sm font-semibold text-gray-700">Categories</h3>
|
||||
<h3 className="text-sm font-semibold text-gray-300">Categories</h3>
|
||||
<button
|
||||
onClick={() => openAdd('category')}
|
||||
className="text-blue-600 hover:text-blue-800 transition-colors"
|
||||
className="text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{categories.length === 0 ? (
|
||||
<p className="text-xs text-gray-400 text-center py-8">No categories</p>
|
||||
<p className="text-xs text-gray-600 text-center py-8">No categories</p>
|
||||
) : (
|
||||
categories.map((cat) => (
|
||||
<div
|
||||
@@ -156,21 +156,21 @@ export default function AdminCTI() {
|
||||
className={itemClass(selectedCategory?.id === cat.id)}
|
||||
onClick={() => selectCategory(cat)}
|
||||
>
|
||||
<span className="text-sm text-gray-800">{cat.name}</span>
|
||||
<span className="text-sm text-gray-300">{cat.name}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); openEdit('category', cat) }}
|
||||
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-gray-700 transition-all"
|
||||
className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-gray-300 transition-all"
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDelete('category', cat) }}
|
||||
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-600 transition-all"
|
||||
className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 transition-all"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
<ChevronRight size={14} className="text-gray-300" />
|
||||
<ChevronRight size={14} className="text-gray-700" />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
@@ -181,16 +181,16 @@ export default function AdminCTI() {
|
||||
{/* Types */}
|
||||
<div className={panelClass}>
|
||||
<div className={panelHeaderClass}>
|
||||
<h3 className="text-sm font-semibold text-gray-700">
|
||||
<h3 className="text-sm font-semibold text-gray-300">
|
||||
Types
|
||||
{selectedCategory && (
|
||||
<span className="ml-1 font-normal text-gray-400">— {selectedCategory.name}</span>
|
||||
<span className="ml-1 font-normal text-gray-500">— {selectedCategory.name}</span>
|
||||
)}
|
||||
</h3>
|
||||
{selectedCategory && (
|
||||
<button
|
||||
onClick={() => openAdd('type')}
|
||||
className="text-blue-600 hover:text-blue-800 transition-colors"
|
||||
className="text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
@@ -198,9 +198,9 @@ export default function AdminCTI() {
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{!selectedCategory ? (
|
||||
<p className="text-xs text-gray-400 text-center py-8">Select a category</p>
|
||||
<p className="text-xs text-gray-600 text-center py-8">Select a category</p>
|
||||
) : types.length === 0 ? (
|
||||
<p className="text-xs text-gray-400 text-center py-8">No types</p>
|
||||
<p className="text-xs text-gray-600 text-center py-8">No types</p>
|
||||
) : (
|
||||
types.map((type) => (
|
||||
<div
|
||||
@@ -208,21 +208,21 @@ export default function AdminCTI() {
|
||||
className={itemClass(selectedType?.id === type.id)}
|
||||
onClick={() => selectType(type)}
|
||||
>
|
||||
<span className="text-sm text-gray-800">{type.name}</span>
|
||||
<span className="text-sm text-gray-300">{type.name}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); openEdit('type', type) }}
|
||||
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-gray-700 transition-all"
|
||||
className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-gray-300 transition-all"
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDelete('type', type) }}
|
||||
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-600 transition-all"
|
||||
className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 transition-all"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
<ChevronRight size={14} className="text-gray-300" />
|
||||
<ChevronRight size={14} className="text-gray-700" />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
@@ -233,16 +233,16 @@ export default function AdminCTI() {
|
||||
{/* Items */}
|
||||
<div className={panelClass}>
|
||||
<div className={panelHeaderClass}>
|
||||
<h3 className="text-sm font-semibold text-gray-700">
|
||||
<h3 className="text-sm font-semibold text-gray-300">
|
||||
Items
|
||||
{selectedType && (
|
||||
<span className="ml-1 font-normal text-gray-400">— {selectedType.name}</span>
|
||||
<span className="ml-1 font-normal text-gray-500">— {selectedType.name}</span>
|
||||
)}
|
||||
</h3>
|
||||
{selectedType && (
|
||||
<button
|
||||
onClick={() => openAdd('item')}
|
||||
className="text-blue-600 hover:text-blue-800 transition-colors"
|
||||
className="text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
@@ -250,23 +250,23 @@ export default function AdminCTI() {
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{!selectedType ? (
|
||||
<p className="text-xs text-gray-400 text-center py-8">Select a type</p>
|
||||
<p className="text-xs text-gray-600 text-center py-8">Select a type</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-xs text-gray-400 text-center py-8">No items</p>
|
||||
<p className="text-xs text-gray-600 text-center py-8">No items</p>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<div key={item.id} className={itemClass(false)} onClick={() => {}}>
|
||||
<span className="text-sm text-gray-800">{item.name}</span>
|
||||
<span className="text-sm text-gray-300">{item.name}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); openEdit('item', item) }}
|
||||
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-gray-700 transition-all"
|
||||
className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-gray-300 transition-all"
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDelete('item', item) }}
|
||||
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-600 transition-all"
|
||||
className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 transition-all"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
@@ -286,21 +286,21 @@ export default function AdminCTI() {
|
||||
>
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={nameValue}
|
||||
onChange={(e) => setNameValue(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full bg-gray-800 border border-gray-700 text-gray-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
className="px-4 py-2 text-sm text-gray-400 border border-gray-700 rounded-lg hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@@ -25,9 +25,24 @@ const BLANK_FORM: UserForm = {
|
||||
const ROLE_LABELS: Record<Role, string> = {
|
||||
ADMIN: 'Admin',
|
||||
AGENT: 'Agent',
|
||||
USER: 'User',
|
||||
SERVICE: 'Service',
|
||||
}
|
||||
|
||||
const ROLE_BADGE: Record<Role, string> = {
|
||||
ADMIN: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
|
||||
AGENT: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
USER: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
|
||||
SERVICE: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
|
||||
}
|
||||
|
||||
const ROLE_DESCRIPTIONS: Record<Role, string> = {
|
||||
ADMIN: 'Full access — manage users, CTI config, close and delete tickets',
|
||||
AGENT: 'Manage tickets — create, update, assign, comment, change status',
|
||||
USER: 'Basic access — view tickets and add comments only',
|
||||
SERVICE: 'Automation account — authenticates via API key, no password login',
|
||||
}
|
||||
|
||||
export default function AdminUsers() {
|
||||
const { user: authUser } = useAuth()
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
@@ -133,8 +148,8 @@ export default function AdminUsers() {
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
const labelClass = 'block text-sm font-medium text-gray-700 mb-1'
|
||||
'w-full bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent'
|
||||
const labelClass = 'block text-sm font-medium text-gray-300 mb-1'
|
||||
|
||||
return (
|
||||
<Layout
|
||||
@@ -149,9 +164,9 @@ export default function AdminUsers() {
|
||||
</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">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<thead className="border-b border-gray-800">
|
||||
<tr>
|
||||
<th className="text-left px-5 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
User
|
||||
@@ -168,31 +183,23 @@ export default function AdminUsers() {
|
||||
<th className="px-5 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
<tbody className="divide-y divide-gray-800">
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className="hover:bg-gray-50">
|
||||
<td className="px-5 py-3 font-medium text-gray-900">{u.displayName}</td>
|
||||
<tr key={u.id} className="hover:bg-gray-800/50">
|
||||
<td className="px-5 py-3 font-medium text-gray-100">{u.displayName}</td>
|
||||
<td className="px-5 py-3 text-gray-500 font-mono text-xs">{u.username}</td>
|
||||
<td className="px-5 py-3">
|
||||
<span
|
||||
className={`inline-flex px-2 py-0.5 rounded text-xs font-medium ${
|
||||
u.role === 'ADMIN'
|
||||
? 'bg-purple-100 text-purple-700'
|
||||
: u.role === 'SERVICE'
|
||||
? 'bg-orange-100 text-orange-700'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
<span className={`inline-flex px-2 py-0.5 rounded text-xs font-medium border ${ROLE_BADGE[u.role]}`}>
|
||||
{ROLE_LABELS[u.role]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-gray-500">{u.email}</td>
|
||||
<td className="px-5 py-3 text-gray-400">{u.email}</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{u.role === 'SERVICE' && (
|
||||
<button
|
||||
onClick={() => handleRegenerateKey(u)}
|
||||
className="text-gray-400 hover:text-gray-700 transition-colors"
|
||||
className="text-gray-600 hover:text-gray-300 transition-colors"
|
||||
title="Regenerate API key"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
@@ -200,14 +207,14 @@ export default function AdminUsers() {
|
||||
)}
|
||||
<button
|
||||
onClick={() => openEdit(u)}
|
||||
className="text-gray-400 hover:text-gray-700 transition-colors"
|
||||
className="text-gray-600 hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
{u.id !== authUser?.id && (
|
||||
<button
|
||||
onClick={() => handleDelete(u)}
|
||||
className="text-gray-400 hover:text-red-600 transition-colors"
|
||||
className="text-gray-600 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
@@ -228,17 +235,17 @@ export default function AdminUsers() {
|
||||
>
|
||||
{newApiKey ? (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<p className="text-sm font-medium text-amber-800 mb-2">
|
||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg p-4">
|
||||
<p className="text-sm font-medium text-amber-400 mb-2">
|
||||
API Key — copy it now, it won't be shown again
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-xs bg-white border border-amber-200 rounded px-3 py-2 font-mono break-all">
|
||||
<code className="flex-1 text-xs bg-gray-800 border border-gray-700 text-gray-300 rounded px-3 py-2 font-mono break-all">
|
||||
{newApiKey}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard(newApiKey)}
|
||||
className="flex-shrink-0 text-amber-700 hover:text-amber-900 transition-colors"
|
||||
className="flex-shrink-0 text-amber-400 hover:text-amber-300 transition-colors"
|
||||
>
|
||||
{copiedKey === newApiKey ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
@@ -254,7 +261,7 @@ export default function AdminUsers() {
|
||||
) : (
|
||||
<form onSubmit={modal === 'add' ? handleAdd : handleEdit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 text-sm px-3 py-2 rounded-lg">
|
||||
<div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm px-3 py-2 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -296,7 +303,10 @@ export default function AdminUsers() {
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>
|
||||
Password {modal === 'edit' && <span className="text-gray-400 font-normal">(leave blank to keep current)</span>}
|
||||
Password{' '}
|
||||
{modal === 'edit' && (
|
||||
<span className="text-gray-500 font-normal">(leave blank to keep current)</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
@@ -316,16 +326,18 @@ export default function AdminUsers() {
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="AGENT">Agent</option>
|
||||
<option value="USER">User</option>
|
||||
<option value="ADMIN">Admin</option>
|
||||
<option value="SERVICE">Service (API key auth)</option>
|
||||
<option value="SERVICE">Service</option>
|
||||
</select>
|
||||
<p className="mt-1.5 text-xs text-gray-500">{ROLE_DESCRIPTIONS[form.role]}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
className="px-4 py-2 text-sm text-gray-400 border border-gray-700 rounded-lg hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@@ -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 interface User {
|
||||
|
||||
Reference in New Issue
Block a user