mirror of
https://github.com/arkorty/B.Tech-Project-III.git
synced 2026-04-19 12:41:48 +00:00
925 lines
41 KiB
TypeScript
925 lines
41 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useRef, useState, useCallback } from "react";
|
|
import Sidebar from "../components/Sidebar";
|
|
import TopBar from "../components/TopBar";
|
|
import {
|
|
fetchJiraTickets,
|
|
fetchJiraConfig,
|
|
fetchJiraTicketStatus,
|
|
createJiraTicket,
|
|
searchJiraUsers,
|
|
JiraTicket,
|
|
JiraConfig,
|
|
Group,
|
|
fetchGroups,
|
|
formatRelativeTime,
|
|
} from "../lib/api";
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function formatDate(iso: string): string {
|
|
if (!iso) return "—";
|
|
try {
|
|
return new Date(iso).toLocaleString("en-GB", {
|
|
day: "2-digit", month: "short", year: "numeric",
|
|
hour: "2-digit", minute: "2-digit",
|
|
});
|
|
} catch {
|
|
return iso;
|
|
}
|
|
}
|
|
|
|
function priorityColor(priority: string): string {
|
|
switch ((priority || "").toLowerCase()) {
|
|
case "highest": return "#EF4444";
|
|
case "high": return "#F97316";
|
|
case "medium": return "#EAB308";
|
|
case "low": return "#3B82F6";
|
|
case "lowest": return "#71717A";
|
|
default: return "#A78BFA";
|
|
}
|
|
}
|
|
|
|
function statusColor(status: string): string {
|
|
const s = (status || "").toLowerCase();
|
|
if (s.includes("done") || s.includes("closed") || s.includes("resolved")) return "#34D399";
|
|
if (s.includes("progress") || s.includes("review")) return "#60A5FA";
|
|
if (s.includes("todo") || s.includes("open") || s === "unknown") return "#A78BFA";
|
|
return "#EAB308";
|
|
}
|
|
|
|
function PriorityBadge({ priority }: { priority: string }) {
|
|
const color = priorityColor(priority);
|
|
return (
|
|
<span
|
|
className="text-[9px] font-bold uppercase tracking-[0.1em] px-2 py-0.5 rounded-full border"
|
|
style={{ color, borderColor: `${color}40`, background: `${color}15` }}
|
|
>
|
|
{priority || "Medium"}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function StatusBadge({ status }: { status: string }) {
|
|
const color = statusColor(status);
|
|
return (
|
|
<span
|
|
className="text-[9px] font-bold uppercase tracking-[0.1em] px-2 py-0.5 rounded-full border flex items-center gap-1"
|
|
style={{ color, borderColor: `${color}40`, background: `${color}15` }}
|
|
>
|
|
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0" style={{ background: color }} />
|
|
{status === "Unknown" ? "Pending" : status}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// ─── Create Ticket Modal ───────────────────────────────────────────────────────
|
|
|
|
type JiraUser = { account_id: string; display_name: string; email: string };
|
|
|
|
function CreateTicketModal({
|
|
config,
|
|
onClose,
|
|
onCreated,
|
|
}: {
|
|
config: JiraConfig;
|
|
onClose: () => void;
|
|
onCreated: (ticket: { key: string; url: string }) => void;
|
|
}) {
|
|
const [summary, setSummary] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
const [projectKey, setProjectKey] = useState(config.default_project || "");
|
|
const [issueType, setIssueType] = useState("Task");
|
|
const [priority, setPriority] = useState("Medium");
|
|
const [labels, setLabels] = useState("thirdeye");
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [errorDetails, setErrorDetails] = useState<string>("");
|
|
|
|
// Assignee search
|
|
const [assigneeQuery, setAssigneeQuery] = useState("");
|
|
const [assigneeSuggestions, setAssigneeSuggestions] = useState<JiraUser[]>([]);
|
|
const [selectedAssignee, setSelectedAssignee] = useState<JiraUser | null>(null);
|
|
const [searchingAssignee, setSearchingAssignee] = useState(false);
|
|
const assigneeTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
const handleAssigneeInput = (val: string) => {
|
|
setAssigneeQuery(val);
|
|
setSelectedAssignee(null);
|
|
if (assigneeTimeout.current) clearTimeout(assigneeTimeout.current);
|
|
if (val.trim().length < 2) { setAssigneeSuggestions([]); return; }
|
|
assigneeTimeout.current = setTimeout(async () => {
|
|
setSearchingAssignee(true);
|
|
try {
|
|
const users = await searchJiraUsers(val.trim());
|
|
setAssigneeSuggestions(users);
|
|
} catch {
|
|
setAssigneeSuggestions([]);
|
|
} finally {
|
|
setSearchingAssignee(false);
|
|
}
|
|
}, 350);
|
|
};
|
|
|
|
const selectAssignee = (user: JiraUser) => {
|
|
setSelectedAssignee(user);
|
|
setAssigneeQuery(user.display_name);
|
|
setAssigneeSuggestions([]);
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!summary.trim()) return;
|
|
setLoading(true);
|
|
setError("");
|
|
setErrorDetails("");
|
|
try {
|
|
const result = await createJiraTicket({
|
|
summary: summary.trim(),
|
|
description: description.trim(),
|
|
project_key: projectKey || undefined,
|
|
issue_type: issueType,
|
|
priority,
|
|
labels: labels.split(",").map((l) => l.trim()).filter(Boolean),
|
|
assignee_account_id: selectedAssignee?.account_id || undefined,
|
|
});
|
|
if (result.ok && result.key) {
|
|
onCreated({ key: result.key, url: result.url || "" });
|
|
} else {
|
|
setError(result.error || "Failed to create ticket");
|
|
if (result.details) {
|
|
const d = result.details;
|
|
if (typeof d === "object" && d !== null) {
|
|
setErrorDetails(JSON.stringify(d, null, 2));
|
|
} else {
|
|
setErrorDetails(String(d));
|
|
}
|
|
}
|
|
}
|
|
} catch (err: unknown) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
|
<div
|
|
className="relative neon-card-gradient rounded-2xl border border-[#A78BFA]/20 w-full max-w-lg shadow-2xl animate-fade-in-scale opacity-0 max-h-[90vh] overflow-y-auto"
|
|
style={{ boxShadow: "0 0 60px rgba(167,139,250,0.12)" }}
|
|
>
|
|
<div className="p-6 border-b border-white/5">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded-lg bg-[rgba(167,139,250,0.15)] flex items-center justify-center">
|
|
<span className="material-symbols-outlined text-[#A78BFA]" style={{ fontSize: "16px" }}>add_task</span>
|
|
</div>
|
|
<div>
|
|
<h3 className="text-[14px] font-bold text-white">Create Jira Ticket</h3>
|
|
<p className="text-[10px] text-zinc-500 uppercase tracking-wider">Manual ticket creation</p>
|
|
</div>
|
|
</div>
|
|
<button onClick={onClose} className="text-zinc-500 hover:text-zinc-300 transition-colors">
|
|
<span className="material-symbols-outlined">close</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
|
{/* Summary */}
|
|
<div>
|
|
<label className="block text-[10px] font-bold text-zinc-400 uppercase tracking-wider mb-1.5">
|
|
Summary *
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={summary}
|
|
onChange={(e) => setSummary(e.target.value)}
|
|
placeholder="Short, actionable ticket title..."
|
|
required
|
|
className="w-full bg-[#141419] border border-white/10 rounded-lg px-3 py-2.5 text-[13px] text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-[#A78BFA]/50"
|
|
/>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div>
|
|
<label className="block text-[10px] font-bold text-zinc-400 uppercase tracking-wider mb-1.5">
|
|
Description
|
|
</label>
|
|
<textarea
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder="Full description, context, next steps..."
|
|
rows={3}
|
|
className="w-full bg-[#141419] border border-white/10 rounded-lg px-3 py-2.5 text-[13px] text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-[#A78BFA]/50 resize-none"
|
|
/>
|
|
</div>
|
|
|
|
{/* Project / Type / Priority */}
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<div>
|
|
<label className="block text-[10px] font-bold text-zinc-400 uppercase tracking-wider mb-1.5">
|
|
Project Key
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={projectKey}
|
|
onChange={(e) => setProjectKey(e.target.value.toUpperCase())}
|
|
placeholder={config.default_project || "ENG"}
|
|
className="w-full bg-[#141419] border border-white/10 rounded-lg px-3 py-2.5 text-[13px] text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-[#A78BFA]/50 font-mono-data"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-[10px] font-bold text-zinc-400 uppercase tracking-wider mb-1.5">
|
|
Type
|
|
</label>
|
|
<select
|
|
value={issueType}
|
|
onChange={(e) => setIssueType(e.target.value)}
|
|
className="w-full bg-[#141419] border border-white/10 rounded-lg px-3 py-2.5 text-[13px] text-zinc-200 focus:outline-none focus:border-[#A78BFA]/50"
|
|
>
|
|
{["Task", "Bug", "Story", "Epic"].map((t) => (
|
|
<option key={t} value={t}>{t}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-[10px] font-bold text-zinc-400 uppercase tracking-wider mb-1.5">
|
|
Priority
|
|
</label>
|
|
<select
|
|
value={priority}
|
|
onChange={(e) => setPriority(e.target.value)}
|
|
className="w-full bg-[#141419] border border-white/10 rounded-lg px-3 py-2.5 text-[13px] text-zinc-200 focus:outline-none focus:border-[#A78BFA]/50"
|
|
>
|
|
{["Highest", "High", "Medium", "Low", "Lowest"].map((p) => (
|
|
<option key={p} value={p}>{p}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Assignee search */}
|
|
<div className="relative">
|
|
<label className="block text-[10px] font-bold text-zinc-400 uppercase tracking-wider mb-1.5">
|
|
Assignee
|
|
</label>
|
|
<div className="relative">
|
|
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" style={{ fontSize: "15px" }}>
|
|
{selectedAssignee ? "account_circle" : "person_search"}
|
|
</span>
|
|
<input
|
|
type="text"
|
|
value={assigneeQuery}
|
|
onChange={(e) => handleAssigneeInput(e.target.value)}
|
|
placeholder="Search by name or email..."
|
|
className="w-full bg-[#141419] border border-white/10 rounded-lg pl-9 pr-9 py-2.5 text-[13px] text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-[#A78BFA]/50"
|
|
style={{ borderColor: selectedAssignee ? "rgba(167,139,250,0.4)" : undefined }}
|
|
/>
|
|
{searchingAssignee && (
|
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 w-3 h-3 border-2 border-[#A78BFA]/30 border-t-[#A78BFA] rounded-full animate-spin" />
|
|
)}
|
|
{selectedAssignee && (
|
|
<button
|
|
type="button"
|
|
onClick={() => { setSelectedAssignee(null); setAssigneeQuery(""); }}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
|
|
>
|
|
<span className="material-symbols-outlined" style={{ fontSize: "15px" }}>close</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
{/* Dropdown suggestions */}
|
|
{assigneeSuggestions.length > 0 && (
|
|
<div className="absolute z-10 w-full mt-1 bg-[#1a1a20] border border-white/10 rounded-lg shadow-2xl overflow-hidden">
|
|
{assigneeSuggestions.map((u) => (
|
|
<button
|
|
key={u.account_id}
|
|
type="button"
|
|
onClick={() => selectAssignee(u)}
|
|
className="w-full flex items-center gap-3 px-3 py-2.5 text-left hover:bg-[#A78BFA]/10 transition-colors"
|
|
>
|
|
<div className="w-7 h-7 rounded-full bg-[#A78BFA]/20 flex items-center justify-center flex-shrink-0">
|
|
<span className="material-symbols-outlined text-[#A78BFA]" style={{ fontSize: "14px" }}>person</span>
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-[12px] text-zinc-200 truncate">{u.display_name}</p>
|
|
<p className="text-[10px] text-zinc-500 truncate">{u.email}</p>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
{assigneeQuery.trim().length >= 2 && !searchingAssignee && assigneeSuggestions.length === 0 && !selectedAssignee && (
|
|
<p className="mt-1 text-[10px] text-zinc-600">No users found for “{assigneeQuery}”</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Labels */}
|
|
<div>
|
|
<label className="block text-[10px] font-bold text-zinc-400 uppercase tracking-wider mb-1.5">
|
|
Labels <span className="text-zinc-600 normal-case font-normal">(comma-separated, no spaces in label)</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={labels}
|
|
onChange={(e) => setLabels(e.target.value)}
|
|
placeholder="thirdeye,dashboard"
|
|
className="w-full bg-[#141419] border border-white/10 rounded-lg px-3 py-2.5 text-[13px] text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-[#A78BFA]/50"
|
|
/>
|
|
</div>
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 space-y-1">
|
|
<p className="text-[12px] text-red-400 font-semibold">{error}</p>
|
|
{errorDetails && (
|
|
<pre className="text-[10px] text-red-500/70 whitespace-pre-wrap break-all font-mono-data max-h-24 overflow-y-auto">
|
|
{errorDetails}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-3 pt-2">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="flex-1 px-4 py-2.5 rounded-lg border border-white/10 text-[12px] text-zinc-400 hover:text-zinc-200 hover:border-white/20 transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={loading || !summary.trim()}
|
|
className="flex-1 px-4 py-2.5 rounded-lg bg-[#A78BFA] text-[12px] font-semibold text-[#1a0040] hover:bg-[#c4b5fd] transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|
>
|
|
{loading ? (
|
|
<div className="w-4 h-4 border-2 border-[#1a0040]/30 border-t-[#1a0040] rounded-full animate-spin" />
|
|
) : (
|
|
<span className="material-symbols-outlined" style={{ fontSize: "16px" }}>add_task</span>
|
|
)}
|
|
{loading ? "Creating..." : "Create Ticket"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Ticket Detail Modal ───────────────────────────────────────────────────────
|
|
|
|
function TicketDetailModal({
|
|
ticket,
|
|
onClose,
|
|
onRefreshStatus,
|
|
}: {
|
|
ticket: JiraTicket;
|
|
onClose: () => void;
|
|
onRefreshStatus: (key: string) => void;
|
|
}) {
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
|
|
const handleRefresh = async () => {
|
|
setRefreshing(true);
|
|
await onRefreshStatus(ticket.jira_key);
|
|
setRefreshing(false);
|
|
};
|
|
|
|
const openInJira = () => {
|
|
if (ticket.jira_url) window.open(ticket.jira_url, "_blank", "noopener,noreferrer");
|
|
};
|
|
|
|
const priorColor = priorityColor(ticket.jira_priority);
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
|
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
|
<div
|
|
className="relative w-full max-w-md rounded-2xl border border-[#A78BFA]/20 shadow-2xl overflow-hidden"
|
|
style={{ background: "linear-gradient(135deg,#0f0c18 0%,#13101f 100%)", boxShadow: "0 0 60px rgba(167,139,250,0.15)" }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Top accent bar coloured by priority */}
|
|
<div className="h-[3px] w-full" style={{ background: `linear-gradient(90deg, ${priorColor}, transparent)` }} />
|
|
|
|
{/* Header */}
|
|
<div className="px-6 pt-5 pb-4 flex items-start justify-between gap-3 border-b border-white/5">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<div className="w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0 bg-[rgba(167,139,250,0.15)]">
|
|
<span className="material-symbols-outlined text-[#A78BFA]" style={{ fontSize: "18px" }}>confirmation_number</span>
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-[11px] font-mono-data text-[#A78BFA] tracking-widest uppercase mb-0.5">{ticket.jira_key || "—"}</p>
|
|
<h3 className="text-[14px] font-bold text-white leading-snug line-clamp-2">
|
|
{ticket.jira_summary || "(no summary)"}
|
|
</h3>
|
|
</div>
|
|
</div>
|
|
<button onClick={onClose} className="text-zinc-500 hover:text-zinc-300 transition-colors flex-shrink-0 mt-0.5">
|
|
<span className="material-symbols-outlined">close</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="px-6 py-5 space-y-4">
|
|
{/* Status + Priority row */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="bg-[#0c0814]/60 rounded-xl p-3.5 border border-white/5">
|
|
<p className="text-[9px] font-mono-data text-zinc-500 uppercase tracking-widest mb-2">Status</p>
|
|
<StatusBadge status={ticket.status} />
|
|
</div>
|
|
<div className="bg-[#0c0814]/60 rounded-xl p-3.5 border border-white/5">
|
|
<p className="text-[9px] font-mono-data text-zinc-500 uppercase tracking-widest mb-2">Priority</p>
|
|
<PriorityBadge priority={ticket.jira_priority} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Meta grid */}
|
|
<div className="grid grid-cols-2 gap-3 text-[12px]">
|
|
<div className="bg-[#0c0814]/60 rounded-xl p-3.5 border border-white/5">
|
|
<p className="text-[9px] font-mono-data text-zinc-500 uppercase tracking-widest mb-1.5">Raised</p>
|
|
<p className="text-zinc-300 font-medium">{formatDate(ticket.raised_at)}</p>
|
|
</div>
|
|
<div className="bg-[#0c0814]/60 rounded-xl p-3.5 border border-white/5">
|
|
<p className="text-[9px] font-mono-data text-zinc-500 uppercase tracking-widest mb-1.5">Group</p>
|
|
<p className="text-zinc-300 font-mono-data truncate">{ticket.group_id || "—"}</p>
|
|
</div>
|
|
{ticket.assignee && (
|
|
<div className="col-span-2 bg-[#0c0814]/60 rounded-xl p-3.5 border border-white/5">
|
|
<p className="text-[9px] font-mono-data text-zinc-500 uppercase tracking-widest mb-1.5">Assignee</p>
|
|
<div className="flex items-center gap-2">
|
|
<span className="material-symbols-outlined text-zinc-400" style={{ fontSize: "16px" }}>account_circle</span>
|
|
<p className="text-zinc-300">{ticket.assignee}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Jira URL preview */}
|
|
{ticket.jira_url && (
|
|
<div className="flex items-center gap-2 px-3 py-2.5 rounded-lg bg-[#0c0814]/60 border border-white/5">
|
|
<span className="material-symbols-outlined text-zinc-600" style={{ fontSize: "14px" }}>link</span>
|
|
<p className="text-[10px] font-mono-data text-zinc-600 truncate flex-1">{ticket.jira_url}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer actions */}
|
|
<div className="px-6 pb-6 flex items-center gap-3">
|
|
{/* Primary — open in Jira */}
|
|
<button
|
|
onClick={openInJira}
|
|
disabled={!ticket.jira_url}
|
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl bg-[#A78BFA] text-[13px] font-bold text-[#1a0040] hover:bg-[#c4b5fd] active:scale-95 transition-all disabled:opacity-40 disabled:cursor-not-allowed shadow-[0_0_20px_rgba(167,139,250,0.3)]"
|
|
>
|
|
<span className="material-symbols-outlined" style={{ fontSize: "17px" }}>open_in_new</span>
|
|
Open in Jira
|
|
</button>
|
|
|
|
{/* Secondary — refresh */}
|
|
<button
|
|
onClick={handleRefresh}
|
|
disabled={refreshing || !ticket.jira_key}
|
|
className="w-11 h-11 flex items-center justify-center rounded-xl border border-white/10 text-zinc-400 hover:text-[#A78BFA] hover:border-[#A78BFA]/30 active:scale-95 transition-all disabled:opacity-40"
|
|
title="Refresh status from Jira"
|
|
>
|
|
<span className={`material-symbols-outlined ${refreshing ? "animate-spin" : ""}`} style={{ fontSize: "18px" }}>refresh</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Ticket Row ────────────────────────────────────────────────────────────────
|
|
|
|
function TicketRow({
|
|
ticket,
|
|
onSelect,
|
|
onRefreshStatus,
|
|
}: {
|
|
ticket: JiraTicket;
|
|
onSelect: (t: JiraTicket) => void;
|
|
onRefreshStatus: (key: string) => void;
|
|
}) {
|
|
return (
|
|
<tr
|
|
className="border-b border-white/5 cursor-pointer hover:bg-[rgba(167,139,250,0.07)] active:bg-[rgba(167,139,250,0.12)] transition-colors group"
|
|
onClick={() => onSelect(ticket)}
|
|
>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-[#A78BFA] font-bold text-[12px] font-mono-data group-hover:underline underline-offset-2">
|
|
{ticket.jira_key || "—"}
|
|
</span>
|
|
<span className="material-symbols-outlined text-zinc-700 group-hover:text-[#A78BFA] transition-colors" style={{ fontSize: "11px" }}>
|
|
chevron_right
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3 max-w-[300px]">
|
|
<p className="text-[12px] text-zinc-300 truncate group-hover:text-white transition-colors">{ticket.jira_summary || "—"}</p>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<StatusBadge status={ticket.status} />
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<PriorityBadge priority={ticket.jira_priority} />
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className="text-[11px] text-zinc-500 font-mono-data">{ticket.group_id || "—"}</span>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className="text-[11px] text-zinc-500">{formatDate(ticket.raised_at)}</span>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
{ticket.jira_key && (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); onRefreshStatus(ticket.jira_key); }}
|
|
className="text-zinc-600 hover:text-[#A78BFA] transition-colors"
|
|
title="Refresh status from Jira"
|
|
>
|
|
<span className="material-symbols-outlined" style={{ fontSize: "16px" }}>refresh</span>
|
|
</button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
// ─── Main Page ─────────────────────────────────────────────────────────────────
|
|
|
|
type SortKey = "raised_at" | "jira_key" | "jira_priority" | "status";
|
|
|
|
export default function JiraPage() {
|
|
const [tickets, setTickets] = useState<JiraTicket[]>([]);
|
|
const [config, setConfig] = useState<JiraConfig | null>(null);
|
|
const [groups, setGroups] = useState<Group[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
const [selectedTicket, setSelectedTicket] = useState<JiraTicket | null>(null);
|
|
const [successMsg, setSuccessMsg] = useState("");
|
|
|
|
// Filters
|
|
const [filterGroup, setFilterGroup] = useState("");
|
|
const [filterStatus, setFilterStatus] = useState("");
|
|
const [filterPriority, setFilterPriority] = useState("");
|
|
const [dateFrom, setDateFrom] = useState("");
|
|
const [dateTo, setDateTo] = useState("");
|
|
const [search, setSearch] = useState("");
|
|
|
|
// Sorting
|
|
const [sortKey, setSortKey] = useState<SortKey>("raised_at");
|
|
const [sortAsc, setSortAsc] = useState(false);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [tix, cfg, grps] = await Promise.all([
|
|
fetchJiraTickets({ group_id: filterGroup || undefined, date_from: dateFrom || undefined, date_to: dateTo || undefined }),
|
|
fetchJiraConfig(),
|
|
fetchGroups(),
|
|
]);
|
|
setTickets(tix);
|
|
setConfig(cfg);
|
|
setGroups(grps);
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [filterGroup, dateFrom, dateTo]);
|
|
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
const refreshStatus = async (key: string) => {
|
|
try {
|
|
const data = await fetchJiraTicketStatus(key);
|
|
setTickets((prev) =>
|
|
prev.map((t) =>
|
|
t.jira_key === key
|
|
? { ...t, status: data.status, assignee: data.assignee, jira_summary: data.summary || t.jira_summary }
|
|
: t
|
|
)
|
|
);
|
|
} catch (e) {
|
|
console.error("Status refresh failed:", e);
|
|
}
|
|
};
|
|
|
|
const handleCreated = (ticket: { key: string; url: string }) => {
|
|
setShowCreate(false);
|
|
setSuccessMsg(`Ticket ${ticket.key} created successfully!`);
|
|
setTimeout(() => setSuccessMsg(""), 4000);
|
|
load();
|
|
};
|
|
|
|
// Filter + sort
|
|
const severityRank: Record<string, number> = { Highest: 5, High: 4, Medium: 3, Low: 2, Lowest: 1 };
|
|
|
|
const displayed = tickets
|
|
.filter((t) => {
|
|
if (filterStatus && t.status !== filterStatus) return false;
|
|
if (filterPriority && (t.jira_priority || "").toLowerCase() !== filterPriority.toLowerCase()) return false;
|
|
if (search && !t.jira_key.toLowerCase().includes(search.toLowerCase()) &&
|
|
!t.jira_summary.toLowerCase().includes(search.toLowerCase())) return false;
|
|
return true;
|
|
})
|
|
.sort((a, b) => {
|
|
let cmp = 0;
|
|
if (sortKey === "raised_at") cmp = a.raised_at.localeCompare(b.raised_at);
|
|
else if (sortKey === "jira_key") cmp = a.jira_key.localeCompare(b.jira_key);
|
|
else if (sortKey === "jira_priority")
|
|
cmp = (severityRank[a.jira_priority] || 3) - (severityRank[b.jira_priority] || 3);
|
|
else if (sortKey === "status") cmp = a.status.localeCompare(b.status);
|
|
return sortAsc ? cmp : -cmp;
|
|
});
|
|
|
|
const toggleSort = (key: SortKey) => {
|
|
if (sortKey === key) setSortAsc((a) => !a);
|
|
else { setSortKey(key); setSortAsc(false); }
|
|
};
|
|
|
|
const SortIcon = ({ k }: { k: SortKey }) => (
|
|
<span className="material-symbols-outlined" style={{ fontSize: "12px" }}>
|
|
{sortKey !== k ? "unfold_more" : sortAsc ? "expand_less" : "expand_more"}
|
|
</span>
|
|
);
|
|
|
|
const openCount = tickets.filter(t => !["Done", "Closed", "Resolved"].includes(t.status)).length;
|
|
const doneCount = tickets.filter(t => ["Done", "Closed", "Resolved"].includes(t.status)).length;
|
|
const criticalCount = tickets.filter(t => ["Highest", "High"].includes(t.jira_priority)).length;
|
|
|
|
const exportCSV = () => {
|
|
const header = ["Key", "Summary", "Status", "Priority", "Group", "Raised At"].join(",");
|
|
const rows = displayed.map((t) =>
|
|
[t.jira_key, `"${t.jira_summary.replace(/"/g, '""')}"`, t.status, t.jira_priority, t.group_id, t.raised_at].join(",")
|
|
);
|
|
const blob = new Blob([[header, ...rows].join("\n")], { type: "text/csv" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url; a.download = "jira_tickets.csv"; a.click();
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen flex" style={{ backgroundColor: "#09090B" }}>
|
|
<Sidebar />
|
|
<div className="flex-1 ml-[240px] flex flex-col min-h-screen">
|
|
<TopBar />
|
|
<main className="flex-1 p-8">
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between mb-8 animate-fade-in-up opacity-0">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-xl bg-[rgba(167,139,250,0.15)] flex items-center justify-center">
|
|
<span className="material-symbols-outlined text-[#A78BFA]">bug_report</span>
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-white tracking-tight">Jira Tickets</h1>
|
|
<p className="text-[11px] text-zinc-500 uppercase tracking-[0.2em]">
|
|
ThirdEye-raised · {config?.default_project || "All Projects"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{config?.configured && (
|
|
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-emerald-500/10 border border-emerald-500/20">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
|
<span className="text-[10px] font-bold text-emerald-400 uppercase tracking-wider">
|
|
Jira Connected
|
|
</span>
|
|
</div>
|
|
)}
|
|
<button
|
|
onClick={exportCSV}
|
|
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-white/10 text-[11px] text-zinc-400 hover:text-zinc-200 hover:border-white/20 transition-colors"
|
|
>
|
|
<span className="material-symbols-outlined" style={{ fontSize: "15px" }}>download</span>
|
|
Export CSV
|
|
</button>
|
|
{config?.configured && (
|
|
<button
|
|
onClick={() => setShowCreate(true)}
|
|
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[#A78BFA] text-[12px] font-semibold text-[#1a0040] hover:bg-[#c4b5fd] transition-colors"
|
|
>
|
|
<span className="material-symbols-outlined" style={{ fontSize: "16px" }}>add</span>
|
|
New Ticket
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Success message */}
|
|
{successMsg && (
|
|
<div className="mb-4 p-3 rounded-lg bg-emerald-500/10 border border-emerald-500/20 text-[12px] text-emerald-400 flex items-center gap-2 animate-fade-in-up opacity-0">
|
|
<span className="material-symbols-outlined" style={{ fontSize: "16px" }}>check_circle</span>
|
|
{successMsg}
|
|
</div>
|
|
)}
|
|
|
|
{/* Jira not configured banner */}
|
|
{config && !config.configured && (
|
|
<div className="mb-6 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-center gap-3">
|
|
<span className="material-symbols-outlined text-amber-400">warning</span>
|
|
<div>
|
|
<p className="text-[13px] font-semibold text-amber-300">Jira not configured</p>
|
|
<p className="text-[11px] text-amber-600 mt-0.5">
|
|
Set JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN and ENABLE_JIRA=true in your .env to connect Jira.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-4 gap-4 mb-6 animate-fade-in-up opacity-0 delay-100">
|
|
{[
|
|
{ label: "Total Raised", value: tickets.length, icon: "confirmation_number", color: "#A78BFA" },
|
|
{ label: "Open", value: openCount, icon: "radio_button_unchecked", color: "#60A5FA" },
|
|
{ label: "Resolved", value: doneCount, icon: "check_circle", color: "#34D399" },
|
|
{ label: "High Priority", value: criticalCount, icon: "priority_high", color: "#F87171" },
|
|
].map((s) => (
|
|
<div key={s.label} className="neon-card-gradient rounded-xl p-4 border border-white/5 flex items-center gap-4">
|
|
<div className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0" style={{ background: `${s.color}18` }}>
|
|
<span className="material-symbols-outlined" style={{ fontSize: "18px", color: s.color }}>{s.icon}</span>
|
|
</div>
|
|
<div>
|
|
<p className="text-2xl font-bold text-white">{s.value}</p>
|
|
<p className="text-[10px] text-zinc-500 uppercase tracking-wider">{s.label}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="flex items-center gap-3 flex-wrap mb-5 animate-fade-in-up opacity-0 delay-200">
|
|
<div className="relative">
|
|
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" style={{ fontSize: "15px" }}>
|
|
search
|
|
</span>
|
|
<input
|
|
type="text"
|
|
placeholder="Search key or summary..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="bg-[#141419] border border-white/10 rounded-lg pl-9 pr-4 py-2 text-[12px] text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-[#A78BFA]/50 w-52"
|
|
/>
|
|
</div>
|
|
|
|
<select
|
|
value={filterGroup}
|
|
onChange={(e) => setFilterGroup(e.target.value)}
|
|
className="bg-[#141419] border border-white/10 rounded-lg px-3 py-2 text-[12px] text-zinc-300 focus:outline-none focus:border-[#A78BFA]/50"
|
|
>
|
|
<option value="">All Groups</option>
|
|
{groups.map((g) => (
|
|
<option key={g.group_id} value={g.group_id}>{g.group_name}</option>
|
|
))}
|
|
</select>
|
|
|
|
<select
|
|
value={filterStatus}
|
|
onChange={(e) => setFilterStatus(e.target.value)}
|
|
className="bg-[#141419] border border-white/10 rounded-lg px-3 py-2 text-[12px] text-zinc-300 focus:outline-none focus:border-[#A78BFA]/50"
|
|
>
|
|
<option value="">All Statuses</option>
|
|
{["To Do", "In Progress", "In Review", "Done", "Unknown"].map((s) => (
|
|
<option key={s} value={s}>{s}</option>
|
|
))}
|
|
</select>
|
|
|
|
<select
|
|
value={filterPriority}
|
|
onChange={(e) => setFilterPriority(e.target.value)}
|
|
className="bg-[#141419] border border-white/10 rounded-lg px-3 py-2 text-[12px] text-zinc-300 focus:outline-none focus:border-[#A78BFA]/50"
|
|
>
|
|
<option value="">All Priorities</option>
|
|
{["Highest", "High", "Medium", "Low", "Lowest"].map((p) => (
|
|
<option key={p} value={p}>{p}</option>
|
|
))}
|
|
</select>
|
|
|
|
<input
|
|
type="date"
|
|
title="From date"
|
|
value={dateFrom ? dateFrom.slice(0, 10) : ""}
|
|
onChange={(e) => setDateFrom(e.target.value ? new Date(e.target.value).toISOString() : "")}
|
|
className="bg-[#141419] border border-white/10 rounded-lg px-3 py-2 text-[12px] text-zinc-300 focus:outline-none focus:border-[#A78BFA]/50"
|
|
/>
|
|
<input
|
|
type="date"
|
|
title="To date"
|
|
value={dateTo ? dateTo.slice(0, 10) : ""}
|
|
onChange={(e) => setDateTo(e.target.value ? new Date(e.target.value).toISOString() : "")}
|
|
className="bg-[#141419] border border-white/10 rounded-lg px-3 py-2 text-[12px] text-zinc-300 focus:outline-none focus:border-[#A78BFA]/50"
|
|
/>
|
|
|
|
{(filterGroup || filterStatus || filterPriority || dateFrom || dateTo || search) && (
|
|
<button
|
|
onClick={() => { setFilterGroup(""); setFilterStatus(""); setFilterPriority(""); setDateFrom(""); setDateTo(""); setSearch(""); }}
|
|
className="text-[10px] text-zinc-500 hover:text-zinc-300 uppercase tracking-wider px-3 py-2 rounded-lg border border-white/10 transition-colors flex items-center gap-1"
|
|
>
|
|
<span className="material-symbols-outlined" style={{ fontSize: "13px" }}>filter_alt_off</span>
|
|
Clear
|
|
</button>
|
|
)}
|
|
|
|
<button
|
|
onClick={load}
|
|
className="ml-auto flex items-center gap-1.5 text-[11px] text-zinc-500 hover:text-[#A78BFA] transition-colors"
|
|
>
|
|
<span className="material-symbols-outlined" style={{ fontSize: "15px" }}>refresh</span>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<div className="neon-card-gradient rounded-2xl border border-white/5 overflow-hidden animate-fade-in-up opacity-0 delay-300">
|
|
{loading ? (
|
|
<div className="flex flex-col items-center justify-center h-64 text-zinc-600">
|
|
<div className="w-8 h-8 border-2 border-[#A78BFA]/30 border-t-[#A78BFA] rounded-full animate-spin mb-3" />
|
|
<p className="text-[11px] uppercase tracking-wider">Loading tickets...</p>
|
|
</div>
|
|
) : displayed.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center h-64 text-zinc-600">
|
|
<span className="material-symbols-outlined text-4xl mb-2">confirmation_number</span>
|
|
<p className="text-[13px] uppercase tracking-wider">No tickets found</p>
|
|
<p className="text-[10px] text-zinc-700 mt-1">Tickets appear after ThirdEye raises them via /jira or auto-raise</p>
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b border-white/5 bg-[rgba(255,255,255,0.02)]">
|
|
{(
|
|
[
|
|
{ label: "Key", key: "jira_key" },
|
|
{ label: "Summary", key: null },
|
|
{ label: "Status", key: "status" },
|
|
{ label: "Priority", key: "jira_priority" },
|
|
{ label: "Group", key: null },
|
|
{ label: "Raised", key: "raised_at" },
|
|
{ label: "", key: null },
|
|
] as { label: string; key: SortKey | null }[]
|
|
).map(({ label, key }) => (
|
|
<th
|
|
key={label}
|
|
className={`px-4 py-3 text-left text-[10px] font-bold text-zinc-500 uppercase tracking-wider ${key ? "cursor-pointer hover:text-zinc-300" : ""}`}
|
|
onClick={key ? () => toggleSort(key) : undefined}
|
|
>
|
|
<div className="flex items-center gap-1">
|
|
{label}
|
|
{key && <SortIcon k={key} />}
|
|
</div>
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{displayed.map((ticket) => (
|
|
<TicketRow
|
|
key={ticket.id}
|
|
ticket={ticket}
|
|
onSelect={setSelectedTicket}
|
|
onRefreshStatus={refreshStatus}
|
|
/>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<p className="text-[10px] text-zinc-700 mt-3">
|
|
Showing {displayed.length} of {tickets.length} tickets
|
|
</p>
|
|
</main>
|
|
</div>
|
|
|
|
{showCreate && config && (
|
|
<CreateTicketModal config={config} onClose={() => setShowCreate(false)} onCreated={handleCreated} />
|
|
)}
|
|
|
|
{selectedTicket && (
|
|
<TicketDetailModal
|
|
ticket={selectedTicket}
|
|
onClose={() => setSelectedTicket(null)}
|
|
onRefreshStatus={async (key) => {
|
|
await refreshStatus(key);
|
|
// Update the selected ticket's status in-place
|
|
setSelectedTicket((prev) =>
|
|
prev
|
|
? { ...prev, status: tickets.find((t) => t.jira_key === key)?.status ?? prev.status }
|
|
: null
|
|
);
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|