feat: Initial TicketHub release

- FastAPI backend with SQLite
- Projects, Tickets, Comments, Webhooks
- Modern web UI (TailwindCSS)
- Webhook support for integrations
- Docker support
This commit is contained in:
Ricel Leite 2026-02-18 16:38:46 -03:00
parent 75ab2936cc
commit 9aa13a508f
14 changed files with 1043 additions and 2 deletions

View File

@ -1,3 +1,88 @@
# tickethub
# 🎫 TicketHub
Lightweight open-source ticket/issue tracking system with webhooks
Lightweight open-source ticket/issue tracking system with webhook support.
## Features
- **Projects** - Organize tickets by project with unique keys (e.g., PROJ-123)
- **Tickets** - Create, update, and track issues with status and priority
- **Comments** - Add comments to tickets for collaboration
- **Webhooks** - Trigger external systems on ticket events
- **Simple** - SQLite database, no complex setup required
## Quick Start
### Docker
```bash
docker-compose up -d
```
Access at http://localhost:8080
### Manual
```bash
cd backend
pip install -r requirements.txt
uvicorn app.main:app --host 0.0.0.0 --port 8000
```
## API Endpoints
### Projects
- `GET /api/projects` - List all projects
- `POST /api/projects` - Create project
- `GET /api/projects/{id}` - Get project
- `DELETE /api/projects/{id}` - Delete project
### Tickets
- `GET /api/tickets` - List tickets (filter by `project_id`, `status`)
- `POST /api/tickets` - Create ticket
- `GET /api/tickets/{id}` - Get ticket
- `GET /api/tickets/key/{key}` - Get ticket by key (e.g., PROJ-123)
- `PATCH /api/tickets/{id}` - Update ticket
- `DELETE /api/tickets/{id}` - Delete ticket
### Comments
- `GET /api/tickets/{id}/comments` - List comments
- `POST /api/tickets/{id}/comments` - Add comment
### Webhooks
- `GET /api/webhooks` - List webhooks
- `POST /api/webhooks` - Create webhook
- `DELETE /api/webhooks/{id}` - Delete webhook
- `PATCH /api/webhooks/{id}/toggle` - Enable/disable webhook
## Webhook Events
When configured, TicketHub sends POST requests to your webhook URL:
```json
{
"event": "ticket.created",
"timestamp": "2026-02-18T12:00:00Z",
"data": {
"id": 1,
"key": "PROJ-1",
"title": "Issue title",
"description": "...",
"status": "open",
"priority": "medium"
}
}
```
Events: `ticket.created`, `ticket.updated`, `comment.added`
## Integration with JIRA AI Fixer
Configure webhook URL in your project pointing to JIRA AI Fixer:
```
https://jira-fixer.example.com/api/webhook/tickethub
```
## License
MIT

14
backend/Dockerfile Normal file
View File

@ -0,0 +1,14 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
ENV DATABASE_PATH=/data/tickethub.db
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

41
backend/app/main.py Normal file
View File

@ -0,0 +1,41 @@
"""
TicketHub - Lightweight Issue Tracking System
"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from contextlib import asynccontextmanager
import os
from app.routers import tickets, projects, webhooks, health
from app.services.database import init_db
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
yield
app = FastAPI(
title="TicketHub",
description="Lightweight open-source ticket/issue tracking system",
version="1.0.0",
lifespan=lifespan
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# API routes
app.include_router(health.router, prefix="/api", tags=["health"])
app.include_router(projects.router, prefix="/api/projects", tags=["projects"])
app.include_router(tickets.router, prefix="/api/tickets", tags=["tickets"])
app.include_router(webhooks.router, prefix="/api/webhooks", tags=["webhooks"])
# Serve frontend
if os.path.exists("/app/static"):
app.mount("/", StaticFiles(directory="/app/static", html=True), name="static")

View File

@ -0,0 +1,66 @@
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
from enum import Enum
class TicketStatus(str, Enum):
OPEN = "open"
IN_PROGRESS = "in_progress"
RESOLVED = "resolved"
CLOSED = "closed"
class TicketPriority(str, Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
class ProjectCreate(BaseModel):
name: str
key: str = Field(..., pattern="^[A-Z]{2,10}$")
description: Optional[str] = None
webhook_url: Optional[str] = None
class Project(ProjectCreate):
id: int
created_at: datetime
ticket_count: int = 0
class TicketCreate(BaseModel):
project_id: int
title: str
description: str
priority: TicketPriority = TicketPriority.MEDIUM
labels: List[str] = []
class Ticket(TicketCreate):
id: int
key: str # e.g., "PROJ-123"
status: TicketStatus = TicketStatus.OPEN
created_at: datetime
updated_at: datetime
class TicketUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
status: Optional[TicketStatus] = None
priority: Optional[TicketPriority] = None
labels: Optional[List[str]] = None
class Comment(BaseModel):
id: int
ticket_id: int
author: str
content: str
created_at: datetime
class CommentCreate(BaseModel):
author: str = "system"
content: str
class WebhookConfig(BaseModel):
id: int
project_id: int
url: str
events: List[str] = ["ticket.created", "ticket.updated", "comment.added"]
active: bool = True

View File

@ -0,0 +1,7 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/health")
async def health_check():
return {"status": "healthy", "service": "tickethub"}

View File

@ -0,0 +1,65 @@
from fastapi import APIRouter, HTTPException
from typing import List
from app.models import Project, ProjectCreate
from app.services.database import get_db
import json
router = APIRouter()
@router.get("", response_model=List[Project])
async def list_projects():
db = await get_db()
cursor = await db.execute("""
SELECT p.*, COUNT(t.id) as ticket_count
FROM projects p
LEFT JOIN tickets t ON p.id = t.project_id
GROUP BY p.id
""")
rows = await cursor.fetchall()
await db.close()
return [dict(row) for row in rows]
@router.post("", response_model=Project)
async def create_project(project: ProjectCreate):
db = await get_db()
try:
cursor = await db.execute(
"INSERT INTO projects (name, key, description, webhook_url) VALUES (?, ?, ?, ?)",
(project.name, project.key.upper(), project.description, project.webhook_url)
)
await db.commit()
project_id = cursor.lastrowid
cursor = await db.execute("SELECT * FROM projects WHERE id = ?", (project_id,))
row = await cursor.fetchone()
await db.close()
return {**dict(row), "ticket_count": 0}
except Exception as e:
await db.close()
raise HTTPException(status_code=400, detail=str(e))
@router.get("/{project_id}", response_model=Project)
async def get_project(project_id: int):
db = await get_db()
cursor = await db.execute("""
SELECT p.*, COUNT(t.id) as ticket_count
FROM projects p
LEFT JOIN tickets t ON p.id = t.project_id
WHERE p.id = ?
GROUP BY p.id
""", (project_id,))
row = await cursor.fetchone()
await db.close()
if not row:
raise HTTPException(status_code=404, detail="Project not found")
return dict(row)
@router.delete("/{project_id}")
async def delete_project(project_id: int):
db = await get_db()
await db.execute("DELETE FROM tickets WHERE project_id = ?", (project_id,))
await db.execute("DELETE FROM webhooks WHERE project_id = ?", (project_id,))
await db.execute("DELETE FROM projects WHERE id = ?", (project_id,))
await db.commit()
await db.close()
return {"status": "deleted"}

View File

@ -0,0 +1,205 @@
from fastapi import APIRouter, HTTPException, Query
from typing import List, Optional
from app.models import Ticket, TicketCreate, TicketUpdate, Comment, CommentCreate, TicketStatus
from app.services.database import get_db
from app.services.webhook import trigger_webhook
import json
from datetime import datetime
router = APIRouter()
@router.get("", response_model=List[Ticket])
async def list_tickets(
project_id: Optional[int] = None,
status: Optional[TicketStatus] = None,
limit: int = Query(default=50, le=100)
):
db = await get_db()
query = "SELECT * FROM tickets WHERE 1=1"
params = []
if project_id:
query += " AND project_id = ?"
params.append(project_id)
if status:
query += " AND status = ?"
params.append(status.value)
query += " ORDER BY created_at DESC LIMIT ?"
params.append(limit)
cursor = await db.execute(query, params)
rows = await cursor.fetchall()
await db.close()
tickets = []
for row in rows:
ticket = dict(row)
ticket["labels"] = json.loads(ticket.get("labels", "[]"))
tickets.append(ticket)
return tickets
@router.post("", response_model=Ticket)
async def create_ticket(ticket: TicketCreate):
db = await get_db()
# Get project and increment sequence
cursor = await db.execute("SELECT * FROM projects WHERE id = ?", (ticket.project_id,))
project = await cursor.fetchone()
if not project:
await db.close()
raise HTTPException(status_code=404, detail="Project not found")
project = dict(project)
new_seq = project["ticket_sequence"] + 1
ticket_key = f"{project['key']}-{new_seq}"
await db.execute("UPDATE projects SET ticket_sequence = ? WHERE id = ?", (new_seq, ticket.project_id))
now = datetime.utcnow().isoformat()
cursor = await db.execute(
"""INSERT INTO tickets (project_id, key, title, description, priority, labels, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(ticket.project_id, ticket_key, ticket.title, ticket.description,
ticket.priority.value, json.dumps(ticket.labels), now, now)
)
await db.commit()
ticket_id = cursor.lastrowid
cursor = await db.execute("SELECT * FROM tickets WHERE id = ?", (ticket_id,))
row = await cursor.fetchone()
await db.close()
result = dict(row)
result["labels"] = json.loads(result.get("labels", "[]"))
# Trigger webhook
await trigger_webhook(ticket.project_id, "ticket.created", result)
return result
@router.get("/{ticket_id}", response_model=Ticket)
async def get_ticket(ticket_id: int):
db = await get_db()
cursor = await db.execute("SELECT * FROM tickets WHERE id = ?", (ticket_id,))
row = await cursor.fetchone()
await db.close()
if not row:
raise HTTPException(status_code=404, detail="Ticket not found")
result = dict(row)
result["labels"] = json.loads(result.get("labels", "[]"))
return result
@router.get("/key/{ticket_key}", response_model=Ticket)
async def get_ticket_by_key(ticket_key: str):
db = await get_db()
cursor = await db.execute("SELECT * FROM tickets WHERE key = ?", (ticket_key.upper(),))
row = await cursor.fetchone()
await db.close()
if not row:
raise HTTPException(status_code=404, detail="Ticket not found")
result = dict(row)
result["labels"] = json.loads(result.get("labels", "[]"))
return result
@router.patch("/{ticket_id}", response_model=Ticket)
async def update_ticket(ticket_id: int, update: TicketUpdate):
db = await get_db()
cursor = await db.execute("SELECT * FROM tickets WHERE id = ?", (ticket_id,))
existing = await cursor.fetchone()
if not existing:
await db.close()
raise HTTPException(status_code=404, detail="Ticket not found")
existing = dict(existing)
updates = []
params = []
if update.title is not None:
updates.append("title = ?")
params.append(update.title)
if update.description is not None:
updates.append("description = ?")
params.append(update.description)
if update.status is not None:
updates.append("status = ?")
params.append(update.status.value)
if update.priority is not None:
updates.append("priority = ?")
params.append(update.priority.value)
if update.labels is not None:
updates.append("labels = ?")
params.append(json.dumps(update.labels))
if updates:
updates.append("updated_at = ?")
params.append(datetime.utcnow().isoformat())
params.append(ticket_id)
await db.execute(f"UPDATE tickets SET {', '.join(updates)} WHERE id = ?", params)
await db.commit()
cursor = await db.execute("SELECT * FROM tickets WHERE id = ?", (ticket_id,))
row = await cursor.fetchone()
await db.close()
result = dict(row)
result["labels"] = json.loads(result.get("labels", "[]"))
# Trigger webhook
await trigger_webhook(existing["project_id"], "ticket.updated", result)
return result
@router.delete("/{ticket_id}")
async def delete_ticket(ticket_id: int):
db = await get_db()
await db.execute("DELETE FROM comments WHERE ticket_id = ?", (ticket_id,))
await db.execute("DELETE FROM tickets WHERE id = ?", (ticket_id,))
await db.commit()
await db.close()
return {"status": "deleted"}
# Comments
@router.get("/{ticket_id}/comments", response_model=List[Comment])
async def list_comments(ticket_id: int):
db = await get_db()
cursor = await db.execute(
"SELECT * FROM comments WHERE ticket_id = ? ORDER BY created_at ASC",
(ticket_id,)
)
rows = await cursor.fetchall()
await db.close()
return [dict(row) for row in rows]
@router.post("/{ticket_id}/comments", response_model=Comment)
async def add_comment(ticket_id: int, comment: CommentCreate):
db = await get_db()
cursor = await db.execute("SELECT project_id FROM tickets WHERE id = ?", (ticket_id,))
ticket = await cursor.fetchone()
if not ticket:
await db.close()
raise HTTPException(status_code=404, detail="Ticket not found")
cursor = await db.execute(
"INSERT INTO comments (ticket_id, author, content) VALUES (?, ?, ?)",
(ticket_id, comment.author, comment.content)
)
await db.commit()
comment_id = cursor.lastrowid
cursor = await db.execute("SELECT * FROM comments WHERE id = ?", (comment_id,))
row = await cursor.fetchone()
await db.close()
result = dict(row)
# Trigger webhook
await trigger_webhook(ticket["project_id"], "comment.added", {
"ticket_id": ticket_id,
"comment": result
})
return result

View File

@ -0,0 +1,82 @@
from fastapi import APIRouter, HTTPException
from typing import List
from pydantic import BaseModel
from app.services.database import get_db
import json
router = APIRouter()
class WebhookCreate(BaseModel):
project_id: int
url: str
events: List[str] = ["ticket.created", "ticket.updated", "comment.added"]
class WebhookResponse(BaseModel):
id: int
project_id: int
url: str
events: List[str]
active: bool
@router.get("", response_model=List[WebhookResponse])
async def list_webhooks(project_id: int = None):
db = await get_db()
if project_id:
cursor = await db.execute("SELECT * FROM webhooks WHERE project_id = ?", (project_id,))
else:
cursor = await db.execute("SELECT * FROM webhooks")
rows = await cursor.fetchall()
await db.close()
result = []
for row in rows:
wh = dict(row)
wh["events"] = json.loads(wh.get("events", "[]"))
wh["active"] = bool(wh.get("active", 1))
result.append(wh)
return result
@router.post("", response_model=WebhookResponse)
async def create_webhook(webhook: WebhookCreate):
db = await get_db()
cursor = await db.execute(
"INSERT INTO webhooks (project_id, url, events) VALUES (?, ?, ?)",
(webhook.project_id, webhook.url, json.dumps(webhook.events))
)
await db.commit()
webhook_id = cursor.lastrowid
cursor = await db.execute("SELECT * FROM webhooks WHERE id = ?", (webhook_id,))
row = await cursor.fetchone()
await db.close()
result = dict(row)
result["events"] = json.loads(result.get("events", "[]"))
result["active"] = bool(result.get("active", 1))
return result
@router.delete("/{webhook_id}")
async def delete_webhook(webhook_id: int):
db = await get_db()
await db.execute("DELETE FROM webhooks WHERE id = ?", (webhook_id,))
await db.commit()
await db.close()
return {"status": "deleted"}
@router.patch("/{webhook_id}/toggle")
async def toggle_webhook(webhook_id: int):
db = await get_db()
await db.execute("UPDATE webhooks SET active = NOT active WHERE id = ?", (webhook_id,))
await db.commit()
cursor = await db.execute("SELECT * FROM webhooks WHERE id = ?", (webhook_id,))
row = await cursor.fetchone()
await db.close()
if not row:
raise HTTPException(status_code=404, detail="Webhook not found")
result = dict(row)
result["events"] = json.loads(result.get("events", "[]"))
result["active"] = bool(result.get("active", 1))
return result

View File

View File

@ -0,0 +1,68 @@
"""
SQLite database for simplicity. Can be swapped for PostgreSQL.
"""
import aiosqlite
import os
from datetime import datetime
DB_PATH = os.getenv("DATABASE_PATH", "/data/tickethub.db")
async def get_db():
db = await aiosqlite.connect(DB_PATH)
db.row_factory = aiosqlite.Row
return db
async def init_db():
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
db = await get_db()
await db.executescript("""
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
key TEXT UNIQUE NOT NULL,
description TEXT,
webhook_url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ticket_sequence INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
key TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
description TEXT,
status TEXT DEFAULT 'open',
priority TEXT DEFAULT 'medium',
labels TEXT DEFAULT '[]',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects(id)
);
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER NOT NULL,
author TEXT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (ticket_id) REFERENCES tickets(id)
);
CREATE TABLE IF NOT EXISTS webhooks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
url TEXT NOT NULL,
events TEXT DEFAULT '["ticket.created","ticket.updated","comment.added"]',
active INTEGER DEFAULT 1,
FOREIGN KEY (project_id) REFERENCES projects(id)
);
CREATE INDEX IF NOT EXISTS idx_tickets_project ON tickets(project_id);
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
CREATE INDEX IF NOT EXISTS idx_comments_ticket ON comments(ticket_id);
""")
await db.commit()
await db.close()

View File

@ -0,0 +1,47 @@
"""
Webhook dispatcher - sends events to configured URLs
"""
import httpx
import json
from datetime import datetime
from app.services.database import get_db
async def trigger_webhook(project_id: int, event: str, payload: dict):
"""Send webhook to all configured endpoints for this project"""
db = await get_db()
cursor = await db.execute(
"SELECT * FROM webhooks WHERE project_id = ? AND active = 1",
(project_id,)
)
webhooks = await cursor.fetchall()
# Also check project's default webhook_url
cursor = await db.execute("SELECT webhook_url FROM projects WHERE id = ?", (project_id,))
project = await cursor.fetchone()
await db.close()
urls = []
for wh in webhooks:
wh = dict(wh)
events = json.loads(wh.get("events", "[]"))
if event in events or not events:
urls.append(wh["url"])
if project and project["webhook_url"]:
urls.append(project["webhook_url"])
# Deduplicate
urls = list(set(urls))
webhook_payload = {
"event": event,
"timestamp": datetime.utcnow().isoformat(),
"data": payload
}
async with httpx.AsyncClient(timeout=10.0) as client:
for url in urls:
try:
await client.post(url, json=webhook_payload)
except Exception as e:
print(f"Webhook failed for {url}: {e}")

6
backend/requirements.txt Normal file
View File

@ -0,0 +1,6 @@
fastapi==0.109.0
uvicorn==0.27.0
aiosqlite==0.19.0
httpx==0.26.0
pydantic==2.5.3
python-multipart==0.0.6

16
docker-compose.yml Normal file
View File

@ -0,0 +1,16 @@
version: "3.8"
services:
tickethub:
build: ./backend
ports:
- "8080:8000"
volumes:
- tickethub_data:/data
- ./frontend/static:/app/static:ro
environment:
- DATABASE_PATH=/data/tickethub.db
restart: unless-stopped
volumes:
tickethub_data:

339
frontend/static/index.html Normal file
View File

@ -0,0 +1,339 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TicketHub</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; }
.status-open { background: #dbeafe; color: #1e40af; }
.status-in_progress { background: #fef3c7; color: #92400e; }
.status-resolved { background: #d1fae5; color: #065f46; }
.status-closed { background: #e5e7eb; color: #374151; }
.priority-low { border-left: 4px solid #10b981; }
.priority-medium { border-left: 4px solid #f59e0b; }
.priority-high { border-left: 4px solid #f97316; }
.priority-critical { border-left: 4px solid #ef4444; }
</style>
</head>
<body class="bg-gray-50">
<div id="app" class="min-h-screen">
<!-- Header -->
<header class="bg-white shadow-sm border-b">
<div class="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-2xl">🎫</span>
<h1 class="text-xl font-bold text-gray-900">TicketHub</h1>
</div>
<button onclick="showProjectModal()" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition">
+ New Project
</button>
</div>
</header>
<!-- Main Content -->
<main class="max-w-7xl mx-auto px-4 py-6">
<!-- Projects -->
<div class="mb-6">
<h2 class="text-lg font-semibold text-gray-900 mb-3">Projects</h2>
<div id="projects-list" class="flex gap-3 flex-wrap">
<!-- Projects loaded here -->
</div>
</div>
<!-- Tickets -->
<div class="bg-white rounded-xl shadow-sm border">
<div class="p-4 border-b flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900">Tickets</h2>
<button onclick="showTicketModal()" id="new-ticket-btn" class="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition hidden">
+ New Ticket
</button>
</div>
<div id="tickets-list" class="divide-y">
<div class="p-8 text-center text-gray-500">Select a project to view tickets</div>
</div>
</div>
</main>
</div>
<!-- Project Modal -->
<div id="project-modal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-white rounded-xl p-6 w-full max-w-md mx-4">
<h3 class="text-lg font-semibold mb-4">New Project</h3>
<form id="project-form" onsubmit="createProject(event)">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Project Name</label>
<input type="text" name="name" required class="w-full border rounded-lg px-3 py-2" placeholder="My Project">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Key (2-10 uppercase letters)</label>
<input type="text" name="key" required pattern="[A-Za-z]{2,10}" class="w-full border rounded-lg px-3 py-2 uppercase" placeholder="PROJ">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Webhook URL (optional)</label>
<input type="url" name="webhook_url" class="w-full border rounded-lg px-3 py-2" placeholder="https://...">
</div>
</div>
<div class="flex gap-3 mt-6">
<button type="button" onclick="hideProjectModal()" class="flex-1 border rounded-lg py-2 hover:bg-gray-50">Cancel</button>
<button type="submit" class="flex-1 bg-blue-600 text-white rounded-lg py-2 hover:bg-blue-700">Create</button>
</div>
</form>
</div>
</div>
<!-- Ticket Modal -->
<div id="ticket-modal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-white rounded-xl p-6 w-full max-w-lg mx-4">
<h3 class="text-lg font-semibold mb-4">New Ticket</h3>
<form id="ticket-form" onsubmit="createTicket(event)">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Title</label>
<input type="text" name="title" required class="w-full border rounded-lg px-3 py-2" placeholder="Issue title">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea name="description" rows="4" required class="w-full border rounded-lg px-3 py-2" placeholder="Describe the issue..."></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Priority</label>
<select name="priority" class="w-full border rounded-lg px-3 py-2">
<option value="low">Low</option>
<option value="medium" selected>Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
</div>
</div>
<div class="flex gap-3 mt-6">
<button type="button" onclick="hideTicketModal()" class="flex-1 border rounded-lg py-2 hover:bg-gray-50">Cancel</button>
<button type="submit" class="flex-1 bg-green-600 text-white rounded-lg py-2 hover:bg-green-700">Create</button>
</div>
</form>
</div>
</div>
<!-- Ticket Detail Modal -->
<div id="ticket-detail-modal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-white rounded-xl w-full max-w-2xl mx-4 max-h-[90vh] overflow-hidden flex flex-col">
<div class="p-4 border-b flex items-center justify-between">
<h3 id="ticket-detail-key" class="text-lg font-semibold"></h3>
<button onclick="hideTicketDetail()" class="text-gray-500 hover:text-gray-700"></button>
</div>
<div class="p-6 overflow-y-auto flex-1">
<div id="ticket-detail-content"></div>
<div class="mt-6 border-t pt-4">
<h4 class="font-medium mb-3">Comments</h4>
<div id="ticket-comments" class="space-y-3 mb-4"></div>
<form onsubmit="addComment(event)" class="flex gap-2">
<input type="text" id="comment-input" placeholder="Add a comment..." class="flex-1 border rounded-lg px-3 py-2">
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700">Send</button>
</form>
</div>
</div>
</div>
</div>
<script>
const API = '/api';
let currentProject = null;
let currentTicket = null;
// Load projects on start
loadProjects();
async function loadProjects() {
const res = await fetch(`${API}/projects`);
const projects = await res.json();
const container = document.getElementById('projects-list');
if (projects.length === 0) {
container.innerHTML = '<div class="text-gray-500">No projects yet. Create one to get started!</div>';
return;
}
container.innerHTML = projects.map(p => `
<button onclick="selectProject(${p.id})"
class="px-4 py-2 rounded-lg border ${currentProject?.id === p.id ? 'bg-blue-100 border-blue-300' : 'bg-white hover:bg-gray-50'} transition">
<span class="font-medium">${p.key}</span>
<span class="text-gray-500 ml-1">(${p.ticket_count})</span>
</button>
`).join('');
}
async function selectProject(id) {
const res = await fetch(`${API}/projects/${id}`);
currentProject = await res.json();
loadProjects();
loadTickets();
document.getElementById('new-ticket-btn').classList.remove('hidden');
}
async function loadTickets() {
if (!currentProject) return;
const res = await fetch(`${API}/tickets?project_id=${currentProject.id}`);
const tickets = await res.json();
const container = document.getElementById('tickets-list');
if (tickets.length === 0) {
container.innerHTML = '<div class="p-8 text-center text-gray-500">No tickets yet. Create one!</div>';
return;
}
container.innerHTML = tickets.map(t => `
<div onclick="showTicketDetail(${t.id})" class="p-4 hover:bg-gray-50 cursor-pointer priority-${t.priority}">
<div class="flex items-center justify-between">
<div>
<span class="text-sm font-mono text-gray-500">${t.key}</span>
<span class="font-medium ml-2">${t.title}</span>
</div>
<span class="px-2 py-1 rounded text-xs font-medium status-${t.status}">${t.status.replace('_', ' ')}</span>
</div>
<p class="text-sm text-gray-500 mt-1 truncate">${t.description}</p>
</div>
`).join('');
}
async function showTicketDetail(id) {
const res = await fetch(`${API}/tickets/${id}`);
currentTicket = await res.json();
document.getElementById('ticket-detail-key').textContent = currentTicket.key;
document.getElementById('ticket-detail-content').innerHTML = `
<h2 class="text-xl font-semibold">${currentTicket.title}</h2>
<div class="flex gap-2 mt-2">
<span class="px-2 py-1 rounded text-xs font-medium status-${currentTicket.status}">${currentTicket.status.replace('_', ' ')}</span>
<span class="px-2 py-1 rounded text-xs font-medium bg-gray-100">${currentTicket.priority}</span>
</div>
<p class="mt-4 text-gray-700 whitespace-pre-wrap">${currentTicket.description}</p>
<div class="mt-4 flex gap-2">
<select onchange="updateTicketStatus(this.value)" class="border rounded px-2 py-1 text-sm">
<option value="open" ${currentTicket.status === 'open' ? 'selected' : ''}>Open</option>
<option value="in_progress" ${currentTicket.status === 'in_progress' ? 'selected' : ''}>In Progress</option>
<option value="resolved" ${currentTicket.status === 'resolved' ? 'selected' : ''}>Resolved</option>
<option value="closed" ${currentTicket.status === 'closed' ? 'selected' : ''}>Closed</option>
</select>
</div>
`;
loadComments();
document.getElementById('ticket-detail-modal').classList.remove('hidden');
document.getElementById('ticket-detail-modal').classList.add('flex');
}
async function loadComments() {
const res = await fetch(`${API}/tickets/${currentTicket.id}/comments`);
const comments = await res.json();
document.getElementById('ticket-comments').innerHTML = comments.length === 0
? '<div class="text-gray-500 text-sm">No comments yet</div>'
: comments.map(c => `
<div class="bg-gray-50 rounded-lg p-3">
<div class="flex justify-between text-sm">
<span class="font-medium">${c.author}</span>
<span class="text-gray-500">${new Date(c.created_at).toLocaleString()}</span>
</div>
<p class="mt-1">${c.content}</p>
</div>
`).join('');
}
async function addComment(e) {
e.preventDefault();
const input = document.getElementById('comment-input');
if (!input.value.trim()) return;
await fetch(`${API}/tickets/${currentTicket.id}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ author: 'user', content: input.value })
});
input.value = '';
loadComments();
}
async function updateTicketStatus(status) {
await fetch(`${API}/tickets/${currentTicket.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
});
loadTickets();
}
function hideTicketDetail() {
document.getElementById('ticket-detail-modal').classList.add('hidden');
document.getElementById('ticket-detail-modal').classList.remove('flex');
}
function showProjectModal() {
document.getElementById('project-modal').classList.remove('hidden');
document.getElementById('project-modal').classList.add('flex');
}
function hideProjectModal() {
document.getElementById('project-modal').classList.add('hidden');
document.getElementById('project-modal').classList.remove('flex');
}
function showTicketModal() {
document.getElementById('ticket-modal').classList.remove('hidden');
document.getElementById('ticket-modal').classList.add('flex');
}
function hideTicketModal() {
document.getElementById('ticket-modal').classList.add('hidden');
document.getElementById('ticket-modal').classList.remove('flex');
}
async function createProject(e) {
e.preventDefault();
const form = e.target;
const data = {
name: form.name.value,
key: form.key.value.toUpperCase(),
webhook_url: form.webhook_url.value || null
};
await fetch(`${API}/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
form.reset();
hideProjectModal();
loadProjects();
}
async function createTicket(e) {
e.preventDefault();
const form = e.target;
const data = {
project_id: currentProject.id,
title: form.title.value,
description: form.description.value,
priority: form.priority.value
};
await fetch(`${API}/tickets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
form.reset();
hideTicketModal();
loadTickets();
loadProjects();
}
</script>
</body>
</html>