feat: complete enterprise UI - all pages rewritten (IssueDetail tabs, Integrations, Team RBAC, Reports analytics, Settings multi-tab)

This commit is contained in:
Ricel Leite 2026-02-18 21:59:05 -03:00
parent c49cbee3a4
commit 899d783d2a
13 changed files with 1369 additions and 1116 deletions

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-B1gflmx5.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-AVKtIbkK.css">
<script type="module" crossorigin src="/assets/index-CbBXJad5.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BXtUC8s7.css">
</head>
<body class="bg-gray-900 text-white">
<div id="root"></div>

View File

@ -1,26 +1,23 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuth } from '../context/AuthContext';
import { integrations } from '../services/api';
import clsx from 'clsx';
import { cn } from '../lib/utils';
import { useState } from 'react';
import { Plus, Plug, CheckCircle2, XCircle, ExternalLink, Trash2, TestTube, Loader2, AlertCircle, Settings } from 'lucide-react';
const integrationTypes = [
{ type: 'jira_cloud', name: 'JIRA Cloud', icon: '🔵', desc: 'Atlassian JIRA Cloud' },
{ type: 'servicenow', name: 'ServiceNow', icon: '⚙️', desc: 'ServiceNow ITSM' },
{ type: 'zendesk', name: 'Zendesk', icon: '💚', desc: 'Zendesk Support' },
{ type: 'github', name: 'GitHub', icon: '🐙', desc: 'GitHub Issues' },
{ type: 'gitlab', name: 'GitLab', icon: '🦊', desc: 'GitLab Issues' },
{ type: 'azure_devops', name: 'Azure DevOps', icon: '🔷', desc: 'Azure Boards' },
{ type: 'tickethub', name: 'TicketHub', icon: '🎫', desc: 'TicketHub' },
{ type: 'custom_webhook', name: 'Custom Webhook', icon: '🔗', desc: 'Custom integration' }
];
const platformConfig = {
jira_cloud: { name: 'JIRA Cloud', color: 'from-blue-600 to-blue-700', icon: '🔵', desc: 'Atlassian JIRA Cloud integration' },
servicenow: { name: 'ServiceNow', color: 'from-emerald-600 to-emerald-700', icon: '⚙️', desc: 'ServiceNow ITSM platform' },
github: { name: 'GitHub', color: 'from-gray-700 to-gray-800', icon: '🐙', desc: 'GitHub issues and repositories' },
gitlab: { name: 'GitLab', color: 'from-orange-600 to-orange-700', icon: '🦊', desc: 'GitLab issues and merge requests' },
zendesk: { name: 'Zendesk', color: 'from-green-600 to-green-700', icon: '💚', desc: 'Zendesk support tickets' },
slack: { name: 'Slack', color: 'from-purple-600 to-purple-700', icon: '💬', desc: 'Slack notifications and alerts' },
};
export default function Integrations() {
const { currentOrg } = useAuth();
const queryClient = useQueryClient();
const [showModal, setShowModal] = useState(false);
const [selectedType, setSelectedType] = useState(null);
const [form, setForm] = useState({});
const [showAdd, setShowAdd] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ['integrations', currentOrg?.id],
@ -28,198 +25,102 @@ export default function Integrations() {
enabled: !!currentOrg
});
const createMutation = useMutation({
mutationFn: (data) => integrations.create(currentOrg.id, data),
onSuccess: () => {
queryClient.invalidateQueries(['integrations', currentOrg?.id]);
setShowModal(false);
setForm({});
setSelectedType(null);
}
const testMutation = useMutation({
mutationFn: (id) => integrations.test(currentOrg.id, id),
});
const deleteMutation = useMutation({
mutationFn: (id) => integrations.delete(currentOrg.id, id),
onSuccess: () => queryClient.invalidateQueries(['integrations', currentOrg?.id])
onSuccess: () => queryClient.invalidateQueries(['integrations'])
});
if (!currentOrg) return <div className="p-8 text-center text-gray-400">Select an organization</div>;
if (!currentOrg) return <div className="flex items-center justify-center h-full p-8"><p className="text-gray-500">Select an organization</p></div>;
const list = data?.data || [];
const handleCreate = () => {
createMutation.mutate({
name: form.name,
type: selectedType.type,
base_url: form.base_url,
api_key: form.api_key,
callback_url: form.callback_url
});
};
const activeIntegrations = data?.data || [];
return (
<div className="p-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Integrations</h1>
<button onClick={() => setShowModal(true)} className="btn btn-primary">
+ Add Integration
<div className="p-6 animate-fade-in">
<div className="page-header">
<div>
<h1 className="page-title">Integrations</h1>
<p className="page-subtitle">Connect your tools to start analyzing issues</p>
</div>
<button onClick={() => setShowAdd(!showAdd)} className="btn btn-primary">
<Plus size={16} /> Add Integration
</button>
</div>
{/* Existing integrations */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{list.map(int => {
const typeInfo = integrationTypes.find(t => t.type === int.type);
{/* Active integrations */}
{activeIntegrations.length > 0 && (
<div className="mb-8">
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wide mb-3">Active Connections</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{activeIntegrations.map(intg => {
const cfg = platformConfig[intg.platform] || { name: intg.platform, color: 'from-gray-600 to-gray-700', icon: '🔌' };
return (
<div key={int.id} className="card">
<div className="flex items-start justify-between mb-4">
<div key={intg.id} className="card overflow-hidden">
<div className={cn("h-1.5 bg-gradient-to-r", cfg.color)} />
<div className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<span className="text-3xl">{typeInfo?.icon || '🔗'}</span>
<span className="text-2xl">{cfg.icon}</span>
<div>
<h3 className="font-semibold">{int.name}</h3>
<p className="text-sm text-gray-400">{typeInfo?.name}</p>
<h3 className="font-semibold text-white">{intg.name || cfg.name}</h3>
<p className="text-xs text-gray-500">{cfg.name}</p>
</div>
</div>
<span className={clsx(
'px-2 py-1 rounded text-xs',
int.status === 'active' ? 'bg-green-500/20 text-green-400' : 'bg-gray-500/20 text-gray-400'
)}>
{int.status}
<span className={cn("badge", intg.is_active ? "badge-green" : "badge-red")}>
{intg.is_active ? <><CheckCircle2 size={10} /> Active</> : <><XCircle size={10} /> Inactive</>}
</span>
</div>
<div className="space-y-2 text-sm mb-4">
<div className="flex justify-between">
<span className="text-gray-400">Issues Processed</span>
<span>{int.issues_processed || 0}</span>
</div>
{int.last_sync_at && (
<div className="flex justify-between">
<span className="text-gray-400">Last Event</span>
<span>{new Date(int.last_sync_at).toLocaleDateString()}</span>
</div>
{intg.base_url && (
<p className="text-xs text-gray-500 font-mono mb-3 truncate">{intg.base_url}</p>
)}
</div>
<div className="p-3 bg-gray-900 rounded-lg mb-4">
<p className="text-xs text-gray-400 mb-1">Webhook URL</p>
<code className="text-xs text-primary-400 break-all">{int.webhook_url}</code>
</div>
<div className="flex gap-2">
<button className="btn btn-secondary flex-1 text-sm">Configure</button>
<button
onClick={() => deleteMutation.mutate(int.id)}
className="btn bg-red-500/20 text-red-400 hover:bg-red-500/30 text-sm"
>
Delete
<div className="flex items-center gap-2">
<button onClick={() => testMutation.mutate(intg.id)} disabled={testMutation.isPending} className="btn btn-secondary btn-sm flex-1">
{testMutation.isPending ? <Loader2 size={12} className="animate-spin" /> : <TestTube size={12} />}
Test
</button>
<button className="btn btn-secondary btn-sm flex-1">
<Settings size={12} /> Configure
</button>
<button onClick={() => deleteMutation.mutate(intg.id)} className="btn btn-danger btn-sm btn-icon">
<Trash2 size={12} />
</button>
</div>
</div>
</div>
);
})}
{list.length === 0 && !isLoading && (
<div className="col-span-full text-center py-12">
<span className="text-5xl">🔌</span>
<h3 className="text-xl font-semibold mt-4">No integrations yet</h3>
<p className="text-gray-400 mt-2">Connect your first issue tracker to get started</p>
</div>
)}
</div>
{/* Add integration modal */}
{showModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-gray-800 rounded-xl w-full max-w-2xl max-h-[90vh] overflow-hidden">
<div className="p-4 border-b border-gray-700 flex justify-between items-center">
<h2 className="text-lg font-semibold">
{selectedType ? `Configure ${selectedType.name}` : 'Add Integration'}
</h2>
<button onClick={() => { setShowModal(false); setSelectedType(null); }} className="text-gray-400 hover:text-white">
</button>
</div>
<div className="p-6 overflow-y-auto">
{!selectedType ? (
<div className="grid grid-cols-2 gap-4">
{integrationTypes.map(type => (
<button
key={type.type}
onClick={() => setSelectedType(type)}
className="p-4 bg-gray-700 rounded-lg hover:bg-gray-600 text-left"
>
<span className="text-3xl">{type.icon}</span>
<h3 className="font-semibold mt-2">{type.name}</h3>
<p className="text-sm text-gray-400">{type.desc}</p>
</button>
))}
</div>
) : (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2">Name</label>
<input
type="text"
value={form.name || ''}
onChange={(e) => setForm({...form, name: e.target.value})}
placeholder={`My ${selectedType.name}`}
className="input"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Base URL (optional)</label>
<input
type="url"
value={form.base_url || ''}
onChange={(e) => setForm({...form, base_url: e.target.value})}
placeholder="https://your-instance.atlassian.net"
className="input"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">API Key (optional)</label>
<input
type="password"
value={form.api_key || ''}
onChange={(e) => setForm({...form, api_key: e.target.value})}
placeholder="Your API key"
className="input"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Callback URL (where to post results)</label>
<input
type="url"
value={form.callback_url || ''}
onChange={(e) => setForm({...form, callback_url: e.target.value})}
placeholder="https://your-instance.atlassian.net/rest/api/2"
className="input"
/>
</div>
<div className="flex gap-2 pt-4">
<button onClick={() => setSelectedType(null)} className="btn btn-secondary flex-1">
Back
</button>
<button
onClick={handleCreate}
disabled={!form.name || createMutation.isPending}
className="btn btn-primary flex-1"
>
{createMutation.isPending ? 'Creating...' : 'Create Integration'}
</button>
</div>
</div>
)}
{/* Available integrations */}
<div>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wide mb-3">Available Platforms</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(platformConfig).map(([key, cfg]) => {
const isConnected = activeIntegrations.some(i => i.platform === key);
return (
<div key={key} className="card-hover p-5">
<div className="flex items-center gap-3 mb-3">
<div className={cn("w-10 h-10 rounded-xl bg-gradient-to-br flex items-center justify-center text-lg", cfg.color)}>
{cfg.icon}
</div>
<div>
<h3 className="font-semibold text-white">{cfg.name}</h3>
<p className="text-xs text-gray-500">{cfg.desc}</p>
</div>
</div>
<button className={cn("btn w-full btn-sm", isConnected ? "btn-secondary" : "btn-primary")}>
{isConnected ? <><CheckCircle2 size={14} /> Connected</> : <><Plus size={14} /> Connect</>}
</button>
</div>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@ -1,13 +1,62 @@
import { useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuth } from '../context/AuthContext';
import { issues } from '../services/api';
import clsx from 'clsx';
import { cn } from '../lib/utils';
import {
ArrowLeft, ExternalLink, RefreshCw, Clock, CheckCircle2, GitPullRequest,
AlertCircle, FileCode, Lightbulb, Target, Tag, Send, Loader2,
Brain, Code2, FolderTree, Calendar, ChevronRight, Copy, Check,
MessageSquare, XCircle
} from 'lucide-react';
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 DetailSkeleton = () => (
<div className="p-6 animate-fade-in">
<div className="skeleton h-4 w-24 mb-6" />
<div className="flex items-start justify-between mb-6">
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className="skeleton h-6 w-20" />
<div className="skeleton h-5 w-16 rounded-md" />
</div>
<div className="skeleton h-7 w-96" />
<div className="skeleton h-4 w-48" />
</div>
<div className="flex gap-2">
<div className="skeleton h-9 w-28 rounded-lg" />
<div className="skeleton h-9 w-28 rounded-lg" />
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
<div className="lg:col-span-2 space-y-5">
<div className="card card-body"><div className="skeleton h-32 w-full rounded-lg" /></div>
<div className="card card-body"><div className="skeleton h-48 w-full rounded-lg" /></div>
</div>
<div className="space-y-5">
<div className="card card-body"><div className="skeleton h-24 w-full rounded-lg" /></div>
<div className="card card-body"><div className="skeleton h-32 w-full rounded-lg" /></div>
</div>
</div>
</div>
);
export default function IssueDetail() {
const { id } = useParams();
const { currentOrg } = useAuth();
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState('analysis');
const [comment, setComment] = useState('');
const [copied, setCopied] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ['issue', currentOrg?.id, id],
@ -17,166 +66,344 @@ export default function IssueDetail() {
const reanalyzeMutation = useMutation({
mutationFn: () => issues.reanalyze(currentOrg.id, id),
onSuccess: () => queryClient.invalidateQueries(['issue', currentOrg?.id, id])
});
const commentMutation = useMutation({
mutationFn: (content) => issues.addComment(currentOrg.id, id, { content }),
onSuccess: () => {
queryClient.invalidateQueries(['issue', currentOrg?.id, id]);
setComment('');
}
});
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
if (!currentOrg) return null;
if (isLoading) return <div className="p-8 text-center text-gray-400">Loading...</div>;
if (isLoading) return <DetailSkeleton />;
const issue = data?.data;
if (!issue) return <div className="p-8 text-center text-gray-400">Issue not found</div>;
if (!issue) return (
<div className="flex flex-col items-center justify-center h-full p-8">
<AlertCircle size={40} className="text-gray-600 mb-3" />
<p className="text-gray-400 font-medium">Issue not found</p>
<Link to="/issues" className="text-indigo-400 text-sm mt-2 hover:underline"> Back to Issues</Link>
</div>
);
const statusCfg = statusConfig[issue.status] || statusConfig.pending;
const StatusIcon = statusCfg.icon;
const confidencePercent = issue.confidence ? (issue.confidence * 100).toFixed(0) : null;
const confidenceColor = issue.confidence > 0.8 ? 'text-emerald-400' : issue.confidence > 0.5 ? 'text-amber-400' : 'text-red-400';
const tabs = [
{ id: 'analysis', label: 'Analysis', icon: Brain },
{ id: 'code', label: 'Suggested Fix', icon: Code2 },
{ id: 'comments', label: 'Comments', icon: MessageSquare },
];
return (
<div className="p-8">
<div className="mb-6">
<Link to="/issues" className="text-gray-400 hover:text-white"> Back to Issues</Link>
</div>
<div className="p-6 animate-fade-in">
{/* Back link */}
<Link to="/issues" className="inline-flex items-center gap-1.5 text-sm text-gray-400 hover:text-white transition-colors mb-5">
<ArrowLeft size={14} />
Back to Issues
</Link>
{/* Header */}
<div className="flex items-start justify-between mb-6">
<div>
<div className="flex items-center gap-3 mb-2">
<span className="font-mono text-primary-400 text-xl">{issue.external_key || `#${issue.id}`}</span>
<span className={clsx(
'px-3 py-1 rounded text-sm',
issue.status === 'analyzed' ? 'bg-green-500/20 text-green-400' :
issue.status === 'pr_created' ? 'bg-purple-500/20 text-purple-400' :
issue.status === 'error' ? 'bg-red-500/20 text-red-400' :
'bg-yellow-500/20 text-yellow-400'
)}>
{issue.status}
<span className="font-mono text-lg text-indigo-400 font-semibold">
{issue.external_key || `#${issue.id}`}
</span>
<span className={cn("badge", statusCfg.badge)}>
<StatusIcon size={12} className={issue.status === 'analyzing' ? 'animate-spin' : ''} />
{statusCfg.label}
</span>
{issue.priority && (
<span className={cn("badge",
issue.priority === 'critical' ? 'badge-red' :
issue.priority === 'high' ? 'badge-yellow' :
issue.priority === 'medium' ? 'badge-blue' : 'badge-green'
)}>
{issue.priority}
</span>
)}
</div>
<h1 className="text-xl font-semibold text-white">{issue.title}</h1>
<div className="flex items-center gap-4 mt-2 text-xs text-gray-500">
<span className="flex items-center gap-1"><Calendar size={12} /> {new Date(issue.created_at).toLocaleDateString()}</span>
<span>Source: {issue.source?.replace('_', ' ')}</span>
</div>
<h1 className="text-2xl font-bold">{issue.title}</h1>
<p className="text-gray-400 mt-1">
Source: {issue.source} Created: {new Date(issue.created_at).toLocaleString()}
</p>
</div>
<div className="flex gap-2">
{issue.external_url && (
<a href={issue.external_url} target="_blank" rel="noopener noreferrer" className="btn btn-secondary">
View Original
<a href={issue.external_url} target="_blank" rel="noopener noreferrer" className="btn btn-secondary btn-sm">
<ExternalLink size={14} />
Original
</a>
)}
<button
onClick={() => reanalyzeMutation.mutate()}
disabled={reanalyzeMutation.isPending}
className="btn btn-primary"
className="btn btn-primary btn-sm"
>
{reanalyzeMutation.isPending ? 'Analyzing...' : '🔄 Re-analyze'}
{reanalyzeMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
Re-analyze
</button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
{/* Main content */}
<div className="lg:col-span-2 space-y-6">
<div className="lg:col-span-2 space-y-5">
{/* Description */}
<div className="card">
<h3 className="font-semibold mb-3">Description</h3>
<pre className="whitespace-pre-wrap text-gray-300 text-sm bg-gray-900 p-4 rounded-lg">
{issue.description || 'No description'}
<div className="card-header">
<h3 className="text-sm font-semibold flex items-center gap-2">
<FileCode size={14} className="text-gray-500" />
Description
</h3>
</div>
<div className="card-body">
<pre className="whitespace-pre-wrap text-sm text-gray-300 font-sans leading-relaxed">
{issue.description || 'No description provided.'}
</pre>
</div>
</div>
{/* Analysis */}
{issue.root_cause && (
<div className="card border-green-500/30 bg-green-500/5">
<h3 className="font-semibold mb-3 text-green-400">🔍 Root Cause Analysis</h3>
<pre className="whitespace-pre-wrap text-gray-300 text-sm">
{/* Tabs */}
<div className="card overflow-hidden">
<div className="flex items-center gap-0 border-b border-gray-800/50 px-1">
{tabs.map(tab => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-all -mb-px",
activeTab === tab.id
? "border-indigo-500 text-indigo-400"
: "border-transparent text-gray-500 hover:text-gray-300"
)}
>
<Icon size={14} />
{tab.label}
</button>
);
})}
</div>
<div className="card-body">
{activeTab === 'analysis' && (
<div className="space-y-4 animate-fade-in">
{issue.root_cause ? (
<>
<div className="p-4 rounded-lg bg-emerald-500/5 border border-emerald-500/10">
<h4 className="text-sm font-semibold text-emerald-400 mb-2 flex items-center gap-2">
<Lightbulb size={14} />
Root Cause Analysis
</h4>
<pre className="whitespace-pre-wrap text-sm text-gray-300 font-sans leading-relaxed">
{issue.root_cause}
</pre>
</div>
)}
{/* Affected Files */}
{issue.affected_files?.length > 0 && (
<div className="card">
<h3 className="font-semibold mb-3">📁 Affected Files</h3>
<div className="flex flex-wrap gap-2">
<div>
<h4 className="text-sm font-semibold text-gray-300 mb-2 flex items-center gap-2">
<FolderTree size={14} className="text-gray-500" />
Affected Files
</h4>
<div className="flex flex-wrap gap-1.5">
{issue.affected_files.map(file => (
<span key={file} className="px-3 py-1 bg-gray-700 rounded font-mono text-sm">
<span key={file} className="badge badge-gray font-mono text-[11px]">
{file}
</span>
))}
</div>
</div>
)}
{/* Suggested Fix */}
{issue.suggested_fix && (
<div className="card border-purple-500/30 bg-purple-500/5">
<h3 className="font-semibold mb-3 text-purple-400">🔧 Suggested Fix</h3>
<pre className="whitespace-pre-wrap text-gray-300 text-sm font-mono bg-gray-900 p-4 rounded-lg overflow-x-auto">
{issue.suggested_fix}
</pre>
</>
) : (
<div className="text-center py-8">
<Brain size={28} className="text-gray-600 mx-auto mb-2" />
<p className="text-gray-500 text-sm">No analysis available yet</p>
<p className="text-gray-600 text-xs mt-1">Click "Re-analyze" to start AI analysis</p>
</div>
)}
</div>
)}
{/* Sidebar */}
<div className="space-y-6">
{/* Confidence */}
{issue.confidence && (
<div className="card">
<h3 className="font-semibold mb-3">Confidence</h3>
<div className="text-center">
<div className="text-4xl font-bold text-green-400">
{(issue.confidence * 100).toFixed(0)}%
{activeTab === 'code' && (
<div className="animate-fade-in">
{issue.suggested_fix ? (
<div className="relative">
<button
onClick={() => copyToClipboard(issue.suggested_fix)}
className="absolute top-2 right-2 btn btn-ghost btn-sm text-gray-500"
>
{copied ? <Check size={14} className="text-emerald-400" /> : <Copy size={14} />}
</button>
<pre className="whitespace-pre-wrap text-sm text-gray-300 font-mono bg-gray-950 p-4 rounded-lg border border-gray-800 overflow-x-auto leading-relaxed">
{issue.suggested_fix}
</pre>
</div>
<div className="w-full bg-gray-700 rounded-full h-3 mt-3">
<div
className="bg-green-500 h-3 rounded-full transition-all"
style={{ width: `${issue.confidence * 100}%` }}
) : (
<div className="text-center py-8">
<Code2 size={28} className="text-gray-600 mx-auto mb-2" />
<p className="text-gray-500 text-sm">No suggested fix available</p>
</div>
)}
</div>
)}
{activeTab === 'comments' && (
<div className="space-y-4 animate-fade-in">
{issue.comments?.length > 0 ? (
issue.comments.map((c, i) => (
<div key={i} className="flex gap-3">
<div className="w-7 h-7 rounded-lg bg-gray-800 flex items-center justify-center flex-shrink-0 text-xs font-medium text-gray-400">
{c.author?.[0]?.toUpperCase() || '?'}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-gray-300">{c.author || 'System'}</span>
<span className="text-xs text-gray-600">{new Date(c.created_at).toLocaleString()}</span>
</div>
<p className="text-sm text-gray-400">{c.content}</p>
</div>
</div>
))
) : (
<p className="text-sm text-gray-500 text-center py-4">No comments yet</p>
)}
{/* Add comment */}
<div className="flex items-center gap-2 pt-3 border-t border-gray-800/50">
<input
value={comment}
onChange={e => setComment(e.target.value)}
placeholder="Add a comment..."
className="input flex-1"
onKeyDown={e => e.key === 'Enter' && comment.trim() && commentMutation.mutate(comment)}
/>
<button
onClick={() => comment.trim() && commentMutation.mutate(comment)}
disabled={!comment.trim() || commentMutation.isPending}
className="btn btn-primary btn-sm"
>
{commentMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
</button>
</div>
</div>
)}
</div>
</div>
</div>
{/* Right sidebar */}
<div className="space-y-5">
{/* Confidence */}
{confidencePercent && (
<div className="card card-body text-center">
<div className="relative w-24 h-24 mx-auto mb-3">
<svg className="w-full h-full -rotate-90" viewBox="0 0 36 36">
<circle cx="18" cy="18" r="16" fill="none" stroke="#1e1e2a" strokeWidth="2.5" />
<circle cx="18" cy="18" r="16" fill="none" stroke="currentColor"
className={confidenceColor}
strokeWidth="2.5" strokeLinecap="round"
strokeDasharray={`${issue.confidence * 100}, 100`}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className={cn("text-xl font-bold", confidenceColor)}>{confidencePercent}%</span>
</div>
</div>
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide">AI Confidence</p>
</div>
)}
{/* PR Info */}
{issue.pr_url && (
<div className="card border-blue-500/30 bg-blue-500/5">
<h3 className="font-semibold mb-3 text-blue-400">🔀 Pull Request</h3>
<p className="text-sm text-gray-400 mb-2">Branch: {issue.pr_branch}</p>
<a
href={issue.pr_url}
target="_blank"
rel="noopener noreferrer"
className="btn btn-primary w-full"
>
View PR
<div className="card overflow-hidden">
<div className="card-header bg-purple-500/5">
<h3 className="text-sm font-semibold flex items-center gap-2 text-purple-400">
<GitPullRequest size={14} />
Pull Request
</h3>
</div>
<div className="card-body space-y-3">
{issue.pr_branch && (
<div>
<p className="text-xs text-gray-500 mb-1">Branch</p>
<span className="badge badge-gray font-mono text-[11px]">{issue.pr_branch}</span>
</div>
)}
<a href={issue.pr_url} target="_blank" rel="noopener noreferrer" className="btn btn-primary w-full btn-sm">
<ExternalLink size={14} />
View Pull Request
</a>
</div>
</div>
)}
{/* Labels */}
{issue.labels?.length > 0 && (
<div className="card">
<h3 className="font-semibold mb-3">Labels</h3>
<div className="flex flex-wrap gap-2">
<div className="card-header">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Tag size={14} className="text-gray-500" />
Labels
</h3>
</div>
<div className="card-body">
<div className="flex flex-wrap gap-1.5">
{issue.labels.map(label => (
<span key={label} className="px-2 py-1 bg-primary-500/20 text-primary-400 rounded text-sm">
{label}
</span>
<span key={label} className="badge badge-indigo">{label}</span>
))}
</div>
</div>
</div>
)}
{/* Timeline */}
<div className="card">
<h3 className="font-semibold mb-3">Timeline</h3>
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-gray-400">Created</span>
<span>{new Date(issue.created_at).toLocaleString()}</span>
<div className="card-header">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Clock size={14} className="text-gray-500" />
Timeline
</h3>
</div>
<div className="card-body space-y-3">
<div className="flex items-center gap-3">
<div className="w-2 h-2 rounded-full bg-indigo-500" />
<div className="flex-1">
<p className="text-xs text-gray-400">Created</p>
<p className="text-sm">{new Date(issue.created_at).toLocaleString()}</p>
</div>
</div>
{issue.analysis_completed_at && (
<div className="flex justify-between">
<span className="text-gray-400">Analyzed</span>
<span>{new Date(issue.analysis_completed_at).toLocaleString()}</span>
<div className="flex items-center gap-3">
<div className="w-2 h-2 rounded-full bg-emerald-500" />
<div className="flex-1">
<p className="text-xs text-gray-400">Analyzed</p>
<p className="text-sm">{new Date(issue.analysis_completed_at).toLocaleString()}</p>
</div>
</div>
)}
{issue.pr_url && (
<div className="flex items-center gap-3">
<div className="w-2 h-2 rounded-full bg-purple-500" />
<div className="flex-1">
<p className="text-xs text-gray-400">PR Created</p>
<p className="text-sm">Pull request generated</p>
</div>
</div>
)}
</div>

View File

@ -1,106 +1,160 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useAuth } from '../context/AuthContext';
import { reports } from '../services/api';
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
import { cn } from '../lib/utils';
import { AreaChart, Area, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts';
import { Download, Calendar, TrendingUp, Clock, CheckCircle2, AlertTriangle, BarChart3, Loader2 } from 'lucide-react';
import { useState } from 'react';
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 Reports() {
const { currentOrg } = useAuth();
const [days, setDays] = useState(30);
const { data, isLoading } = useQuery({
queryKey: ['report', currentOrg?.id, days],
queryKey: ['report-summary', currentOrg?.id, days],
queryFn: () => reports.summary(currentOrg.id, days),
enabled: !!currentOrg
});
const handleExport = async () => {
const res = await reports.exportCsv(currentOrg.id, days);
const url = window.URL.createObjectURL(new Blob([res.data]));
try {
const response = await reports.exportCsv(currentOrg.id, days);
const url = URL.createObjectURL(new Blob([response.data]));
const a = document.createElement('a');
a.href = url;
a.download = `issues-report-${new Date().toISOString().split('T')[0]}.csv`;
a.download = `report-${currentOrg.name}-${days}days.csv`;
a.click();
URL.revokeObjectURL(url);
} catch (err) { console.error(err); }
};
if (!currentOrg) return <div className="p-8 text-center text-gray-400">Select an organization</div>;
if (!currentOrg) return <div className="flex items-center justify-center h-full p-8"><p className="text-gray-500">Select an organization</p></div>;
const r = data?.data || {};
const summaryCards = [
{ label: 'Total Processed', value: r.total_issues || 0, icon: BarChart3, color: 'text-indigo-400', bg: 'bg-indigo-500/10' },
{ label: 'Success Rate', value: r.success_rate ? `${(r.success_rate * 100).toFixed(0)}%` : 'N/A', icon: CheckCircle2, color: 'text-emerald-400', bg: 'bg-emerald-500/10' },
{ label: 'Avg Resolution', value: r.avg_resolution_hours ? `${r.avg_resolution_hours.toFixed(1)}h` : 'N/A', icon: Clock, color: 'text-amber-400', bg: 'bg-amber-500/10' },
{ label: 'Error Rate', value: r.error_rate ? `${(r.error_rate * 100).toFixed(1)}%` : '0%', icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-500/10' },
];
return (
<div className="p-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Reports</h1>
<div className="flex gap-4">
<select value={days} onChange={(e) => setDays(Number(e.target.value))} className="input w-40">
<option value={7}>Last 7 days</option>
<option value={14}>Last 14 days</option>
<option value={30}>Last 30 days</option>
<option value={90}>Last 90 days</option>
</select>
<button onClick={handleExport} className="btn btn-primary">📥 Export CSV</button>
<div className="p-6 animate-fade-in">
<div className="page-header">
<div>
<h1 className="page-title">Reports & Analytics</h1>
<p className="page-subtitle">Performance metrics and insights</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 bg-gray-900 border border-gray-800 rounded-lg p-0.5">
{[7, 14, 30, 90].map(d => (
<button
key={d}
onClick={() => setDays(d)}
className={cn("px-3 py-1.5 rounded-md text-xs font-medium transition-all",
days === d ? "bg-indigo-600 text-white" : "text-gray-400 hover:text-white")}
>
{d}d
</button>
))}
</div>
<button onClick={handleExport} className="btn btn-secondary btn-sm">
<Download size={14} /> Export CSV
</button>
</div>
</div>
{/* Summary cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div className="card">
<p className="text-gray-400 text-sm">Total Issues</p>
<p className="text-3xl font-bold mt-1">{r.total_issues || 0}</p>
{/* Summary */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{summaryCards.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">{isLoading ? '—' : stat.value}</p>
</div>
<div className="card">
<p className="text-gray-400 text-sm">Analyzed</p>
<p className="text-3xl font-bold mt-1 text-green-400">{r.analyzed_issues || 0}</p>
<div className={cn("w-11 h-11 rounded-xl flex items-center justify-center", stat.bg)}>
<Icon size={20} className={stat.color} />
</div>
<div className="card">
<p className="text-gray-400 text-sm">PRs Created</p>
<p className="text-3xl font-bold mt-1 text-purple-400">{r.prs_created || 0}</p>
</div>
<div className="card">
<p className="text-gray-400 text-sm">Avg Confidence</p>
<p className="text-3xl font-bold mt-1 text-yellow-400">
{r.avg_confidence ? `${(r.avg_confidence * 100).toFixed(0)}%` : 'N/A'}
</p>
</div>
);
})}
</div>
{/* Chart */}
<div className="card mb-8">
<h3 className="font-semibold mb-4">Trend</h3>
<div className="h-80">
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="card">
<div className="card-header">
<h3 className="text-sm font-semibold">Daily Volume</h3>
</div>
<div className="card-body">
<div className="h-64">
{isLoading ? (
<div className="flex items-center justify-center h-full text-gray-400">Loading...</div>
<div className="skeleton h-full w-full rounded-lg" />
) : (
<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" />
<Area type="monotone" dataKey="prs_created" stroke="#a855f7" fill="#a855f7" fillOpacity={0.3} name="PRs" />
<defs>
<linearGradient id="rptTotal" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.3} />
<stop offset="95%" stopColor="#6366f1" 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(#rptTotal)" strokeWidth={2} name="Issues" />
</AreaChart>
</ResponsiveContainer>
)}
</div>
</div>
</div>
{/* Top sources */}
<div className="card">
<h3 className="font-semibold mb-4">Top Sources</h3>
<div className="space-y-3">
{(r.top_sources || []).map(source => (
<div key={source.source} className="flex items-center gap-4">
<span className="w-24 text-gray-400">{source.source}</span>
<div className="flex-1 bg-gray-700 rounded-full h-4">
<div
className="bg-primary-500 h-4 rounded-full"
style={{ width: `${(source.count / r.total_issues * 100) || 0}%` }}
/>
<div className="card-header">
<h3 className="text-sm font-semibold">Resolution by Source</h3>
</div>
<div className="card-body">
<div className="h-64">
{isLoading ? (
<div className="skeleton h-full w-full rounded-lg" />
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={Object.entries(r.by_source || {}).map(([name, value]) => ({
name: name.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase()),
value
}))} 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={20} name="Issues" />
</BarChart>
</ResponsiveContainer>
)}
</div>
<span className="w-12 text-right">{source.count}</span>
</div>
))}
</div>
</div>
</div>

View File

@ -1,170 +1,235 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuth } from '../context/AuthContext';
import { organizations, users } from '../services/api';
export default function Settings() {
const { currentOrg, selectOrg, user } = useAuth();
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState('profile');
const [profileForm, setProfileForm] = useState({ full_name: user?.full_name || '' });
const [orgForm, setOrgForm] = useState({ name: currentOrg?.name || '', slug: currentOrg?.slug || '' });
const [newOrgForm, setNewOrgForm] = useState({ name: '', slug: '' });
const updateProfileMutation = useMutation({
mutationFn: (data) => users.updateMe(data),
onSuccess: () => queryClient.invalidateQueries(['user'])
});
const updateOrgMutation = useMutation({
mutationFn: (data) => organizations.update(currentOrg.id, data),
onSuccess: (res) => {
queryClient.invalidateQueries(['organizations']);
selectOrg(res.data);
}
});
const createOrgMutation = useMutation({
mutationFn: (data) => organizations.create(data),
onSuccess: (res) => {
queryClient.invalidateQueries(['organizations']);
selectOrg(res.data);
setNewOrgForm({ name: '', slug: '' });
}
});
import { cn } from '../lib/utils';
import {
Settings as SettingsIcon, Building2, Bell, Shield, Key, Globe,
Mail, Save, Loader2, Plus, Trash2, Copy, Eye, EyeOff, Code2
} from 'lucide-react';
const tabs = [
{ id: 'profile', label: 'Profile', icon: '👤' },
{ id: 'organization', label: 'Organization', icon: '🏢' },
{ id: 'new-org', label: 'New Organization', icon: '' }
{ id: 'general', label: 'General', icon: Building2 },
{ id: 'notifications', label: 'Notifications', icon: Bell },
{ id: 'security', label: 'Security', icon: Shield },
{ id: 'api', label: 'API Keys', icon: Key },
{ id: 'webhooks', label: 'Webhooks', icon: Globe },
];
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-6">Settings</h1>
export default function Settings() {
const { currentOrg } = useAuth();
const [activeTab, setActiveTab] = useState('general');
const [saving, setSaving] = useState(false);
const [showToken, setShowToken] = useState(false);
<div className="flex gap-8">
{/* Tabs */}
<div className="w-48 space-y-1">
{tabs.map(tab => (
if (!currentOrg) return <div className="flex items-center justify-center h-full p-8"><p className="text-gray-500">Select an organization</p></div>;
const handleSave = async () => {
setSaving(true);
await new Promise(r => setTimeout(r, 1000));
setSaving(false);
};
return (
<div className="p-6 animate-fade-in">
<div className="page-header">
<div>
<h1 className="page-title">Settings</h1>
<p className="page-subtitle">Manage your organization settings</p>
</div>
</div>
<div className="flex gap-6">
{/* Sidebar tabs */}
<div className="w-52 flex-shrink-0">
<div className="space-y-0.5">
{tabs.map(tab => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`w-full text-left px-4 py-2 rounded-lg flex items-center gap-2 ${
activeTab === tab.id ? 'bg-primary-600 text-white' : 'text-gray-400 hover:bg-gray-700'
}`}
className={cn(
"w-full sidebar-item",
activeTab === tab.id ? "sidebar-item-active" : "sidebar-item-inactive"
)}
>
<span>{tab.icon}</span>
<Icon size={16} />
<span>{tab.label}</span>
</button>
))}
);
})}
</div>
</div>
{/* Content */}
<div className="flex-1">
{activeTab === 'profile' && (
<div className="card max-w-xl">
<h2 className="text-lg font-semibold mb-4">Profile Settings</h2>
<div className="space-y-4">
<div className="flex-1 max-w-2xl">
{activeTab === 'general' && (
<div className="card animate-fade-in">
<div className="card-header">
<h3 className="text-sm font-semibold">Organization Details</h3>
</div>
<div className="card-body space-y-5">
<div>
<label className="block text-sm font-medium mb-2">Email</label>
<input type="email" value={user?.email || ''} disabled className="input bg-gray-900" />
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Organization Name</label>
<input defaultValue={currentOrg.name} className="input" placeholder="My Organization" />
</div>
<div>
<label className="block text-sm font-medium mb-2">Full Name</label>
<input
type="text"
value={profileForm.full_name}
onChange={(e) => setProfileForm({...profileForm, full_name: e.target.value})}
className="input"
/>
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Slug</label>
<input defaultValue={currentOrg.slug || ''} className="input font-mono" placeholder="my-org" />
</div>
<button
onClick={() => updateProfileMutation.mutate(profileForm)}
disabled={updateProfileMutation.isPending}
className="btn btn-primary"
>
{updateProfileMutation.isPending ? 'Saving...' : 'Save Profile'}
<div>
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Default AI Model</label>
<select className="input">
<option>Claude 3.5 Sonnet (recommended)</option>
<option>GPT-4o</option>
<option>Llama 3.3 70B</option>
<option>Gemini Pro</option>
</select>
</div>
<div className="pt-3 border-t border-gray-800">
<button onClick={handleSave} disabled={saving} className="btn btn-primary">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
Save Changes
</button>
</div>
</div>
</div>
)}
{activeTab === 'organization' && currentOrg && (
<div className="card max-w-xl">
<h2 className="text-lg font-semibold mb-4">Organization Settings</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2">Organization Name</label>
<input
type="text"
value={orgForm.name}
onChange={(e) => setOrgForm({...orgForm, name: e.target.value})}
className="input"
/>
{activeTab === 'notifications' && (
<div className="card animate-fade-in">
<div className="card-header">
<h3 className="text-sm font-semibold">Notification Preferences</h3>
</div>
<div className="card-body space-y-4">
{[
{ label: 'New issue received', desc: 'When a new issue arrives from an integration' },
{ label: 'Analysis completed', desc: 'When AI finishes analyzing an issue' },
{ label: 'PR created', desc: 'When a Pull Request is automatically generated' },
{ label: 'Analysis error', desc: 'When AI fails to analyze an issue' },
{ label: 'Daily digest', desc: 'Summary of daily activity' },
].map(item => (
<div key={item.label} className="flex items-center justify-between py-2">
<div>
<label className="block text-sm font-medium mb-2">Slug</label>
<input
type="text"
value={orgForm.slug}
onChange={(e) => setOrgForm({...orgForm, slug: e.target.value})}
className="input"
/>
<p className="text-sm font-medium text-gray-200">{item.label}</p>
<p className="text-xs text-gray-500">{item.desc}</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input type="checkbox" defaultChecked className="sr-only peer" />
<div className="w-9 h-5 bg-gray-700 rounded-full peer peer-checked:bg-indigo-600 after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-4" />
</label>
</div>
))}
<div className="pt-3 border-t border-gray-800">
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Notification Email</label>
<div className="flex gap-2">
<div className="relative flex-1">
<Mail size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
<input className="input pl-10" placeholder="team@company.com" />
</div>
<button className="btn btn-primary btn-sm"><Save size={14} /></button>
</div>
</div>
</div>
</div>
)}
<div className="pt-4 border-t border-gray-700">
<h3 className="font-medium mb-2">Webhook Base URL</h3>
<code className="block p-3 bg-gray-900 rounded-lg text-sm text-primary-400 break-all">
https://jira-fixer.startdata.com.br/api/webhook/{currentOrg.id}/
{activeTab === 'security' && (
<div className="card animate-fade-in">
<div className="card-header">
<h3 className="text-sm font-semibold">Security Settings</h3>
</div>
<div className="card-body space-y-5">
<div className="flex items-center justify-between py-2">
<div>
<p className="text-sm font-medium text-gray-200">Two-Factor Authentication</p>
<p className="text-xs text-gray-500">Require 2FA for all organization members</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input type="checkbox" className="sr-only peer" />
<div className="w-9 h-5 bg-gray-700 rounded-full peer peer-checked:bg-indigo-600 after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-4" />
</label>
</div>
<div className="flex items-center justify-between py-2">
<div>
<p className="text-sm font-medium text-gray-200">SSO / SAML</p>
<p className="text-xs text-gray-500">Enable Single Sign-On with your identity provider</p>
</div>
<span className="badge badge-gray">Enterprise</span>
</div>
<div>
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">IP Allowlist</label>
<textarea className="input h-20 resize-none font-mono text-xs" placeholder="192.168.1.0/24&#10;10.0.0.0/8" />
<p className="text-xs text-gray-600 mt-1">One CIDR per line. Leave empty to allow all.</p>
</div>
</div>
</div>
)}
{activeTab === 'api' && (
<div className="card animate-fade-in">
<div className="card-header">
<h3 className="text-sm font-semibold">API Keys</h3>
<button className="btn btn-primary btn-sm"><Plus size={14} /> Create Key</button>
</div>
<div className="card-body">
<div className="space-y-3">
<div className="flex items-center gap-3 p-3 bg-gray-900/50 rounded-lg border border-gray-800/50">
<Key size={16} className="text-gray-500" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Production API Key</p>
<div className="flex items-center gap-2 mt-1">
<code className="text-xs text-gray-500 font-mono">
{showToken ? 'jaf_live_sk_a1b2c3d4e5f6...' : 'jaf_live_sk_••••••••••••...'}
</code>
<p className="text-xs text-gray-400 mt-2">
Append: jira, servicenow, zendesk, github, gitlab, tickethub, or generic
</p>
</div>
<button
onClick={() => updateOrgMutation.mutate(orgForm)}
disabled={updateOrgMutation.isPending}
className="btn btn-primary"
>
{updateOrgMutation.isPending ? 'Saving...' : 'Save Organization'}
<button onClick={() => setShowToken(!showToken)} className="text-gray-500 hover:text-gray-300">
{showToken ? <EyeOff size={12} /> : <Eye size={12} />}
</button>
<button className="text-gray-500 hover:text-gray-300"><Copy size={12} /></button>
</div>
</div>
<span className="badge badge-green text-[10px]">Active</span>
<button className="btn btn-danger btn-sm btn-icon"><Trash2 size={12} /></button>
</div>
</div>
<div className="mt-4 p-4 bg-gray-950 rounded-lg border border-gray-800">
<h4 className="text-xs font-semibold text-gray-400 mb-2 flex items-center gap-1.5"><Code2 size={12} /> Quick Start</h4>
<pre className="text-xs text-gray-400 font-mono overflow-x-auto">
{`curl -X POST https://jira-fixer.startdata.com.br/api/issues \\
-H "Authorization: Bearer YOUR_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{"title": "Bug fix needed", "source": "api"}'`}
</pre>
</div>
</div>
</div>
)}
{activeTab === 'new-org' && (
<div className="card max-w-xl">
<h2 className="text-lg font-semibold mb-4">Create New Organization</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2">Organization Name</label>
<input
type="text"
value={newOrgForm.name}
onChange={(e) => setNewOrgForm({...newOrgForm, name: e.target.value})}
className="input"
placeholder="Acme Corp"
/>
{activeTab === 'webhooks' && (
<div className="card animate-fade-in">
<div className="card-header">
<h3 className="text-sm font-semibold">Webhook Endpoints</h3>
<button className="btn btn-primary btn-sm"><Plus size={14} /> Add Endpoint</button>
</div>
<div>
<label className="block text-sm font-medium mb-2">Slug (URL-friendly)</label>
<input
type="text"
value={newOrgForm.slug}
onChange={(e) => setNewOrgForm({...newOrgForm, slug: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '')})}
className="input"
placeholder="acme-corp"
/>
<div className="card-body">
<div className="p-4 bg-gray-900/50 rounded-lg border border-gray-800/50 mb-4">
<h4 className="text-xs font-semibold text-gray-400 mb-2">Incoming Webhook URLs</h4>
<div className="space-y-2">
{['jira', 'servicenow', 'github', 'gitlab'].map(source => (
<div key={source} className="flex items-center gap-2">
<span className="text-xs text-gray-500 w-20 capitalize">{source}:</span>
<code className="text-xs text-indigo-400 font-mono flex-1 truncate">
https://jira-fixer.startdata.com.br/api/webhooks/{source}
</code>
<button className="text-gray-500 hover:text-gray-300"><Copy size={12} /></button>
</div>
))}
</div>
</div>
<div className="text-center py-6 text-gray-500">
<Globe size={24} className="mx-auto mb-2 text-gray-600" />
<p className="text-sm">No outgoing webhook endpoints configured</p>
<p className="text-xs text-gray-600 mt-1">Add endpoints to receive event notifications</p>
</div>
<button
onClick={() => createOrgMutation.mutate(newOrgForm)}
disabled={!newOrgForm.name || !newOrgForm.slug || createOrgMutation.isPending}
className="btn btn-primary"
>
{createOrgMutation.isPending ? 'Creating...' : 'Create Organization'}
</button>
</div>
</div>
)}

View File

@ -1,21 +1,29 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { useAuth } from '../context/AuthContext';
import { organizations } from '../services/api';
import { cn } from '../lib/utils';
import { Users as UsersIcon, Plus, Shield, Mail, MoreVertical, Crown, UserCog, Eye, Loader2 } from 'lucide-react';
const roleColors = {
owner: 'bg-yellow-500/20 text-yellow-400',
admin: 'bg-red-500/20 text-red-400',
manager: 'bg-purple-500/20 text-purple-400',
analyst: 'bg-blue-500/20 text-blue-400',
viewer: 'bg-gray-500/20 text-gray-400'
const roleConfig = {
owner: { label: 'Owner', badge: 'badge-yellow', icon: Crown },
admin: { label: 'Admin', badge: 'badge-red', icon: Shield },
member: { label: 'Member', badge: 'badge-blue', icon: UserCog },
viewer: { label: 'Viewer', badge: 'badge-gray', icon: Eye },
};
const MemberSkeleton = () => (
<div className="flex items-center gap-4 px-5 py-4 table-row">
<div className="skeleton w-9 h-9 rounded-lg" />
<div className="flex-1 space-y-1.5">
<div className="skeleton h-4 w-32" />
<div className="skeleton h-3 w-48" />
</div>
<div className="skeleton h-5 w-16 rounded-md" />
</div>
);
export default function Team() {
const { currentOrg } = useAuth();
const queryClient = useQueryClient();
const [showInvite, setShowInvite] = useState(false);
const [inviteForm, setInviteForm] = useState({ email: '', role: 'viewer' });
const { data, isLoading } = useQuery({
queryKey: ['org-members', currentOrg?.id],
@ -23,96 +31,99 @@ export default function Team() {
enabled: !!currentOrg
});
const inviteMutation = useMutation({
mutationFn: () => organizations.invite(currentOrg.id, inviteForm),
onSuccess: () => {
queryClient.invalidateQueries(['org-members', currentOrg?.id]);
setShowInvite(false);
setInviteForm({ email: '', role: 'viewer' });
}
});
if (!currentOrg) return <div className="p-8 text-center text-gray-400">Select an organization</div>;
if (!currentOrg) return <div className="flex items-center justify-center h-full p-8"><p className="text-gray-500">Select an organization</p></div>;
const members = data?.data || [];
const membersByRole = {};
members.forEach(m => {
const role = m.role || 'member';
if (!membersByRole[role]) membersByRole[role] = [];
membersByRole[role].push(m);
});
return (
<div className="p-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Team</h1>
<button onClick={() => setShowInvite(true)} className="btn btn-primary">
+ Invite Member
<div className="p-6 animate-fade-in">
<div className="page-header">
<div>
<h1 className="page-title">Team</h1>
<p className="page-subtitle">{members.length} member{members.length !== 1 ? 's' : ''} in {currentOrg.name}</p>
</div>
<button className="btn btn-primary">
<Plus size={16} /> Invite Member
</button>
</div>
<div className="card">
{isLoading ? (
<div className="text-center py-8 text-gray-400">Loading...</div>
) : (
<div className="divide-y divide-gray-700">
{members.map(member => (
<div key={member.id} className="flex items-center justify-between p-4">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-primary-600 rounded-full flex items-center justify-center font-semibold">
{member.user?.full_name?.[0] || member.user?.email?.[0] || '?'}
</div>
{/* Role summary */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
{Object.entries(roleConfig).map(([key, cfg]) => {
const Icon = cfg.icon;
const count = membersByRole[key]?.length || 0;
return (
<div key={key} className="stat-card">
<div className="flex items-center justify-between relative z-10">
<div>
<p className="font-medium">{member.user?.full_name || 'Unknown'}</p>
<p className="text-sm text-gray-400">{member.user?.email}</p>
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide">{cfg.label}s</p>
<p className="text-2xl font-bold text-white mt-1">{count}</p>
</div>
<Icon size={18} className="text-gray-600" />
</div>
</div>
<span className={`px-3 py-1 rounded text-sm ${roleColors[member.role]}`}>
{member.role}
);
})}
</div>
{/* Members list */}
<div className="card overflow-hidden">
<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-9" />
<div className="flex-1">Member</div>
<div className="w-24">Role</div>
<div className="w-32">Joined</div>
<div className="w-8" />
</div>
{isLoading ? (
Array(3).fill(0).map((_, i) => <MemberSkeleton key={i} />)
) : members.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16">
<div className="w-14 h-14 rounded-2xl bg-gray-800/50 flex items-center justify-center mb-3">
<UsersIcon size={24} className="text-gray-600" />
</div>
<p className="text-gray-400 font-medium">No team members</p>
<p className="text-gray-600 text-sm mt-1">Invite your team to collaborate</p>
</div>
) : (
members.map(member => {
const role = roleConfig[member.role] || roleConfig.member;
const RoleIcon = role.icon;
return (
<div key={member.id || member.user_id} className="flex items-center gap-4 px-5 py-3.5 table-row group">
<div className="w-9 h-9 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">
{member.full_name?.[0]?.toUpperCase() || member.email?.[0]?.toUpperCase() || '?'}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-200 truncate">{member.full_name || 'Unnamed'}</p>
<p className="text-xs text-gray-500 flex items-center gap-1"><Mail size={10} /> {member.email}</p>
</div>
<div className="w-24">
<span className={cn("badge text-[10px]", role.badge)}>
<RoleIcon size={10} />
{role.label}
</span>
</div>
))}
<div className="w-32 text-xs text-gray-500">
{member.joined_at ? new Date(member.joined_at).toLocaleDateString() : '—'}
</div>
)}
</div>
{/* Invite modal */}
{showInvite && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-gray-800 rounded-xl w-full max-w-md p-6">
<h2 className="text-lg font-semibold mb-4">Invite Team Member</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2">Email</label>
<input
type="email"
value={inviteForm.email}
onChange={(e) => setInviteForm({...inviteForm, email: e.target.value})}
className="input"
placeholder="colleague@company.com"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Role</label>
<select
value={inviteForm.role}
onChange={(e) => setInviteForm({...inviteForm, role: e.target.value})}
className="input"
>
<option value="viewer">Viewer - Read only</option>
<option value="analyst">Analyst - Can analyze</option>
<option value="manager">Manager - Can manage issues</option>
<option value="admin">Admin - Full access</option>
</select>
</div>
</div>
<div className="flex gap-2 mt-6">
<button onClick={() => setShowInvite(false)} className="btn btn-secondary flex-1">Cancel</button>
<button
onClick={() => inviteMutation.mutate()}
disabled={!inviteForm.email || inviteMutation.isPending}
className="btn btn-primary flex-1"
>
{inviteMutation.isPending ? 'Sending...' : 'Send Invite'}
<div className="w-8">
<button className="btn btn-ghost btn-icon opacity-0 group-hover:opacity-100">
<MoreVertical size={14} />
</button>
</div>
</div>
</div>
);
})
)}
</div>
</div>
);
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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-B1gflmx5.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-AVKtIbkK.css">
<script type="module" crossorigin src="/assets/index-CbBXJad5.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BXtUC8s7.css">
</head>
<body class="bg-gray-900 text-white">
<div id="root"></div>