This commit is contained in:
2026-04-05 00:43:23 +05:30
commit 8be37d3e92
425 changed files with 101853 additions and 0 deletions

View File

@@ -0,0 +1,924 @@
"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 &ldquo;{assigneeQuery}&rdquo;</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>
);
}