feat: enterprise UI rewrite - dark theme, Lucide icons, Cmd+K, sidebar, kanban board, stat cards, badges

Pages rewritten: Dashboard, Tickets, Board, TicketDetail, NewTicket, Projects, Team, Reports, Integrations, Automation, Settings
Design: gray-950 base, blue-600 accent, Inter font, custom animations, skeleton loaders
This commit is contained in:
Ricel Leite 2026-02-18 22:08:45 -03:00
parent 927a906bbd
commit 25e5ac36c6
23 changed files with 2386 additions and 1569 deletions

2
.gitignore vendored
View File

@ -21,3 +21,5 @@ Thumbs.db
# Database # Database
*.db *.db
*.sqlite *.sqlite
frontend/node_modules
frontend/dist

File diff suppressed because it is too large Load Diff

View File

@ -10,12 +10,24 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@tanstack/react-query": "^5.17.0",
"axios": "^1.6.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^3.3.0",
"lucide-react": "^0.574.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.22.0", "react-router-dom": "^6.22.0",
"@tanstack/react-query": "^5.17.0", "tailwind-merge": "^3.4.1"
"axios": "^1.6.0",
"date-fns": "^3.3.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.48", "@types/react": "^18.2.48",

View File

@ -1,134 +1,176 @@
import { Outlet, NavLink, useLocation } from 'react-router-dom' import { Outlet, NavLink, useLocation } from 'react-router-dom'
import { useState } from 'react' import { useState, useEffect } from 'react'
import { cn } from '../lib/utils'
import {
LayoutDashboard, TicketCheck, Kanban, FolderOpen, Users, BarChart3,
Plug, Zap as AutomationIcon, Settings, Search, Bell, PanelLeftClose,
PanelLeftOpen, ChevronRight, Ticket
} from 'lucide-react'
const navSections = [ const navSections = [
{ {
title: 'Work', title: 'Work',
items: [ items: [
{ to: '/', label: 'Dashboard', icon: '📊' }, { to: '/', label: 'Dashboard', icon: LayoutDashboard },
{ to: '/tickets', label: 'All Tickets', icon: '🎫' }, { to: '/tickets', label: 'All Tickets', icon: TicketCheck },
{ to: '/board', label: 'Board', icon: '📋' }, { to: '/board', label: 'Board', icon: Kanban },
], ],
}, },
{ {
title: 'Management', title: 'Management',
items: [ items: [
{ to: '/projects', label: 'Projects', icon: '📁' }, { to: '/projects', label: 'Projects', icon: FolderOpen },
{ to: '/team', label: 'Team', icon: '👥' }, { to: '/team', label: 'Team', icon: Users },
{ to: '/reports', label: 'Reports', icon: '📈' }, { to: '/reports', label: 'Reports', icon: BarChart3 },
], ],
}, },
{ {
title: 'Configuration', title: 'Configuration',
items: [ items: [
{ to: '/integrations', label: 'Integrations', icon: '🔌' }, { to: '/integrations', label: 'Integrations', icon: Plug },
{ to: '/automation', label: 'Automation', icon: '⚡' }, { to: '/automation', label: 'Automation', icon: AutomationIcon },
{ to: '/settings', label: 'Settings', icon: '⚙️' }, { to: '/settings', label: 'Settings', icon: Settings },
], ],
}, },
] ]
const breadcrumbMap: Record<string, string> = {
'/': 'Dashboard', '/tickets': 'Tickets', '/tickets/new': 'New Ticket',
'/board': 'Board', '/projects': 'Projects', '/team': 'Team',
'/reports': 'Reports', '/integrations': 'Integrations',
'/automation': 'Automation', '/settings': 'Settings',
}
export default function Layout() { export default function Layout() {
const [collapsed, setCollapsed] = useState(false)
const location = useLocation() const location = useLocation()
const [collapsed, setCollapsed] = useState(false)
const [showSearch, setShowSearch] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); setShowSearch(s => !s) }
if (e.key === 'Escape') setShowSearch(false)
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [])
const getBreadcrumbs = () => {
const parts = location.pathname.split('/').filter(Boolean)
if (!parts.length) return [{ label: 'Dashboard', path: '/' }]
const crumbs: { label: string; path: string }[] = []
let cur = ''
for (const p of parts) {
cur += `/${p}`
crumbs.push({ label: breadcrumbMap[cur] || p.charAt(0).toUpperCase() + p.slice(1), path: cur })
}
return crumbs
}
const isActive = (path: string) => path === '/' ? location.pathname === '/' : location.pathname.startsWith(path)
return ( return (
<div className="min-h-screen bg-gray-50 flex"> <div className="min-h-screen flex bg-gray-950 text-gray-200">
{/* Sidebar */} {/* Search */}
<aside className={`${collapsed ? 'w-16' : 'w-64'} bg-white border-r border-gray-200 flex flex-col transition-all duration-200`}> {showSearch && (
<div className="p-4 border-b border-gray-200"> <div className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]" onClick={() => setShowSearch(false)}>
<div className="flex items-center gap-2"> <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div className="w-8 h-8 bg-gradient-to-br from-blue-600 to-purple-600 rounded-lg flex items-center justify-center"> <div className="relative w-full max-w-lg mx-4 animate-slide-up" onClick={e => e.stopPropagation()}>
<span className="text-white text-lg">🎫</span> <div className="card border-gray-700 shadow-2xl">
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-800">
<Search size={18} className="text-gray-500" />
<input autoFocus value={searchQuery} onChange={e => setSearchQuery(e.target.value)}
placeholder="Search tickets, projects..." className="flex-1 bg-transparent text-sm outline-none placeholder:text-gray-500" />
<kbd className="kbd">ESC</kbd>
</div>
<div className="p-2 max-h-80 overflow-auto">
{navSections.flatMap(s => s.items).filter(i => i.label.toLowerCase().includes(searchQuery.toLowerCase())).map(item => (
<a key={item.to} href={item.to} className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-gray-300 hover:bg-gray-800"
onClick={() => { setShowSearch(false); setSearchQuery('') }}>
<item.icon size={14} className="text-gray-500" />
{item.label}
</a>
))}
</div>
</div>
</div> </div>
{!collapsed && (
<div>
<h1 className="font-bold text-gray-900">TicketHub</h1>
<p className="text-xs text-gray-500">Enterprise</p>
</div> </div>
)} )}
{/* Sidebar */}
<aside className={cn("fixed top-0 left-0 h-full flex flex-col border-r border-gray-800/50 bg-gray-950 z-40 transition-all duration-300",
collapsed ? "w-[68px]" : "w-[260px]")}>
<div className={cn("h-14 flex items-center border-b border-gray-800/50 px-4", collapsed && "justify-center px-0")}>
{collapsed ? (
<div className="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center"><Ticket size={16} className="text-white" /></div>
) : (
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20">
<Ticket size={16} className="text-white" />
</div> </div>
<div>
<h1 className="text-sm font-semibold text-white">TicketHub</h1>
<p className="text-[10px] text-gray-500 font-medium">Enterprise v2.0</p>
</div>
</div>
)}
</div> </div>
<nav className="flex-1 overflow-y-auto py-4">
{navSections.map((section, i) => (
<div key={i} className="mb-6">
{!collapsed && ( {!collapsed && (
<h3 className="px-4 text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2"> <div className="px-3 pt-3">
{section.title} <button onClick={() => setShowSearch(true)}
</h3> className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-gray-500 hover:text-gray-300 bg-gray-900/40 hover:bg-gray-800/60 border border-gray-800/30 transition-all text-sm">
<Search size={14} /><span className="flex-1 text-left">Search...</span>
<div className="flex gap-0.5"><kbd className="kbd"></kbd><kbd className="kbd">K</kbd></div>
</button>
</div>
)} )}
<ul className="space-y-1 px-2">
{section.items.map(item => ( <nav className="flex-1 overflow-auto px-3 py-4 space-y-5">
<li key={item.to}> {navSections.map(section => (
<NavLink <div key={section.title}>
to={item.to} {!collapsed && <p className="text-[10px] font-semibold uppercase tracking-wider text-gray-600 px-3 mb-1.5">{section.title}</p>}
end={item.to === '/'} <div className="space-y-0.5">
className={({ isActive }) => {section.items.map(item => {
`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors ${ const active = isActive(item.to)
isActive return (
? 'bg-blue-50 text-blue-700 font-medium' <NavLink key={item.to} to={item.to} title={collapsed ? item.label : undefined}
: 'text-gray-600 hover:bg-gray-100' className={cn("sidebar-item", active ? "sidebar-item-active" : "sidebar-item-inactive", collapsed && "justify-center px-0")}>
}` <item.icon size={18} strokeWidth={active ? 2 : 1.5} />
}
>
<span>{item.icon}</span>
{!collapsed && <span>{item.label}</span>} {!collapsed && <span>{item.label}</span>}
</NavLink> </NavLink>
</li> )
))} })}
</ul> </div>
</div> </div>
))} ))}
</nav> </nav>
<button <div className="px-3 py-2 border-t border-gray-800/50">
onClick={() => setCollapsed(!collapsed)} <button onClick={() => setCollapsed(!collapsed)} className={cn("sidebar-item sidebar-item-inactive w-full", collapsed && "justify-center px-0")}>
className="p-4 border-t border-gray-200 text-gray-400 hover:text-gray-600 flex items-center justify-center" {collapsed ? <PanelLeftOpen size={18} /> : <PanelLeftClose size={18} />}
> {!collapsed && <span>Collapse</span>}
<svg className={`w-5 h-5 transition-transform ${collapsed ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
</button> </button>
</div>
</aside> </aside>
<div className="flex-1 flex flex-col overflow-hidden"> <div className={cn("flex-1 flex flex-col transition-all duration-300", collapsed ? "ml-[68px]" : "ml-[260px]")}>
{/* Top Bar */} <header className="h-14 flex items-center justify-between px-6 border-b border-gray-800/50 bg-gray-950/80 backdrop-blur-md sticky top-0 z-30">
<header className="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6"> <div className="flex items-center gap-1.5 text-sm">
<div className="flex items-center gap-4"> {getBreadcrumbs().map((c, i) => (
<div className="relative"> <div key={c.path} className="flex items-center gap-1.5">
<input {i > 0 && <ChevronRight size={12} className="text-gray-600" />}
type="text" <a href={c.path} className={cn("hover:text-white transition-colors", i === getBreadcrumbs().length - 1 ? "text-white font-medium" : "text-gray-400")}>{c.label}</a>
placeholder="Search tickets, projects..."
className="w-80 bg-gray-100 border-0 rounded-lg pl-10 pr-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<svg className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div> </div>
))}
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-2">
<button className="relative p-2 text-gray-400 hover:text-gray-600"> <button onClick={() => setShowSearch(true)} className="btn-ghost btn-icon rounded-lg"><Search size={16} /></button>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <button className="btn-ghost btn-icon rounded-lg relative"><Bell size={16} /><span className="absolute top-1.5 right-1.5 w-2 h-2 rounded-full bg-blue-500" /></button>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-red-500 rounded-full"></span>
</button>
<button className="relative p-2 text-gray-400 hover:text-gray-600">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
<div className="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-white text-sm font-semibold">
RL
</div>
</div> </div>
</header> </header>
<main className="flex-1 overflow-auto"><Outlet /></main>
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div> </div>
</div> </div>
) )

View File

@ -1,31 +1,52 @@
import { ButtonHTMLAttributes, ReactNode } from 'react' import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { const buttonVariants = cva(
variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'success' "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
size?: 'sm' | 'md' | 'lg' {
children: ReactNode variants: {
loading?: boolean variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
} }
export function Button({ variant = 'primary', size = 'md', children, loading, className = '', disabled, ...props }: ButtonProps) { const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
const variants = { ({ className, variant, size, asChild = false, ...props }, ref) => {
primary: 'bg-blue-600 hover:bg-blue-700 text-white', const Comp = asChild ? Slot : "button"
secondary: 'bg-gray-100 hover:bg-gray-200 text-gray-700 border border-gray-300',
danger: 'bg-red-600 hover:bg-red-700 text-white',
ghost: 'bg-transparent hover:bg-gray-100 text-gray-600',
success: 'bg-green-600 hover:bg-green-700 text-white',
}
const sizes = { sm: 'px-3 py-1.5 text-sm', md: 'px-4 py-2', lg: 'px-6 py-3 text-lg' }
return ( return (
<button <Comp
className={`rounded-lg font-medium transition-colors flex items-center justify-center gap-2 className={cn(buttonVariants({ variant, size, className }))}
${variants[variant]} ${sizes[size]} ${disabled || loading ? 'opacity-50 cursor-not-allowed' : ''} ${className}`} ref={ref}
disabled={disabled || loading}
{...props} {...props}
> />
{loading && <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>}
{children}
</button>
) )
} }
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

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

View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@ -1,163 +1,41 @@
import { useState } from 'react' import { Plus, Zap, ArrowRight, Trash2 } from 'lucide-react'
import { Card, Button, Input, Select, Modal, Badge } from '../components/ui' import { cn } from '../lib/utils'
interface Rule { const mockRules = [
id: string { id: 1, name: 'Auto-assign critical', trigger: 'Ticket priority = Critical', action: 'Assign to on-call team', active: true },
name: string { id: 2, name: 'Slack notify on new', trigger: 'New ticket created', action: 'Send to #support-tickets', active: true },
enabled: boolean { id: 3, name: 'Auto-close stale', trigger: 'Resolved > 7 days', action: 'Close ticket', active: false },
trigger: string
actions: string[]
runs: number
}
const mockRules: Rule[] = [
{ id: '1', name: 'Auto-assign critical tickets', enabled: true, trigger: 'When priority is Critical', actions: ['Assign to On-Call', 'Send Slack notification'], runs: 23 },
{ id: '2', name: 'Close stale tickets', enabled: true, trigger: 'When ticket has no activity for 14 days', actions: ['Add comment', 'Close ticket'], runs: 45 },
{ id: '3', name: 'Escalate high priority', enabled: false, trigger: 'When high priority ticket is open for 24h', actions: ['Send email to manager'], runs: 12 },
] ]
export default function Automation() { export default function Automation() {
const [rules, setRules] = useState<Rule[]>(mockRules)
const [showCreate, setShowCreate] = useState(false)
const toggleRule = (id: string) => {
setRules(rules.map(r => r.id === id ? { ...r, enabled: !r.enabled } : r))
}
return ( return (
<div className="p-6"> <div className="p-6 animate-fade-in">
<div className="flex justify-between items-center mb-6"> <div className="page-header">
<div> <div><h1 className="page-title">Automation</h1><p className="page-subtitle">Rules engine for repetitive tasks</p></div>
<h1 className="text-2xl font-bold text-gray-900">Automation</h1> <button className="btn btn-primary"><Plus size={16} /> New Rule</button>
<p className="text-gray-500">Automate repetitive tasks with rules</p>
</div> </div>
<Button onClick={() => setShowCreate(true)}> <div className="space-y-3">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> {mockRules.map(rule => (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /> <div key={rule.id} className="card p-5 flex items-center gap-4">
</svg> <div className={cn("w-10 h-10 rounded-xl flex items-center justify-center", rule.active ? "bg-blue-500/10" : "bg-gray-800/50")}>
Create Rule <Zap size={18} className={rule.active ? "text-blue-400" : "text-gray-600"} />
</Button>
</div> </div>
<div className="flex-1">
{/* Stats */} <h3 className="text-sm font-semibold text-white">{rule.name}</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6"> <div className="flex items-center gap-2 text-xs text-gray-500 mt-1">
<Card> <span className="badge badge-gray text-[10px]">{rule.trigger}</span>
<div className="text-center"> <ArrowRight size={10} />
<div className="text-3xl font-bold text-gray-900">{rules.length}</div> <span className="badge badge-blue text-[10px]">{rule.action}</span>
<div className="text-sm text-gray-500">Total Rules</div>
</div> </div>
</Card>
<Card>
<div className="text-center">
<div className="text-3xl font-bold text-green-600">{rules.filter(r => r.enabled).length}</div>
<div className="text-sm text-gray-500">Active Rules</div>
</div> </div>
</Card> <label className="relative inline-flex items-center cursor-pointer">
<Card> <input type="checkbox" defaultChecked={rule.active} className="sr-only peer" />
<div className="text-center"> <div className="w-9 h-5 bg-gray-700 rounded-full peer peer-checked:bg-blue-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" />
<div className="text-3xl font-bold text-blue-600">{rules.reduce((a, r) => a + r.runs, 0)}</div> </label>
<div className="text-sm text-gray-500">Total Runs</div> <button className="btn btn-ghost btn-icon"><Trash2 size={14} /></button>
</div> </div>
</Card>
</div>
{/* Rules List */}
<div className="space-y-4">
{rules.map(rule => (
<Card key={rule.id}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<button
onClick={() => toggleRule(rule.id)}
className={`w-12 h-6 rounded-full transition-colors relative ${rule.enabled ? 'bg-green-500' : 'bg-gray-300'}`}
>
<span className={`absolute w-5 h-5 bg-white rounded-full top-0.5 transition-transform ${rule.enabled ? 'translate-x-6' : 'translate-x-0.5'}`} />
</button>
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-900">{rule.name}</h3>
<Badge variant={rule.enabled ? 'success' : 'default'}>{rule.enabled ? 'Active' : 'Disabled'}</Badge>
</div>
<p className="text-sm text-gray-500 mt-1">{rule.trigger}</p>
<div className="flex gap-2 mt-2">
{rule.actions.map((action, i) => (
<span key={i} className="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded">{action}</span>
))} ))}
</div> </div>
</div> </div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<div className="text-lg font-semibold text-gray-900">{rule.runs}</div>
<div className="text-xs text-gray-500">runs</div>
</div>
<Button variant="ghost" size="sm">Edit</Button>
</div>
</div>
</Card>
))}
</div>
{/* Create Modal */}
<Modal open={showCreate} onClose={() => setShowCreate(false)} title="Create Automation Rule" size="lg">
<div className="space-y-4">
<Input label="Rule Name" placeholder="My automation rule" />
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Trigger</label>
<Select
options={[
{ value: 'created', label: 'When a ticket is created' },
{ value: 'updated', label: 'When a ticket is updated' },
{ value: 'status_changed', label: 'When status changes' },
{ value: 'priority_changed', label: 'When priority changes' },
{ value: 'stale', label: 'When ticket becomes stale' },
]}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Conditions</label>
<div className="space-y-2">
<div className="flex gap-2">
<Select options={[
{ value: 'priority', label: 'Priority' },
{ value: 'status', label: 'Status' },
{ value: 'assignee', label: 'Assignee' },
{ value: 'project', label: 'Project' },
]} className="w-40" />
<Select options={[
{ value: 'eq', label: 'equals' },
{ value: 'neq', label: 'not equals' },
{ value: 'contains', label: 'contains' },
]} className="w-40" />
<Input placeholder="Value" className="flex-1" />
</div>
</div>
<Button variant="ghost" size="sm" className="mt-2">+ Add condition</Button>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Actions</label>
<div className="space-y-2">
<Select options={[
{ value: 'assign', label: 'Assign to user' },
{ value: 'comment', label: 'Add comment' },
{ value: 'status', label: 'Change status' },
{ value: 'priority', label: 'Change priority' },
{ value: 'notify', label: 'Send notification' },
{ value: 'webhook', label: 'Call webhook' },
]} />
</div>
<Button variant="ghost" size="sm" className="mt-2">+ Add action</Button>
</div>
<div className="flex gap-3 pt-4 border-t">
<Button variant="secondary" className="flex-1" onClick={() => setShowCreate(false)}>Cancel</Button>
<Button className="flex-1">Create Rule</Button>
</div>
</div>
</Modal>
</div>
) )
} }

View File

@ -1,94 +1,67 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { Card, Badge, Avatar, Select } from '../components/ui'
import { ticketsApi, projectsApi, Ticket } from '../services/api' import { ticketsApi, projectsApi, Ticket } from '../services/api'
import { cn } from '../lib/utils'
import { useState } from 'react' import { useState } from 'react'
import { Link } from 'react-router-dom'
import { GripVertical, Plus, Filter } from 'lucide-react'
const columns = [ const columns = [
{ id: 'open', label: 'Open', color: 'blue' }, { key: 'open', label: 'Open', color: 'border-blue-500' },
{ id: 'in_progress', label: 'In Progress', color: 'yellow' }, { key: 'in_progress', label: 'In Progress', color: 'border-amber-500' },
{ id: 'resolved', label: 'Resolved', color: 'green' }, { key: 'resolved', label: 'Resolved', color: 'border-emerald-500' },
{ id: 'closed', label: 'Closed', color: 'gray' }, { key: 'closed', label: 'Closed', color: 'border-gray-500' },
] ]
const priorityBadge: Record<string, string> = { critical: 'badge-red', high: 'badge-orange', medium: 'badge-yellow', low: 'badge-green' }
export default function Board() { export default function Board() {
const [projectId, setProjectId] = useState<string>('') const [projectId, setProjectId] = useState<number | undefined>()
const queryClient = useQueryClient() const { data: tickets } = useQuery({ queryKey: ['tickets', projectId], queryFn: () => ticketsApi.list(projectId) })
const { data: projects } = useQuery({ queryKey: ['projects'], queryFn: () => projectsApi.list() })
const { data: projects } = useQuery({ queryKey: ['projects'], queryFn: projectsApi.list }) const grouped: Record<string, Ticket[]> = { open: [], in_progress: [], resolved: [], closed: [] }
const { data: tickets } = useQuery({ ;(tickets || []).forEach(t => { if (grouped[t.status]) grouped[t.status].push(t) })
queryKey: ['tickets', projectId],
queryFn: () => ticketsApi.list(projectId ? Number(projectId) : undefined),
})
const updateMutation = useMutation({
mutationFn: ({ id, status }: { id: number; status: string }) => ticketsApi.update(id, { status: status as any }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tickets'] }),
})
const getTicketsByStatus = (status: string) => tickets?.filter(t => t.status === status) || []
const priorityColors: Record<string, string> = {
low: 'border-l-green-500',
medium: 'border-l-yellow-500',
high: 'border-l-orange-500',
critical: 'border-l-red-500',
}
return ( return (
<div className="p-6 h-full flex flex-col"> <div className="p-6 animate-fade-in">
<div className="flex justify-between items-center mb-6"> <div className="page-header">
<div> <div><h1 className="page-title">Board</h1><p className="page-subtitle">Kanban view of your tickets</p></div>
<h1 className="text-2xl font-bold text-gray-900">Board</h1> <div className="flex items-center gap-2">
<p className="text-gray-500">Drag and drop to update status</p> <select value={projectId || ''} onChange={e => setProjectId(e.target.value ? Number(e.target.value) : undefined)} className="input h-9 text-xs w-40">
<option value="">All Projects</option>
{(projects || []).map(p => <option key={p.id} value={p.id}>{p.key} - {p.name}</option>)}
</select>
<Link to="/tickets/new" className="btn btn-primary btn-sm"><Plus size={14} /> New</Link>
</div> </div>
<Select
options={[{ value: '', label: 'All Projects' }, ...(projects?.map(p => ({ value: String(p.id), label: p.name })) || [])]}
value={projectId}
onChange={e => setProjectId(e.target.value)}
className="w-48"
/>
</div> </div>
<div className="flex-1 flex gap-4 overflow-x-auto pb-4"> <div className="flex gap-4 overflow-x-auto pb-4">
{columns.map(col => ( {columns.map(col => (
<div key={col.id} className="flex-shrink-0 w-80"> <div key={col.key} className="kanban-col">
<div className={`flex items-center gap-2 mb-3 px-2`}> <div className={cn("flex items-center justify-between mb-3 pb-2 border-b-2", col.color)}>
<div className={`w-2 h-2 rounded-full bg-${col.color}-500`} style={{ backgroundColor: { blue: '#3b82f6', yellow: '#eab308', green: '#22c55e', gray: '#6b7280' }[col.color] }} /> <h3 className="text-sm font-semibold">{col.label}</h3>
<h3 className="font-semibold text-gray-700">{col.label}</h3> <span className="badge badge-gray text-[10px]">{grouped[col.key].length}</span>
<span className="text-sm text-gray-400">({getTicketsByStatus(col.id).length})</span> </div>
<div className="space-y-2 min-h-[200px]">
{grouped[col.key].map(ticket => (
<Link key={ticket.id} to={`/tickets/${ticket.id}`}
className="card p-3 hover:border-gray-700 hover:bg-gray-900/80 transition-all cursor-pointer group block">
<div className="flex items-start gap-2">
<GripVertical size={14} className="text-gray-700 mt-0.5 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-[10px] font-mono text-blue-400">{ticket.key}</span>
<p className="text-sm font-medium mt-0.5 group-hover:text-white transition-colors line-clamp-2">{ticket.title}</p>
<div className="flex items-center gap-2 mt-2">
<span className={cn("badge text-[10px]", priorityBadge[ticket.priority])}>{ticket.priority}</span>
{ticket.assignee && <span className="text-[10px] text-gray-500">@{ticket.assignee}</span>}
</div> </div>
<div
className="bg-gray-100 rounded-xl p-2 min-h-[calc(100vh-250px)] space-y-2"
onDragOver={e => e.preventDefault()}
onDrop={e => {
const ticketId = e.dataTransfer.getData('ticketId')
if (ticketId) updateMutation.mutate({ id: Number(ticketId), status: col.id })
}}
>
{getTicketsByStatus(col.id).map(ticket => (
<Link
key={ticket.id}
to={`/tickets/${ticket.id}`}
draggable
onDragStart={e => e.dataTransfer.setData('ticketId', String(ticket.id))}
className={`block bg-white rounded-lg p-3 shadow-sm border-l-4 ${priorityColors[ticket.priority]} hover:shadow-md transition-shadow cursor-grab active:cursor-grabbing`}
>
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-mono text-gray-500">{ticket.key}</span>
</div> </div>
<p className="font-medium text-gray-900 text-sm">{ticket.title}</p>
<div className="flex items-center justify-between mt-3">
<Badge variant={ticket.priority === 'critical' || ticket.priority === 'high' ? 'error' : 'default'} size="sm">
{ticket.priority}
</Badge>
{ticket.assignee && <Avatar name={ticket.assignee} size="sm" />}
</div> </div>
</Link> </Link>
))} ))}
{getTicketsByStatus(col.id).length === 0 && ( {grouped[col.key].length === 0 && (
<div className="text-center text-gray-400 py-8 text-sm">No tickets</div> <div className="flex items-center justify-center h-20 text-xs text-gray-600 border border-dashed border-gray-800 rounded-lg">
No tickets
</div>
)} )}
</div> </div>
</div> </div>

View File

@ -1,143 +1,91 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { ticketsApi, projectsApi } from '../services/api'
import { TicketCheck, FolderOpen, CheckCircle2, Clock, AlertCircle, TrendingUp, ArrowUpRight } from 'lucide-react'
import { cn } from '../lib/utils'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { Card } from '../components/ui'
import { projectsApi, ticketsApi, Ticket } from '../services/api' const statusBadge: Record<string, string> = { open: 'badge-blue', in_progress: 'badge-yellow', resolved: 'badge-green', closed: 'badge-gray' }
const priorityBadge: Record<string, string> = { critical: 'badge-red', high: 'badge-orange', medium: 'badge-yellow', low: 'badge-green' }
export default function Dashboard() { export default function Dashboard() {
const { data: projects } = useQuery({ queryKey: ['projects'], queryFn: projectsApi.list }) const { data: tickets, isLoading: tl } = useQuery({ queryKey: ['tickets'], queryFn: () => ticketsApi.list() })
const { data: tickets } = useQuery({ queryKey: ['tickets'], queryFn: () => ticketsApi.list() }) const { data: projects, isLoading: pl } = useQuery({ queryKey: ['projects'], queryFn: () => projectsApi.list() })
const loading = tl || pl
const t = tickets || []; const p = projects || []
const open = t.filter(x => x.status === 'open').length
const inProg = t.filter(x => x.status === 'in_progress').length
const resolved = t.filter(x => x.status === 'resolved').length
const stats = { const stats = [
total: tickets?.length || 0, { label: 'Total Tickets', value: t.length, icon: TicketCheck, color: 'text-blue-400', bg: 'bg-blue-500/10' },
open: tickets?.filter(t => t.status === 'open').length || 0, { label: 'Open', value: open, icon: AlertCircle, color: 'text-amber-400', bg: 'bg-amber-500/10' },
inProgress: tickets?.filter(t => t.status === 'in_progress').length || 0, { label: 'In Progress', value: inProg, icon: Clock, color: 'text-purple-400', bg: 'bg-purple-500/10' },
resolved: tickets?.filter(t => t.status === 'resolved' || t.status === 'closed').length || 0, { label: 'Resolved', value: resolved, icon: CheckCircle2, color: 'text-emerald-400', bg: 'bg-emerald-500/10' },
critical: tickets?.filter(t => t.priority === 'critical').length || 0, ]
}
const recentTickets = tickets?.slice(0, 5) || []
return ( return (
<div className="p-6"> <div className="p-6 animate-fade-in">
<div className="flex justify-between items-center mb-6"> <div className="page-header">
<div><h1 className="page-title">Dashboard</h1><p className="page-subtitle">Overview of your workspace</p></div>
<Link to="/tickets/new" className="btn btn-primary"><TicketCheck size={16} /> New Ticket</Link>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{stats.map(s => {
const Icon = s.icon
return (
<div key={s.label} className="stat-card">
<div className="flex items-center justify-between relative z-10">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1> <p className="text-xs font-medium text-gray-400 uppercase tracking-wide">{s.label}</p>
<p className="text-gray-500">Overview of your workspace</p> <p className="text-2xl font-bold text-white mt-1">{loading ? '—' : s.value}</p>
</div> </div>
<Link to="/tickets/new"> <div className={cn("w-11 h-11 rounded-xl flex items-center justify-center", s.bg)}><Icon size={20} className={s.color} /></div>
<button className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 flex items-center gap-2"> </div>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> </div>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /> )
</svg> })}
New Ticket </div>
</button>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Recent tickets */}
<div className="card">
<div className="card-header"><h3 className="text-sm font-semibold">Recent Tickets</h3>
<Link to="/tickets" className="text-xs text-blue-400 hover:underline flex items-center gap-1">View all <ArrowUpRight size={12} /></Link></div>
{loading ? <div className="card-body space-y-3">{Array(4).fill(0).map((_, i) => <div key={i} className="skeleton h-12 rounded-lg" />)}</div> : (
<div>{t.slice(0, 6).map(ticket => (
<Link key={ticket.id} to={`/tickets/${ticket.id}`} className="flex items-center gap-3 px-5 py-3 table-row group">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-mono text-blue-400">{ticket.key}</span>
<span className={cn("badge text-[10px]", statusBadge[ticket.status])}>{ticket.status.replace('_', ' ')}</span>
</div>
<p className="text-sm truncate mt-0.5 group-hover:text-white transition-colors">{ticket.title}</p>
</div>
<span className={cn("badge text-[10px]", priorityBadge[ticket.priority])}>{ticket.priority}</span>
</Link> </Link>
</div> ))}</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
<StatCard label="Total Tickets" value={stats.total} color="gray" />
<StatCard label="Open" value={stats.open} color="blue" />
<StatCard label="In Progress" value={stats.inProgress} color="yellow" />
<StatCard label="Resolved" value={stats.resolved} color="green" />
<StatCard label="Critical" value={stats.critical} color="red" />
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Recent Tickets */}
<div className="lg:col-span-2">
<Card padding="none">
<div className="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
<h2 className="font-semibold text-gray-900">Recent Tickets</h2>
<Link to="/tickets" className="text-sm text-blue-600 hover:text-blue-700">View all </Link>
</div>
<div className="divide-y divide-gray-100">
{recentTickets.length === 0 ? (
<div className="p-8 text-center text-gray-500">No tickets yet</div>
) : (
recentTickets.map(ticket => <TicketRow key={ticket.id} ticket={ticket} />)
)} )}
</div> </div>
</Card>
</div>
{/* Projects */} {/* Projects */}
<div> <div className="card">
<Card padding="none"> <div className="card-header"><h3 className="text-sm font-semibold">Projects</h3>
<div className="px-6 py-4 border-b border-gray-200 flex justify-between items-center"> <Link to="/projects" className="text-xs text-blue-400 hover:underline flex items-center gap-1">Manage <ArrowUpRight size={12} /></Link></div>
<h2 className="font-semibold text-gray-900">Projects</h2> {loading ? <div className="card-body space-y-3">{Array(3).fill(0).map((_, i) => <div key={i} className="skeleton h-14 rounded-lg" />)}</div> : (
<Link to="/projects" className="text-sm text-blue-600 hover:text-blue-700">Manage </Link> <div className="card-body space-y-2">
{p.length === 0 ? (
<div className="text-center py-8"><FolderOpen size={24} className="text-gray-600 mx-auto mb-2" /><p className="text-sm text-gray-500">No projects yet</p></div>
) : p.map(proj => (
<div key={proj.id} className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800/50 transition-colors">
<div className="w-9 h-9 rounded-lg bg-blue-600/15 flex items-center justify-center text-xs font-bold text-blue-400">{proj.key}</div>
<div className="flex-1 min-w-0"><p className="text-sm font-medium truncate">{proj.name}</p><p className="text-xs text-gray-500">{proj.ticket_count} tickets</p></div>
<TrendingUp size={14} className="text-gray-600" />
</div> </div>
<div className="divide-y divide-gray-100">
{projects?.map(project => (
<Link key={project.id} to={`/projects/${project.id}`} className="block px-6 py-4 hover:bg-gray-50">
<div className="flex justify-between items-center">
<div>
<p className="font-medium text-gray-900">{project.name}</p>
<p className="text-sm text-gray-500">{project.key}</p>
</div>
<span className="text-sm text-gray-400">{project.ticket_count} tickets</span>
</div>
</Link>
))} ))}
{!projects?.length && <div className="p-6 text-center text-gray-500">No projects yet</div>}
</div> </div>
</Card> )}
</div> </div>
</div> </div>
</div> </div>
) )
} }
function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
const colors: Record<string, string> = {
gray: 'bg-gray-100 text-gray-600',
blue: 'bg-blue-100 text-blue-600',
yellow: 'bg-yellow-100 text-yellow-600',
green: 'bg-green-100 text-green-600',
red: 'bg-red-100 text-red-600',
}
return (
<Card>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">{label}</p>
<p className="text-3xl font-bold text-gray-900 mt-1">{value}</p>
</div>
<div className={`w-12 h-12 rounded-full flex items-center justify-center ${colors[color]}`}>
<span className="text-xl">🎫</span>
</div>
</div>
</Card>
)
}
function TicketRow({ ticket }: { ticket: Ticket }) {
const statusColors: Record<string, string> = {
open: 'bg-blue-100 text-blue-700',
in_progress: 'bg-yellow-100 text-yellow-700',
resolved: 'bg-green-100 text-green-700',
closed: 'bg-gray-100 text-gray-700',
}
const priorityIcons: Record<string, string> = {
low: '🟢', medium: '🟡', high: '🟠', critical: '🔴'
}
return (
<Link to={`/tickets/${ticket.id}`} className="block px-6 py-4 hover:bg-gray-50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span>{priorityIcons[ticket.priority]}</span>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-mono text-gray-500">{ticket.key}</span>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[ticket.status]}`}>
{ticket.status.replace('_', ' ')}
</span>
</div>
<p className="font-medium text-gray-900">{ticket.title}</p>
</div>
</div>
</div>
</Link>
)
}

View File

@ -1,128 +1,32 @@
import { useState } from 'react' import { cn } from '../lib/utils'
import { Card, Button, Input, Modal, Badge, Tabs, TabsList, TabsTrigger, TabsContent } from '../components/ui' import { Plus, CheckCircle2 } from 'lucide-react'
const integrations = { const platforms = [
connected: [ { key: 'github', name: 'GitHub', desc: 'Issues and pull requests', color: 'from-gray-700 to-gray-800', icon: '🐙' },
{ id: '1', name: 'JIRA AI Fixer', type: 'ai', icon: '🤖', status: 'active', url: 'https://jira-fixer.startdata.com.br' }, { key: 'gitlab', name: 'GitLab', desc: 'Issues and merge requests', color: 'from-orange-600 to-orange-700', icon: '🦊' },
], { key: 'jira', name: 'JIRA', desc: 'Atlassian JIRA Cloud', color: 'from-blue-600 to-blue-700', icon: '🔵' },
available: [ { key: 'servicenow', name: 'ServiceNow', desc: 'ITSM platform', color: 'from-emerald-600 to-emerald-700', icon: '⚙️' },
{ id: 'github', name: 'GitHub', type: 'repo', icon: '🐙', description: 'Link tickets to commits and PRs' }, { key: 'slack', name: 'Slack', desc: 'Notifications & alerts', color: 'from-purple-600 to-purple-700', icon: '💬' },
{ id: 'gitlab', name: 'GitLab', type: 'repo', icon: '🦊', description: 'Connect GitLab issues and MRs' }, { key: 'email', name: 'Email', desc: 'Ticket creation via email', color: 'from-red-600 to-red-700', icon: '📧' },
{ id: 'slack', name: 'Slack', type: 'notify', icon: '💬', description: 'Get notifications in Slack' }, ]
{ id: 'teams', name: 'Microsoft Teams', type: 'notify', icon: '👥', description: 'Teams notifications' },
{ id: 'jira', name: 'Jira', type: 'sync', icon: '🔵', description: 'Sync with Jira issues' },
{ id: 'servicenow', name: 'ServiceNow', type: 'sync', icon: '🟢', description: 'Sync incidents' },
{ id: 'email', name: 'Email', type: 'notify', icon: '📧', description: 'Email notifications' },
{ id: 'zapier', name: 'Zapier', type: 'automation', icon: '⚡', description: 'Connect 5000+ apps' },
],
}
export default function Integrations() { export default function Integrations() {
const [showConnect, setShowConnect] = useState<string | null>(null)
return ( return (
<div className="p-6"> <div className="p-6 animate-fade-in">
<div className="flex justify-between items-center mb-6"> <div className="page-header">
<div> <div><h1 className="page-title">Integrations</h1><p className="page-subtitle">Connect your tools</p></div>
<h1 className="text-2xl font-bold text-gray-900">Integrations</h1>
<p className="text-gray-500">Connect TicketHub with your tools</p>
</div> </div>
</div>
<Tabs defaultValue="connected">
<TabsList>
<TabsTrigger value="connected">Connected ({integrations.connected.length})</TabsTrigger>
<TabsTrigger value="available">Available ({integrations.available.length})</TabsTrigger>
</TabsList>
<TabsContent value="connected">
{integrations.connected.length === 0 ? (
<Card className="text-center py-12">
<span className="text-4xl mb-4 block">🔌</span>
<h3 className="text-lg font-semibold text-gray-900">No integrations yet</h3>
<p className="text-gray-500 mt-2">Connect your first integration to get started</p>
</Card>
) : (
<div className="space-y-4">
{integrations.connected.map(int => (
<Card key={int.id}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center text-2xl">
{int.icon}
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-900">{int.name}</h3>
<Badge variant="success">Connected</Badge>
</div>
<p className="text-sm text-gray-500">{int.url}</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm">Configure</Button>
<Button variant="ghost" size="sm" className="text-red-600">Disconnect</Button>
</div>
</div>
</Card>
))}
</div>
)}
</TabsContent>
<TabsContent value="available">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{integrations.available.map(int => ( {platforms.map(p => (
<Card key={int.id}> <div key={p.key} className="card-hover p-5">
<div className="flex items-start gap-4"> <div className="flex items-center gap-3 mb-3">
<div className="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center text-2xl"> <div className={cn("w-10 h-10 rounded-xl bg-gradient-to-br flex items-center justify-center text-lg", p.color)}>{p.icon}</div>
{int.icon} <div><h3 className="font-semibold text-white">{p.name}</h3><p className="text-xs text-gray-500">{p.desc}</p></div>
</div> </div>
<div className="flex-1"> <button className="btn btn-secondary w-full btn-sm"><Plus size={14} /> Connect</button>
<h3 className="font-semibold text-gray-900">{int.name}</h3>
<p className="text-sm text-gray-500 mt-1">{int.description}</p>
<Button variant="secondary" size="sm" className="mt-3" onClick={() => setShowConnect(int.id)}>
Connect
</Button>
</div> </div>
</div>
</Card>
))} ))}
</div> </div>
</TabsContent>
</Tabs>
{/* Webhook Section */}
<div className="mt-8">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Webhooks</h2>
<Card>
<p className="text-sm text-gray-600 mb-4">
Send ticket events to external services via webhooks. Configure webhook URLs per project in Project Settings.
</p>
<div className="space-y-2">
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
<Badge variant="info">POST</Badge>
<code className="text-sm font-mono text-gray-700">/api/webhooks/incoming</code>
<span className="text-sm text-gray-500">- Receive events from external services</span>
</div>
</div>
<p className="text-xs text-gray-500 mt-4">
Events sent: ticket.created, ticket.updated, ticket.resolved, comment.added
</p>
</Card>
</div>
{/* Connect Modal */}
<Modal open={!!showConnect} onClose={() => setShowConnect(null)} title={`Connect ${integrations.available.find(i => i.id === showConnect)?.name || ''}`}>
<div className="space-y-4">
<Input label="API Key / Token" type="password" placeholder="Enter your API key..." />
<Input label="Instance URL" placeholder="https://..." />
<div className="flex gap-3 pt-4">
<Button variant="secondary" className="flex-1" onClick={() => setShowConnect(null)}>Cancel</Button>
<Button className="flex-1">Connect</Button>
</div>
</div>
</Modal>
</div> </div>
) )
} }

View File

@ -1,80 +1,52 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useQuery, useMutation } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Card, Button, Input, Select } from '../components/ui' import { ticketsApi, projectsApi } from '../services/api'
import { projectsApi, ticketsApi } from '../services/api' import { ArrowLeft, Send, Loader2 } from 'lucide-react'
export default function NewTicket() { export default function NewTicket() {
const navigate = useNavigate() const navigate = useNavigate()
const { data: projects } = useQuery({ queryKey: ['projects'], queryFn: projectsApi.list }) const qc = useQueryClient()
const [form, setForm] = useState({ project_id: '', title: '', description: '', priority: 'medium' })
const [form, setForm] = useState({ const { data: projects } = useQuery({ queryKey: ['projects'], queryFn: () => projectsApi.list() })
project_id: '', const mut = useMutation({
title: '', mutationFn: () => ticketsApi.create({ ...form, project_id: Number(form.project_id) }),
description: '', onSuccess: () => { qc.invalidateQueries({ queryKey: ['tickets'] }); navigate('/tickets') }
priority: 'medium',
})
const createMutation = useMutation({
mutationFn: ticketsApi.create,
onSuccess: (ticket) => navigate(`/tickets/${ticket.id}`),
}) })
return ( return (
<div className="p-6 max-w-3xl mx-auto"> <div className="p-6 animate-fade-in max-w-2xl">
<div className="mb-6"> <a href="/tickets" className="inline-flex items-center gap-1.5 text-sm text-gray-400 hover:text-white transition-colors mb-5"><ArrowLeft size={14} /> Back</a>
<h1 className="text-2xl font-bold text-gray-900">Create Ticket</h1> <h1 className="page-title mb-6">New Ticket</h1>
<p className="text-gray-500">Submit a new ticket for tracking</p> <form onSubmit={e => { e.preventDefault(); mut.mutate() }} className="card card-body space-y-5">
</div>
<Card>
<form onSubmit={e => { e.preventDefault(); createMutation.mutate({ ...form, project_id: Number(form.project_id) }) }} className="space-y-6">
<Select
label="Project"
options={[{ value: '', label: 'Select a project...' }, ...(projects?.map(p => ({ value: String(p.id), label: `${p.key} - ${p.name}` })) || [])]}
value={form.project_id}
onChange={e => setForm({ ...form, project_id: e.target.value })}
required
/>
<Input
label="Title"
placeholder="Brief summary of the issue"
value={form.title}
onChange={e => setForm({ ...form, title: e.target.value })}
required
/>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label> <label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Project</label>
<textarea <select value={form.project_id} onChange={e => setForm({ ...form, project_id: e.target.value })} className="input" required>
rows={6} <option value="">Select project</option>
placeholder="Detailed description of the issue..." {(projects || []).map(p => <option key={p.id} value={p.id}>{p.key} - {p.name}</option>)}
value={form.description} </select>
onChange={e => setForm({ ...form, description: e.target.value })}
className="w-full border border-gray-300 rounded-lg px-4 py-2.5 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
</div> </div>
<div>
<Select <label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Title</label>
label="Priority" <input value={form.title} onChange={e => setForm({ ...form, title: e.target.value })} className="input" placeholder="Brief summary" required />
options={[ </div>
{ value: 'low', label: '🟢 Low - Minor issue, can wait' }, <div>
{ value: 'medium', label: '🟡 Medium - Standard priority' }, <label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Description</label>
{ value: 'high', label: '🟠 High - Important, needs attention soon' }, <textarea value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} className="input h-32 resize-none" placeholder="Describe the issue..." required />
{ value: 'critical', label: '🔴 Critical - Urgent, blocking work' }, </div>
]} <div>
value={form.priority} <label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Priority</label>
onChange={e => setForm({ ...form, priority: e.target.value })} <select value={form.priority} onChange={e => setForm({ ...form, priority: e.target.value })} className="input">
/> <option value="low">Low</option><option value="medium">Medium</option><option value="high">High</option><option value="critical">Critical</option>
</select>
<div className="flex gap-3 pt-4 border-t"> </div>
<Button type="button" variant="secondary" onClick={() => navigate(-1)}>Cancel</Button> <div className="flex gap-2 pt-2">
<Button type="submit" loading={createMutation.isPending}>Create Ticket</Button> <button type="button" onClick={() => navigate('/tickets')} className="btn btn-secondary">Cancel</button>
<button type="submit" disabled={mut.isPending} className="btn btn-primary">
{mut.isPending ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />} Create Ticket
</button>
</div> </div>
</form> </form>
</Card>
</div> </div>
) )
} }

View File

@ -1,172 +1,64 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom' import { projectsApi } from '../services/api'
import { Card, Button, Input, Modal, Badge, Table, TableHeader, TableHead, TableBody, TableRow, TableCell } from '../components/ui' import { cn } from '../lib/utils'
import { projectsApi, Project } from '../services/api' import { Plus, FolderOpen, Settings, Trash2, Globe, Loader2 } from 'lucide-react'
export default function Projects() { export default function Projects() {
const [showCreate, setShowCreate] = useState(false) const qc = useQueryClient()
const [editProject, setEditProject] = useState<Project | null>(null) const [showNew, setShowNew] = useState(false)
const queryClient = useQueryClient() const [form, setForm] = useState({ name: '', key: '', webhook_url: '' })
const { data: projects, isLoading } = useQuery({ queryKey: ['projects'], queryFn: () => projectsApi.list() })
const { data: projects } = useQuery({ queryKey: ['projects'], queryFn: projectsApi.list }) const createMut = useMutation({ mutationFn: () => projectsApi.create(form), onSuccess: () => { qc.invalidateQueries({ queryKey: ['projects'] }); setShowNew(false); setForm({ name: '', key: '', webhook_url: '' }) } })
const deleteMut = useMutation({ mutationFn: (id: number) => projectsApi.delete(id), onSuccess: () => qc.invalidateQueries({ queryKey: ['projects'] }) })
const createMutation = useMutation({
mutationFn: projectsApi.create,
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['projects'] }); setShowCreate(false) },
})
const updateMutation = useMutation({
mutationFn: ({ id, ...data }: { id: number } & Partial<Project>) => projectsApi.update(id, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['projects'] }); setEditProject(null) },
})
const deleteMutation = useMutation({
mutationFn: projectsApi.delete,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['projects'] }),
})
return ( return (
<div className="p-6"> <div className="p-6 animate-fade-in">
<div className="flex justify-between items-center mb-6"> <div className="page-header">
<div> <div><h1 className="page-title">Projects</h1><p className="page-subtitle">{projects?.length || 0} projects</p></div>
<h1 className="text-2xl font-bold text-gray-900">Projects</h1> <button onClick={() => setShowNew(!showNew)} className="btn btn-primary"><Plus size={16} /> New Project</button>
<p className="text-gray-500">Manage your projects and their settings</p>
</div>
<Button onClick={() => setShowCreate(true)}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
New Project
</Button>
</div> </div>
{/* Projects Grid */} {showNew && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <form onSubmit={e => { e.preventDefault(); createMut.mutate() }} className="card card-body mb-6 animate-slide-up space-y-4 max-w-lg">
{projects?.map(project => ( <div className="grid grid-cols-2 gap-4">
<Card key={project.id}> <div><label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Name</label>
<div className="flex items-start justify-between mb-4"> <input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} className="input" placeholder="My Project" required /></div>
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center text-white font-bold"> <div><label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Key</label>
{project.key.slice(0, 2)} <input value={form.key} onChange={e => setForm({ ...form, key: e.target.value.toUpperCase() })} className="input font-mono uppercase" placeholder="PROJ" pattern="[A-Za-z]{2,10}" required /></div>
</div>
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={() => setEditProject(project)}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</Button>
</div>
</div>
<h3 className="font-semibold text-gray-900">{project.name}</h3>
<p className="text-sm text-gray-500 mb-4">{project.key}</p>
{project.description && (
<p className="text-sm text-gray-600 mb-4 line-clamp-2">{project.description}</p>
)}
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
<div className="flex items-center gap-4 text-sm text-gray-500">
<span className="flex items-center gap-1">
<span>🎫</span> {project.ticket_count} tickets
</span>
</div>
<Link to={`/tickets?project=${project.id}`}>
<Button variant="ghost" size="sm">View </Button>
</Link>
</div>
{project.webhook_url && (
<div className="mt-3 pt-3 border-t border-gray-100">
<div className="flex items-center gap-2 text-xs">
<Badge variant="success" size="sm">Webhook Active</Badge>
</div>
</div>
)}
</Card>
))}
{!projects?.length && (
<div className="col-span-full text-center py-12 text-gray-500">
<span className="text-4xl mb-4 block">📁</span>
<p>No projects yet. Create your first project to get started.</p>
</div>
)}
</div>
{/* Create Modal */}
<Modal open={showCreate} onClose={() => setShowCreate(false)} title="Create Project">
<ProjectForm onSubmit={data => createMutation.mutate(data)} loading={createMutation.isPending} />
</Modal>
{/* Edit Modal */}
<Modal open={!!editProject} onClose={() => setEditProject(null)} title="Edit Project">
{editProject && (
<ProjectForm
initialData={editProject}
onSubmit={data => updateMutation.mutate({ id: editProject.id, ...data })}
loading={updateMutation.isPending}
onDelete={() => { deleteMutation.mutate(editProject.id); setEditProject(null) }}
/>
)}
</Modal>
</div>
)
}
function ProjectForm({ initialData, onSubmit, loading, onDelete }: {
initialData?: Project
onSubmit: (data: Partial<Project>) => void
loading: boolean
onDelete?: () => void
}) {
const [form, setForm] = useState({
name: initialData?.name || '',
key: initialData?.key || '',
description: initialData?.description || '',
webhook_url: initialData?.webhook_url || '',
})
return (
<form onSubmit={e => { e.preventDefault(); onSubmit(form) }} className="space-y-4">
<Input
label="Project Name"
value={form.name}
onChange={e => setForm({ ...form, name: e.target.value })}
required
/>
<Input
label="Project Key"
value={form.key}
onChange={e => setForm({ ...form, key: e.target.value.toUpperCase() })}
placeholder="e.g., PROJ"
pattern="[A-Z]{2,10}"
hint="2-10 uppercase letters"
required
disabled={!!initialData}
/>
<Input
label="Description"
value={form.description}
onChange={e => setForm({ ...form, description: e.target.value })}
/>
<Input
label="Webhook URL"
value={form.webhook_url}
onChange={e => setForm({ ...form, webhook_url: e.target.value })}
placeholder="https://..."
hint="Receive notifications when tickets are created or updated"
/>
<div className="flex gap-3 pt-4 border-t">
{onDelete && (
<Button type="button" variant="danger" onClick={onDelete}>Delete Project</Button>
)}
<div className="flex-1" />
<Button type="submit" loading={loading}>
{initialData ? 'Save Changes' : 'Create Project'}
</Button>
</div> </div>
<div><label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Webhook URL (optional)</label>
<input value={form.webhook_url} onChange={e => setForm({ ...form, webhook_url: e.target.value })} className="input" placeholder="https://..." /></div>
<div className="flex gap-2"><button type="button" onClick={() => setShowNew(false)} className="btn btn-secondary btn-sm">Cancel</button>
<button type="submit" disabled={createMut.isPending} className="btn btn-primary btn-sm">{createMut.isPending ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />} Create</button></div>
</form> </form>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{isLoading ? Array(3).fill(0).map((_, i) => <div key={i} className="card card-body"><div className="skeleton h-20 rounded-lg" /></div>) :
(projects || []).length === 0 ? (
<div className="col-span-full card card-body flex flex-col items-center py-16">
<FolderOpen size={32} className="text-gray-600 mb-3" /><p className="text-gray-400 font-medium">No projects yet</p><p className="text-gray-600 text-sm">Create a project to start tracking tickets</p>
</div>
) : (projects || []).map(p => (
<div key={p.id} className="card overflow-hidden">
<div className="h-1.5 bg-gradient-to-r from-blue-600 to-blue-700" />
<div className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-600/15 flex items-center justify-center text-sm font-bold text-blue-400">{p.key}</div>
<div><h3 className="font-semibold text-white">{p.name}</h3><p className="text-xs text-gray-500">{p.ticket_count} tickets</p></div>
</div>
</div>
{p.webhook_url && <p className="text-xs text-gray-500 font-mono truncate mb-3 flex items-center gap-1"><Globe size={10} /> {p.webhook_url}</p>}
<div className="flex gap-2">
<a href={`/tickets?project=${p.id}`} className="btn btn-secondary btn-sm flex-1">View Tickets</a>
<button onClick={() => confirm('Delete project?') && deleteMut.mutate(p.id)} className="btn btn-danger btn-sm btn-icon"><Trash2 size={12} /></button>
</div>
</div>
</div>
))}
</div>
</div>
) )
} }

View File

@ -1,135 +1,71 @@
import { Card, Select } from '../components/ui' import { useQuery } from '@tanstack/react-query'
import { ticketsApi } from '../services/api'
import { BarChart3, CheckCircle2, Clock, AlertTriangle, Download } from 'lucide-react'
import { cn } from '../lib/utils'
import { useState } from 'react'
export default function Reports() { export default function Reports() {
const [days] = useState(30)
const { data: tickets, isLoading } = useQuery({ queryKey: ['tickets'], queryFn: () => ticketsApi.list() })
const t = tickets || []
const open = t.filter(x => x.status === 'open').length
const resolved = t.filter(x => x.status === 'resolved' || x.status === 'closed').length
const rate = t.length ? ((resolved / t.length) * 100).toFixed(0) : '0'
const critical = t.filter(x => x.priority === 'critical').length
const stats = [
{ label: 'Total', value: t.length, icon: BarChart3, color: 'text-blue-400', bg: 'bg-blue-500/10' },
{ label: 'Resolution Rate', value: `${rate}%`, icon: CheckCircle2, color: 'text-emerald-400', bg: 'bg-emerald-500/10' },
{ label: 'Open', value: open, icon: Clock, color: 'text-amber-400', bg: 'bg-amber-500/10' },
{ label: 'Critical', value: critical, icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-500/10' },
]
const byPriority = ['critical', 'high', 'medium', 'low'].map(p => ({ name: p, count: t.filter(x => x.priority === p).length }))
const byStatus = ['open', 'in_progress', 'resolved', 'closed'].map(s => ({ name: s.replace('_', ' '), count: t.filter(x => x.status === s).length }))
return ( return (
<div className="p-6"> <div className="p-6 animate-fade-in">
<div className="flex justify-between items-center mb-6"> <div className="page-header">
<div> <div><h1 className="page-title">Reports & Analytics</h1><p className="page-subtitle">Performance metrics</p></div>
<h1 className="text-2xl font-bold text-gray-900">Reports</h1> <button className="btn btn-secondary btn-sm"><Download size={14} /> Export</button>
<p className="text-gray-500">Analytics and insights</p>
</div>
<Select
options={[
{ value: '7d', label: 'Last 7 days' },
{ value: '30d', label: 'Last 30 days' },
{ value: '90d', label: 'Last 90 days' },
{ value: 'year', label: 'This year' },
]}
className="w-40"
/>
</div> </div>
{/* KPIs */} <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8"> {stats.map(s => { const Icon = s.icon; return (
<KPICard title="Tickets Created" value="127" change="+12%" trend="up" /> <div key={s.label} className="stat-card"><div className="flex items-center justify-between relative z-10">
<KPICard title="Tickets Resolved" value="98" change="+18%" trend="up" /> <div><p className="text-xs font-medium text-gray-400 uppercase tracking-wide">{s.label}</p><p className="text-2xl font-bold text-white mt-1">{isLoading ? '—' : s.value}</p></div>
<KPICard title="Avg Resolution Time" value="4.2h" change="-15%" trend="up" /> <div className={cn("w-11 h-11 rounded-xl flex items-center justify-center", s.bg)}><Icon size={20} className={s.color} /></div>
<KPICard title="Open Tickets" value="29" change="-8%" trend="up" /> </div></div>
)})}
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Status Distribution */} <div className="card"><div className="card-header"><h3 className="text-sm font-semibold">By Status</h3></div>
<Card> <div className="card-body space-y-3">
<h3 className="font-semibold text-gray-900 mb-4">Status Distribution</h3> {byStatus.map(s => (
<div className="space-y-3"> <div key={s.name} className="flex items-center gap-3">
{[ <span className="text-sm text-gray-400 w-24 capitalize">{s.name}</span>
{ label: 'Open', value: 29, pct: 23, color: '#3b82f6' }, <div className="flex-1 bg-gray-800 rounded-full h-2"><div className="bg-blue-500 h-2 rounded-full transition-all" style={{ width: `${t.length ? (s.count / t.length * 100) : 0}%` }} /></div>
{ label: 'In Progress', value: 18, pct: 14, color: '#eab308' }, <span className="text-sm font-mono text-gray-300 w-8 text-right">{s.count}</span>
{ label: 'Resolved', value: 62, pct: 49, color: '#22c55e' },
{ label: 'Closed', value: 18, pct: 14, color: '#6b7280' },
].map(item => (
<div key={item.label}>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-700">{item.label}</span>
<span className="text-gray-500">{item.value} ({item.pct}%)</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${item.pct}%`, backgroundColor: item.color }} />
</div>
</div> </div>
))} ))}
</div> </div>
</Card>
{/* Priority Distribution */}
<Card>
<h3 className="font-semibold text-gray-900 mb-4">Priority Distribution</h3>
<div className="space-y-3">
{[
{ label: 'Critical', value: 5, pct: 4, color: '#ef4444' },
{ label: 'High', value: 23, pct: 18, color: '#f97316' },
{ label: 'Medium', value: 67, pct: 53, color: '#eab308' },
{ label: 'Low', value: 32, pct: 25, color: '#22c55e' },
].map(item => (
<div key={item.label}>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-700">{item.label}</span>
<span className="text-gray-500">{item.value} ({item.pct}%)</span>
</div> </div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden"> <div className="card"><div className="card-header"><h3 className="text-sm font-semibold">By Priority</h3></div>
<div className="h-full rounded-full" style={{ width: `${item.pct}%`, backgroundColor: item.color }} /> <div className="card-body space-y-3">
{byPriority.map(p => {
const colors: Record<string, string> = { critical: 'bg-red-500', high: 'bg-orange-500', medium: 'bg-amber-500', low: 'bg-emerald-500' }
return (
<div key={p.name} className="flex items-center gap-3">
<span className="text-sm text-gray-400 w-24 capitalize">{p.name}</span>
<div className="flex-1 bg-gray-800 rounded-full h-2"><div className={cn("h-2 rounded-full transition-all", colors[p.name])} style={{ width: `${t.length ? (p.count / t.length * 100) : 0}%` }} /></div>
<span className="text-sm font-mono text-gray-300 w-8 text-right">{p.count}</span>
</div> </div>
</div> )
))} })}
</div>
</Card>
</div>
{/* Tickets Over Time */}
<Card>
<h3 className="font-semibold text-gray-900 mb-4">Tickets Over Time</h3>
<div className="h-64 flex items-end justify-between gap-2">
{[15, 22, 18, 30, 25, 35, 28, 40, 32, 45, 38, 42].map((h, i) => (
<div key={i} className="flex-1 flex flex-col items-center gap-1">
<div className="w-full bg-blue-500 rounded-t" style={{ height: `${(h / 45) * 100}%` }} />
<span className="text-xs text-gray-500">{['J','F','M','A','M','J','J','A','S','O','N','D'][i]}</span>
</div>
))}
</div>
</Card>
{/* Top Assignees */}
<Card className="mt-6">
<h3 className="font-semibold text-gray-900 mb-4">Top Performers</h3>
<div className="space-y-4">
{[
{ name: 'AI Assistant', resolved: 45, time: '2.1h' },
{ name: 'Ricel Leite', resolved: 32, time: '3.5h' },
{ name: 'Developer', resolved: 21, time: '5.2h' },
].map((user, i) => (
<div key={i} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex items-center gap-3">
<span className="w-6 h-6 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center text-sm font-bold">
{i + 1}
</span>
<span className="font-medium text-gray-900">{user.name}</span>
</div>
<div className="flex items-center gap-6 text-sm">
<div>
<span className="text-gray-500">Resolved:</span>
<span className="font-semibold ml-1">{user.resolved}</span>
</div>
<div>
<span className="text-gray-500">Avg Time:</span>
<span className="font-semibold ml-1">{user.time}</span>
</div> </div>
</div> </div>
</div> </div>
))}
</div>
</Card>
</div> </div>
) )
} }
function KPICard({ title, value, change, trend }: { title: string; value: string; change: string; trend: 'up' | 'down' }) {
return (
<Card>
<p className="text-sm text-gray-500">{title}</p>
<p className="text-3xl font-bold text-gray-900 mt-1">{value}</p>
<p className={`text-sm mt-1 ${trend === 'up' ? 'text-green-600' : 'text-red-600'}`}>
{change} vs last period
</p>
</Card>
)
}

View File

@ -1,175 +1,81 @@
import { Card, Input, Select, Button, Tabs, TabsList, TabsTrigger, TabsContent } from '../components/ui' import { useState } from 'react'
import { cn } from '../lib/utils'
import { Building2, Bell, Shield, Key, Globe, Save, Loader2, Plus, Copy, Eye, EyeOff, Code2, Trash2 } from 'lucide-react'
const tabs = [
{ 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 },
]
export default function Settings() { export default function Settings() {
const [activeTab, setActiveTab] = useState('general')
const [saving, setSaving] = useState(false)
const [showToken, setShowToken] = useState(false)
const handleSave = async () => { setSaving(true); await new Promise(r => setTimeout(r, 800)); setSaving(false) }
return ( return (
<div className="p-6"> <div className="p-6 animate-fade-in">
<div className="mb-6"> <div className="page-header"><div><h1 className="page-title">Settings</h1><p className="page-subtitle">Workspace configuration</p></div></div>
<h1 className="text-2xl font-bold text-gray-900">Settings</h1> <div className="flex gap-6">
<p className="text-gray-500">Manage your workspace settings</p> <div className="w-48 flex-shrink-0 space-y-0.5">
{tabs.map(tab => { const Icon = tab.icon; return (
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
className={cn("w-full sidebar-item", activeTab === tab.id ? "sidebar-item-active" : "sidebar-item-inactive")}>
<Icon size={16} /><span>{tab.label}</span>
</button>
)})}
</div> </div>
<div className="flex-1 max-w-2xl">
<Tabs defaultValue="general"> {activeTab === 'general' && (
<TabsList> <div className="card animate-fade-in"><div className="card-header"><h3 className="text-sm font-semibold">Workspace Details</h3></div>
<TabsTrigger value="general">General</TabsTrigger> <div className="card-body space-y-5">
<TabsTrigger value="notifications">Notifications</TabsTrigger> <div><label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Workspace Name</label><input defaultValue="My Workspace" className="input" /></div>
<TabsTrigger value="security">Security</TabsTrigger> <div><label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Default Assignee</label><input className="input" placeholder="user@company.com" /></div>
<TabsTrigger value="api">API</TabsTrigger> <div><label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Timezone</label>
</TabsList> <select className="input"><option>America/Sao_Paulo</option><option>America/New_York</option><option>UTC</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</button></div>
<TabsContent value="general"> </div></div>
<Card> )}
<h3 className="font-semibold text-gray-900 mb-4">Workspace Settings</h3> {activeTab === 'notifications' && (
<div className="space-y-4 max-w-lg"> <div className="card animate-fade-in"><div className="card-header"><h3 className="text-sm font-semibold">Notification Preferences</h3></div>
<Input label="Workspace Name" defaultValue="StartData" /> <div className="card-body space-y-4">
<Input label="Workspace URL" defaultValue="tickethub.startdata.com.br" disabled /> {[{ l: 'New ticket', d: 'When a ticket is created' },{ l: 'Status change', d: 'When ticket status updates' },{ l: 'Comments', d: 'When someone comments' },{ l: 'Assignment', d: 'When assigned to you' }].map(item => (
<Select <div key={item.l} className="flex items-center justify-between py-2"><div><p className="text-sm font-medium text-gray-200">{item.l}</p><p className="text-xs text-gray-500">{item.d}</p></div>
label="Default Timezone" <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-blue-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>
options={[ ))}</div></div>
{ value: 'America/Sao_Paulo', label: 'America/Sao_Paulo (GMT-3)' }, )}
{ value: 'America/New_York', label: 'America/New_York (GMT-5)' }, {activeTab === 'security' && (
{ value: 'UTC', label: 'UTC' }, <div className="card animate-fade-in"><div className="card-header"><h3 className="text-sm font-semibold">Security</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 members</p></div>
<Select <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-blue-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>
label="Language" <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">Enterprise single sign-on</p></div><span className="badge badge-gray">Enterprise</span></div>
options={[ </div></div>
{ value: 'en', label: 'English' }, )}
{ value: 'pt-BR', label: 'Português (Brasil)' }, {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</button></div>
/> <div className="card-body">
<Select <div className="flex items-center gap-3 p-3 bg-gray-900/50 rounded-lg border border-gray-800/50">
label="Date Format" <Key size={16} className="text-gray-500" /><div className="flex-1 min-w-0"><p className="text-sm font-medium">Production Key</p>
options={[ <div className="flex items-center gap-2 mt-1"><code className="text-xs text-gray-500 font-mono">{showToken ? 'th_live_sk_a1b2c3...' : 'th_live_sk_••••••...'}</code>
{ value: 'DD/MM/YYYY', label: 'DD/MM/YYYY' }, <button onClick={() => setShowToken(!showToken)} className="text-gray-500 hover:text-gray-300">{showToken ? <EyeOff size={12} /> : <Eye size={12} />}</button>
{ value: 'MM/DD/YYYY', label: 'MM/DD/YYYY' }, <button className="text-gray-500 hover:text-gray-300"><Copy size={12} /></button></div></div>
{ value: 'YYYY-MM-DD', label: 'YYYY-MM-DD' }, <span className="badge badge-green text-[10px]">Active</span><button className="btn btn-danger btn-sm btn-icon"><Trash2 size={12} /></button></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://tickethub.startdata.com.br/api/tickets \\
<div className="pt-4"> -H "Authorization: Bearer YOUR_KEY" \\
<Button>Save Changes</Button> -d '{"title": "New bug"}'`}</pre></div>
</div></div>
)}
{activeTab === 'webhooks' && (
<div className="card animate-fade-in"><div className="card-header"><h3 className="text-sm font-semibold">Webhooks</h3><button className="btn btn-primary btn-sm"><Plus size={14} /> Add</button></div>
<div className="card-body"><div className="text-center py-8"><Globe size={24} className="text-gray-600 mx-auto mb-2" /><p className="text-sm text-gray-500">No webhooks configured</p></div></div></div>
)}
</div> </div>
</div> </div>
</Card>
</TabsContent>
<TabsContent value="notifications">
<Card>
<h3 className="font-semibold text-gray-900 mb-4">Email Notifications</h3>
<div className="space-y-3 max-w-lg">
{[
'When a ticket is assigned to me',
'When someone comments on my tickets',
'When a ticket I follow is updated',
'Daily summary of open tickets',
'Weekly team performance report',
].map(item => (
<label key={item} className="flex items-center gap-3">
<input type="checkbox" defaultChecked={!item.includes('Weekly')} className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
<span className="text-gray-700">{item}</span>
</label>
))}
</div>
<h3 className="font-semibold text-gray-900 mt-8 mb-4">Slack Notifications</h3>
<div className="space-y-4 max-w-lg">
<Input label="Slack Webhook URL" placeholder="https://hooks.slack.com/..." />
<Select
label="Notification Level"
options={[
{ value: 'all', label: 'All ticket events' },
{ value: 'important', label: 'Important only (high/critical)' },
{ value: 'mentions', label: 'Mentions only' },
]}
/>
<div className="pt-4">
<Button>Save Notifications</Button>
</div>
</div>
</Card>
</TabsContent>
<TabsContent value="security">
<Card>
<h3 className="font-semibold text-gray-900 mb-4">Authentication</h3>
<div className="space-y-4 max-w-lg">
<Select
label="SSO Provider"
options={[
{ value: 'none', label: 'None (Email/Password)' },
{ value: 'google', label: 'Google Workspace' },
{ value: 'okta', label: 'Okta' },
{ value: 'azure', label: 'Azure AD' },
{ value: 'saml', label: 'SAML 2.0' },
]}
/>
<label className="flex items-center gap-3">
<input type="checkbox" defaultChecked className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
<span className="text-gray-700">Require two-factor authentication (2FA)</span>
</label>
<h4 className="font-medium text-gray-900 mt-6">Session Settings</h4>
<Select
label="Session Timeout"
options={[
{ value: '1h', label: '1 hour' },
{ value: '8h', label: '8 hours' },
{ value: '24h', label: '24 hours' },
{ value: '7d', label: '7 days' },
]}
/>
<h4 className="font-medium text-gray-900 mt-6">IP Restrictions</h4>
<Input label="Allowed IP Addresses" placeholder="192.168.1.0/24, 10.0.0.0/8" hint="Leave empty to allow all IPs" />
<div className="pt-4">
<Button>Save Security Settings</Button>
</div>
</div>
</Card>
</TabsContent>
<TabsContent value="api">
<Card>
<h3 className="font-semibold text-gray-900 mb-4">API Keys</h3>
<p className="text-sm text-gray-600 mb-4">
Use API keys to authenticate with the TicketHub API.
</p>
<div className="space-y-3 mb-6">
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<p className="font-mono text-sm">tk_live_</p>
<p className="text-xs text-gray-500 mt-1">Created Feb 18, 2026</p>
</div>
<div className="flex gap-2">
<Button variant="ghost" size="sm">Reveal</Button>
<Button variant="ghost" size="sm" className="text-red-600">Revoke</Button>
</div>
</div>
</div>
<Button variant="secondary">Generate New API Key</Button>
<h3 className="font-semibold text-gray-900 mt-8 mb-4">API Documentation</h3>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg font-mono">
<span className="text-blue-600">GET</span>
<span>/api/projects</span>
</div>
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg font-mono">
<span className="text-green-600">POST</span>
<span>/api/tickets</span>
</div>
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg font-mono">
<span className="text-yellow-600">PATCH</span>
<span>/api/tickets/:id</span>
</div>
</div>
<Button variant="ghost" className="mt-4">View Full API Documentation </Button>
</Card>
</TabsContent>
</Tabs>
</div> </div>
) )
} }

View File

@ -1,118 +1,53 @@
import { useState } from 'react' import { Users as UsersIcon, Plus, Shield, Crown, UserCog, Eye, Mail, MoreVertical } from 'lucide-react'
import { Card, Button, Input, Modal, Badge, Avatar, Select } from '../components/ui' import { cn } from '../lib/utils'
interface TeamMember { const roleConfig: Record<string, { label: string; badge: string; icon: any }> = {
id: string owner: { label: 'Owner', badge: 'badge-yellow', icon: Crown },
name: string admin: { label: 'Admin', badge: 'badge-red', icon: Shield },
email: string member: { label: 'Member', badge: 'badge-blue', icon: UserCog },
role: 'admin' | 'member' | 'viewer' viewer: { label: 'Viewer', badge: 'badge-gray', icon: Eye },
avatar: string
tickets: number
lastActive: string
} }
const mockTeam: TeamMember[] = [ const mockMembers = [
{ id: '1', name: 'Ricel Leite', email: 'ricel.souza@gmail.com', role: 'admin', avatar: 'RL', tickets: 15, lastActive: '2026-02-18T18:00:00Z' }, { id: 1, name: 'Admin User', email: 'admin@company.com', role: 'owner', joined: '2024-01-15' },
{ id: '2', name: 'AI Assistant', email: 'ai@tickethub.local', role: 'member', avatar: '🤖', tickets: 45, lastActive: '2026-02-18T18:30:00Z' }, { id: 2, name: 'Developer', email: 'dev@company.com', role: 'admin', joined: '2024-02-10' },
{ id: 3, name: 'Support Agent', email: 'support@company.com', role: 'member', joined: '2024-03-05' },
] ]
export default function Team() { export default function Team() {
const [team] = useState<TeamMember[]>(mockTeam)
const [showInvite, setShowInvite] = useState(false)
const roleColors: Record<string, 'error' | 'info' | 'default'> = {
admin: 'error', member: 'info', viewer: 'default'
}
return ( return (
<div className="p-6"> <div className="p-6 animate-fade-in">
<div className="flex justify-between items-center mb-6"> <div className="page-header">
<div> <div><h1 className="page-title">Team</h1><p className="page-subtitle">{mockMembers.length} members</p></div>
<h1 className="text-2xl font-bold text-gray-900">Team</h1> <button className="btn btn-primary"><Plus size={16} /> Invite Member</button>
<p className="text-gray-500">Manage team members and permissions</p>
</div>
<Button onClick={() => setShowInvite(true)}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
Invite Member
</Button>
</div> </div>
{/* Role Legend */} <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="flex gap-6 mb-6"> {Object.entries(roleConfig).map(([key, cfg]) => {
<div className="flex items-center gap-2"> const Icon = cfg.icon; const count = mockMembers.filter(m => m.role === key).length
<Badge variant="error">Admin</Badge> return (<div key={key} className="stat-card"><div className="flex items-center justify-between relative z-10">
<span className="text-sm text-gray-500">Full access</span> <div><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>
</div> <Icon size={18} className="text-gray-600" /></div></div>)
<div className="flex items-center gap-2"> })}
<Badge variant="info">Member</Badge>
<span className="text-sm text-gray-500">Create & edit tickets</span>
</div>
<div className="flex items-center gap-2">
<Badge variant="default">Viewer</Badge>
<span className="text-sm text-gray-500">Read-only</span>
</div>
</div> </div>
{/* Team Grid */} <div className="card overflow-hidden">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <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">
{team.map(member => ( <div className="w-9" /><div className="flex-1">Member</div><div className="w-24">Role</div><div className="w-28">Joined</div><div className="w-8" />
<Card key={member.id}>
<div className="flex items-start gap-4">
{member.avatar.length <= 2 ? (
<Avatar name={member.name} size="lg" />
) : (
<div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center text-2xl">
{member.avatar}
</div> </div>
)} {mockMembers.map(m => {
<div className="flex-1"> const r = roleConfig[m.role] || roleConfig.member; const Icon = r.icon
<div className="flex items-center gap-2"> return (
<h3 className="font-semibold text-gray-900">{member.name}</h3> <div key={m.id} className="flex items-center gap-4 px-5 py-3.5 table-row group">
<Badge variant={roleColors[member.role]} size="sm">{member.role}</Badge> <div className="w-9 h-9 rounded-lg bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-xs font-semibold text-white">{m.name[0]}</div>
<div className="flex-1 min-w-0"><p className="text-sm font-medium truncate">{m.name}</p><p className="text-xs text-gray-500 flex items-center gap-1"><Mail size={10} /> {m.email}</p></div>
<div className="w-24"><span className={cn("badge text-[10px]", r.badge)}><Icon size={10} /> {r.label}</span></div>
<div className="w-28 text-xs text-gray-500">{new Date(m.joined).toLocaleDateString()}</div>
<div className="w-8"><button className="btn btn-ghost btn-icon opacity-0 group-hover:opacity-100"><MoreVertical size={14} /></button></div>
</div> </div>
<p className="text-sm text-gray-500">{member.email}</p> )
})}
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4 mt-4 pt-4 border-t border-gray-100">
<div className="text-center">
<div className="text-xl font-bold text-gray-900">{member.tickets}</div>
<div className="text-xs text-gray-500">Tickets Assigned</div>
</div>
<div className="text-center">
<div className="text-sm text-gray-900">{new Date(member.lastActive).toLocaleDateString()}</div>
<div className="text-xs text-gray-500">Last Active</div>
</div>
</div>
<div className="flex gap-2 mt-4">
<Button variant="ghost" size="sm" className="flex-1">Edit Role</Button>
<Button variant="ghost" size="sm" className="flex-1 text-red-600">Remove</Button>
</div>
</Card>
))}
</div>
{/* Invite Modal */}
<Modal open={showInvite} onClose={() => setShowInvite(false)} title="Invite Team Member">
<div className="space-y-4">
<Input label="Email Address" type="email" placeholder="colleague@company.com" />
<Select
label="Role"
options={[
{ value: 'viewer', label: 'Viewer - Read-only access' },
{ value: 'member', label: 'Member - Create & edit tickets' },
{ value: 'admin', label: 'Admin - Full access' },
]}
/>
<div className="flex gap-3 pt-4">
<Button variant="secondary" className="flex-1" onClick={() => setShowInvite(false)}>Cancel</Button>
<Button className="flex-1">Send Invite</Button>
</div>
</div>
</Modal>
</div>
) )
} }

View File

@ -1,232 +1,131 @@
import { useState } from 'react' 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 { Card, Button, Badge, Select, Input, Avatar, Modal } from '../components/ui' import { ticketsApi } from '../services/api'
import { ticketsApi, Ticket } from '../services/api' import { cn } from '../lib/utils'
import { ArrowLeft, Clock, CheckCircle2, AlertCircle, MessageSquare, Send, Tag, Calendar, FileText, Loader2 } from 'lucide-react'
const statusCfg: Record<string, { badge: string; label: string }> = {
open: { badge: 'badge-blue', label: 'Open' }, in_progress: { badge: 'badge-yellow', label: 'In Progress' },
resolved: { badge: 'badge-green', label: 'Resolved' }, closed: { badge: 'badge-gray', label: 'Closed' },
}
const priorityCfg: Record<string, { badge: string; label: string }> = {
critical: { badge: 'badge-red', label: 'Critical' }, high: { badge: 'badge-orange', label: 'High' },
medium: { badge: 'badge-yellow', label: 'Medium' }, low: { badge: 'badge-green', label: 'Low' },
}
export default function TicketDetail() { export default function TicketDetail() {
const { id } = useParams() const { id } = useParams()
const queryClient = useQueryClient() const qc = useQueryClient()
const [comment, setComment] = useState('') const [comment, setComment] = useState('')
const [showAssign, setShowAssign] = useState(false) const [activeTab, setActiveTab] = useState('details')
const { data: ticket, isLoading } = useQuery({ const { data: ticket, isLoading } = useQuery({ queryKey: ['ticket', id], queryFn: () => ticketsApi.get(Number(id)) })
queryKey: ['ticket', id], const { data: comments } = useQuery({ queryKey: ['comments', id], queryFn: () => ticketsApi.getComments(Number(id)), enabled: !!id })
queryFn: () => ticketsApi.get(Number(id)),
enabled: !!id, const updateMut = useMutation({
mutationFn: (data: any) => ticketsApi.update(Number(id), data),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['ticket', id] }); qc.invalidateQueries({ queryKey: ['tickets'] }) }
})
const commentMut = useMutation({
mutationFn: () => ticketsApi.addComment(Number(id), { author: 'user', content: comment }),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['comments', id] }); setComment('') }
}) })
const { data: comments } = useQuery({ if (isLoading) return (
queryKey: ['ticket-comments', id], <div className="p-6 animate-fade-in space-y-4">
queryFn: () => ticketsApi.getComments(Number(id)), <div className="skeleton h-4 w-24" /><div className="skeleton h-7 w-96" /><div className="skeleton h-48 w-full rounded-xl" />
enabled: !!id, </div>
}) )
if (!ticket) return <div className="p-6 text-center"><AlertCircle size={40} className="text-gray-600 mx-auto mb-3" /><p className="text-gray-400">Ticket not found</p></div>
const updateMutation = useMutation({ const sc = statusCfg[ticket.status] || statusCfg.open
mutationFn: (data: Partial<Ticket>) => ticketsApi.update(Number(id), data), const pc = priorityCfg[ticket.priority] || priorityCfg.medium
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ticket', id] }), const tabs = [{ id: 'details', label: 'Details', icon: FileText }, { id: 'comments', label: 'Comments', icon: MessageSquare, count: comments?.length }]
})
const commentMutation = useMutation({
mutationFn: (content: string) => ticketsApi.addComment(Number(id), { author: 'User', content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ticket-comments', id] })
setComment('')
},
})
if (isLoading) return <div className="p-6 text-center">Loading...</div>
if (!ticket) return <div className="p-6 text-center">Ticket not found</div>
const statusColors: Record<string, 'info' | 'warning' | 'success' | 'default'> = {
open: 'info', in_progress: 'warning', resolved: 'success', closed: 'default'
}
const priorityColors: Record<string, 'success' | 'warning' | 'error'> = {
low: 'success', medium: 'warning', high: 'error', critical: 'error'
}
return ( return (
<div className="p-6"> <div className="p-6 animate-fade-in">
{/* Breadcrumb */} <Link to="/tickets" className="inline-flex items-center gap-1.5 text-sm text-gray-400 hover:text-white transition-colors mb-5"><ArrowLeft size={14} /> Back</Link>
<div className="mb-4">
<Link to="/tickets" className="text-blue-600 hover:text-blue-700 text-sm"> Back to Tickets</Link>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="flex items-start justify-between mb-6">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* Header */}
<Card>
<div className="flex items-start justify-between mb-4">
<div> <div>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-3 mb-2">
<span className="font-mono text-blue-600 text-lg">{ticket.key}</span> <span className="font-mono text-lg text-blue-400 font-semibold">{ticket.key}</span>
<Badge variant={statusColors[ticket.status]} size="md">{ticket.status.replace('_', ' ')}</Badge> <span className={cn("badge", sc.badge)}>{sc.label}</span>
<Badge variant={priorityColors[ticket.priority]} size="md">{ticket.priority}</Badge> <span className={cn("badge", pc.badge)}>{pc.label}</span>
</div> </div>
<h1 className="text-2xl font-bold text-gray-900">{ticket.title}</h1> <h1 className="text-xl font-semibold text-white">{ticket.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(ticket.created_at).toLocaleDateString()}</span>
{ticket.assignee && <span>Assignee: {ticket.assignee}</span>}
</div> </div>
<Button variant="secondary" size="sm"> </div>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <select value={ticket.status} onChange={e => updateMut.mutate({ status: e.target.value })} className="input h-9 w-36 text-xs">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /> <option value="open">Open</option><option value="in_progress">In Progress</option>
</svg> <option value="resolved">Resolved</option><option value="closed">Closed</option>
Edit </select>
</Button>
</div> </div>
<div className="prose max-w-none"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
<h4 className="text-sm font-medium text-gray-500 mb-2">Description</h4> <div className="lg:col-span-2">
<pre className="whitespace-pre-wrap font-sans text-gray-700 bg-gray-50 p-4 rounded-lg"> <div className="card overflow-hidden">
{ticket.description} <div className="flex items-center gap-0 border-b border-gray-800/50 px-1">
</pre> {tabs.map(tab => {
</div> const Icon = tab.icon
</Card> return (
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
{/* Comments */} className={cn("flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-all -mb-px",
<Card> activeTab === tab.id ? "border-blue-500 text-blue-400" : "border-transparent text-gray-500 hover:text-gray-300")}>
<h3 className="font-semibold text-gray-900 mb-4">Activity</h3> <Icon size={14} />{tab.label}{tab.count !== undefined && <span className="badge badge-gray text-[10px]">{tab.count}</span>}
<div className="space-y-4 mb-6">
{comments?.map(c => (
<div key={c.id} className="flex gap-3">
<Avatar name={c.author} size="sm" />
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-gray-900">{c.author}</span>
<span className="text-xs text-gray-500">{new Date(c.created_at).toLocaleString()}</span>
</div>
<pre className="whitespace-pre-wrap font-sans text-sm text-gray-700 bg-gray-50 p-3 rounded-lg">
{c.content}
</pre>
</div>
</div>
))}
{!comments?.length && <p className="text-gray-500 text-sm">No comments yet</p>}
</div>
<div className="border-t pt-4">
<textarea
value={comment}
onChange={e => setComment(e.target.value)}
placeholder="Add a comment..."
rows={3}
className="w-full border border-gray-300 rounded-lg px-4 py-2.5 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<div className="flex justify-end mt-2">
<Button onClick={() => commentMutation.mutate(comment)} disabled={!comment.trim()} loading={commentMutation.isPending}>
Add Comment
</Button>
</div>
</div>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Actions */}
<Card>
<h3 className="font-semibold text-gray-900 mb-4">Actions</h3>
<div className="space-y-3">
<Select
label="Status"
options={[
{ value: 'open', label: 'Open' },
{ value: 'in_progress', label: 'In Progress' },
{ value: 'resolved', label: 'Resolved' },
{ value: 'closed', label: 'Closed' },
]}
value={ticket.status}
onChange={e => updateMutation.mutate({ status: e.target.value as any })}
/>
<Select
label="Priority"
options={[
{ value: 'low', label: '🟢 Low' },
{ value: 'medium', label: '🟡 Medium' },
{ value: 'high', label: '🟠 High' },
{ value: 'critical', label: '🔴 Critical' },
]}
value={ticket.priority}
onChange={e => updateMutation.mutate({ priority: e.target.value as any })}
/>
</div>
</Card>
{/* Details */}
<Card>
<h3 className="font-semibold text-gray-900 mb-4">Details</h3>
<dl className="space-y-4 text-sm">
<div>
<dt className="text-gray-500">Assignee</dt>
<dd className="mt-1">
{ticket.assignee ? (
<div className="flex items-center gap-2">
<Avatar name={ticket.assignee} size="sm" />
<span>{ticket.assignee}</span>
</div>
) : (
<button onClick={() => setShowAssign(true)} className="text-blue-600 hover:text-blue-700">
+ Assign
</button> </button>
)
})}
</div>
<div className="card-body">
{activeTab === 'details' && (
<div className="animate-fade-in">
<pre className="whitespace-pre-wrap text-sm text-gray-300 font-sans leading-relaxed">{ticket.description || 'No description'}</pre>
</div>
)} )}
</dd> {activeTab === 'comments' && (
</div> <div className="space-y-4 animate-fade-in">
<div> {(comments || []).map(c => (
<dt className="text-gray-500">Reporter</dt> <div key={c.id} className="flex gap-3">
<dd className="mt-1">{ticket.reporter || 'Unknown'}</dd> <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> <div className="flex-1">
<div> <div className="flex items-center gap-2 mb-1"><span className="text-sm font-medium text-gray-300">{c.author}</span><span className="text-xs text-gray-600">{new Date(c.created_at).toLocaleString()}</span></div>
<dt className="text-gray-500">Created</dt> <p className="text-sm text-gray-400">{c.content}</p>
<dd className="mt-1">{new Date(ticket.created_at).toLocaleString()}</dd>
</div>
<div>
<dt className="text-gray-500">Updated</dt>
<dd className="mt-1">{new Date(ticket.updated_at).toLocaleString()}</dd>
</div>
</dl>
</Card>
{/* Related */}
<Card>
<h3 className="font-semibold text-gray-900 mb-4">Integrations</h3>
<div className="space-y-3 text-sm">
<button className="w-full text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50 flex items-center gap-3">
<span className="text-xl">🤖</span>
<div>
<p className="font-medium">Analyze with AI</p>
<p className="text-gray-500">Get AI suggestions for this ticket</p>
</div>
</button>
<button className="w-full text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50 flex items-center gap-3">
<span className="text-xl">🔗</span>
<div>
<p className="font-medium">Link to PR</p>
<p className="text-gray-500">Connect to a pull request</p>
</div>
</button>
</div>
</Card>
</div> </div>
</div> </div>
{/* Assign Modal */}
<Modal open={showAssign} onClose={() => setShowAssign(false)} title="Assign Ticket">
<div className="space-y-4">
<Input label="Assignee" placeholder="Search team members..." />
<div className="space-y-2">
{['Ricel Leite', 'AI Assistant', 'Developer'].map(name => (
<button
key={name}
onClick={() => { updateMutation.mutate({ assignee: name }); setShowAssign(false) }}
className="w-full flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 text-left"
>
<Avatar name={name} size="sm" />
<span>{name}</span>
</button>
))} ))}
{(!comments || comments.length === 0) && <p className="text-sm text-gray-500 text-center py-4">No comments yet</p>}
<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() && commentMut.mutate()} />
<button onClick={() => comment.trim() && commentMut.mutate()} disabled={!comment.trim() || commentMut.isPending} className="btn btn-primary btn-sm">
{commentMut.isPending ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
</button>
</div>
</div>
)}
</div>
</div>
</div>
<div className="space-y-5">
<div className="card"><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-blue-500" /><div><p className="text-xs text-gray-400">Created</p><p className="text-sm">{new Date(ticket.created_at).toLocaleString()}</p></div></div>
<div className="flex items-center gap-3"><div className="w-2 h-2 rounded-full bg-amber-500" /><div><p className="text-xs text-gray-400">Updated</p><p className="text-sm">{new Date(ticket.updated_at).toLocaleString()}</p></div></div>
</div>
</div>
{ticket.labels && ticket.labels.length > 0 && (
<div className="card"><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">{ticket.labels.map(l => <span key={l} className="badge badge-blue">{l}</span>)}</div></div>
</div>
)}
</div> </div>
</div> </div>
</Modal>
</div> </div>
) )
} }

View File

@ -1,141 +1,87 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { Card, Button, Select, Badge, Table, TableHeader, TableHead, TableBody, TableRow, TableCell, Avatar } from '../components/ui' import { ticketsApi, projectsApi } from '../services/api'
import { ticketsApi, projectsApi, Ticket } from '../services/api' import { cn } from '../lib/utils'
import { Search, Plus, SlidersHorizontal, ChevronRight, TicketCheck, Clock, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react'
const statusCfg: Record<string, { badge: string; icon: any; label: string }> = {
open: { badge: 'badge-blue', icon: AlertCircle, label: 'Open' },
in_progress: { badge: 'badge-yellow', icon: Clock, label: 'In Progress' },
resolved: { badge: 'badge-green', icon: CheckCircle2, label: 'Resolved' },
closed: { badge: 'badge-gray', icon: CheckCircle2, label: 'Closed' },
}
const priorityCfg: Record<string, { badge: string; label: string }> = {
critical: { badge: 'badge-red', label: 'Critical' }, high: { badge: 'badge-orange', label: 'High' },
medium: { badge: 'badge-yellow', label: 'Medium' }, low: { badge: 'badge-green', label: 'Low' },
}
export default function Tickets() { export default function Tickets() {
const [filters, setFilters] = useState({ project: '', status: '', priority: '' }) const [status, setStatus] = useState('')
const [projectId, setProjectId] = useState<number | undefined>()
const [search, setSearch] = useState('')
const [showFilters, setShowFilters] = useState(false)
const { data: tickets } = useQuery({ const { data: tickets, isLoading } = useQuery({ queryKey: ['tickets', projectId, status], queryFn: () => ticketsApi.list(projectId, status) })
queryKey: ['tickets', filters], const { data: projects } = useQuery({ queryKey: ['projects'], queryFn: () => projectsApi.list() })
queryFn: () => ticketsApi.list(filters.project ? Number(filters.project) : undefined, filters.status || undefined),
})
const { data: projects } = useQuery({ queryKey: ['projects'], queryFn: projectsApi.list })
const filteredTickets = tickets?.filter(t => { const filtered = (tickets || []).filter(t => !search || t.title.toLowerCase().includes(search.toLowerCase()) || t.key.toLowerCase().includes(search.toLowerCase()))
if (filters.priority && t.priority !== filters.priority) return false const counts: Record<string, number> = {}
return true ;(tickets || []).forEach(t => { counts[t.status] = (counts[t.status] || 0) + 1 })
}) || []
const statusColors: Record<string, 'info' | 'warning' | 'success' | 'default'> = {
open: 'info', in_progress: 'warning', resolved: 'success', closed: 'default'
}
const priorityColors: Record<string, 'success' | 'warning' | 'error' | 'default'> = {
low: 'success', medium: 'warning', high: 'error', critical: 'error'
}
return ( return (
<div className="p-6"> <div className="p-6 animate-fade-in">
<div className="flex justify-between items-center mb-6"> <div className="page-header">
<div> <div><h1 className="page-title">Tickets</h1><p className="page-subtitle">{tickets?.length || 0} total</p></div>
<h1 className="text-2xl font-bold text-gray-900">Tickets</h1> <Link to="/tickets/new" className="btn btn-primary"><Plus size={16} /> New Ticket</Link>
<p className="text-gray-500">{filteredTickets.length} tickets found</p>
</div> </div>
<Link to="/tickets/new">
<Button> <div className="flex items-center gap-1 mb-4 overflow-x-auto pb-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <button onClick={() => setStatus('')} className={cn("badge cursor-pointer", !status ? "badge-blue" : "badge-gray hover:bg-gray-700/50")}>All {tickets?.length || 0}</button>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /> {Object.entries(statusCfg).map(([k, v]) => (
</svg> <button key={k} onClick={() => setStatus(status === k ? '' : k)} className={cn("badge cursor-pointer", status === k ? v.badge : "badge-gray hover:bg-gray-700/50")}>{v.label} {counts[k] || 0}</button>
New Ticket ))}
</Button> </div>
<div className="card mb-4">
<div className="flex items-center gap-3 px-4 py-3">
<Search size={16} className="text-gray-500" />
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search tickets..." className="flex-1 bg-transparent text-sm outline-none placeholder:text-gray-500" />
<button onClick={() => setShowFilters(!showFilters)} className={cn("btn btn-sm btn-ghost", showFilters && "text-blue-400")}><SlidersHorizontal size={14} /> Filters</button>
</div>
{showFilters && (
<div className="px-4 py-3 border-t border-gray-800/50 flex items-center gap-3 animate-slide-up">
<select value={projectId || ''} onChange={e => setProjectId(e.target.value ? Number(e.target.value) : undefined)} className="input h-8 text-xs w-40">
<option value="">All Projects</option>
{(projects || []).map(p => <option key={p.id} value={p.id}>{p.key} - {p.name}</option>)}
</select>
</div>
)}
</div>
<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-20">Key</div><div className="flex-1">Title</div><div className="w-24">Status</div><div className="w-20">Priority</div><div className="w-8" />
</div>
{isLoading ? Array(5).fill(0).map((_, i) => (
<div key={i} className="flex items-center gap-4 px-5 py-4 table-row"><div className="skeleton h-4 w-16" /><div className="flex-1"><div className="skeleton h-4 w-3/4" /></div><div className="skeleton h-5 w-16 rounded-md" /></div>
)) : filtered.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"><TicketCheck size={24} className="text-gray-600" /></div><p className="text-gray-400 font-medium">No tickets found</p></div>
) : filtered.map(ticket => {
const sc = statusCfg[ticket.status] || statusCfg.open
const pc = priorityCfg[ticket.priority] || priorityCfg.medium
const SI = sc.icon
return (
<Link key={ticket.id} to={`/tickets/${ticket.id}`} className="flex items-center gap-4 px-5 py-3.5 table-row group">
<div className="w-20"><span className="font-mono text-xs text-blue-400">{ticket.key}</span></div>
<div className="flex-1 min-w-0"><p className="text-sm font-medium truncate group-hover:text-white transition-colors">{ticket.title}</p></div>
<div className="w-24"><span className={cn("badge text-[10px]", sc.badge)}><SI size={10} />{sc.label}</span></div>
<div className="w-20"><span className={cn("badge text-[10px]", pc.badge)}>{pc.label}</span></div>
<div className="w-8"><ChevronRight size={14} className="text-gray-600 group-hover:text-gray-400" /></div>
</Link> </Link>
)
})}
</div> </div>
{/* Filters */}
<Card className="mb-6">
<div className="flex gap-4 items-end">
<Select
label="Project"
options={[{ value: '', label: 'All Projects' }, ...(projects?.map(p => ({ value: String(p.id), label: p.name })) || [])]}
value={filters.project}
onChange={e => setFilters({ ...filters, project: e.target.value })}
/>
<Select
label="Status"
options={[
{ value: '', label: 'All Status' },
{ value: 'open', label: 'Open' },
{ value: 'in_progress', label: 'In Progress' },
{ value: 'resolved', label: 'Resolved' },
{ value: 'closed', label: 'Closed' },
]}
value={filters.status}
onChange={e => setFilters({ ...filters, status: e.target.value })}
/>
<Select
label="Priority"
options={[
{ value: '', label: 'All Priorities' },
{ value: 'critical', label: '🔴 Critical' },
{ value: 'high', label: '🟠 High' },
{ value: 'medium', label: '🟡 Medium' },
{ value: 'low', label: '🟢 Low' },
]}
value={filters.priority}
onChange={e => setFilters({ ...filters, priority: e.target.value })}
/>
<Button variant="secondary" onClick={() => setFilters({ project: '', status: '', priority: '' })}>
Clear Filters
</Button>
</div>
</Card>
{/* Tickets Table */}
<Card padding="none">
<Table>
<TableHeader>
<TableHead className="w-24">Key</TableHead>
<TableHead>Title</TableHead>
<TableHead className="w-32">Status</TableHead>
<TableHead className="w-32">Priority</TableHead>
<TableHead className="w-40">Assignee</TableHead>
<TableHead className="w-32">Created</TableHead>
</TableHeader>
<TableBody>
{filteredTickets.length === 0 ? (
<TableRow>
<TableCell className="text-center text-gray-500 py-8" colSpan={6}>
No tickets found
</TableCell>
</TableRow>
) : (
filteredTickets.map(ticket => (
<TableRow key={ticket.id} onClick={() => window.location.href = `/tickets/${ticket.id}`}>
<TableCell>
<span className="font-mono text-blue-600">{ticket.key}</span>
</TableCell>
<TableCell>
<p className="font-medium text-gray-900">{ticket.title}</p>
<p className="text-sm text-gray-500 truncate max-w-md">{ticket.description}</p>
</TableCell>
<TableCell>
<Badge variant={statusColors[ticket.status]}>{ticket.status.replace('_', ' ')}</Badge>
</TableCell>
<TableCell>
<Badge variant={priorityColors[ticket.priority]}>{ticket.priority}</Badge>
</TableCell>
<TableCell>
{ticket.assignee ? (
<div className="flex items-center gap-2">
<Avatar name={ticket.assignee} size="sm" />
<span className="text-sm">{ticket.assignee}</span>
</div>
) : (
<span className="text-gray-400">Unassigned</span>
)}
</TableCell>
<TableCell>
<span className="text-sm text-gray-500">
{new Date(ticket.created_at).toLocaleDateString()}
</span>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</Card>
</div> </div>
) )
} }

View File

@ -1,5 +1,22 @@
export default { export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: { extend: {} }, darkMode: 'class',
plugins: [], theme: {
extend: {
colors: {
gray: {
950: '#0a0a0f', 900: '#111118', 850: '#16161f', 800: '#1e1e2a',
750: '#252533', 700: '#2e2e3e', 600: '#3f3f52', 500: '#5a5a70',
400: '#8888a0', 300: '#aaaabe', 200: '#ccccdc', 100: '#e5e5f0', 50: '#f5f5fa',
},
},
fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'], mono: ['JetBrains Mono', 'monospace'] },
animation: { 'fade-in': 'fadeIn 0.3s ease-out', 'slide-up': 'slideUp 0.2s ease-out' },
keyframes: {
fadeIn: { '0%': { opacity: '0' }, '100%': { opacity: '1' } },
slideUp: { '0%': { opacity: '0', transform: 'translateY(4px)' }, '100%': { opacity: '1', transform: 'translateY(0)' } },
},
}
},
plugins: []
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">🎫</text></svg>

After

Width:  |  Height:  |  Size: 110 B

14
frontend_build/index.html Normal file
View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TicketHub</title>
<script type="module" crossorigin src="/assets/index-B0rSrOyT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D_4Latsh.css">
</head>
<body>
<div id="root"></div>
</body>
</html>