feat: Professional UI v2
- Sidebar navigation with project list - Project details view with stats - Edit/delete project functionality - Better ticket list with priority icons - Improved modals and forms - Responsive design
This commit is contained in:
parent
03f68061b1
commit
f695884784
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,568 @@
|
||||||
|
"""
|
||||||
|
TicketHub - Lightweight Issue Tracking System
|
||||||
|
Professional Edition
|
||||||
|
"""
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
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="2.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"])
|
||||||
|
|
||||||
|
# Professional Frontend
|
||||||
|
HTML = """<!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', system-ui, 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; }
|
||||||
|
.sidebar { width: 280px; }
|
||||||
|
.main-content { margin-left: 280px; }
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar { width: 100%; position: relative; }
|
||||||
|
.main-content { margin-left: 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-50">
|
||||||
|
<div id="app" class="min-h-screen flex">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="sidebar fixed h-full bg-white border-r border-gray-200 flex flex-col">
|
||||||
|
<div class="p-4 border-b border-gray-200">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-2xl">🎫</span>
|
||||||
|
<h1 class="text-xl font-bold text-gray-900">TicketHub</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Issue Tracking System</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 border-b border-gray-200">
|
||||||
|
<button onclick="showProjectModal()" class="w-full bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 flex items-center justify-center gap-2">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
|
||||||
|
New Project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto p-4">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Projects</h3>
|
||||||
|
<div id="projects-list" class="space-y-1"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 border-t border-gray-200 text-xs text-gray-500">
|
||||||
|
<div id="stats" class="space-y-1"></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="main-content flex-1 p-6">
|
||||||
|
<div id="welcome-view" class="h-full flex items-center justify-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<span class="text-6xl">📋</span>
|
||||||
|
<h2 class="text-xl font-semibold text-gray-700 mt-4">Select a project</h2>
|
||||||
|
<p class="text-gray-500 mt-2">Choose a project from the sidebar or create a new one</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="project-view" class="hidden">
|
||||||
|
<!-- Project Header -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border p-6 mb-6">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-3xl" id="proj-icon">📁</span>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900" id="proj-name"></h2>
|
||||||
|
<p class="text-gray-500" id="proj-key"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-600 mt-3" id="proj-desc"></p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="showEditProjectModal()" class="px-3 py-2 text-gray-600 hover:bg-gray-100 rounded-lg">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
|
||||||
|
</button>
|
||||||
|
<button onclick="deleteProject()" class="px-3 py-2 text-red-600 hover:bg-red-50 rounded-lg">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Project Stats -->
|
||||||
|
<div class="grid grid-cols-4 gap-4 mt-6">
|
||||||
|
<div class="bg-blue-50 rounded-lg p-3 text-center">
|
||||||
|
<div class="text-2xl font-bold text-blue-600" id="stat-total">0</div>
|
||||||
|
<div class="text-xs text-blue-600">Total</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-yellow-50 rounded-lg p-3 text-center">
|
||||||
|
<div class="text-2xl font-bold text-yellow-600" id="stat-open">0</div>
|
||||||
|
<div class="text-xs text-yellow-600">Open</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-purple-50 rounded-lg p-3 text-center">
|
||||||
|
<div class="text-2xl font-bold text-purple-600" id="stat-progress">0</div>
|
||||||
|
<div class="text-xs text-purple-600">In Progress</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-green-50 rounded-lg p-3 text-center">
|
||||||
|
<div class="text-2xl font-bold text-green-600" id="stat-resolved">0</div>
|
||||||
|
<div class="text-xs text-green-600">Resolved</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Webhook Config -->
|
||||||
|
<div class="mt-6 p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path></svg>
|
||||||
|
<span class="text-gray-600">Webhook:</span>
|
||||||
|
<code class="text-xs bg-white px-2 py-1 rounded border" id="proj-webhook">Not configured</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tickets Section -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border">
|
||||||
|
<div class="p-4 border-b flex items-center justify-between">
|
||||||
|
<h3 class="font-semibold text-gray-900">Tickets</h3>
|
||||||
|
<button onclick="showTicketModal()" class="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
|
||||||
|
New Ticket
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="tickets-list" class="divide-y">
|
||||||
|
<div class="p-8 text-center text-gray-500">No tickets yet</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create 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 shadow-xl">
|
||||||
|
<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 border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500" placeholder="My Project">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Key (2-10 letters)</label>
|
||||||
|
<input type="text" name="key" required pattern="[A-Za-z]{2,10}" class="w-full border border-gray-300 rounded-lg px-3 py-2 uppercase focus:ring-2 focus:ring-blue-500" placeholder="PROJ">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||||
|
<textarea name="description" rows="2" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500" placeholder="Optional description"></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Webhook URL</label>
|
||||||
|
<input type="url" name="webhook_url" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500" placeholder="https://...">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Receive notifications when tickets are created/updated</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 mt-6">
|
||||||
|
<button type="button" onclick="hideProjectModal()" class="flex-1 border border-gray-300 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>
|
||||||
|
|
||||||
|
<!-- Edit Project Modal -->
|
||||||
|
<div id="edit-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 shadow-xl">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">Edit Project</h3>
|
||||||
|
<form id="edit-project-form" onsubmit="updateProject(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" id="edit-proj-name" required class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||||
|
<textarea name="description" id="edit-proj-desc" rows="2" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500"></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Webhook URL</label>
|
||||||
|
<input type="url" name="webhook_url" id="edit-proj-webhook" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 mt-6">
|
||||||
|
<button type="button" onclick="hideEditProjectModal()" class="flex-1 border border-gray-300 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">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create 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 shadow-xl">
|
||||||
|
<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 border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500">
|
||||||
|
</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 border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500"></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Priority</label>
|
||||||
|
<select name="priority" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500">
|
||||||
|
<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 border-gray-300 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="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 shadow-xl">
|
||||||
|
<div class="p-4 border-b flex justify-between items-center bg-gray-50">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="font-mono text-blue-600" id="detail-key"></span>
|
||||||
|
<span class="px-2 py-1 rounded text-xs font-medium" id="detail-status"></span>
|
||||||
|
</div>
|
||||||
|
<button onclick="hideDetail()" class="text-gray-500 hover:text-gray-700">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 overflow-y-auto flex-1">
|
||||||
|
<div id="detail-content"></div>
|
||||||
|
<div class="mt-6 border-t pt-4">
|
||||||
|
<h4 class="font-semibold text-gray-900 mb-3">Comments</h4>
|
||||||
|
<div id="comments" class="space-y-3 mb-4 max-h-64 overflow-y-auto"></div>
|
||||||
|
<form onsubmit="addComment(event)" class="flex gap-2">
|
||||||
|
<input type="text" id="comment-input" class="flex-1 border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500" placeholder="Add a comment...">
|
||||||
|
<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 curProj = null;
|
||||||
|
let curTkt = null;
|
||||||
|
let allProjects = [];
|
||||||
|
|
||||||
|
// Load projects on start
|
||||||
|
loadProjects();
|
||||||
|
|
||||||
|
async function loadProjects() {
|
||||||
|
const r = await fetch(API + '/projects');
|
||||||
|
allProjects = await r.json();
|
||||||
|
const c = document.getElementById('projects-list');
|
||||||
|
|
||||||
|
if (!allProjects.length) {
|
||||||
|
c.innerHTML = '<div class="text-gray-400 text-sm">No projects yet</div>';
|
||||||
|
updateGlobalStats([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
c.innerHTML = allProjects.map(x => `
|
||||||
|
<button onclick="selectProject(${x.id})"
|
||||||
|
class="w-full text-left px-3 py-2 rounded-lg transition-colors ${curProj?.id === x.id ? 'bg-blue-100 text-blue-700' : 'hover:bg-gray-100 text-gray-700'}">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="font-medium">${x.key}</span>
|
||||||
|
<span class="text-xs ${curProj?.id === x.id ? 'text-blue-500' : 'text-gray-400'}">${x.ticket_count}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs ${curProj?.id === x.id ? 'text-blue-500' : 'text-gray-500'} truncate">${x.name}</div>
|
||||||
|
</button>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
updateGlobalStats(allProjects);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGlobalStats(projects) {
|
||||||
|
const total = projects.reduce((a, p) => a + (p.ticket_count || 0), 0);
|
||||||
|
document.getElementById('stats').innerHTML = `
|
||||||
|
<div>Total Projects: ${projects.length}</div>
|
||||||
|
<div>Total Tickets: ${total}</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectProject(id) {
|
||||||
|
const r = await fetch(API + '/projects/' + id);
|
||||||
|
curProj = await r.json();
|
||||||
|
|
||||||
|
document.getElementById('welcome-view').classList.add('hidden');
|
||||||
|
document.getElementById('project-view').classList.remove('hidden');
|
||||||
|
|
||||||
|
document.getElementById('proj-name').textContent = curProj.name;
|
||||||
|
document.getElementById('proj-key').textContent = curProj.key;
|
||||||
|
document.getElementById('proj-desc').textContent = curProj.description || 'No description';
|
||||||
|
document.getElementById('proj-webhook').textContent = curProj.webhook_url || 'Not configured';
|
||||||
|
|
||||||
|
loadProjects();
|
||||||
|
loadTickets();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTickets() {
|
||||||
|
if (!curProj) return;
|
||||||
|
|
||||||
|
const r = await fetch(API + '/tickets?project_id=' + curProj.id);
|
||||||
|
const tickets = await r.json();
|
||||||
|
|
||||||
|
// Update stats
|
||||||
|
document.getElementById('stat-total').textContent = tickets.length;
|
||||||
|
document.getElementById('stat-open').textContent = tickets.filter(t => t.status === 'open').length;
|
||||||
|
document.getElementById('stat-progress').textContent = tickets.filter(t => t.status === 'in_progress').length;
|
||||||
|
document.getElementById('stat-resolved').textContent = tickets.filter(t => t.status === 'resolved' || t.status === 'closed').length;
|
||||||
|
|
||||||
|
const c = document.getElementById('tickets-list');
|
||||||
|
if (!tickets.length) {
|
||||||
|
c.innerHTML = '<div class="p-8 text-center text-gray-500">No tickets yet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityIcon = {low: '🟢', medium: '🟡', high: '🟠', critical: '🔴'};
|
||||||
|
|
||||||
|
c.innerHTML = tickets.map(x => `
|
||||||
|
<div onclick="showDetail(${x.id})" class="p-4 hover:bg-gray-50 cursor-pointer priority-${x.priority}">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm font-mono text-gray-500">${x.key}</span>
|
||||||
|
<span>${priorityIcon[x.priority] || '🟡'}</span>
|
||||||
|
</div>
|
||||||
|
<h4 class="font-medium text-gray-900 mt-1">${x.title}</h4>
|
||||||
|
<p class="text-sm text-gray-500 mt-1 line-clamp-2">${x.description}</p>
|
||||||
|
</div>
|
||||||
|
<span class="px-2 py-1 rounded text-xs font-medium status-${x.status} ml-4">${x.status.replace('_', ' ')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showDetail(id) {
|
||||||
|
const r = await fetch(API + '/tickets/' + id);
|
||||||
|
curTkt = await r.json();
|
||||||
|
|
||||||
|
document.getElementById('detail-key').textContent = curTkt.key;
|
||||||
|
document.getElementById('detail-status').textContent = curTkt.status.replace('_', ' ');
|
||||||
|
document.getElementById('detail-status').className = 'px-2 py-1 rounded text-xs font-medium status-' + curTkt.status;
|
||||||
|
|
||||||
|
const priorityIcon = {low: '🟢', medium: '🟡', high: '🟠', critical: '🔴'};
|
||||||
|
|
||||||
|
document.getElementById('detail-content').innerHTML = `
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900">${curTkt.title}</h2>
|
||||||
|
<div class="flex items-center gap-3 mt-3">
|
||||||
|
<span class="text-sm">${priorityIcon[curTkt.priority] || '🟡'} ${curTkt.priority}</span>
|
||||||
|
<span class="text-sm text-gray-400">Created ${new Date(curTkt.created_at).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 p-4 bg-gray-50 rounded-lg">
|
||||||
|
<pre class="whitespace-pre-wrap font-sans text-sm text-gray-700">${curTkt.description}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<label class="text-sm font-medium text-gray-700">Status</label>
|
||||||
|
<select onchange="updateStatus(this.value)" class="ml-2 border border-gray-300 rounded px-2 py-1 text-sm">
|
||||||
|
<option value="open" ${curTkt.status === 'open' ? 'selected' : ''}>Open</option>
|
||||||
|
<option value="in_progress" ${curTkt.status === 'in_progress' ? 'selected' : ''}>In Progress</option>
|
||||||
|
<option value="resolved" ${curTkt.status === 'resolved' ? 'selected' : ''}>Resolved</option>
|
||||||
|
<option value="closed" ${curTkt.status === 'closed' ? 'selected' : ''}>Closed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
loadComments();
|
||||||
|
document.getElementById('detail-modal').classList.remove('hidden');
|
||||||
|
document.getElementById('detail-modal').classList.add('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadComments() {
|
||||||
|
const r = await fetch(API + '/tickets/' + curTkt.id + '/comments');
|
||||||
|
const comments = await r.json();
|
||||||
|
|
||||||
|
document.getElementById('comments').innerHTML = comments.length ? comments.map(x => `
|
||||||
|
<div class="bg-gray-50 rounded-lg p-3">
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<span class="font-medium text-gray-900">${x.author}</span>
|
||||||
|
<span class="text-gray-500">${new Date(x.created_at).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<pre class="mt-2 whitespace-pre-wrap font-sans text-sm text-gray-700">${x.content}</pre>
|
||||||
|
</div>
|
||||||
|
`).join('') : '<div class="text-gray-500 text-sm">No comments yet</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addComment(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const input = document.getElementById('comment-input');
|
||||||
|
if (!input.value.trim()) return;
|
||||||
|
|
||||||
|
await fetch(API + '/tickets/' + curTkt.id + '/comments', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ author: 'User', content: input.value })
|
||||||
|
});
|
||||||
|
|
||||||
|
input.value = '';
|
||||||
|
loadComments();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateStatus(status) {
|
||||||
|
await fetch(API + '/tickets/' + curTkt.id, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status })
|
||||||
|
});
|
||||||
|
loadTickets();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteProject() {
|
||||||
|
if (!confirm('Delete this project and all its tickets?')) return;
|
||||||
|
await fetch(API + '/projects/' + curProj.id, { method: 'DELETE' });
|
||||||
|
curProj = null;
|
||||||
|
document.getElementById('welcome-view').classList.remove('hidden');
|
||||||
|
document.getElementById('project-view').classList.add('hidden');
|
||||||
|
loadProjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showEditProjectModal() {
|
||||||
|
document.getElementById('edit-proj-name').value = curProj.name;
|
||||||
|
document.getElementById('edit-proj-desc').value = curProj.description || '';
|
||||||
|
document.getElementById('edit-proj-webhook').value = curProj.webhook_url || '';
|
||||||
|
document.getElementById('edit-project-modal').classList.remove('hidden');
|
||||||
|
document.getElementById('edit-project-modal').classList.add('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideEditProjectModal() {
|
||||||
|
document.getElementById('edit-project-modal').classList.add('hidden');
|
||||||
|
document.getElementById('edit-project-modal').classList.remove('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateProject(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = e.target;
|
||||||
|
await fetch(API + '/projects/' + curProj.id, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: form.name.value,
|
||||||
|
description: form.description.value || null,
|
||||||
|
webhook_url: form.webhook_url.value || null
|
||||||
|
})
|
||||||
|
});
|
||||||
|
hideEditProjectModal();
|
||||||
|
selectProject(curProj.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideDetail() {
|
||||||
|
document.getElementById('detail-modal').classList.add('hidden');
|
||||||
|
document.getElementById('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;
|
||||||
|
await fetch(API + '/projects', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: form.name.value,
|
||||||
|
key: form.key.value.toUpperCase(),
|
||||||
|
description: form.description.value || null,
|
||||||
|
webhook_url: form.webhook_url.value || null
|
||||||
|
})
|
||||||
|
});
|
||||||
|
form.reset();
|
||||||
|
hideProjectModal();
|
||||||
|
loadProjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTicket(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = e.target;
|
||||||
|
await fetch(API + '/tickets', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
project_id: curProj.id,
|
||||||
|
title: form.title.value,
|
||||||
|
description: form.description.value,
|
||||||
|
priority: form.priority.value
|
||||||
|
})
|
||||||
|
});
|
||||||
|
form.reset();
|
||||||
|
hideTicketModal();
|
||||||
|
loadTickets();
|
||||||
|
loadProjects();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
async def root():
|
||||||
|
return HTML
|
||||||
|
|
@ -63,3 +63,51 @@ async def delete_project(project_id: int):
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.close()
|
await db.close()
|
||||||
return {"status": "deleted"}
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
class ProjectUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
webhook_url: Optional[str] = None
|
||||||
|
|
||||||
|
@router.patch("/{project_id}", response_model=Project)
|
||||||
|
async def update_project(project_id: int, update: ProjectUpdate):
|
||||||
|
db = await get_db()
|
||||||
|
|
||||||
|
# Check if exists
|
||||||
|
cursor = await db.execute("SELECT * FROM projects WHERE id = ?", (project_id,))
|
||||||
|
if not await cursor.fetchone():
|
||||||
|
await db.close()
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
# Build update query
|
||||||
|
updates = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if update.name is not None:
|
||||||
|
updates.append("name = ?")
|
||||||
|
params.append(update.name)
|
||||||
|
if update.description is not None:
|
||||||
|
updates.append("description = ?")
|
||||||
|
params.append(update.description if update.description else None)
|
||||||
|
if update.webhook_url is not None:
|
||||||
|
updates.append("webhook_url = ?")
|
||||||
|
params.append(update.webhook_url if update.webhook_url else None)
|
||||||
|
|
||||||
|
if updates:
|
||||||
|
params.append(project_id)
|
||||||
|
await db.execute(f"UPDATE projects SET {', '.join(updates)} WHERE id = ?", params)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
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()
|
||||||
|
return dict(row)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue