Initial commit: TicketingSystem
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user