commit 21894fad7a5f09d45746f53232476880b4850bd0 Author: josh Date: Mon Mar 30 19:38:32 2026 -0400 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..30a3ecc --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# ── Registry ────────────────────────────────────────────────────────────────── +# Hostname of your container registry (no trailing slash) +REGISTRY=gitea.thewrightserver.net + +# Image tag to deploy (default: latest) +TAG=latest + +# ── PostgreSQL ──────────────────────────────────────────────────────────────── +POSTGRES_PASSWORD=change-this-to-a-strong-password + +# ── App ─────────────────────────────────────────────────────────────────────── +# Generate with: openssl rand -hex 64 +JWT_SECRET=change-this-to-a-long-random-string + +# Fully-qualified domain — must match your Traefik routing rule +DOMAIN=tickets.thewrightserver.net diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..85d21dd --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,63 @@ +name: Build & Push + +on: + push: + branches: [main] + workflow_dispatch: + +env: + REGISTRY: ${{ vars.REGISTRY }} + OWNER: ${{ github.repository_owner }} + +jobs: + build-server: + name: Build Server + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Log in to registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ env.OWNER }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push server + uses: docker/build-push-action@v6 + with: + context: ./server + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.OWNER }}/ticketing-server:latest + ${{ env.REGISTRY }}/${{ env.OWNER }}/ticketing-server:${{ github.sha }} + + build-client: + name: Build Client + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Log in to registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ env.OWNER }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push client + uses: docker/build-push-action@v6 + with: + context: ./client + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.OWNER }}/ticketing-client:latest + ${{ env.REGISTRY }}/${{ env.OWNER }}/ticketing-client:${{ github.sha }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d5c4f8c --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# Environment +.env +*.env.local + +# Logs +*.log + +# OS +.DS_Store +Thumbs.db + +# Claude Code +.claude/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..95f4df5 --- /dev/null +++ b/README.md @@ -0,0 +1,213 @@ +# TicketingSystem + +Internal ticketing system with CTI-based routing, severity levels, and n8n/automation integration. + +## Features + +- **CTI routing** — tickets are categorised by Category → Type → Item, reroutable at any time +- **Severity 1–5** — SEV 1 (critical) through SEV 5 (minimal); dashboard sorts by severity +- **Status lifecycle** — Open → In Progress → Resolved → Closed; resolved tickets auto-close after 14 days +- **Comments** — threaded comments per ticket with author attribution +- **Roles** — Admin, Agent, Service (API key auth for automation accounts) +- **Admin panel** — manage users and the full CTI hierarchy via UI +- **n8n ready** — service accounts authenticate via `X-Api-Key` header + +--- + +## Production Deployment + +### Prerequisites + +- Docker + Docker Compose +- Traefik running with a `proxy` Docker network +- Access to your Gitea container registry + +### 1. Copy files to your server + +```bash +scp docker-compose.yml .env.example user@your-server:~/ticketing/ +``` + +### 2. Configure environment + +```bash +cd ~/ticketing +cp .env.example .env +``` + +Edit `.env`: + +```env +REGISTRY=gitea.thewrightserver.net +TAG=latest +POSTGRES_PASSWORD= +JWT_SECRET= +DOMAIN=tickets.thewrightserver.net +``` + +### 3. Create the initial database migration + +Run this **once** on your local dev machine before first deploy, then commit the result: + +```bash +cd server +npm install +# point at your local postgres or use DATABASE_URL from .env +npm run db:migrate # creates prisma/migrations/ +``` + +Commit the generated `server/prisma/migrations/` folder — `prisma migrate deploy` in the container will apply it on startup. + +### 4. Deploy + +```bash +docker compose pull +docker compose up -d +``` + +### 5. Seed (first deploy only) + +```bash +docker compose exec server npm run db:seed +``` + +This creates: +- `admin` user (password: `admin123`) — **change this immediately** +- `goddard` service account — API key is printed to the console; copy it now + +--- + +## Development + +### Requirements + +- Node.js 22+ +- PostgreSQL (local or via Docker) + +### Start Postgres + +```bash +docker run -d \ + --name ticketing-pg \ + -e POSTGRES_DB=ticketing \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=postgres \ + -p 5432:5432 \ + postgres:16-alpine +``` + +### Server + +```bash +cd server +cp .env.example .env # set DATABASE_URL and JWT_SECRET +npm install +npm run db:migrate # creates tables + migration files +npm run db:seed # seeds admin + Goddard + sample CTI +npm run dev # http://localhost:3000 +``` + +### Client + +```bash +cd client +npm install +npm run dev # http://localhost:5173 (proxies /api to :3000) +``` + +--- + +## n8n Integration (Goddard) + +The `goddard` service account authenticates via API key — no login flow needed. + +**Create a ticket from n8n:** + +``` +POST https://tickets.thewrightserver.net/api/tickets +X-Api-Key: sk_ +Content-Type: application/json + +{ + "title": "[Plex] Backup - 2026-03-30T02:00:00", + "overview": "Automated nightly Plex backup completed.", + "severity": 5, + "categoryId": "", + "typeId": "", + "itemId": "", + "assigneeId": "" +} +``` + +CTI IDs can be fetched from: +- `GET /api/cti/categories` +- `GET /api/cti/types?categoryId=` +- `GET /api/cti/items?typeId=` + +To regenerate the Goddard API key: Admin → Users → refresh icon next to Goddard. + +--- + +## CI/CD + +Push to `main` triggers `.gitea/workflows/build.yml`, which builds and pushes two images in parallel: + +| Image | Tag | +|---|---| +| `$REGISTRY/josh/ticketing-server` | `latest`, `` | +| `$REGISTRY/josh/ticketing-client` | `latest`, `` | + +**Gitea repository secrets/variables required:** + +| Name | Type | Value | +|---|---|---| +| `REGISTRY` | Variable | `gitea.thewrightserver.net` | +| `REGISTRY_TOKEN` | Secret | Gitea personal access token with `write:packages` | + +Set these under **Repository → Settings → Actions → Variables / Secrets**. + +To deploy a specific commit SHA instead of latest: + +```bash +TAG= docker compose up -d +``` + +--- + +## Environment Variables + +| Variable | Required | Description | +|---|---|---| +| `DATABASE_URL` | Yes | PostgreSQL connection string | +| `JWT_SECRET` | Yes | Secret for signing JWTs — use `openssl rand -hex 64` | +| `CLIENT_URL` | Yes | Allowed CORS origin (your domain) | +| `PORT` | No | Server port (default: `3000`) | +| `REGISTRY` | Deploy | Container registry hostname | +| `POSTGRES_PASSWORD` | Deploy | Postgres password | +| `DOMAIN` | Deploy | Public domain for Traefik routing | +| `TAG` | Deploy | Image tag to deploy (default: `latest`) | + +--- + +## Ticket Severity + +| Level | Label | Meaning | +|---|---|---| +| 1 | SEV 1 | Critical — immediate action required | +| 2 | SEV 2 | High — significant impact | +| 3 | SEV 3 | Medium — standard priority | +| 4 | SEV 4 | Low — minor issue | +| 5 | SEV 5 | Minimal — informational / automated | + +Tickets are sorted SEV 1 → SEV 5 on the dashboard. Paging by severity is planned for a future release. + +--- + +## Ticket Status Lifecycle + +``` +OPEN → IN_PROGRESS → RESOLVED ──(14 days)──→ CLOSED + ↑ + re-opens reset + the 14-day timer +``` diff --git a/client/.dockerignore b/client/.dockerignore new file mode 100644 index 0000000..3b24e4a --- /dev/null +++ b/client/.dockerignore @@ -0,0 +1,4 @@ +node_modules +dist +.env +*.log diff --git a/client/Dockerfile b/client/Dockerfile new file mode 100644 index 0000000..af0a407 --- /dev/null +++ b/client/Dockerfile @@ -0,0 +1,12 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..3ff5fef --- /dev/null +++ b/client/index.html @@ -0,0 +1,12 @@ + + + + + + Ticketing System + + +
+ + + diff --git a/client/nginx.conf b/client/nginx.conf new file mode 100644 index 0000000..017f14c --- /dev/null +++ b/client/nginx.conf @@ -0,0 +1,18 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + # React Router — serve index.html for all non-asset paths + location / { + try_files $uri $uri/ /index.html; + } + + # Proxy API calls to the backend + location /api { + proxy_pass http://server:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..75d724c --- /dev/null +++ b/client/package.json @@ -0,0 +1,32 @@ +{ + "name": "ticketing-client", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@hookform/resolvers": "^3.9.1", + "axios": "^1.7.9", + "date-fns": "^3.6.0", + "lucide-react": "^0.468.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-hook-form": "^7.54.2", + "react-router-dom": "^6.28.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.16", + "typescript": "^5.7.2", + "vite": "^6.0.5" + } +} diff --git a/client/postcss.config.js b/client/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/client/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..abf032b --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,32 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +import { AuthProvider } from './contexts/AuthContext' +import PrivateRoute from './components/PrivateRoute' +import AdminRoute from './components/AdminRoute' +import Login from './pages/Login' +import Dashboard from './pages/Dashboard' +import TicketDetail from './pages/TicketDetail' +import NewTicket from './pages/NewTicket' +import AdminUsers from './pages/admin/Users' +import AdminCTI from './pages/admin/CTI' + +export default function App() { + return ( + + + + } /> + }> + } /> + } /> + } /> + }> + } /> + } /> + + + } /> + + + + ) +} diff --git a/client/src/api/client.ts b/client/src/api/client.ts new file mode 100644 index 0000000..f598681 --- /dev/null +++ b/client/src/api/client.ts @@ -0,0 +1,7 @@ +import axios from 'axios' + +const api = axios.create({ + baseURL: '/api', +}) + +export default api diff --git a/client/src/components/AdminRoute.tsx b/client/src/components/AdminRoute.tsx new file mode 100644 index 0000000..fbce507 --- /dev/null +++ b/client/src/components/AdminRoute.tsx @@ -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' ? : +} diff --git a/client/src/components/CTISelect.tsx b/client/src/components/CTISelect.tsx new file mode 100644 index 0000000..6198632 --- /dev/null +++ b/client/src/components/CTISelect.tsx @@ -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([]) + const [types, setTypes] = useState([]) + const [items, setItems] = useState([]) + + useEffect(() => { + api.get('/cti/categories').then((r) => setCategories(r.data)) + }, []) + + useEffect(() => { + if (!value.categoryId) { + setTypes([]) + setItems([]) + return + } + api + .get('/cti/types', { params: { categoryId: value.categoryId } }) + .then((r) => setTypes(r.data)) + }, [value.categoryId]) + + useEffect(() => { + if (!value.typeId) { + setItems([]) + return + } + api + .get('/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 ( +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ ) +} diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx new file mode 100644 index 0000000..60cee22 --- /dev/null +++ b/client/src/components/Layout.tsx @@ -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 ( +
+ {/* Sidebar */} + + + {/* Main */} +
+ {(title || action) && ( +
+ {title &&

{title}

} + {action &&
{action}
} +
+ )} +
{children}
+
+
+ ) +} diff --git a/client/src/components/Modal.tsx b/client/src/components/Modal.tsx new file mode 100644 index 0000000..f084fe3 --- /dev/null +++ b/client/src/components/Modal.tsx @@ -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 ( +
{ if (e.target === e.currentTarget) onClose() }} + > +
+
+

{title}

+ +
+
{children}
+
+
+ ) +} diff --git a/client/src/components/PrivateRoute.tsx b/client/src/components/PrivateRoute.tsx new file mode 100644 index 0000000..1e78d8e --- /dev/null +++ b/client/src/components/PrivateRoute.tsx @@ -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 ( +
+
Loading...
+
+ ) + } + + return user ? : +} diff --git a/client/src/components/SeverityBadge.tsx b/client/src/components/SeverityBadge.tsx new file mode 100644 index 0000000..271f3d1 --- /dev/null +++ b/client/src/components/SeverityBadge.tsx @@ -0,0 +1,16 @@ +const config: Record = { + 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 ( + + {label} + + ) +} diff --git a/client/src/components/StatusBadge.tsx b/client/src/components/StatusBadge.tsx new file mode 100644 index 0000000..8e5f46f --- /dev/null +++ b/client/src/components/StatusBadge.tsx @@ -0,0 +1,17 @@ +import { TicketStatus } from '../types' + +const config: Record = { + 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 ( + + {label} + + ) +} diff --git a/client/src/contexts/AuthContext.tsx b/client/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..00b4940 --- /dev/null +++ b/client/src/contexts/AuthContext.tsx @@ -0,0 +1,59 @@ +import { createContext, useContext, useState, useEffect, ReactNode } from 'react' +import api from '../api/client' +import { User } from '../types' + +interface AuthContextType { + user: User | null + loading: boolean + login: (username: string, password: string) => Promise + logout: () => void +} + +const AuthContext = createContext(null!) + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + const token = localStorage.getItem('token') + if (token) { + api.defaults.headers.common['Authorization'] = `Bearer ${token}` + api + .get('/auth/me') + .then((res) => setUser(res.data)) + .catch(() => { + localStorage.removeItem('token') + delete api.defaults.headers.common['Authorization'] + }) + .finally(() => setLoading(false)) + } else { + setLoading(false) + } + }, []) + + const login = async (username: string, password: string) => { + const res = await api.post<{ token: string; user: User }>('/auth/login', { + username, + password, + }) + const { token, user } = res.data + localStorage.setItem('token', token) + api.defaults.headers.common['Authorization'] = `Bearer ${token}` + setUser(user) + } + + const logout = () => { + localStorage.removeItem('token') + delete api.defaults.headers.common['Authorization'] + setUser(null) + } + + return ( + + {children} + + ) +} + +export const useAuth = () => useContext(AuthContext) diff --git a/client/src/index.css b/client/src/index.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/client/src/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/client/src/main.tsx b/client/src/main.tsx new file mode 100644 index 0000000..520b520 --- /dev/null +++ b/client/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App' + +createRoot(document.getElementById('root')!).render( + + + +) diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx new file mode 100644 index 0000000..c391814 --- /dev/null +++ b/client/src/pages/Dashboard.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [search, setSearch] = useState('') + const [status, setStatus] = useState('') + const [severity, setSeverity] = useState('') + + const fetchTickets = useCallback(() => { + setLoading(true) + const params: Record = {} + if (status) params.status = status + if (severity) params.severity = severity + if (search) params.search = search + api + .get('/tickets', { params }) + .then((r) => setTickets(r.data)) + .finally(() => setLoading(false)) + }, [status, severity, search]) + + useEffect(() => { + const t = setTimeout(fetchTickets, 300) + return () => clearTimeout(t) + }, [fetchTickets]) + + return ( + + + New Ticket + + } + > + {/* Filters */} +
+
+ + 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" + /> +
+ + + + +
+ + {/* Ticket list */} + {loading ? ( +
Loading...
+ ) : tickets.length === 0 ? ( +
No tickets found
+ ) : ( +
+ {tickets.map((ticket) => ( + + {/* Severity stripe */} +
+ +
+
+ + + + {ticket.category.name} › {ticket.type.name} › {ticket.item.name} + +
+

+ {ticket.title} +

+

{ticket.overview}

+
+ +
+
+ {ticket.assignee?.displayName ?? 'Unassigned'} +
+
{ticket._count?.comments ?? 0} comments
+
{formatDistanceToNow(new Date(ticket.createdAt), { addSuffix: true })}
+
+ + ))} +
+ )} + + ) +} diff --git a/client/src/pages/Login.tsx b/client/src/pages/Login.tsx new file mode 100644 index 0000000..b22c5db --- /dev/null +++ b/client/src/pages/Login.tsx @@ -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 ( +
+
+
+

Ticketing System

+

Sign in to your account

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ + +
+
+
+ ) +} diff --git a/client/src/pages/NewTicket.tsx b/client/src/pages/NewTicket.tsx new file mode 100644 index 0000000..ecb108a --- /dev/null +++ b/client/src/pages/NewTicket.tsx @@ -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([]) + const [error, setError] = useState('') + const [submitting, setSubmitting] = useState(false) + + const [form, setForm] = useState({ + title: '', + overview: '', + severity: 3, + assigneeId: '', + categoryId: '', + typeId: '', + itemId: '', + }) + + useEffect(() => { + api.get('/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 = { + 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 ( + +
+
+ {error && ( +
+ {error} +
+ )} + +
+ + setForm((f) => ({ ...f, title: e.target.value }))} + required + className={inputClass} + placeholder="Brief description of the issue" + /> +
+ +
+ +