340 lines
16 KiB
HTML
340 lines
16 KiB
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', 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>
|