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:
7
client/src/components/AdminRoute.tsx
Normal file
7
client/src/components/AdminRoute.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Navigate, Outlet } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
export default function AdminRoute() {
|
||||
const { user } = useAuth()
|
||||
return user?.role === 'ADMIN' ? <Outlet /> : <Navigate to="/" replace />
|
||||
}
|
||||
110
client/src/components/CTISelect.tsx
Normal file
110
client/src/components/CTISelect.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import api from '../api/client'
|
||||
import { Category, CTIType, Item } from '../types'
|
||||
|
||||
interface CTISelectProps {
|
||||
value: { categoryId: string; typeId: string; itemId: string }
|
||||
onChange: (value: { categoryId: string; typeId: string; itemId: string }) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function CTISelect({ value, onChange, disabled }: CTISelectProps) {
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [types, setTypes] = useState<CTIType[]>([])
|
||||
const [items, setItems] = useState<Item[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Category[]>('/cti/categories').then((r) => setCategories(r.data))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!value.categoryId) {
|
||||
setTypes([])
|
||||
setItems([])
|
||||
return
|
||||
}
|
||||
api
|
||||
.get<CTIType[]>('/cti/types', { params: { categoryId: value.categoryId } })
|
||||
.then((r) => setTypes(r.data))
|
||||
}, [value.categoryId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!value.typeId) {
|
||||
setItems([])
|
||||
return
|
||||
}
|
||||
api
|
||||
.get<Item[]>('/cti/items', { params: { typeId: value.typeId } })
|
||||
.then((r) => setItems(r.data))
|
||||
}, [value.typeId])
|
||||
|
||||
const handleCategory = (categoryId: string) => {
|
||||
onChange({ categoryId, typeId: '', itemId: '' })
|
||||
}
|
||||
|
||||
const handleType = (typeId: string) => {
|
||||
onChange({ ...value, typeId, itemId: '' })
|
||||
}
|
||||
|
||||
const handleItem = (itemId: string) => {
|
||||
onChange({ ...value, itemId })
|
||||
}
|
||||
|
||||
const selectClass =
|
||||
'block w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-50 disabled:text-gray-400'
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Category</label>
|
||||
<select
|
||||
value={value.categoryId}
|
||||
onChange={(e) => handleCategory(e.target.value)}
|
||||
disabled={disabled}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Type</label>
|
||||
<select
|
||||
value={value.typeId}
|
||||
onChange={(e) => handleType(e.target.value)}
|
||||
disabled={disabled || !value.categoryId}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
{types.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Item</label>
|
||||
<select
|
||||
value={value.itemId}
|
||||
onChange={(e) => handleItem(e.target.value)}
|
||||
disabled={disabled || !value.typeId}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
{items.map((i) => (
|
||||
<option key={i.id} value={i.id}>
|
||||
{i.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
88
client/src/components/Layout.tsx
Normal file
88
client/src/components/Layout.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { LayoutDashboard, Users, Settings, LogOut, Plus } from 'lucide-react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode
|
||||
title?: string
|
||||
action?: ReactNode
|
||||
}
|
||||
|
||||
export default function Layout({ children, title, action }: LayoutProps) {
|
||||
const { user, logout } = useAuth()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
|
||||
...(user?.role === 'ADMIN'
|
||||
? [
|
||||
{ to: '/admin/users', icon: Users, label: 'Users' },
|
||||
{ to: '/admin/cti', icon: Settings, label: 'CTI Config' },
|
||||
]
|
||||
: []),
|
||||
]
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-100 overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-60 bg-gray-900 text-white flex flex-col flex-shrink-0">
|
||||
<div className="p-5 border-b border-gray-700">
|
||||
<h1 className="text-base font-bold text-white tracking-wide">Ticketing</h1>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{user?.displayName}</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-3 space-y-0.5">
|
||||
{navItems.map(({ to, icon: Icon, label }) => (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
location.pathname === to
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Icon size={15} />
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="p-3 border-t border-gray-700 space-y-0.5">
|
||||
<Link
|
||||
to="/tickets/new"
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-gray-300 hover:bg-gray-800 hover:text-white transition-colors"
|
||||
>
|
||||
<Plus size={15} />
|
||||
New Ticket
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-gray-300 hover:bg-gray-800 hover:text-white w-full transition-colors"
|
||||
>
|
||||
<LogOut size={15} />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{(title || action) && (
|
||||
<header className="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between flex-shrink-0">
|
||||
{title && <h2 className="text-lg font-semibold text-gray-900">{title}</h2>}
|
||||
{action && <div>{action}</div>}
|
||||
</header>
|
||||
)}
|
||||
<main className="flex-1 overflow-auto p-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
38
client/src/components/Modal.tsx
Normal file
38
client/src/components/Modal.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { ReactNode, useEffect } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
interface ModalProps {
|
||||
title: string
|
||||
onClose: () => void
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function Modal({ title, onClose, children }: ModalProps) {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md mx-4">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-base font-semibold text-gray-900">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-6 py-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
client/src/components/PrivateRoute.tsx
Normal file
16
client/src/components/PrivateRoute.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Navigate, Outlet } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
export default function PrivateRoute() {
|
||||
const { user, loading } = useAuth()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-gray-50">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return user ? <Outlet /> : <Navigate to="/login" replace />
|
||||
}
|
||||
16
client/src/components/SeverityBadge.tsx
Normal file
16
client/src/components/SeverityBadge.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
const config: Record<number, { label: string; className: string }> = {
|
||||
1: { label: 'SEV 1', className: 'bg-red-100 text-red-800 border-red-200' },
|
||||
2: { label: 'SEV 2', className: 'bg-orange-100 text-orange-800 border-orange-200' },
|
||||
3: { label: 'SEV 3', className: 'bg-yellow-100 text-yellow-800 border-yellow-200' },
|
||||
4: { label: 'SEV 4', className: 'bg-blue-100 text-blue-800 border-blue-200' },
|
||||
5: { label: 'SEV 5', className: 'bg-gray-100 text-gray-600 border-gray-200' },
|
||||
}
|
||||
|
||||
export default function SeverityBadge({ severity }: { severity: number }) {
|
||||
const { label, className } = config[severity] ?? config[5]
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold border ${className}`}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
17
client/src/components/StatusBadge.tsx
Normal file
17
client/src/components/StatusBadge.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { TicketStatus } from '../types'
|
||||
|
||||
const config: Record<TicketStatus, { label: string; className: string }> = {
|
||||
OPEN: { label: 'Open', className: 'bg-blue-100 text-blue-800 border-blue-200' },
|
||||
IN_PROGRESS: { label: 'In Progress', className: 'bg-yellow-100 text-yellow-800 border-yellow-200' },
|
||||
RESOLVED: { label: 'Resolved', className: 'bg-green-100 text-green-800 border-green-200' },
|
||||
CLOSED: { label: 'Closed', className: 'bg-gray-100 text-gray-500 border-gray-200' },
|
||||
}
|
||||
|
||||
export default function StatusBadge({ status }: { status: TicketStatus }) {
|
||||
const { label, className } = config[status]
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border ${className}`}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user