feat: complete enterprise UI - all pages rewritten (IssueDetail tabs, Integrations, Team RBAC, Reports analytics, Settings multi-tab)
This commit is contained in:
parent
c49cbee3a4
commit
899d783d2a
|
|
@ -5,8 +5,8 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>JIRA AI Fixer</title>
|
<title>JIRA AI Fixer</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<script type="module" crossorigin src="/assets/index-B1gflmx5.js"></script>
|
<script type="module" crossorigin src="/assets/index-CbBXJad5.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-AVKtIbkK.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BXtUC8s7.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-900 text-white">
|
<body class="bg-gray-900 text-white">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,23 @@
|
||||||
import { useState } from 'react';
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { integrations } from '../services/api';
|
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 = [
|
const platformConfig = {
|
||||||
{ type: 'jira_cloud', name: 'JIRA Cloud', icon: '🔵', desc: 'Atlassian JIRA Cloud' },
|
jira_cloud: { name: 'JIRA Cloud', color: 'from-blue-600 to-blue-700', icon: '🔵', desc: 'Atlassian JIRA Cloud integration' },
|
||||||
{ type: 'servicenow', name: 'ServiceNow', icon: '⚙️', desc: 'ServiceNow ITSM' },
|
servicenow: { name: 'ServiceNow', color: 'from-emerald-600 to-emerald-700', icon: '⚙️', desc: 'ServiceNow ITSM platform' },
|
||||||
{ type: 'zendesk', name: 'Zendesk', icon: '💚', desc: 'Zendesk Support' },
|
github: { name: 'GitHub', color: 'from-gray-700 to-gray-800', icon: '🐙', desc: 'GitHub issues and repositories' },
|
||||||
{ type: 'github', name: 'GitHub', icon: '🐙', desc: 'GitHub Issues' },
|
gitlab: { name: 'GitLab', color: 'from-orange-600 to-orange-700', icon: '🦊', desc: 'GitLab issues and merge requests' },
|
||||||
{ type: 'gitlab', name: 'GitLab', icon: '🦊', desc: 'GitLab Issues' },
|
zendesk: { name: 'Zendesk', color: 'from-green-600 to-green-700', icon: '💚', desc: 'Zendesk support tickets' },
|
||||||
{ type: 'azure_devops', name: 'Azure DevOps', icon: '🔷', desc: 'Azure Boards' },
|
slack: { name: 'Slack', color: 'from-purple-600 to-purple-700', icon: '💬', desc: 'Slack notifications and alerts' },
|
||||||
{ type: 'tickethub', name: 'TicketHub', icon: '🎫', desc: 'TicketHub' },
|
};
|
||||||
{ type: 'custom_webhook', name: 'Custom Webhook', icon: '🔗', desc: 'Custom integration' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Integrations() {
|
export default function Integrations() {
|
||||||
const { currentOrg } = useAuth();
|
const { currentOrg } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
const [selectedType, setSelectedType] = useState(null);
|
|
||||||
const [form, setForm] = useState({});
|
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['integrations', currentOrg?.id],
|
queryKey: ['integrations', currentOrg?.id],
|
||||||
|
|
@ -28,198 +25,102 @@ export default function Integrations() {
|
||||||
enabled: !!currentOrg
|
enabled: !!currentOrg
|
||||||
});
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const testMutation = useMutation({
|
||||||
mutationFn: (data) => integrations.create(currentOrg.id, data),
|
mutationFn: (id) => integrations.test(currentOrg.id, id),
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries(['integrations', currentOrg?.id]);
|
|
||||||
setShowModal(false);
|
|
||||||
setForm({});
|
|
||||||
setSelectedType(null);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (id) => integrations.delete(currentOrg.id, id),
|
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 activeIntegrations = 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
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8">
|
<div className="p-6 animate-fade-in">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="page-header">
|
||||||
<h1 className="text-2xl font-bold">Integrations</h1>
|
<div>
|
||||||
<button onClick={() => setShowModal(true)} className="btn btn-primary">
|
<h1 className="page-title">Integrations</h1>
|
||||||
+ Add Integration
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Existing integrations */}
|
{/* Active integrations */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
{activeIntegrations.length > 0 && (
|
||||||
{list.map(int => {
|
<div className="mb-8">
|
||||||
const typeInfo = integrationTypes.find(t => t.type === int.type);
|
<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 (
|
return (
|
||||||
<div key={int.id} className="card">
|
<div key={intg.id} className="card overflow-hidden">
|
||||||
<div className="flex items-start justify-between mb-4">
|
<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">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-3xl">{typeInfo?.icon || '🔗'}</span>
|
<span className="text-2xl">{cfg.icon}</span>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold">{int.name}</h3>
|
<h3 className="font-semibold text-white">{intg.name || cfg.name}</h3>
|
||||||
<p className="text-sm text-gray-400">{typeInfo?.name}</p>
|
<p className="text-xs text-gray-500">{cfg.name}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={clsx(
|
<span className={cn("badge", intg.is_active ? "badge-green" : "badge-red")}>
|
||||||
'px-2 py-1 rounded text-xs',
|
{intg.is_active ? <><CheckCircle2 size={10} /> Active</> : <><XCircle size={10} /> Inactive</>}
|
||||||
int.status === 'active' ? 'bg-green-500/20 text-green-400' : 'bg-gray-500/20 text-gray-400'
|
|
||||||
)}>
|
|
||||||
{int.status}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{intg.base_url && (
|
||||||
<div className="space-y-2 text-sm mb-4">
|
<p className="text-xs text-gray-500 font-mono mb-3 truncate">{intg.base_url}</p>
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => testMutation.mutate(intg.id)} disabled={testMutation.isPending} className="btn btn-secondary btn-sm flex-1">
|
||||||
<div className="p-3 bg-gray-900 rounded-lg mb-4">
|
{testMutation.isPending ? <Loader2 size={12} className="animate-spin" /> : <TestTube size={12} />}
|
||||||
<p className="text-xs text-gray-400 mb-1">Webhook URL</p>
|
Test
|
||||||
<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
|
|
||||||
</button>
|
</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>
|
||||||
</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>
|
||||||
</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>
|
||||||
</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>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,62 @@
|
||||||
|
import { useState } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { issues } from '../services/api';
|
import { issues } from '../services/api';
|
||||||
import clsx from 'clsx';
|
import { cn } from '../lib/utils';
|
||||||
|
import {
|
||||||
|
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() {
|
export default function IssueDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { currentOrg } = useAuth();
|
const { currentOrg } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [activeTab, setActiveTab] = useState('analysis');
|
||||||
|
const [comment, setComment] = useState('');
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['issue', currentOrg?.id, id],
|
queryKey: ['issue', currentOrg?.id, id],
|
||||||
|
|
@ -17,166 +66,344 @@ export default function IssueDetail() {
|
||||||
|
|
||||||
const reanalyzeMutation = useMutation({
|
const reanalyzeMutation = useMutation({
|
||||||
mutationFn: () => issues.reanalyze(currentOrg.id, id),
|
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: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries(['issue', currentOrg?.id, id]);
|
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 (!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;
|
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 (
|
return (
|
||||||
<div className="p-8">
|
<div className="p-6 animate-fade-in">
|
||||||
<div className="mb-6">
|
{/* Back link */}
|
||||||
<Link to="/issues" className="text-gray-400 hover:text-white">← Back to Issues</Link>
|
<Link to="/issues" className="inline-flex items-center gap-1.5 text-sm text-gray-400 hover:text-white transition-colors mb-5">
|
||||||
</div>
|
<ArrowLeft size={14} />
|
||||||
|
Back to Issues
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
<div className="flex items-start justify-between mb-6">
|
<div className="flex items-start justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<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="font-mono text-lg text-indigo-400 font-semibold">
|
||||||
<span className={clsx(
|
{issue.external_key || `#${issue.id}`}
|
||||||
'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>
|
</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>
|
</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>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{issue.external_url && (
|
{issue.external_url && (
|
||||||
<a href={issue.external_url} target="_blank" rel="noopener noreferrer" className="btn btn-secondary">
|
<a href={issue.external_url} target="_blank" rel="noopener noreferrer" className="btn btn-secondary btn-sm">
|
||||||
View Original →
|
<ExternalLink size={14} />
|
||||||
|
Original
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => reanalyzeMutation.mutate()}
|
onClick={() => reanalyzeMutation.mutate()}
|
||||||
disabled={reanalyzeMutation.isPending}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Main content */}
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="lg:col-span-2 space-y-5">
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3 className="font-semibold mb-3">Description</h3>
|
<div className="card-header">
|
||||||
<pre className="whitespace-pre-wrap text-gray-300 text-sm bg-gray-900 p-4 rounded-lg">
|
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||||
{issue.description || 'No description'}
|
<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>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Analysis */}
|
{/* Tabs */}
|
||||||
{issue.root_cause && (
|
<div className="card overflow-hidden">
|
||||||
<div className="card border-green-500/30 bg-green-500/5">
|
<div className="flex items-center gap-0 border-b border-gray-800/50 px-1">
|
||||||
<h3 className="font-semibold mb-3 text-green-400">🔍 Root Cause Analysis</h3>
|
{tabs.map(tab => {
|
||||||
<pre className="whitespace-pre-wrap text-gray-300 text-sm">
|
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}
|
{issue.root_cause}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Affected Files */}
|
|
||||||
{issue.affected_files?.length > 0 && (
|
{issue.affected_files?.length > 0 && (
|
||||||
<div className="card">
|
<div>
|
||||||
<h3 className="font-semibold mb-3">📁 Affected Files</h3>
|
<h4 className="text-sm font-semibold text-gray-300 mb-2 flex items-center gap-2">
|
||||||
<div className="flex flex-wrap 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 => (
|
{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}
|
{file}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
{/* Suggested Fix */}
|
) : (
|
||||||
{issue.suggested_fix && (
|
<div className="text-center py-8">
|
||||||
<div className="card border-purple-500/30 bg-purple-500/5">
|
<Brain size={28} className="text-gray-600 mx-auto mb-2" />
|
||||||
<h3 className="font-semibold mb-3 text-purple-400">🔧 Suggested Fix</h3>
|
<p className="text-gray-500 text-sm">No analysis available yet</p>
|
||||||
<pre className="whitespace-pre-wrap text-gray-300 text-sm font-mono bg-gray-900 p-4 rounded-lg overflow-x-auto">
|
<p className="text-gray-600 text-xs mt-1">Click "Re-analyze" to start AI analysis</p>
|
||||||
{issue.suggested_fix}
|
|
||||||
</pre>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Sidebar */}
|
{activeTab === 'code' && (
|
||||||
<div className="space-y-6">
|
<div className="animate-fade-in">
|
||||||
{/* Confidence */}
|
{issue.suggested_fix ? (
|
||||||
{issue.confidence && (
|
<div className="relative">
|
||||||
<div className="card">
|
<button
|
||||||
<h3 className="font-semibold mb-3">Confidence</h3>
|
onClick={() => copyToClipboard(issue.suggested_fix)}
|
||||||
<div className="text-center">
|
className="absolute top-2 right-2 btn btn-ghost btn-sm text-gray-500"
|
||||||
<div className="text-4xl font-bold text-green-400">
|
>
|
||||||
{(issue.confidence * 100).toFixed(0)}%
|
{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>
|
||||||
<div className="w-full bg-gray-700 rounded-full h-3 mt-3">
|
) : (
|
||||||
<div
|
<div className="text-center py-8">
|
||||||
className="bg-green-500 h-3 rounded-full transition-all"
|
<Code2 size={28} className="text-gray-600 mx-auto mb-2" />
|
||||||
style={{ width: `${issue.confidence * 100}%` }}
|
<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>
|
||||||
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* PR Info */}
|
{/* PR Info */}
|
||||||
{issue.pr_url && (
|
{issue.pr_url && (
|
||||||
<div className="card border-blue-500/30 bg-blue-500/5">
|
<div className="card overflow-hidden">
|
||||||
<h3 className="font-semibold mb-3 text-blue-400">🔀 Pull Request</h3>
|
<div className="card-header bg-purple-500/5">
|
||||||
<p className="text-sm text-gray-400 mb-2">Branch: {issue.pr_branch}</p>
|
<h3 className="text-sm font-semibold flex items-center gap-2 text-purple-400">
|
||||||
<a
|
<GitPullRequest size={14} />
|
||||||
href={issue.pr_url}
|
Pull Request
|
||||||
target="_blank"
|
</h3>
|
||||||
rel="noopener noreferrer"
|
</div>
|
||||||
className="btn btn-primary w-full"
|
<div className="card-body space-y-3">
|
||||||
>
|
{issue.pr_branch && (
|
||||||
View PR →
|
<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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Labels */}
|
{/* Labels */}
|
||||||
{issue.labels?.length > 0 && (
|
{issue.labels?.length > 0 && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3 className="font-semibold mb-3">Labels</h3>
|
<div className="card-header">
|
||||||
<div className="flex flex-wrap gap-2">
|
<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 => (
|
{issue.labels.map(label => (
|
||||||
<span key={label} className="px-2 py-1 bg-primary-500/20 text-primary-400 rounded text-sm">
|
<span key={label} className="badge badge-indigo">{label}</span>
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Timeline */}
|
{/* Timeline */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3 className="font-semibold mb-3">Timeline</h3>
|
<div className="card-header">
|
||||||
<div className="space-y-3 text-sm">
|
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||||
<div className="flex justify-between">
|
<Clock size={14} className="text-gray-500" />
|
||||||
<span className="text-gray-400">Created</span>
|
Timeline
|
||||||
<span>{new Date(issue.created_at).toLocaleString()}</span>
|
</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>
|
</div>
|
||||||
{issue.analysis_completed_at && (
|
{issue.analysis_completed_at && (
|
||||||
<div className="flex justify-between">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-gray-400">Analyzed</span>
|
<div className="w-2 h-2 rounded-full bg-emerald-500" />
|
||||||
<span>{new Date(issue.analysis_completed_at).toLocaleString()}</span>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,106 +1,160 @@
|
||||||
import { useState } from 'react';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { reports } from '../services/api';
|
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() {
|
export default function Reports() {
|
||||||
const { currentOrg } = useAuth();
|
const { currentOrg } = useAuth();
|
||||||
const [days, setDays] = useState(30);
|
const [days, setDays] = useState(30);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['report', currentOrg?.id, days],
|
queryKey: ['report-summary', currentOrg?.id, days],
|
||||||
queryFn: () => reports.summary(currentOrg.id, days),
|
queryFn: () => reports.summary(currentOrg.id, days),
|
||||||
enabled: !!currentOrg
|
enabled: !!currentOrg
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
const res = await reports.exportCsv(currentOrg.id, days);
|
try {
|
||||||
const url = window.URL.createObjectURL(new Blob([res.data]));
|
const response = await reports.exportCsv(currentOrg.id, days);
|
||||||
|
const url = URL.createObjectURL(new Blob([response.data]));
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `issues-report-${new Date().toISOString().split('T')[0]}.csv`;
|
a.download = `report-${currentOrg.name}-${days}days.csv`;
|
||||||
a.click();
|
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 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 (
|
return (
|
||||||
<div className="p-8">
|
<div className="p-6 animate-fade-in">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="page-header">
|
||||||
<h1 className="text-2xl font-bold">Reports</h1>
|
<div>
|
||||||
<div className="flex gap-4">
|
<h1 className="page-title">Reports & Analytics</h1>
|
||||||
<select value={days} onChange={(e) => setDays(Number(e.target.value))} className="input w-40">
|
<p className="page-subtitle">Performance metrics and insights</p>
|
||||||
<option value={7}>Last 7 days</option>
|
</div>
|
||||||
<option value={14}>Last 14 days</option>
|
<div className="flex items-center gap-2">
|
||||||
<option value={30}>Last 30 days</option>
|
<div className="flex items-center gap-1 bg-gray-900 border border-gray-800 rounded-lg p-0.5">
|
||||||
<option value={90}>Last 90 days</option>
|
{[7, 14, 30, 90].map(d => (
|
||||||
</select>
|
<button
|
||||||
<button onClick={handleExport} className="btn btn-primary">📥 Export CSV</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary cards */}
|
{/* Summary */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
<div className="card">
|
{summaryCards.map(stat => {
|
||||||
<p className="text-gray-400 text-sm">Total Issues</p>
|
const Icon = stat.icon;
|
||||||
<p className="text-3xl font-bold mt-1">{r.total_issues || 0}</p>
|
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>
|
||||||
<div className="card">
|
<div className={cn("w-11 h-11 rounded-xl flex items-center justify-center", stat.bg)}>
|
||||||
<p className="text-gray-400 text-sm">Analyzed</p>
|
<Icon size={20} className={stat.color} />
|
||||||
<p className="text-3xl font-bold mt-1 text-green-400">{r.analyzed_issues || 0}</p>
|
|
||||||
</div>
|
</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>
|
||||||
<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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chart */}
|
{/* Charts */}
|
||||||
<div className="card mb-8">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
<h3 className="font-semibold mb-4">Trend</h3>
|
<div className="card">
|
||||||
<div className="h-80">
|
<div className="card-header">
|
||||||
|
<h3 className="text-sm font-semibold">Daily Volume</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="h-64">
|
||||||
{isLoading ? (
|
{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%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<AreaChart data={r.daily_breakdown || []}>
|
<AreaChart data={r.daily_breakdown || []}>
|
||||||
<XAxis dataKey="date" tick={{ fill: '#9ca3af', fontSize: 12 }} />
|
<defs>
|
||||||
<YAxis tick={{ fill: '#9ca3af', fontSize: 12 }} />
|
<linearGradient id="rptTotal" x1="0" y1="0" x2="0" y2="1">
|
||||||
<Tooltip contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151' }} />
|
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.3} />
|
||||||
<Area type="monotone" dataKey="total" stroke="#6366f1" fill="#6366f1" fillOpacity={0.3} name="Total" />
|
<stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
|
||||||
<Area type="monotone" dataKey="analyzed" stroke="#22c55e" fill="#22c55e" fillOpacity={0.3} name="Analyzed" />
|
</linearGradient>
|
||||||
<Area type="monotone" dataKey="prs_created" stroke="#a855f7" fill="#a855f7" fillOpacity={0.3} name="PRs" />
|
</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>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Top sources */}
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3 className="font-semibold mb-4">Top Sources</h3>
|
<div className="card-header">
|
||||||
<div className="space-y-3">
|
<h3 className="text-sm font-semibold">Resolution by Source</h3>
|
||||||
{(r.top_sources || []).map(source => (
|
</div>
|
||||||
<div key={source.source} className="flex items-center gap-4">
|
<div className="card-body">
|
||||||
<span className="w-24 text-gray-400">{source.source}</span>
|
<div className="h-64">
|
||||||
<div className="flex-1 bg-gray-700 rounded-full h-4">
|
{isLoading ? (
|
||||||
<div
|
<div className="skeleton h-full w-full rounded-lg" />
|
||||||
className="bg-primary-500 h-4 rounded-full"
|
) : (
|
||||||
style={{ width: `${(source.count / r.total_issues * 100) || 0}%` }}
|
<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>
|
</div>
|
||||||
<span className="w-12 text-right">{source.count}</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,170 +1,235 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { organizations, users } from '../services/api';
|
import { cn } from '../lib/utils';
|
||||||
|
import {
|
||||||
export default function Settings() {
|
Settings as SettingsIcon, Building2, Bell, Shield, Key, Globe,
|
||||||
const { currentOrg, selectOrg, user } = useAuth();
|
Mail, Save, Loader2, Plus, Trash2, Copy, Eye, EyeOff, Code2
|
||||||
const queryClient = useQueryClient();
|
} from 'lucide-react';
|
||||||
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: '' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'profile', label: 'Profile', icon: '👤' },
|
{ id: 'general', label: 'General', icon: Building2 },
|
||||||
{ id: 'organization', label: 'Organization', icon: '🏢' },
|
{ id: 'notifications', label: 'Notifications', icon: Bell },
|
||||||
{ id: 'new-org', label: 'New Organization', icon: '➕' }
|
{ id: 'security', label: 'Security', icon: Shield },
|
||||||
|
{ id: 'api', label: 'API Keys', icon: Key },
|
||||||
|
{ id: 'webhooks', label: 'Webhooks', icon: Globe },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
export default function Settings() {
|
||||||
<div className="p-8">
|
const { currentOrg } = useAuth();
|
||||||
<h1 className="text-2xl font-bold mb-6">Settings</h1>
|
const [activeTab, setActiveTab] = useState('general');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [showToken, setShowToken] = useState(false);
|
||||||
|
|
||||||
<div className="flex gap-8">
|
if (!currentOrg) return <div className="flex items-center justify-center h-full p-8"><p className="text-gray-500">Select an organization</p></div>;
|
||||||
{/* Tabs */}
|
|
||||||
<div className="w-48 space-y-1">
|
const handleSave = async () => {
|
||||||
{tabs.map(tab => (
|
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
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={`w-full text-left px-4 py-2 rounded-lg flex items-center gap-2 ${
|
className={cn(
|
||||||
activeTab === tab.id ? 'bg-primary-600 text-white' : 'text-gray-400 hover:bg-gray-700'
|
"w-full sidebar-item",
|
||||||
}`}
|
activeTab === tab.id ? "sidebar-item-active" : "sidebar-item-inactive"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<span>{tab.icon}</span>
|
<Icon size={16} />
|
||||||
<span>{tab.label}</span>
|
<span>{tab.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1">
|
<div className="flex-1 max-w-2xl">
|
||||||
{activeTab === 'profile' && (
|
{activeTab === 'general' && (
|
||||||
<div className="card max-w-xl">
|
<div className="card animate-fade-in">
|
||||||
<h2 className="text-lg font-semibold mb-4">Profile Settings</h2>
|
<div className="card-header">
|
||||||
<div className="space-y-4">
|
<h3 className="text-sm font-semibold">Organization Details</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2">Email</label>
|
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Organization Name</label>
|
||||||
<input type="email" value={user?.email || ''} disabled className="input bg-gray-900" />
|
<input defaultValue={currentOrg.name} className="input" placeholder="My Organization" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2">Full Name</label>
|
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Slug</label>
|
||||||
<input
|
<input defaultValue={currentOrg.slug || ''} className="input font-mono" placeholder="my-org" />
|
||||||
type="text"
|
|
||||||
value={profileForm.full_name}
|
|
||||||
onChange={(e) => setProfileForm({...profileForm, full_name: e.target.value})}
|
|
||||||
className="input"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div>
|
||||||
onClick={() => updateProfileMutation.mutate(profileForm)}
|
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Default AI Model</label>
|
||||||
disabled={updateProfileMutation.isPending}
|
<select className="input">
|
||||||
className="btn btn-primary"
|
<option>Claude 3.5 Sonnet (recommended)</option>
|
||||||
>
|
<option>GPT-4o</option>
|
||||||
{updateProfileMutation.isPending ? 'Saving...' : 'Save Profile'}
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'organization' && currentOrg && (
|
{activeTab === 'notifications' && (
|
||||||
<div className="card max-w-xl">
|
<div className="card animate-fade-in">
|
||||||
<h2 className="text-lg font-semibold mb-4">Organization Settings</h2>
|
<div className="card-header">
|
||||||
<div className="space-y-4">
|
<h3 className="text-sm font-semibold">Notification Preferences</h3>
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2">Slug</label>
|
<p className="text-sm font-medium text-gray-200">{item.label}</p>
|
||||||
<input
|
<p className="text-xs text-gray-500">{item.desc}</p>
|
||||||
type="text"
|
|
||||||
value={orgForm.slug}
|
|
||||||
onChange={(e) => setOrgForm({...orgForm, slug: e.target.value})}
|
|
||||||
className="input"
|
|
||||||
/>
|
|
||||||
</div>
|
</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">
|
{activeTab === 'security' && (
|
||||||
<h3 className="font-medium mb-2">Webhook Base URL</h3>
|
<div className="card animate-fade-in">
|
||||||
<code className="block p-3 bg-gray-900 rounded-lg text-sm text-primary-400 break-all">
|
<div className="card-header">
|
||||||
https://jira-fixer.startdata.com.br/api/webhook/{currentOrg.id}/
|
<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.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>
|
</code>
|
||||||
<p className="text-xs text-gray-400 mt-2">
|
<button onClick={() => setShowToken(!showToken)} className="text-gray-500 hover:text-gray-300">
|
||||||
Append: jira, servicenow, zendesk, github, gitlab, tickethub, or generic
|
{showToken ? <EyeOff size={12} /> : <Eye size={12} />}
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => updateOrgMutation.mutate(orgForm)}
|
|
||||||
disabled={updateOrgMutation.isPending}
|
|
||||||
className="btn btn-primary"
|
|
||||||
>
|
|
||||||
{updateOrgMutation.isPending ? 'Saving...' : 'Save Organization'}
|
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'new-org' && (
|
{activeTab === 'webhooks' && (
|
||||||
<div className="card max-w-xl">
|
<div className="card animate-fade-in">
|
||||||
<h2 className="text-lg font-semibold mb-4">Create New Organization</h2>
|
<div className="card-header">
|
||||||
<div className="space-y-4">
|
<h3 className="text-sm font-semibold">Webhook Endpoints</h3>
|
||||||
<div>
|
<button className="btn btn-primary btn-sm"><Plus size={14} /> Add Endpoint</button>
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="card-body">
|
||||||
<label className="block text-sm font-medium mb-2">Slug (URL-friendly)</label>
|
<div className="p-4 bg-gray-900/50 rounded-lg border border-gray-800/50 mb-4">
|
||||||
<input
|
<h4 className="text-xs font-semibold text-gray-400 mb-2">Incoming Webhook URLs</h4>
|
||||||
type="text"
|
<div className="space-y-2">
|
||||||
value={newOrgForm.slug}
|
{['jira', 'servicenow', 'github', 'gitlab'].map(source => (
|
||||||
onChange={(e) => setNewOrgForm({...newOrgForm, slug: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '')})}
|
<div key={source} className="flex items-center gap-2">
|
||||||
className="input"
|
<span className="text-xs text-gray-500 w-20 capitalize">{source}:</span>
|
||||||
placeholder="acme-corp"
|
<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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,29 @@
|
||||||
import { useState } from 'react';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { organizations } from '../services/api';
|
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 = {
|
const roleConfig = {
|
||||||
owner: 'bg-yellow-500/20 text-yellow-400',
|
owner: { label: 'Owner', badge: 'badge-yellow', icon: Crown },
|
||||||
admin: 'bg-red-500/20 text-red-400',
|
admin: { label: 'Admin', badge: 'badge-red', icon: Shield },
|
||||||
manager: 'bg-purple-500/20 text-purple-400',
|
member: { label: 'Member', badge: 'badge-blue', icon: UserCog },
|
||||||
analyst: 'bg-blue-500/20 text-blue-400',
|
viewer: { label: 'Viewer', badge: 'badge-gray', icon: Eye },
|
||||||
viewer: 'bg-gray-500/20 text-gray-400'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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() {
|
export default function Team() {
|
||||||
const { currentOrg } = useAuth();
|
const { currentOrg } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [showInvite, setShowInvite] = useState(false);
|
|
||||||
const [inviteForm, setInviteForm] = useState({ email: '', role: 'viewer' });
|
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['org-members', currentOrg?.id],
|
queryKey: ['org-members', currentOrg?.id],
|
||||||
|
|
@ -23,96 +31,99 @@ export default function Team() {
|
||||||
enabled: !!currentOrg
|
enabled: !!currentOrg
|
||||||
});
|
});
|
||||||
|
|
||||||
const inviteMutation = useMutation({
|
if (!currentOrg) return <div className="flex items-center justify-center h-full p-8"><p className="text-gray-500">Select an organization</p></div>;
|
||||||
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>;
|
|
||||||
|
|
||||||
const members = data?.data || [];
|
const members = data?.data || [];
|
||||||
|
const membersByRole = {};
|
||||||
|
members.forEach(m => {
|
||||||
|
const role = m.role || 'member';
|
||||||
|
if (!membersByRole[role]) membersByRole[role] = [];
|
||||||
|
membersByRole[role].push(m);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8">
|
<div className="p-6 animate-fade-in">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="page-header">
|
||||||
<h1 className="text-2xl font-bold">Team</h1>
|
<div>
|
||||||
<button onClick={() => setShowInvite(true)} className="btn btn-primary">
|
<h1 className="page-title">Team</h1>
|
||||||
+ Invite Member
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
{/* Role summary */}
|
||||||
{isLoading ? (
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||||
<div className="text-center py-8 text-gray-400">Loading...</div>
|
{Object.entries(roleConfig).map(([key, cfg]) => {
|
||||||
) : (
|
const Icon = cfg.icon;
|
||||||
<div className="divide-y divide-gray-700">
|
const count = membersByRole[key]?.length || 0;
|
||||||
{members.map(member => (
|
return (
|
||||||
<div key={member.id} className="flex items-center justify-between p-4">
|
<div key={key} className="stat-card">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center justify-between relative z-10">
|
||||||
<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>
|
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{member.user?.full_name || 'Unknown'}</p>
|
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide">{cfg.label}s</p>
|
||||||
<p className="text-sm text-gray-400">{member.user?.email}</p>
|
<p className="text-2xl font-bold text-white mt-1">{count}</p>
|
||||||
|
</div>
|
||||||
|
<Icon size={18} className="text-gray-600" />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="w-32 text-xs text-gray-500">
|
||||||
|
{member.joined_at ? new Date(member.joined_at).toLocaleDateString() : '—'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="w-8">
|
||||||
</div>
|
<button className="btn btn-ghost btn-icon opacity-0 group-hover:opacity-100">
|
||||||
|
<MoreVertical size={14} />
|
||||||
{/* 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'}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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
|
|
@ -5,8 +5,8 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>JIRA AI Fixer</title>
|
<title>JIRA AI Fixer</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<script type="module" crossorigin src="/assets/index-B1gflmx5.js"></script>
|
<script type="module" crossorigin src="/assets/index-CbBXJad5.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-AVKtIbkK.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BXtUC8s7.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-900 text-white">
|
<body class="bg-gray-900 text-white">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue