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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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