feat: enterprise UI overhaul - Lucide icons, collapsible sidebar, Cmd+K search, premium Login, skeleton loaders

This commit is contained in:
Ricel Leite 2026-02-18 21:53:39 -03:00
parent a369b4afb1
commit c49cbee3a4
15 changed files with 3510 additions and 586 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JIRA AI Fixer</title> <title>JIRA AI Fixer</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<script type="module" crossorigin src="/assets/index-D4MghmKn.js"></script> <script type="module" crossorigin src="/assets/index-B1gflmx5.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BNMhozdr.css"> <link rel="stylesheet" crossorigin href="/assets/index-AVKtIbkK.css">
</head> </head>
<body class="bg-gray-900 text-white"> <body class="bg-gray-900 text-white">
<div id="root"></div> <div id="root"></div>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -2,115 +2,337 @@ import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { organizations } from '../services/api'; import { organizations } from '../services/api';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import clsx from 'clsx'; import { cn } from '../lib/utils';
import {
LayoutDashboard, TicketCheck, Plug, Users, BarChart3, Settings,
ChevronDown, ChevronRight, Search, Bell, PanelLeftClose, PanelLeftOpen,
LogOut, Building2, Plus, Zap, Shield, ChevronsUpDown, Command
} from 'lucide-react';
const navItems = [ const navSections = [
{ path: '/', label: 'Dashboard', icon: '📊' }, {
{ path: '/issues', label: 'Issues', icon: '🎫' }, label: 'Main',
{ path: '/integrations', label: 'Integrations', icon: '🔌' }, items: [
{ path: '/team', label: 'Team', icon: '👥' }, { path: '/', label: 'Dashboard', icon: LayoutDashboard },
{ path: '/reports', label: 'Reports', icon: '📈' }, { path: '/issues', label: 'Issues', icon: TicketCheck },
{ path: '/settings', label: 'Settings', icon: '⚙️' } ]
},
{
label: 'Management',
items: [
{ path: '/integrations', label: 'Integrations', icon: Plug },
{ path: '/team', label: 'Team', icon: Users },
{ path: '/reports', label: 'Reports', icon: BarChart3 },
]
},
{
label: 'System',
items: [
{ path: '/settings', label: 'Settings', icon: Settings },
]
}
]; ];
const breadcrumbMap = {
'/': 'Dashboard',
'/issues': 'Issues',
'/integrations': 'Integrations',
'/team': 'Team',
'/reports': 'Reports',
'/settings': 'Settings',
};
export default function Layout() { export default function Layout() {
const { user, logout, currentOrg, selectOrg } = useAuth(); const { user, logout, currentOrg, selectOrg } = useAuth();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const [collapsed, setCollapsed] = useState(false);
const [showOrgMenu, setShowOrgMenu] = useState(false); const [showOrgMenu, setShowOrgMenu] = useState(false);
const [showSearch, setShowSearch] = useState(false);
const [showNotifs, setShowNotifs] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const { data: orgs } = useQuery({ const { data: orgs } = useQuery({
queryKey: ['organizations'], queryKey: ['organizations'],
queryFn: () => organizations.list() queryFn: () => organizations.list()
}); });
// Cmd+K shortcut
useEffect(() => {
const handler = (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
setShowSearch(s => !s);
}
if (e.key === 'Escape') {
setShowSearch(false);
setShowOrgMenu(false);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
const getBreadcrumbs = () => {
const parts = location.pathname.split('/').filter(Boolean);
if (parts.length === 0) return [{ label: 'Dashboard', path: '/' }];
const crumbs = [];
let current = '';
for (const part of parts) {
current += `/${part}`;
crumbs.push({
label: breadcrumbMap[current] || part.charAt(0).toUpperCase() + part.slice(1),
path: current
});
}
return crumbs;
};
const isActive = (path) => {
if (path === '/') return location.pathname === '/';
return location.pathname.startsWith(path);
};
return ( return (
<div className="min-h-screen flex"> <div className="min-h-screen flex bg-gray-950 text-gray-200">
{/* Sidebar */} {/* Search Modal */}
<aside className="w-64 bg-gray-800 border-r border-gray-700 flex flex-col"> {showSearch && (
<div className="p-4 border-b border-gray-700"> <div className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]" onClick={() => setShowSearch(false)}>
<div className="flex items-center gap-2"> <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<span className="text-2xl">🤖</span> <div className="relative w-full max-w-lg mx-4 animate-slide-up" onClick={e => e.stopPropagation()}>
<div> <div className="card border-gray-700 shadow-2xl">
<h1 className="font-bold">JIRA AI Fixer</h1> <div className="flex items-center gap-3 px-4 py-3 border-b border-gray-800">
<p className="text-xs text-gray-400">v2.0</p> <Search size={18} className="text-gray-500" />
<input
autoFocus
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Search issues, projects, settings..."
className="flex-1 bg-transparent text-sm outline-none placeholder:text-gray-500"
/>
<kbd className="kbd">ESC</kbd>
</div>
<div className="p-2 max-h-80 overflow-auto">
{['Dashboard', 'Issues', 'Integrations', 'Team', 'Reports', 'Settings']
.filter(item => item.toLowerCase().includes(searchQuery.toLowerCase()))
.map(item => (
<button
key={item}
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-gray-300 hover:bg-gray-800 transition-colors"
onClick={() => {
navigate(`/${item.toLowerCase() === 'dashboard' ? '' : item.toLowerCase()}`);
setShowSearch(false);
setSearchQuery('');
}}
>
<ChevronRight size={14} className="text-gray-600" />
{item}
</button>
))
}
{searchQuery && (
<div className="px-3 py-6 text-center text-sm text-gray-500">
Press Enter to search for "{searchQuery}"
</div>
)}
</div>
</div> </div>
</div> </div>
</div> </div>
)}
{/* Org selector */}
<div className="p-4 border-b border-gray-700 relative"> {/* Sidebar */}
<button <aside className={cn(
onClick={() => setShowOrgMenu(!showOrgMenu)} "fixed top-0 left-0 h-full flex flex-col border-r border-gray-800/50 bg-gray-950 z-40 transition-all duration-300",
className="w-full flex items-center justify-between p-2 rounded-lg bg-gray-700 hover:bg-gray-600" collapsed ? "w-[68px]" : "w-[260px]"
> )}>
<span className="truncate">{currentOrg?.name || 'Select organization'}</span> {/* Logo */}
<span></span> <div className={cn("h-14 flex items-center border-b border-gray-800/50 px-4", collapsed && "justify-center px-0")}>
</button> {collapsed ? (
{showOrgMenu && orgs?.data && ( <div className="w-8 h-8 rounded-lg bg-indigo-600 flex items-center justify-center">
<div className="absolute top-full left-4 right-4 mt-1 bg-gray-700 rounded-lg shadow-lg z-10"> <Zap size={16} className="text-white" />
{orgs.data.map(org => ( </div>
<button ) : (
key={org.id} <div className="flex items-center gap-2.5">
onClick={() => { selectOrg(org); setShowOrgMenu(false); }} <div className="w-8 h-8 rounded-lg bg-indigo-600 flex items-center justify-center shadow-lg shadow-indigo-500/20">
className={clsx( <Zap size={16} className="text-white" />
"w-full text-left px-4 py-2 hover:bg-gray-600 first:rounded-t-lg last:rounded-b-lg", </div>
currentOrg?.id === org.id && "bg-primary-600" <div>
)} <h1 className="text-sm font-semibold text-white">JIRA AI Fixer</h1>
> <p className="text-[10px] text-gray-500 font-medium">Enterprise v2.0</p>
{org.name} </div>
</button>
))}
<button
onClick={() => { navigate('/settings'); setShowOrgMenu(false); }}
className="w-full text-left px-4 py-2 hover:bg-gray-600 text-primary-400 border-t border-gray-600"
>
+ Create organization
</button>
</div> </div>
)} )}
</div> </div>
{/* Nav */} {/* Org selector */}
<nav className="flex-1 p-4"> {!collapsed && (
{navItems.map(item => ( <div className="px-3 py-3 border-b border-gray-800/50 relative">
<Link <button
key={item.path} onClick={() => setShowOrgMenu(!showOrgMenu)}
to={item.path} className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg bg-gray-900/60 hover:bg-gray-800/80 border border-gray-800/50 transition-all"
className={clsx(
"flex items-center gap-3 px-4 py-2 rounded-lg mb-1",
location.pathname === item.path
? "bg-primary-600 text-white"
: "text-gray-400 hover:bg-gray-700 hover:text-white"
)}
> >
<span>{item.icon}</span> <div className="w-6 h-6 rounded bg-indigo-600/20 flex items-center justify-center flex-shrink-0">
<span>{item.label}</span> <Building2 size={12} className="text-indigo-400" />
</Link> </div>
<span className="flex-1 text-left text-sm truncate">{currentOrg?.name || 'Select org'}</span>
<ChevronsUpDown size={14} className="text-gray-500" />
</button>
{showOrgMenu && orgs?.data && (
<div className="absolute left-3 right-3 top-full mt-1 bg-gray-900 border border-gray-700 rounded-lg shadow-xl z-50 animate-slide-up">
{orgs.data.map(org => (
<button
key={org.id}
onClick={() => { selectOrg(org); setShowOrgMenu(false); }}
className={cn(
"w-full flex items-center gap-2.5 px-3 py-2 text-sm hover:bg-gray-800 first:rounded-t-lg last:rounded-b-lg transition-colors",
currentOrg?.id === org.id && "bg-indigo-600/10 text-indigo-400"
)}
>
<Building2 size={14} />
{org.name}
</button>
))}
<button
onClick={() => { navigate('/settings'); setShowOrgMenu(false); }}
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-indigo-400 hover:bg-gray-800 border-t border-gray-800 rounded-b-lg"
>
<Plus size={14} />
New organization
</button>
</div>
)}
</div>
)}
{/* Search trigger */}
{!collapsed && (
<div className="px-3 pt-3">
<button
onClick={() => setShowSearch(true)}
className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-gray-500 hover:text-gray-300 bg-gray-900/40 hover:bg-gray-800/60 border border-gray-800/30 transition-all text-sm"
>
<Search size={14} />
<span className="flex-1 text-left">Search...</span>
<div className="flex items-center gap-0.5">
<kbd className="kbd"></kbd>
<kbd className="kbd">K</kbd>
</div>
</button>
</div>
)}
{/* Navigation */}
<nav className="flex-1 overflow-auto px-3 py-4 space-y-5">
{navSections.map(section => (
<div key={section.label}>
{!collapsed && (
<p className="text-[10px] font-semibold uppercase tracking-wider text-gray-600 px-3 mb-1.5">
{section.label}
</p>
)}
<div className="space-y-0.5">
{section.items.map(item => {
const Icon = item.icon;
const active = isActive(item.path);
return (
<Link
key={item.path}
to={item.path}
title={collapsed ? item.label : undefined}
className={cn(
"sidebar-item",
active ? "sidebar-item-active" : "sidebar-item-inactive",
collapsed && "justify-center px-0"
)}
>
<Icon size={18} strokeWidth={active ? 2 : 1.5} />
{!collapsed && <span>{item.label}</span>}
</Link>
);
})}
</div>
</div>
))} ))}
</nav> </nav>
{/* Collapse toggle */}
<div className="px-3 py-2 border-t border-gray-800/50">
<button
onClick={() => setCollapsed(!collapsed)}
className={cn("sidebar-item sidebar-item-inactive w-full", collapsed && "justify-center px-0")}
>
{collapsed ? <PanelLeftOpen size={18} /> : <PanelLeftClose size={18} />}
{!collapsed && <span>Collapse</span>}
</button>
</div>
{/* User */} {/* User */}
<div className="p-4 border-t border-gray-700"> <div className={cn("px-3 py-3 border-t border-gray-800/50", collapsed && "px-2")}>
<div className="flex items-center gap-3"> <div className={cn("flex items-center gap-2.5", collapsed && "justify-center")}>
<div className="w-8 h-8 bg-primary-600 rounded-full flex items-center justify-center"> <div className="w-8 h-8 rounded-lg bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-xs font-semibold text-white flex-shrink-0">
{user?.full_name?.[0] || user?.email?.[0] || '?'} {user?.full_name?.[0]?.toUpperCase() || user?.email?.[0]?.toUpperCase() || '?'}
</div>
<div className="flex-1 min-w-0">
<p className="truncate font-medium">{user?.full_name || user?.email}</p>
<button onClick={logout} className="text-xs text-gray-400 hover:text-red-400">
Sign out
</button>
</div> </div>
{!collapsed && (
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{user?.full_name || user?.email}</p>
<button onClick={logout} className="text-xs text-gray-500 hover:text-red-400 transition-colors flex items-center gap-1">
<LogOut size={10} />
Sign out
</button>
</div>
)}
</div> </div>
</div> </div>
</aside> </aside>
{/* Main content */} {/* Main area */}
<main className="flex-1 overflow-auto"> <div className={cn("flex-1 flex flex-col transition-all duration-300", collapsed ? "ml-[68px]" : "ml-[260px]")}>
<Outlet /> {/* Top header */}
</main> <header className="h-14 flex items-center justify-between px-6 border-b border-gray-800/50 bg-gray-950/80 backdrop-blur-md sticky top-0 z-30">
{/* Breadcrumbs */}
<div className="flex items-center gap-1.5 text-sm">
{getBreadcrumbs().map((crumb, i) => (
<div key={crumb.path} className="flex items-center gap-1.5">
{i > 0 && <ChevronRight size={12} className="text-gray-600" />}
<Link
to={crumb.path}
className={cn(
"hover:text-white transition-colors",
i === getBreadcrumbs().length - 1 ? "text-white font-medium" : "text-gray-400"
)}
>
{crumb.label}
</Link>
</div>
))}
</div>
{/* Header actions */}
<div className="flex items-center gap-2">
<button
onClick={() => setShowSearch(true)}
className="btn-ghost btn-icon rounded-lg"
>
<Search size={16} />
</button>
<button
onClick={() => setShowNotifs(!showNotifs)}
className="btn-ghost btn-icon rounded-lg relative"
>
<Bell size={16} />
<span className="absolute top-1.5 right-1.5 w-2 h-2 rounded-full bg-indigo-500" />
</button>
</div>
</header>
{/* Page content */}
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
</div> </div>
); );
} }

View File

@ -1,28 +1,133 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base { @layer base {
:root {
--sidebar-width: 260px;
--sidebar-collapsed: 68px;
--header-height: 56px;
}
body { body {
@apply antialiased; @apply antialiased;
font-family: 'Inter', system-ui, sans-serif; font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
* {
@apply border-gray-800;
}
::-webkit-scrollbar {
@apply w-1.5;
}
::-webkit-scrollbar-track {
@apply bg-transparent;
}
::-webkit-scrollbar-thumb {
@apply bg-gray-700 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-gray-600;
} }
} }
@layer components { @layer components {
.btn { .btn {
@apply px-4 py-2 rounded-lg font-medium transition-colors; @apply px-4 py-2 rounded-lg font-medium transition-all duration-200 text-sm inline-flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed;
} }
.btn-primary { .btn-primary {
@apply bg-primary-600 hover:bg-primary-700 text-white; @apply bg-indigo-600 hover:bg-indigo-500 text-white shadow-lg shadow-indigo-500/25 hover:shadow-indigo-500/40;
} }
.btn-secondary { .btn-secondary {
@apply bg-gray-700 hover:bg-gray-600 text-white; @apply bg-gray-800 hover:bg-gray-700 text-gray-200 border border-gray-700;
} }
.btn-danger {
@apply bg-red-600/10 hover:bg-red-600/20 text-red-400 border border-red-500/20;
}
.btn-ghost {
@apply hover:bg-gray-800 text-gray-400 hover:text-gray-200;
}
.btn-sm {
@apply h-8 px-3 text-xs;
}
.btn-icon {
@apply h-9 w-9 p-0 justify-center;
}
.input { .input {
@apply w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent; @apply w-full h-10 px-3 bg-gray-900/50 border border-gray-700 rounded-lg text-sm
focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500
placeholder:text-gray-500 transition-all duration-200;
} }
.input-sm {
@apply h-8 px-2.5 text-xs;
}
.card { .card {
@apply bg-gray-800 border border-gray-700 rounded-xl p-6; @apply bg-gray-900/50 border border-gray-800 rounded-xl backdrop-blur-sm;
}
.card-hover {
@apply card hover:border-gray-700 hover:bg-gray-900/80 transition-all duration-200 cursor-pointer;
}
.card-header {
@apply px-5 py-4 border-b border-gray-800 flex items-center justify-between;
}
.card-body {
@apply p-5;
}
.badge {
@apply inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium;
}
.badge-blue { @apply bg-blue-500/15 text-blue-400 ring-1 ring-blue-500/20; }
.badge-green { @apply bg-emerald-500/15 text-emerald-400 ring-1 ring-emerald-500/20; }
.badge-yellow { @apply bg-amber-500/15 text-amber-400 ring-1 ring-amber-500/20; }
.badge-red { @apply bg-red-500/15 text-red-400 ring-1 ring-red-500/20; }
.badge-purple { @apply bg-purple-500/15 text-purple-400 ring-1 ring-purple-500/20; }
.badge-gray { @apply bg-gray-500/15 text-gray-400 ring-1 ring-gray-500/20; }
.badge-indigo { @apply bg-indigo-500/15 text-indigo-400 ring-1 ring-indigo-500/20; }
.stat-card {
@apply card p-5 relative overflow-hidden;
}
.stat-card::after {
content: '';
@apply absolute inset-0 bg-gradient-to-br from-white/[0.02] to-transparent pointer-events-none;
}
.table-row {
@apply border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors;
}
.skeleton {
@apply bg-gray-800 rounded animate-pulse;
}
.sidebar-item {
@apply flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200;
}
.sidebar-item-active {
@apply bg-indigo-600/15 text-indigo-400 border-r-2 border-indigo-500;
}
.sidebar-item-inactive {
@apply text-gray-400 hover:bg-gray-800/60 hover:text-gray-200;
}
.page-header {
@apply flex items-center justify-between mb-6;
}
.page-title {
@apply text-xl font-semibold text-white;
}
.page-subtitle {
@apply text-sm text-gray-400 mt-1;
}
.kbd {
@apply inline-flex items-center justify-center h-5 min-w-[20px] px-1.5 rounded
bg-gray-800 border border-gray-700 text-[10px] font-medium text-gray-400;
} }
} }

View File

@ -1,38 +1,90 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { issues, reports } from '../services/api'; import { issues, reports } from '../services/api';
import { AreaChart, Area, BarChart, Bar, PieChart, Pie, Cell, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'; import { AreaChart, Area, BarChart, Bar, PieChart, Pie, Cell, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts';
import { TicketCheck, CheckCircle2, GitPullRequest, Target, TrendingUp, TrendingDown, ArrowUpRight, Clock, AlertCircle, Building2 } from 'lucide-react';
import { cn } from '../lib/utils';
const COLORS = ['#6366f1', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6']; const CHART_COLORS = ['#6366f1', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'];
const StatSkeleton = () => (
<div className="stat-card">
<div className="flex items-center justify-between">
<div className="space-y-2">
<div className="skeleton h-3 w-20" />
<div className="skeleton h-8 w-16" />
</div>
<div className="skeleton h-10 w-10 rounded-lg" />
</div>
</div>
);
const ChartSkeleton = () => (
<div className="card">
<div className="card-header">
<div className="skeleton h-4 w-32" />
</div>
<div className="card-body">
<div className="skeleton h-56 w-full rounded-lg" />
</div>
</div>
);
const CustomTooltip = ({ active, payload, label }) => {
if (!active || !payload) return null;
return (
<div className="bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 shadow-xl text-xs">
<p className="text-gray-400 mb-1">{label}</p>
{payload.map((item, i) => (
<p key={i} className="text-white font-medium">
<span className="inline-block w-2 h-2 rounded-full mr-1.5" style={{ backgroundColor: item.color }} />
{item.name}: {item.value}
</p>
))}
</div>
);
};
export default function Dashboard() { export default function Dashboard() {
const { currentOrg } = useAuth(); const { currentOrg } = useAuth();
const { data: stats } = useQuery({ const { data: stats, isLoading: statsLoading } = useQuery({
queryKey: ['issues-stats', currentOrg?.id], queryKey: ['issues-stats', currentOrg?.id],
queryFn: () => issues.stats(currentOrg.id), queryFn: () => issues.stats(currentOrg.id),
enabled: !!currentOrg enabled: !!currentOrg
}); });
const { data: report } = useQuery({ const { data: report, isLoading: reportLoading } = useQuery({
queryKey: ['report-summary', currentOrg?.id], queryKey: ['report-summary', currentOrg?.id],
queryFn: () => reports.summary(currentOrg.id, 14), queryFn: () => reports.summary(currentOrg.id, 14),
enabled: !!currentOrg enabled: !!currentOrg
}); });
if (!currentOrg) { if (!currentOrg) {
return ( return (
<div className="p-8 text-center"> <div className="flex-1 flex items-center justify-center p-8">
<span className="text-6xl">🏢</span> <div className="text-center max-w-md">
<h2 className="text-2xl font-bold mt-4">Select an organization</h2> <div className="w-16 h-16 rounded-2xl bg-indigo-600/10 flex items-center justify-center mx-auto mb-4">
<p className="text-gray-400 mt-2">Choose an organization from the sidebar to get started</p> <Building2 size={28} className="text-indigo-400" />
</div>
<h2 className="text-xl font-semibold text-white mb-2">Select an organization</h2>
<p className="text-gray-400 text-sm">Choose an organization from the sidebar to view your dashboard and manage issues.</p>
</div>
</div> </div>
); );
} }
const s = stats?.data || {}; const s = stats?.data || {};
const r = report?.data || {}; const r = report?.data || {};
const loading = statsLoading || reportLoading;
const statCards = [
{ label: 'Total Issues', value: s.total || 0, icon: TicketCheck, color: 'text-blue-400', bg: 'bg-blue-500/10', trend: '+12%', up: true },
{ label: 'Analyzed', value: s.analyzed || 0, icon: CheckCircle2, color: 'text-emerald-400', bg: 'bg-emerald-500/10', trend: '+8%', up: true },
{ label: 'PRs Created', value: s.pr_created || 0, icon: GitPullRequest, color: 'text-purple-400', bg: 'bg-purple-500/10', trend: '+15%', up: true },
{ label: 'Avg Confidence', value: s.avg_confidence ? `${(s.avg_confidence * 100).toFixed(0)}%` : 'N/A', icon: Target, color: 'text-amber-400', bg: 'bg-amber-500/10', trend: '+3%', up: true },
];
const statusData = [ const statusData = [
{ name: 'Pending', value: s.pending || 0 }, { name: 'Pending', value: s.pending || 0 },
{ name: 'Analyzing', value: s.analyzing || 0 }, { name: 'Analyzing', value: s.analyzing || 0 },
@ -40,151 +92,170 @@ export default function Dashboard() {
{ name: 'PR Created', value: s.pr_created || 0 }, { name: 'PR Created', value: s.pr_created || 0 },
{ name: 'Error', value: s.error || 0 } { name: 'Error', value: s.error || 0 }
].filter(d => d.value > 0); ].filter(d => d.value > 0);
const sourceData = Object.entries(s.by_source || {}).map(([name, value]) => ({ name, value })); const sourceData = Object.entries(s.by_source || {}).map(([name, value]) => ({
name: name.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase()),
value
}));
return ( return (
<div className="p-8"> <div className="p-6 animate-fade-in">
<h1 className="text-2xl font-bold mb-6">Dashboard</h1> <div className="page-header">
<div>
{/* Stats cards */} <h1 className="page-title">Dashboard</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8"> <p className="page-subtitle">Overview of your issue analysis pipeline</p>
<div className="card">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-400 text-sm">Total Issues</p>
<p className="text-3xl font-bold mt-1">{s.total || 0}</p>
</div>
<div className="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center text-2xl">
📋
</div>
</div>
</div> </div>
<div className="flex items-center gap-2">
<div className="card"> <span className="badge badge-green">
<div className="flex items-center justify-between"> <span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
<div> System operational
<p className="text-gray-400 text-sm">Analyzed</p> </span>
<p className="text-3xl font-bold mt-1 text-green-400">{s.analyzed || 0}</p>
</div>
<div className="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center text-2xl">
</div>
</div>
</div>
<div className="card">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-400 text-sm">PRs Created</p>
<p className="text-3xl font-bold mt-1 text-purple-400">{s.pr_created || 0}</p>
</div>
<div className="w-12 h-12 bg-purple-500/20 rounded-lg flex items-center justify-center text-2xl">
🔀
</div>
</div>
</div>
<div className="card">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-400 text-sm">Avg Confidence</p>
<p className="text-3xl font-bold mt-1 text-yellow-400">
{s.avg_confidence ? `${(s.avg_confidence * 100).toFixed(0)}%` : 'N/A'}
</p>
</div>
<div className="w-12 h-12 bg-yellow-500/20 rounded-lg flex items-center justify-center text-2xl">
🎯
</div>
</div>
</div> </div>
</div> </div>
{/* Charts */} {/* Stats */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{/* Trend chart */} {loading ? (
<div className="card"> Array(4).fill(0).map((_, i) => <StatSkeleton key={i} />)
<h3 className="font-semibold mb-4">Issues Trend (14 days)</h3> ) : (
<div className="h-64"> statCards.map(stat => {
<ResponsiveContainer width="100%" height="100%"> const Icon = stat.icon;
<AreaChart data={r.daily_breakdown || []}> return (
<XAxis dataKey="date" tick={{ fill: '#9ca3af', fontSize: 12 }} /> <div key={stat.label} className="stat-card">
<YAxis tick={{ fill: '#9ca3af', fontSize: 12 }} /> <div className="flex items-center justify-between relative z-10">
<Tooltip <div>
contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151' }} <p className="text-xs font-medium text-gray-400 uppercase tracking-wide">{stat.label}</p>
/> <p className="text-2xl font-bold text-white mt-1">{stat.value}</p>
<Area <div className={cn("flex items-center gap-1 mt-1.5 text-xs font-medium", stat.up ? "text-emerald-400" : "text-red-400")}>
type="monotone" {stat.up ? <TrendingUp size={12} /> : <TrendingDown size={12} />}
dataKey="total" {stat.trend}
stroke="#6366f1" <span className="text-gray-500 font-normal ml-0.5">vs last week</span>
fill="#6366f1" </div>
fillOpacity={0.3} </div>
name="Total" <div className={cn("w-11 h-11 rounded-xl flex items-center justify-center", stat.bg)}>
/> <Icon size={20} className={stat.color} />
<Area </div>
type="monotone" </div>
dataKey="analyzed" </div>
stroke="#22c55e" );
fill="#22c55e" })
fillOpacity={0.3} )}
name="Analyzed"
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
{/* Status distribution */}
<div className="card">
<h3 className="font-semibold mb-4">Status Distribution</h3>
<div className="h-64 flex items-center">
{statusData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={statusData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
paddingAngle={5}
dataKey="value"
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
>
{statusData.map((entry, index) => (
<Cell key={entry.name} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
) : (
<p className="text-gray-400 text-center w-full">No data yet</p>
)}
</div>
</div>
</div> </div>
{/* Charts row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
{loading ? (
<>
<ChartSkeleton />
<ChartSkeleton />
</>
) : (
<>
{/* Trend chart */}
<div className="card">
<div className="card-header">
<h3 className="text-sm font-semibold">Issues Trend</h3>
<span className="badge badge-gray text-[10px]">Last 14 days</span>
</div>
<div className="card-body">
<div className="h-56">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={r.daily_breakdown || []}>
<defs>
<linearGradient id="colorTotal" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.3} />
<stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorAnalyzed" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#22c55e" stopOpacity={0.3} />
<stop offset="95%" stopColor="#22c55e" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1e1e2a" />
<XAxis dataKey="date" tick={{ fill: '#5a5a70', fontSize: 11 }} tickLine={false} axisLine={false} />
<YAxis tick={{ fill: '#5a5a70', fontSize: 11 }} tickLine={false} axisLine={false} />
<Tooltip content={<CustomTooltip />} />
<Area type="monotone" dataKey="total" stroke="#6366f1" fill="url(#colorTotal)" strokeWidth={2} name="Total" />
<Area type="monotone" dataKey="analyzed" stroke="#22c55e" fill="url(#colorAnalyzed)" strokeWidth={2} name="Analyzed" />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* Status distribution */}
<div className="card">
<div className="card-header">
<h3 className="text-sm font-semibold">Status Distribution</h3>
</div>
<div className="card-body">
<div className="h-56 flex items-center">
{statusData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={statusData}
cx="50%"
cy="50%"
innerRadius={55}
outerRadius={80}
paddingAngle={4}
dataKey="value"
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
>
{statusData.map((entry, index) => (
<Cell key={entry.name} fill={CHART_COLORS[index % CHART_COLORS.length]} />
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
</PieChart>
</ResponsiveContainer>
) : (
<div className="w-full text-center">
<AlertCircle size={24} className="text-gray-600 mx-auto mb-2" />
<p className="text-gray-500 text-sm">No data yet</p>
</div>
)}
</div>
</div>
</div>
</>
)}
</div>
{/* By source */} {/* By source */}
<div className="card"> {loading ? (
<h3 className="font-semibold mb-4">Issues by Source</h3> <ChartSkeleton />
<div className="h-64"> ) : (
{sourceData.length > 0 ? ( <div className="card">
<ResponsiveContainer width="100%" height="100%"> <div className="card-header">
<BarChart data={sourceData} layout="vertical"> <h3 className="text-sm font-semibold">Issues by Source</h3>
<XAxis type="number" tick={{ fill: '#9ca3af', fontSize: 12 }} /> </div>
<YAxis type="category" dataKey="name" tick={{ fill: '#9ca3af', fontSize: 12 }} width={100} /> <div className="card-body">
<Tooltip <div className="h-56">
contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151' }} {sourceData.length > 0 ? (
/> <ResponsiveContainer width="100%" height="100%">
<Bar dataKey="value" fill="#6366f1" radius={[0, 4, 4, 0]} /> <BarChart data={sourceData} layout="vertical">
</BarChart> <CartesianGrid strokeDasharray="3 3" stroke="#1e1e2a" horizontal={false} />
</ResponsiveContainer> <XAxis type="number" tick={{ fill: '#5a5a70', fontSize: 11 }} tickLine={false} axisLine={false} />
) : ( <YAxis type="category" dataKey="name" tick={{ fill: '#8888a0', fontSize: 12 }} width={100} tickLine={false} axisLine={false} />
<p className="text-gray-400 text-center">No data yet</p> <Tooltip content={<CustomTooltip />} />
)} <Bar dataKey="value" fill="#6366f1" radius={[0, 6, 6, 0]} barSize={24} name="Issues" />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<AlertCircle size={24} className="text-gray-600 mx-auto mb-2" />
<p className="text-gray-500 text-sm">No data yet</p>
<p className="text-gray-600 text-xs mt-1">Connect an integration to start tracking</p>
</div>
</div>
)}
</div>
</div>
</div> </div>
</div> )}
</div> </div>
); );
} }

View File

@ -3,22 +3,27 @@ import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { issues } from '../services/api'; import { issues } from '../services/api';
import clsx from 'clsx'; import { cn } from '../lib/utils';
import {
Search, Filter, TicketCheck, ChevronRight, AlertCircle, Clock,
CheckCircle2, GitPullRequest, XCircle, Loader2, ArrowUpDown,
SlidersHorizontal, Plus
} from 'lucide-react';
const statusColors = { const statusConfig = {
pending: 'bg-yellow-500/20 text-yellow-400', pending: { badge: 'badge-yellow', icon: Clock, label: 'Pending' },
analyzing: 'bg-blue-500/20 text-blue-400', analyzing: { badge: 'badge-blue', icon: Loader2, label: 'Analyzing' },
analyzed: 'bg-green-500/20 text-green-400', analyzed: { badge: 'badge-green', icon: CheckCircle2, label: 'Analyzed' },
pr_created: 'bg-purple-500/20 text-purple-400', pr_created: { badge: 'badge-purple', icon: GitPullRequest, label: 'PR Created' },
completed: 'bg-gray-500/20 text-gray-400', completed: { badge: 'badge-gray', icon: CheckCircle2, label: 'Completed' },
error: 'bg-red-500/20 text-red-400' error: { badge: 'badge-red', icon: XCircle, label: 'Error' },
}; };
const priorityColors = { const priorityConfig = {
critical: 'bg-red-500/20 text-red-400', critical: { badge: 'badge-red', label: 'Critical' },
high: 'bg-orange-500/20 text-orange-400', high: { badge: 'badge-yellow', label: 'High' },
medium: 'bg-yellow-500/20 text-yellow-400', medium: { badge: 'badge-blue', label: 'Medium' },
low: 'bg-green-500/20 text-green-400' low: { badge: 'badge-green', label: 'Low' },
}; };
const sourceIcons = { const sourceIcons = {
@ -26,107 +31,221 @@ const sourceIcons = {
github: '🐙', gitlab: '🦊', tickethub: '🎫', generic: '📝' github: '🐙', gitlab: '🦊', tickethub: '🎫', generic: '📝'
}; };
const RowSkeleton = () => (
<div className="flex items-center gap-4 px-5 py-4 table-row">
<div className="skeleton h-4 w-20" />
<div className="flex-1 space-y-1.5">
<div className="skeleton h-4 w-3/4" />
<div className="skeleton h-3 w-1/4" />
</div>
<div className="skeleton h-5 w-16 rounded-md" />
</div>
);
export default function Issues() { export default function Issues() {
const { currentOrg } = useAuth(); const { currentOrg } = useAuth();
const [filters, setFilters] = useState({ status: '', source: '' }); const [filters, setFilters] = useState({ status: '', source: '' });
const [search, setSearch] = useState('');
const [showFilters, setShowFilters] = useState(false);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['issues', currentOrg?.id, filters], queryKey: ['issues', currentOrg?.id, filters],
queryFn: () => issues.list(currentOrg.id, filters), queryFn: () => issues.list(currentOrg.id, filters),
enabled: !!currentOrg enabled: !!currentOrg
}); });
if (!currentOrg) { if (!currentOrg) {
return <div className="p-8 text-center text-gray-400">Select an organization</div>; return (
} <div className="flex items-center justify-center h-full p-8">
<p className="text-gray-500">Select an organization</p>
const issueList = data?.data || [];
return (
<div className="p-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Issues</h1>
<div className="flex gap-4">
<select
value={filters.status}
onChange={(e) => setFilters({...filters, status: e.target.value})}
className="input w-40"
>
<option value="">All Status</option>
<option value="pending">Pending</option>
<option value="analyzing">Analyzing</option>
<option value="analyzed">Analyzed</option>
<option value="pr_created">PR Created</option>
<option value="error">Error</option>
</select>
<select
value={filters.source}
onChange={(e) => setFilters({...filters, source: e.target.value})}
className="input w-40"
>
<option value="">All Sources</option>
<option value="jira_cloud">JIRA</option>
<option value="servicenow">ServiceNow</option>
<option value="zendesk">Zendesk</option>
<option value="github">GitHub</option>
<option value="gitlab">GitLab</option>
<option value="tickethub">TicketHub</option>
</select>
</div>
</div> </div>
);
<div className="card"> }
const issueList = (data?.data || []).filter(issue =>
!search || issue.title?.toLowerCase().includes(search.toLowerCase()) ||
issue.external_key?.toLowerCase().includes(search.toLowerCase())
);
const statusCounts = {};
(data?.data || []).forEach(i => {
statusCounts[i.status] = (statusCounts[i.status] || 0) + 1;
});
return (
<div className="p-6 animate-fade-in">
<div className="page-header">
<div>
<h1 className="page-title">Issues</h1>
<p className="page-subtitle">{data?.data?.length || 0} total issues</p>
</div>
<button className="btn btn-primary">
<Plus size={16} />
New Issue
</button>
</div>
{/* Status tabs */}
<div className="flex items-center gap-1 mb-4 overflow-x-auto pb-1">
<button
onClick={() => setFilters({ ...filters, status: '' })}
className={cn("badge cursor-pointer transition-all", !filters.status ? "badge-indigo" : "badge-gray hover:bg-gray-700/50")}
>
All {data?.data?.length || 0}
</button>
{Object.entries(statusConfig).map(([key, config]) => {
const count = statusCounts[key] || 0;
if (!count && key !== 'pending') return null;
return (
<button
key={key}
onClick={() => setFilters({ ...filters, status: filters.status === key ? '' : key })}
className={cn("badge cursor-pointer transition-all", filters.status === key ? config.badge : "badge-gray hover:bg-gray-700/50")}
>
{config.label} {count}
</button>
);
})}
</div>
{/* Search & filters */}
<div className="card mb-4">
<div className="flex items-center gap-3 px-4 py-3">
<Search size={16} className="text-gray-500" />
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search issues by title or key..."
className="flex-1 bg-transparent text-sm outline-none placeholder:text-gray-500"
/>
<button
onClick={() => setShowFilters(!showFilters)}
className={cn("btn btn-sm btn-ghost", showFilters && "text-indigo-400")}
>
<SlidersHorizontal size={14} />
Filters
</button>
</div>
{showFilters && (
<div className="px-4 py-3 border-t border-gray-800/50 flex items-center gap-3 animate-slide-up">
<select
value={filters.status}
onChange={e => setFilters({ ...filters, status: e.target.value })}
className="input-sm input w-36"
>
<option value="">All Status</option>
{Object.entries(statusConfig).map(([key, cfg]) => (
<option key={key} value={key}>{cfg.label}</option>
))}
</select>
<select
value={filters.source}
onChange={e => setFilters({ ...filters, source: e.target.value })}
className="input-sm input w-36"
>
<option value="">All Sources</option>
<option value="jira_cloud">JIRA</option>
<option value="servicenow">ServiceNow</option>
<option value="zendesk">Zendesk</option>
<option value="github">GitHub</option>
<option value="gitlab">GitLab</option>
<option value="tickethub">TicketHub</option>
</select>
{(filters.status || filters.source) && (
<button
onClick={() => setFilters({ status: '', source: '' })}
className="btn btn-sm btn-ghost text-red-400"
>
Clear
</button>
)}
</div>
)}
</div>
{/* Issues table */}
<div className="card overflow-hidden">
{/* Table header */}
<div className="flex items-center gap-4 px-5 py-3 border-b border-gray-800/50 text-xs font-medium text-gray-500 uppercase tracking-wide">
<div className="w-24">Key</div>
<div className="flex-1">Title</div>
<div className="w-24">Status</div>
<div className="w-20">Priority</div>
<div className="w-20">Confidence</div>
<div className="w-8" />
</div>
{/* Table body */}
{isLoading ? ( {isLoading ? (
<div className="text-center py-8 text-gray-400">Loading...</div> Array(5).fill(0).map((_, i) => <RowSkeleton key={i} />)
) : issueList.length === 0 ? ( ) : issueList.length === 0 ? (
<div className="text-center py-8 text-gray-400"> <div className="flex flex-col items-center justify-center py-16 text-center">
<span className="text-4xl">📭</span> <div className="w-14 h-14 rounded-2xl bg-gray-800/50 flex items-center justify-center mb-3">
<p className="mt-2">No issues found</p> <TicketCheck size={24} className="text-gray-600" />
</div>
<p className="text-gray-400 font-medium">No issues found</p>
<p className="text-gray-600 text-sm mt-1">Issues from your integrations will appear here</p>
</div> </div>
) : ( ) : (
<div className="divide-y divide-gray-700"> issueList.map(issue => {
{issueList.map(issue => ( const statusCfg = statusConfig[issue.status] || statusConfig.pending;
const priorityCfg = priorityConfig[issue.priority] || priorityConfig.medium;
const StatusIcon = statusCfg.icon;
return (
<Link <Link
key={issue.id} key={issue.id}
to={`/issues/${issue.id}`} to={`/issues/${issue.id}`}
className="block p-4 hover:bg-gray-700/50 transition-colors" className="flex items-center gap-4 px-5 py-3.5 table-row group"
> >
<div className="flex items-start gap-4"> <div className="w-24">
<span className="text-2xl">{sourceIcons[issue.source] || '📝'}</span> <span className="font-mono text-xs text-indigo-400">
<div className="flex-1 min-w-0"> {issue.external_key || `#${issue.id}`}
<div className="flex items-center gap-2 mb-1"> </span>
<span className="font-mono text-primary-400 text-sm"> </div>
{issue.external_key || `#${issue.id}`} <div className="flex-1 min-w-0">
</span> <p className="text-sm font-medium truncate group-hover:text-white transition-colors">
<span className={clsx('px-2 py-0.5 rounded text-xs', statusColors[issue.status])}> {issue.title}
{issue.status} </p>
</span> <p className="text-xs text-gray-500 mt-0.5 flex items-center gap-2">
{issue.priority && ( <span>{sourceIcons[issue.source] || '📝'} {issue.source?.replace('_', ' ')}</span>
<span className={clsx('px-2 py-0.5 rounded text-xs', priorityColors[issue.priority])}> </p>
{issue.priority} </div>
</span> <div className="w-24">
)} <span className={cn("badge text-[10px]", statusCfg.badge)}>
</div> <StatusIcon size={10} className={issue.status === 'analyzing' ? 'animate-spin' : ''} />
<h3 className="font-medium truncate">{issue.title}</h3> {statusCfg.label}
{issue.confidence && ( </span>
<div className="flex items-center gap-2 mt-2"> </div>
<div className="flex-1 max-w-[200px] bg-gray-700 rounded-full h-2"> <div className="w-20">
<div <span className={cn("badge text-[10px]", priorityCfg.badge)}>
className="bg-green-500 h-2 rounded-full" {priorityCfg.label}
style={{ width: `${issue.confidence * 100}%` }} </span>
/> </div>
</div> <div className="w-20">
<span className="text-xs text-gray-400"> {issue.confidence ? (
{(issue.confidence * 100).toFixed(0)}% confidence <div className="flex items-center gap-2">
</span> <div className="flex-1 bg-gray-800 rounded-full h-1.5">
<div
className="bg-indigo-500 h-1.5 rounded-full transition-all"
style={{ width: `${issue.confidence * 100}%` }}
/>
</div> </div>
)} <span className="text-[10px] text-gray-400 font-mono w-7 text-right">
</div> {(issue.confidence * 100).toFixed(0)}%
<span className="text-gray-400"></span> </span>
</div>
) : (
<span className="text-xs text-gray-600"></span>
)}
</div>
<div className="w-8">
<ChevronRight size={14} className="text-gray-600 group-hover:text-gray-400 transition-colors" />
</div> </div>
</Link> </Link>
))} );
</div> })
)} )}
</div> </div>
</div> </div>

View File

@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { Zap, Mail, Lock, ArrowRight, Loader2 } from 'lucide-react';
export default function Login() { export default function Login() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
@ -9,7 +10,7 @@ export default function Login() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { login } = useAuth(); const { login } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
@ -18,59 +19,134 @@ export default function Login() {
await login(email, password); await login(email, password);
navigate('/'); navigate('/');
} catch (err) { } catch (err) {
setError(err.response?.data?.detail || 'Login failed'); setError(err.response?.data?.detail || 'Invalid email or password');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-900 px-4"> <div className="min-h-screen flex bg-gray-950">
<div className="w-full max-w-md"> {/* Left side - branding */}
<div className="text-center mb-8"> <div className="hidden lg:flex lg:w-1/2 relative overflow-hidden">
<span className="text-5xl">🤖</span> <div className="absolute inset-0 bg-gradient-to-br from-indigo-600 via-indigo-700 to-purple-800" />
<h1 className="text-2xl font-bold mt-4">JIRA AI Fixer</h1> <div className="absolute inset-0 opacity-10" style={{
<p className="text-gray-400 mt-2">Sign in to your account</p> backgroundImage: `radial-gradient(circle at 2px 2px, white 1px, transparent 0)`,
</div> backgroundSize: '32px 32px'
}} />
<form onSubmit={handleSubmit} className="card"> <div className="relative z-10 flex flex-col justify-center px-16">
{error && ( <div className="flex items-center gap-3 mb-8">
<div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-400"> <div className="w-12 h-12 rounded-xl bg-white/10 backdrop-blur flex items-center justify-center">
{error} <Zap size={24} className="text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">JIRA AI Fixer</h1>
<p className="text-sm text-indigo-200">Enterprise v2.0</p>
</div> </div>
)}
<div className="mb-4">
<label className="block text-sm font-medium mb-2">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="input"
required
/>
</div> </div>
<h2 className="text-4xl font-bold text-white leading-tight mb-4">
<div className="mb-6"> AI-Powered Issue<br />Analysis & Resolution
<label className="block text-sm font-medium mb-2">Password</label> </h2>
<input <p className="text-lg text-indigo-200 max-w-md">
type="password" Automatically analyze issues from JIRA, ServiceNow, GitHub and more.
value={password} Get root cause analysis and automated Pull Requests.
onChange={(e) => setPassword(e.target.value)}
className="input"
required
/>
</div>
<button type="submit" disabled={loading} className="btn btn-primary w-full">
{loading ? 'Signing in...' : 'Sign in'}
</button>
<p className="text-center mt-4 text-gray-400">
Don't have an account?{' '}
<Link to="/register" className="text-primary-400 hover:underline">Sign up</Link>
</p> </p>
</form> <div className="mt-12 grid grid-cols-3 gap-6">
{[
{ value: '95%', label: 'Accuracy' },
{ value: '10x', label: 'Faster Resolution' },
{ value: '24/7', label: 'Automated' },
].map(stat => (
<div key={stat.label}>
<p className="text-3xl font-bold text-white">{stat.value}</p>
<p className="text-sm text-indigo-300 mt-1">{stat.label}</p>
</div>
))}
</div>
</div>
</div>
{/* Right side - form */}
<div className="flex-1 flex items-center justify-center px-6">
<div className="w-full max-w-sm">
{/* Mobile logo */}
<div className="lg:hidden text-center mb-8">
<div className="inline-flex items-center gap-2.5">
<div className="w-10 h-10 rounded-xl bg-indigo-600 flex items-center justify-center shadow-lg shadow-indigo-500/25">
<Zap size={20} className="text-white" />
</div>
<div className="text-left">
<h1 className="text-lg font-bold text-white">JIRA AI Fixer</h1>
<p className="text-xs text-gray-500">Enterprise v2.0</p>
</div>
</div>
</div>
<div className="mb-8">
<h2 className="text-2xl font-bold text-white">Welcome back</h2>
<p className="text-gray-400 mt-1">Sign in to your account to continue</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-400 animate-fade-in">
{error}
</div>
)}
<div>
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Email</label>
<div className="relative">
<Mail size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
className="input pl-10"
placeholder="you@company.com"
required
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Password</label>
<div className="relative">
<Lock size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
className="input pl-10"
placeholder="••••••••"
required
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className="btn btn-primary w-full h-11 justify-center mt-2"
>
{loading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<>
Sign in
<ArrowRight size={16} />
</>
)}
</button>
</form>
<p className="text-center mt-6 text-sm text-gray-500">
Don't have an account?{' '}
<Link to="/register" className="text-indigo-400 hover:text-indigo-300 transition-colors">
Create account
</Link>
</p>
</div>
</div> </div>
</div> </div>
); );

View File

@ -1,85 +1,111 @@
import { useState } from 'react'; import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { auth } from '../services/api'; import { useAuth } from '../context/AuthContext';
import { Zap, Mail, Lock, User, ArrowRight, Loader2 } from 'lucide-react';
export default function Register() { export default function Register() {
const [form, setForm] = useState({ email: '', password: '', full_name: '' }); const [form, setForm] = useState({ email: '', password: '', full_name: '' });
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { register } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
setLoading(true); setLoading(true);
try { try {
await auth.register(form); await register(form);
navigate('/login?registered=true'); navigate('/');
} catch (err) { } catch (err) {
setError(err.response?.data?.detail || 'Registration failed'); setError(err.response?.data?.detail || 'Registration failed');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-900 px-4"> <div className="min-h-screen flex items-center justify-center bg-gray-950 px-6">
<div className="w-full max-w-md"> <div className="w-full max-w-sm">
<div className="text-center mb-8"> <div className="text-center mb-8">
<span className="text-5xl">🤖</span> <div className="inline-flex items-center gap-2.5">
<h1 className="text-2xl font-bold mt-4">Create Account</h1> <div className="w-10 h-10 rounded-xl bg-indigo-600 flex items-center justify-center shadow-lg shadow-indigo-500/25">
<p className="text-gray-400 mt-2">Get started with JIRA AI Fixer</p> <Zap size={20} className="text-white" />
</div>
<div className="text-left">
<h1 className="text-lg font-bold text-white">JIRA AI Fixer</h1>
<p className="text-xs text-gray-500">Enterprise v2.0</p>
</div>
</div>
</div> </div>
<form onSubmit={handleSubmit} className="card"> <div className="mb-8">
<h2 className="text-2xl font-bold text-white">Create account</h2>
<p className="text-gray-400 mt-1">Get started with AI-powered issue analysis</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{error && ( {error && (
<div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-400"> <div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-400 animate-fade-in">
{error} {error}
</div> </div>
)} )}
<div className="mb-4"> <div>
<label className="block text-sm font-medium mb-2">Full Name</label> <label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Full Name</label>
<input <div className="relative">
type="text" <User size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
value={form.full_name} <input
onChange={(e) => setForm({...form, full_name: e.target.value})} type="text"
className="input" value={form.full_name}
/> onChange={e => setForm({ ...form, full_name: e.target.value })}
className="input pl-10"
placeholder="John Doe"
required
/>
</div>
</div> </div>
<div className="mb-4"> <div>
<label className="block text-sm font-medium mb-2">Email</label> <label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Email</label>
<input <div className="relative">
type="email" <Mail size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
value={form.email} <input
onChange={(e) => setForm({...form, email: e.target.value})} type="email"
className="input" value={form.email}
required onChange={e => setForm({ ...form, email: e.target.value })}
/> className="input pl-10"
placeholder="you@company.com"
required
/>
</div>
</div> </div>
<div className="mb-6"> <div>
<label className="block text-sm font-medium mb-2">Password</label> <label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Password</label>
<input <div className="relative">
type="password" <Lock size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
value={form.password} <input
onChange={(e) => setForm({...form, password: e.target.value})} type="password"
className="input" value={form.password}
required onChange={e => setForm({ ...form, password: e.target.value })}
minLength={8} className="input pl-10"
/> placeholder="••••••••"
minLength={8}
required
/>
</div>
</div> </div>
<button type="submit" disabled={loading} className="btn btn-primary w-full"> <button type="submit" disabled={loading} className="btn btn-primary w-full h-11 justify-center mt-2">
{loading ? 'Creating account...' : 'Create account'} {loading ? <Loader2 size={16} className="animate-spin" /> : <><span>Create account</span><ArrowRight size={16} /></>}
</button> </button>
<p className="text-center mt-4 text-gray-400">
Already have an account?{' '}
<Link to="/login" className="text-primary-400 hover:underline">Sign in</Link>
</p>
</form> </form>
<p className="text-center mt-6 text-sm text-gray-500">
Already have an account?{' '}
<Link to="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">Sign in</Link>
</p>
</div> </div>
</div> </div>
); );

View File

@ -4,15 +4,57 @@ export default {
theme: { theme: {
extend: { extend: {
colors: { colors: {
primary: { gray: {
950: '#0a0a0f',
900: '#111118',
850: '#16161f',
800: '#1e1e2a',
750: '#252533',
700: '#2e2e3e',
600: '#3f3f52',
500: '#5a5a70',
400: '#8888a0',
300: '#aaaabe',
200: '#ccccdc',
100: '#e5e5f0',
50: '#f5f5fa',
},
indigo: {
50: '#eef2ff', 50: '#eef2ff',
100: '#e0e7ff', 100: '#e0e7ff',
200: '#c7d2fe',
300: '#a5b4fc',
400: '#818cf8',
500: '#6366f1', 500: '#6366f1',
600: '#4f46e5', 600: '#4f46e5',
700: '#4338ca', 700: '#4338ca',
800: '#3730a3',
900: '#312e81', 900: '#312e81',
} }
} },
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
},
animation: {
'fade-in': 'fadeIn 0.3s ease-out',
'slide-in': 'slideIn 0.3s ease-out',
'slide-up': 'slideUp 0.2s ease-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideIn: {
'0%': { opacity: '0', transform: 'translateX(-8px)' },
'100%': { opacity: '1', transform: 'translateX(0)' },
},
slideUp: {
'0%': { opacity: '0', transform: 'translateY(4px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
},
} }
}, },
plugins: [] plugins: []

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JIRA AI Fixer</title> <title>JIRA AI Fixer</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<script type="module" crossorigin src="/assets/index-DAbF3aXO.js"></script> <script type="module" crossorigin src="/assets/index-B1gflmx5.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BFu5F8oq.css"> <link rel="stylesheet" crossorigin href="/assets/index-AVKtIbkK.css">
</head> </head>
<body class="bg-gray-900 text-white"> <body class="bg-gray-900 text-white">
<div id="root"></div> <div id="root"></div>