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">
<title>JIRA AI Fixer</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<script type="module" crossorigin src="/assets/index-D4MghmKn.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BNMhozdr.css">
<script type="module" crossorigin src="/assets/index-B1gflmx5.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-AVKtIbkK.css">
</head>
<body class="bg-gray-900 text-white">
<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 { useQuery } from '@tanstack/react-query';
import { organizations } from '../services/api';
import { useState } from 'react';
import clsx from 'clsx';
import { useState, useEffect } from 'react';
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 = [
{ path: '/', label: 'Dashboard', icon: '📊' },
{ path: '/issues', label: 'Issues', icon: '🎫' },
{ path: '/integrations', label: 'Integrations', icon: '🔌' },
{ path: '/team', label: 'Team', icon: '👥' },
{ path: '/reports', label: 'Reports', icon: '📈' },
{ path: '/settings', label: 'Settings', icon: '⚙️' }
const navSections = [
{
label: 'Main',
items: [
{ path: '/', label: 'Dashboard', icon: LayoutDashboard },
{ path: '/issues', label: 'Issues', icon: TicketCheck },
]
},
{
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() {
const { user, logout, currentOrg, selectOrg } = useAuth();
const location = useLocation();
const navigate = useNavigate();
const [collapsed, setCollapsed] = 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({
queryKey: ['organizations'],
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 (
<div className="min-h-screen flex">
{/* Sidebar */}
<aside className="w-64 bg-gray-800 border-r border-gray-700 flex flex-col">
<div className="p-4 border-b border-gray-700">
<div className="flex items-center gap-2">
<span className="text-2xl">🤖</span>
<div>
<h1 className="font-bold">JIRA AI Fixer</h1>
<p className="text-xs text-gray-400">v2.0</p>
<div className="min-h-screen flex bg-gray-950 text-gray-200">
{/* Search Modal */}
{showSearch && (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]" onClick={() => setShowSearch(false)}>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div className="relative w-full max-w-lg mx-4 animate-slide-up" onClick={e => e.stopPropagation()}>
<div className="card border-gray-700 shadow-2xl">
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-800">
<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>
{/* Org selector */}
<div className="p-4 border-b border-gray-700 relative">
<button
onClick={() => setShowOrgMenu(!showOrgMenu)}
className="w-full flex items-center justify-between p-2 rounded-lg bg-gray-700 hover:bg-gray-600"
>
<span className="truncate">{currentOrg?.name || 'Select organization'}</span>
<span></span>
</button>
{showOrgMenu && orgs?.data && (
<div className="absolute top-full left-4 right-4 mt-1 bg-gray-700 rounded-lg shadow-lg z-10">
{orgs.data.map(org => (
<button
key={org.id}
onClick={() => { selectOrg(org); setShowOrgMenu(false); }}
className={clsx(
"w-full text-left px-4 py-2 hover:bg-gray-600 first:rounded-t-lg last:rounded-b-lg",
currentOrg?.id === org.id && "bg-primary-600"
)}
>
{org.name}
</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>
)}
{/* Sidebar */}
<aside className={cn(
"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",
collapsed ? "w-[68px]" : "w-[260px]"
)}>
{/* Logo */}
<div className={cn("h-14 flex items-center border-b border-gray-800/50 px-4", collapsed && "justify-center px-0")}>
{collapsed ? (
<div className="w-8 h-8 rounded-lg bg-indigo-600 flex items-center justify-center">
<Zap size={16} className="text-white" />
</div>
) : (
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-indigo-600 flex items-center justify-center shadow-lg shadow-indigo-500/20">
<Zap size={16} className="text-white" />
</div>
<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>
</div>
</div>
)}
</div>
{/* Nav */}
<nav className="flex-1 p-4">
{navItems.map(item => (
<Link
key={item.path}
to={item.path}
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"
)}
{/* Org selector */}
{!collapsed && (
<div className="px-3 py-3 border-b border-gray-800/50 relative">
<button
onClick={() => setShowOrgMenu(!showOrgMenu)}
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"
>
<span>{item.icon}</span>
<span>{item.label}</span>
</Link>
<div className="w-6 h-6 rounded bg-indigo-600/20 flex items-center justify-center flex-shrink-0">
<Building2 size={12} className="text-indigo-400" />
</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>
{/* 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 */}
<div className="p-4 border-t border-gray-700">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-primary-600 rounded-full flex items-center justify-center">
{user?.full_name?.[0] || user?.email?.[0] || '?'}
</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 className={cn("px-3 py-3 border-t border-gray-800/50", collapsed && "px-2")}>
<div className={cn("flex items-center gap-2.5", collapsed && "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]?.toUpperCase() || user?.email?.[0]?.toUpperCase() || '?'}
</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>
</aside>
{/* Main content */}
<main className="flex-1 overflow-auto">
<Outlet />
</main>
{/* Main area */}
<div className={cn("flex-1 flex flex-col transition-all duration-300", collapsed ? "ml-[68px]" : "ml-[260px]")}>
{/* Top header */}
<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>
);
}

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 components;
@tailwind utilities;
@layer base {
:root {
--sidebar-width: 260px;
--sidebar-collapsed: 68px;
--header-height: 56px;
}
body {
@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 {
.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 {
@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 {
@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 {
@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 {
@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 { useAuth } from '../context/AuthContext';
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() {
const { currentOrg } = useAuth();
const { data: stats } = useQuery({
const { data: stats, isLoading: statsLoading } = useQuery({
queryKey: ['issues-stats', currentOrg?.id],
queryFn: () => issues.stats(currentOrg.id),
enabled: !!currentOrg
});
const { data: report } = useQuery({
const { data: report, isLoading: reportLoading } = useQuery({
queryKey: ['report-summary', currentOrg?.id],
queryFn: () => reports.summary(currentOrg.id, 14),
enabled: !!currentOrg
});
if (!currentOrg) {
return (
<div className="p-8 text-center">
<span className="text-6xl">🏢</span>
<h2 className="text-2xl font-bold mt-4">Select an organization</h2>
<p className="text-gray-400 mt-2">Choose an organization from the sidebar to get started</p>
<div className="flex-1 flex items-center justify-center p-8">
<div className="text-center max-w-md">
<div className="w-16 h-16 rounded-2xl bg-indigo-600/10 flex items-center justify-center mx-auto mb-4">
<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>
);
}
const s = stats?.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 = [
{ name: 'Pending', value: s.pending || 0 },
{ name: 'Analyzing', value: s.analyzing || 0 },
@ -40,151 +92,170 @@ export default function Dashboard() {
{ name: 'PR Created', value: s.pr_created || 0 },
{ name: 'Error', value: s.error || 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 (
<div className="p-8">
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
{/* Stats cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<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 className="p-6 animate-fade-in">
<div className="page-header">
<div>
<h1 className="page-title">Dashboard</h1>
<p className="page-subtitle">Overview of your issue analysis pipeline</p>
</div>
<div className="card">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-400 text-sm">Analyzed</p>
<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 className="flex items-center gap-2">
<span className="badge badge-green">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
System operational
</span>
</div>
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
{/* Trend chart */}
<div className="card">
<h3 className="font-semibold mb-4">Issues Trend (14 days)</h3>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={r.daily_breakdown || []}>
<XAxis dataKey="date" tick={{ fill: '#9ca3af', fontSize: 12 }} />
<YAxis tick={{ fill: '#9ca3af', fontSize: 12 }} />
<Tooltip
contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151' }}
/>
<Area
type="monotone"
dataKey="total"
stroke="#6366f1"
fill="#6366f1"
fillOpacity={0.3}
name="Total"
/>
<Area
type="monotone"
dataKey="analyzed"
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>
{/* Stats */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{loading ? (
Array(4).fill(0).map((_, i) => <StatSkeleton key={i} />)
) : (
statCards.map(stat => {
const Icon = stat.icon;
return (
<div key={stat.label} className="stat-card">
<div className="flex items-center justify-between relative z-10">
<div>
<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>
<div className={cn("flex items-center gap-1 mt-1.5 text-xs font-medium", stat.up ? "text-emerald-400" : "text-red-400")}>
{stat.up ? <TrendingUp size={12} /> : <TrendingDown size={12} />}
{stat.trend}
<span className="text-gray-500 font-normal ml-0.5">vs last week</span>
</div>
</div>
<div className={cn("w-11 h-11 rounded-xl flex items-center justify-center", stat.bg)}>
<Icon size={20} className={stat.color} />
</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 */}
<div className="card">
<h3 className="font-semibold mb-4">Issues by Source</h3>
<div className="h-64">
{sourceData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={sourceData} layout="vertical">
<XAxis type="number" tick={{ fill: '#9ca3af', fontSize: 12 }} />
<YAxis type="category" dataKey="name" tick={{ fill: '#9ca3af', fontSize: 12 }} width={100} />
<Tooltip
contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151' }}
/>
<Bar dataKey="value" fill="#6366f1" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<p className="text-gray-400 text-center">No data yet</p>
)}
{loading ? (
<ChartSkeleton />
) : (
<div className="card">
<div className="card-header">
<h3 className="text-sm font-semibold">Issues by Source</h3>
</div>
<div className="card-body">
<div className="h-56">
{sourceData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={sourceData} layout="vertical">
<CartesianGrid strokeDasharray="3 3" stroke="#1e1e2a" horizontal={false} />
<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} />
<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>
);
}

View File

@ -3,22 +3,27 @@ import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
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 = {
pending: 'bg-yellow-500/20 text-yellow-400',
analyzing: 'bg-blue-500/20 text-blue-400',
analyzed: 'bg-green-500/20 text-green-400',
pr_created: 'bg-purple-500/20 text-purple-400',
completed: 'bg-gray-500/20 text-gray-400',
error: 'bg-red-500/20 text-red-400'
const statusConfig = {
pending: { badge: 'badge-yellow', icon: Clock, label: 'Pending' },
analyzing: { badge: 'badge-blue', icon: Loader2, label: 'Analyzing' },
analyzed: { badge: 'badge-green', icon: CheckCircle2, label: 'Analyzed' },
pr_created: { badge: 'badge-purple', icon: GitPullRequest, label: 'PR Created' },
completed: { badge: 'badge-gray', icon: CheckCircle2, label: 'Completed' },
error: { badge: 'badge-red', icon: XCircle, label: 'Error' },
};
const priorityColors = {
critical: 'bg-red-500/20 text-red-400',
high: 'bg-orange-500/20 text-orange-400',
medium: 'bg-yellow-500/20 text-yellow-400',
low: 'bg-green-500/20 text-green-400'
const priorityConfig = {
critical: { badge: 'badge-red', label: 'Critical' },
high: { badge: 'badge-yellow', label: 'High' },
medium: { badge: 'badge-blue', label: 'Medium' },
low: { badge: 'badge-green', label: 'Low' },
};
const sourceIcons = {
@ -26,107 +31,221 @@ const sourceIcons = {
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() {
const { currentOrg } = useAuth();
const [filters, setFilters] = useState({ status: '', source: '' });
const [search, setSearch] = useState('');
const [showFilters, setShowFilters] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ['issues', currentOrg?.id, filters],
queryFn: () => issues.list(currentOrg.id, filters),
enabled: !!currentOrg
});
if (!currentOrg) {
return <div className="p-8 text-center text-gray-400">Select an organization</div>;
}
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>
return (
<div className="flex items-center justify-center h-full p-8">
<p className="text-gray-500">Select an organization</p>
</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 ? (
<div className="text-center py-8 text-gray-400">Loading...</div>
Array(5).fill(0).map((_, i) => <RowSkeleton key={i} />)
) : issueList.length === 0 ? (
<div className="text-center py-8 text-gray-400">
<span className="text-4xl">📭</span>
<p className="mt-2">No issues found</p>
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="w-14 h-14 rounded-2xl bg-gray-800/50 flex items-center justify-center mb-3">
<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 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
key={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">
<span className="text-2xl">{sourceIcons[issue.source] || '📝'}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-mono text-primary-400 text-sm">
{issue.external_key || `#${issue.id}`}
</span>
<span className={clsx('px-2 py-0.5 rounded text-xs', statusColors[issue.status])}>
{issue.status}
</span>
{issue.priority && (
<span className={clsx('px-2 py-0.5 rounded text-xs', priorityColors[issue.priority])}>
{issue.priority}
</span>
)}
</div>
<h3 className="font-medium truncate">{issue.title}</h3>
{issue.confidence && (
<div className="flex items-center gap-2 mt-2">
<div className="flex-1 max-w-[200px] bg-gray-700 rounded-full h-2">
<div
className="bg-green-500 h-2 rounded-full"
style={{ width: `${issue.confidence * 100}%` }}
/>
</div>
<span className="text-xs text-gray-400">
{(issue.confidence * 100).toFixed(0)}% confidence
</span>
<div className="w-24">
<span className="font-mono text-xs text-indigo-400">
{issue.external_key || `#${issue.id}`}
</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate group-hover:text-white transition-colors">
{issue.title}
</p>
<p className="text-xs text-gray-500 mt-0.5 flex items-center gap-2">
<span>{sourceIcons[issue.source] || '📝'} {issue.source?.replace('_', ' ')}</span>
</p>
</div>
<div className="w-24">
<span className={cn("badge text-[10px]", statusCfg.badge)}>
<StatusIcon size={10} className={issue.status === 'analyzing' ? 'animate-spin' : ''} />
{statusCfg.label}
</span>
</div>
<div className="w-20">
<span className={cn("badge text-[10px]", priorityCfg.badge)}>
{priorityCfg.label}
</span>
</div>
<div className="w-20">
{issue.confidence ? (
<div className="flex items-center gap-2">
<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-gray-400"></span>
<span className="text-[10px] text-gray-400 font-mono w-7 text-right">
{(issue.confidence * 100).toFixed(0)}%
</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>
</Link>
))}
</div>
);
})
)}
</div>
</div>

View File

@ -1,6 +1,7 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { Zap, Mail, Lock, ArrowRight, Loader2 } from 'lucide-react';
export default function Login() {
const [email, setEmail] = useState('');
@ -9,7 +10,7 @@ export default function Login() {
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
@ -18,59 +19,134 @@ export default function Login() {
await login(email, password);
navigate('/');
} catch (err) {
setError(err.response?.data?.detail || 'Login failed');
setError(err.response?.data?.detail || 'Invalid email or password');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-900 px-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<span className="text-5xl">🤖</span>
<h1 className="text-2xl font-bold mt-4">JIRA AI Fixer</h1>
<p className="text-gray-400 mt-2">Sign in to your account</p>
</div>
<form onSubmit={handleSubmit} className="card">
{error && (
<div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-400">
{error}
<div className="min-h-screen flex bg-gray-950">
{/* Left side - branding */}
<div className="hidden lg:flex lg:w-1/2 relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-indigo-600 via-indigo-700 to-purple-800" />
<div className="absolute inset-0 opacity-10" style={{
backgroundImage: `radial-gradient(circle at 2px 2px, white 1px, transparent 0)`,
backgroundSize: '32px 32px'
}} />
<div className="relative z-10 flex flex-col justify-center px-16">
<div className="flex items-center gap-3 mb-8">
<div className="w-12 h-12 rounded-xl bg-white/10 backdrop-blur flex items-center justify-center">
<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 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 className="mb-6">
<label className="block text-sm font-medium mb-2">Password</label>
<input
type="password"
value={password}
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>
<h2 className="text-4xl font-bold text-white leading-tight mb-4">
AI-Powered Issue<br />Analysis & Resolution
</h2>
<p className="text-lg text-indigo-200 max-w-md">
Automatically analyze issues from JIRA, ServiceNow, GitHub and more.
Get root cause analysis and automated Pull Requests.
</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>
);

View File

@ -1,85 +1,111 @@
import { useState } from 'react';
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() {
const [form, setForm] = useState({ email: '', password: '', full_name: '' });
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { register } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await auth.register(form);
navigate('/login?registered=true');
await register(form);
navigate('/');
} catch (err) {
setError(err.response?.data?.detail || 'Registration failed');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-900 px-4">
<div className="w-full max-w-md">
<div className="min-h-screen flex items-center justify-center bg-gray-950 px-6">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<span className="text-5xl">🤖</span>
<h1 className="text-2xl font-bold mt-4">Create Account</h1>
<p className="text-gray-400 mt-2">Get started with JIRA AI Fixer</p>
<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>
<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 && (
<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}
</div>
)}
<div className="mb-4">
<label className="block text-sm font-medium mb-2">Full Name</label>
<input
type="text"
value={form.full_name}
onChange={(e) => setForm({...form, full_name: e.target.value})}
className="input"
/>
<div>
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Full Name</label>
<div className="relative">
<User size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
<input
type="text"
value={form.full_name}
onChange={e => setForm({ ...form, full_name: e.target.value })}
className="input pl-10"
placeholder="John Doe"
required
/>
</div>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-2">Email</label>
<input
type="email"
value={form.email}
onChange={(e) => setForm({...form, email: e.target.value})}
className="input"
required
/>
<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={form.email}
onChange={e => setForm({ ...form, email: e.target.value })}
className="input pl-10"
placeholder="you@company.com"
required
/>
</div>
</div>
<div className="mb-6">
<label className="block text-sm font-medium mb-2">Password</label>
<input
type="password"
value={form.password}
onChange={(e) => setForm({...form, password: e.target.value})}
className="input"
required
minLength={8}
/>
<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={form.password}
onChange={e => setForm({ ...form, password: e.target.value })}
className="input pl-10"
placeholder="••••••••"
minLength={8}
required
/>
</div>
</div>
<button type="submit" disabled={loading} className="btn btn-primary w-full">
{loading ? 'Creating account...' : 'Create account'}
<button type="submit" disabled={loading} className="btn btn-primary w-full h-11 justify-center mt-2">
{loading ? <Loader2 size={16} className="animate-spin" /> : <><span>Create account</span><ArrowRight size={16} /></>}
</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>
<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>
);

View File

@ -4,15 +4,57 @@ export default {
theme: {
extend: {
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',
100: '#e0e7ff',
200: '#c7d2fe',
300: '#a5b4fc',
400: '#818cf8',
500: '#6366f1',
600: '#4f46e5',
700: '#4338ca',
800: '#3730a3',
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: []

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">
<title>JIRA AI Fixer</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<script type="module" crossorigin src="/assets/index-DAbF3aXO.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BFu5F8oq.css">
<script type="module" crossorigin src="/assets/index-B1gflmx5.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-AVKtIbkK.css">
</head>
<body class="bg-gray-900 text-white">
<div id="root"></div>