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:
parent
927a906bbd
commit
25e5ac36c6
|
|
@ -21,3 +21,5 @@ Thumbs.db
|
|||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -10,12 +10,24 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"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-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.22.0",
|
||||
"@tanstack/react-query": "^5.17.0",
|
||||
"axios": "^1.6.0",
|
||||
"date-fns": "^3.3.0"
|
||||
"tailwind-merge": "^3.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.48",
|
||||
|
|
|
|||
|
|
@ -1,134 +1,176 @@
|
|||
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 = [
|
||||
{
|
||||
title: 'Work',
|
||||
items: [
|
||||
{ to: '/', label: 'Dashboard', icon: '📊' },
|
||||
{ to: '/tickets', label: 'All Tickets', icon: '🎫' },
|
||||
{ to: '/board', label: 'Board', icon: '📋' },
|
||||
{ to: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ to: '/tickets', label: 'All Tickets', icon: TicketCheck },
|
||||
{ to: '/board', label: 'Board', icon: Kanban },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Management',
|
||||
items: [
|
||||
{ to: '/projects', label: 'Projects', icon: '📁' },
|
||||
{ to: '/team', label: 'Team', icon: '👥' },
|
||||
{ to: '/reports', label: 'Reports', icon: '📈' },
|
||||
{ to: '/projects', label: 'Projects', icon: FolderOpen },
|
||||
{ to: '/team', label: 'Team', icon: Users },
|
||||
{ to: '/reports', label: 'Reports', icon: BarChart3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Configuration',
|
||||
items: [
|
||||
{ to: '/integrations', label: 'Integrations', icon: '🔌' },
|
||||
{ to: '/automation', label: 'Automation', icon: '⚡' },
|
||||
{ to: '/settings', label: 'Settings', icon: '⚙️' },
|
||||
{ to: '/integrations', label: 'Integrations', icon: Plug },
|
||||
{ to: '/automation', label: 'Automation', icon: AutomationIcon },
|
||||
{ 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() {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
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 (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
{/* Sidebar */}
|
||||
<aside className={`${collapsed ? 'w-16' : 'w-64'} bg-white border-r border-gray-200 flex flex-col transition-all duration-200`}>
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-blue-600 to-purple-600 rounded-lg flex items-center justify-center">
|
||||
<span className="text-white text-lg">🎫</span>
|
||||
<div className="min-h-screen flex bg-gray-950 text-gray-200">
|
||||
{/* Search */}
|
||||
{showSearch && (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]" onClick={() => setShowSearch(false)}>
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
<div className="relative w-full max-w-lg mx-4 animate-slide-up" onClick={e => e.stopPropagation()}>
|
||||
<div className="card border-gray-700 shadow-2xl">
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-800">
|
||||
<Search size={18} className="text-gray-500" />
|
||||
<input autoFocus value={searchQuery} onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder="Search 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>
|
||||
{!collapsed && (
|
||||
<div>
|
||||
<h1 className="font-bold text-gray-900">TicketHub</h1>
|
||||
<p className="text-xs text-gray-500">Enterprise</p>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto py-4">
|
||||
{navSections.map((section, i) => (
|
||||
<div key={i} className="mb-6">
|
||||
{!collapsed && (
|
||||
<h3 className="px-4 text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">
|
||||
{section.title}
|
||||
</h3>
|
||||
<div className="px-3 pt-3">
|
||||
<button onClick={() => setShowSearch(true)}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-gray-500 hover:text-gray-300 bg-gray-900/40 hover:bg-gray-800/60 border border-gray-800/30 transition-all text-sm">
|
||||
<Search size={14} /><span className="flex-1 text-left">Search...</span>
|
||||
<div className="flex 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 => (
|
||||
<li key={item.to}>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-50 text-blue-700 font-medium'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
|
||||
<nav className="flex-1 overflow-auto px-3 py-4 space-y-5">
|
||||
{navSections.map(section => (
|
||||
<div key={section.title}>
|
||||
{!collapsed && <p className="text-[10px] font-semibold uppercase tracking-wider text-gray-600 px-3 mb-1.5">{section.title}</p>}
|
||||
<div className="space-y-0.5">
|
||||
{section.items.map(item => {
|
||||
const active = isActive(item.to)
|
||||
return (
|
||||
<NavLink key={item.to} to={item.to} title={collapsed ? item.label : undefined}
|
||||
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} />
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="p-4 border-t border-gray-200 text-gray-400 hover:text-gray-600 flex items-center justify-center"
|
||||
>
|
||||
<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>
|
||||
<div className="px-3 py-2 border-t border-gray-800/50">
|
||||
<button onClick={() => setCollapsed(!collapsed)} className={cn("sidebar-item sidebar-item-inactive w-full", collapsed && "justify-center px-0")}>
|
||||
{collapsed ? <PanelLeftOpen size={18} /> : <PanelLeftClose size={18} />}
|
||||
{!collapsed && <span>Collapse</span>}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Top Bar */}
|
||||
<header className="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
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 className={cn("flex-1 flex flex-col transition-all duration-300", collapsed ? "ml-[68px]" : "ml-[260px]")}>
|
||||
<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">
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
{getBreadcrumbs().map((c, i) => (
|
||||
<div key={c.path} className="flex items-center gap-1.5">
|
||||
{i > 0 && <ChevronRight size={12} className="text-gray-600" />}
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<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="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 className="flex items-center gap-2">
|
||||
<button onClick={() => setShowSearch(true)} className="btn-ghost btn-icon rounded-lg"><Search size={16} /></button>
|
||||
<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>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
<main className="flex-1 overflow-auto"><Outlet /></main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'success'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
children: ReactNode
|
||||
loading?: boolean
|
||||
const buttonVariants = cva(
|
||||
"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",
|
||||
{
|
||||
variants: {
|
||||
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 variants = {
|
||||
primary: 'bg-blue-600 hover:bg-blue-700 text-white',
|
||||
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' }
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<button
|
||||
className={`rounded-lg font-medium transition-colors flex items-center justify-center gap-2
|
||||
${variants[variant]} ${sizes[size]} ${disabled || loading ? 'opacity-50 cursor-not-allowed' : ''} ${className}`}
|
||||
disabled={disabled || loading}
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...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 }
|
||||
|
|
|
|||
|
|
@ -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 components;
|
||||
@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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
|
@ -1,163 +1,41 @@
|
|||
import { useState } from 'react'
|
||||
import { Card, Button, Input, Select, Modal, Badge } from '../components/ui'
|
||||
import { Plus, Zap, ArrowRight, Trash2 } from 'lucide-react'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
interface Rule {
|
||||
id: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
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 },
|
||||
const mockRules = [
|
||||
{ id: 1, name: 'Auto-assign critical', trigger: 'Ticket priority = Critical', action: 'Assign to on-call team', active: true },
|
||||
{ id: 2, name: 'Slack notify on new', trigger: 'New ticket created', action: 'Send to #support-tickets', active: true },
|
||||
{ id: 3, name: 'Auto-close stale', trigger: 'Resolved > 7 days', action: 'Close ticket', active: false },
|
||||
]
|
||||
|
||||
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 (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Automation</h1>
|
||||
<p className="text-gray-500">Automate repetitive tasks with rules</p>
|
||||
<div className="p-6 animate-fade-in">
|
||||
<div className="page-header">
|
||||
<div><h1 className="page-title">Automation</h1><p className="page-subtitle">Rules engine for repetitive tasks</p></div>
|
||||
<button className="btn btn-primary"><Plus size={16} /> New Rule</button>
|
||||
</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>
|
||||
Create Rule
|
||||
</Button>
|
||||
<div className="space-y-3">
|
||||
{mockRules.map(rule => (
|
||||
<div key={rule.id} className="card p-5 flex items-center gap-4">
|
||||
<div className={cn("w-10 h-10 rounded-xl flex items-center justify-center", rule.active ? "bg-blue-500/10" : "bg-gray-800/50")}>
|
||||
<Zap size={18} className={rule.active ? "text-blue-400" : "text-gray-600"} />
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<Card>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-gray-900">{rules.length}</div>
|
||||
<div className="text-sm text-gray-500">Total Rules</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-semibold text-white">{rule.name}</h3>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500 mt-1">
|
||||
<span className="badge badge-gray text-[10px]">{rule.trigger}</span>
|
||||
<ArrowRight size={10} />
|
||||
<span className="badge badge-blue text-[10px]">{rule.action}</span>
|
||||
</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>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-blue-600">{rules.reduce((a, r) => a + r.runs, 0)}</div>
|
||||
<div className="text-sm text-gray-500">Total Runs</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" defaultChecked={rule.active} 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>
|
||||
<button className="btn btn-ghost btn-icon"><Trash2 size={14} /></button>
|
||||
</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 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,94 +1,67 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Card, Badge, Avatar, Select } from '../components/ui'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ticketsApi, projectsApi, Ticket } from '../services/api'
|
||||
import { cn } from '../lib/utils'
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { GripVertical, Plus, Filter } from 'lucide-react'
|
||||
|
||||
const columns = [
|
||||
{ id: 'open', label: 'Open', color: 'blue' },
|
||||
{ id: 'in_progress', label: 'In Progress', color: 'yellow' },
|
||||
{ id: 'resolved', label: 'Resolved', color: 'green' },
|
||||
{ id: 'closed', label: 'Closed', color: 'gray' },
|
||||
{ key: 'open', label: 'Open', color: 'border-blue-500' },
|
||||
{ key: 'in_progress', label: 'In Progress', color: 'border-amber-500' },
|
||||
{ key: 'resolved', label: 'Resolved', color: 'border-emerald-500' },
|
||||
{ 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() {
|
||||
const [projectId, setProjectId] = useState<string>('')
|
||||
const queryClient = useQueryClient()
|
||||
const [projectId, setProjectId] = useState<number | undefined>()
|
||||
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 { data: tickets } = useQuery({
|
||||
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',
|
||||
}
|
||||
const grouped: Record<string, Ticket[]> = { open: [], in_progress: [], resolved: [], closed: [] }
|
||||
;(tickets || []).forEach(t => { if (grouped[t.status]) grouped[t.status].push(t) })
|
||||
|
||||
return (
|
||||
<div className="p-6 h-full flex flex-col">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Board</h1>
|
||||
<p className="text-gray-500">Drag and drop to update status</p>
|
||||
<div className="p-6 animate-fade-in">
|
||||
<div className="page-header">
|
||||
<div><h1 className="page-title">Board</h1><p className="page-subtitle">Kanban view of your tickets</p></div>
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
<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 className="flex-1 flex gap-4 overflow-x-auto pb-4">
|
||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||
{columns.map(col => (
|
||||
<div key={col.id} className="flex-shrink-0 w-80">
|
||||
<div className={`flex items-center gap-2 mb-3 px-2`}>
|
||||
<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="font-semibold text-gray-700">{col.label}</h3>
|
||||
<span className="text-sm text-gray-400">({getTicketsByStatus(col.id).length})</span>
|
||||
<div key={col.key} className="kanban-col">
|
||||
<div className={cn("flex items-center justify-between mb-3 pb-2 border-b-2", col.color)}>
|
||||
<h3 className="text-sm font-semibold">{col.label}</h3>
|
||||
<span className="badge badge-gray text-[10px]">{grouped[col.key].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
|
||||
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>
|
||||
<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>
|
||||
</Link>
|
||||
))}
|
||||
{getTicketsByStatus(col.id).length === 0 && (
|
||||
<div className="text-center text-gray-400 py-8 text-sm">No tickets</div>
|
||||
{grouped[col.key].length === 0 && (
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,143 +1,91 @@
|
|||
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 { 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() {
|
||||
const { data: projects } = useQuery({ queryKey: ['projects'], queryFn: projectsApi.list })
|
||||
const { data: tickets } = useQuery({ queryKey: ['tickets'], queryFn: () => ticketsApi.list() })
|
||||
const { data: tickets, isLoading: tl } = 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 = {
|
||||
total: tickets?.length || 0,
|
||||
open: tickets?.filter(t => t.status === 'open').length || 0,
|
||||
inProgress: tickets?.filter(t => t.status === 'in_progress').length || 0,
|
||||
resolved: tickets?.filter(t => t.status === 'resolved' || t.status === 'closed').length || 0,
|
||||
critical: tickets?.filter(t => t.priority === 'critical').length || 0,
|
||||
}
|
||||
|
||||
const recentTickets = tickets?.slice(0, 5) || []
|
||||
const stats = [
|
||||
{ label: 'Total Tickets', value: t.length, icon: TicketCheck, color: 'text-blue-400', bg: 'bg-blue-500/10' },
|
||||
{ label: 'Open', value: open, icon: AlertCircle, color: 'text-amber-400', bg: 'bg-amber-500/10' },
|
||||
{ label: 'In Progress', value: inProg, icon: Clock, color: 'text-purple-400', bg: 'bg-purple-500/10' },
|
||||
{ label: 'Resolved', value: resolved, icon: CheckCircle2, color: 'text-emerald-400', bg: 'bg-emerald-500/10' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="p-6 animate-fade-in">
|
||||
<div className="page-header">
|
||||
<div><h1 className="page-title">Dashboard</h1><p className="page-subtitle">Overview of your 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>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="text-gray-500">Overview of your workspace</p>
|
||||
<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">{loading ? '—' : s.value}</p>
|
||||
</div>
|
||||
<Link to="/tickets/new">
|
||||
<button className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 flex items-center gap-2">
|
||||
<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 Ticket
|
||||
</button>
|
||||
<div className={cn("w-11 h-11 rounded-xl flex items-center justify-center", s.bg)}><Icon size={20} className={s.color} /></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</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 */}
|
||||
<div>
|
||||
<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">Projects</h2>
|
||||
<Link to="/projects" className="text-sm text-blue-600 hover:text-blue-700">Manage →</Link>
|
||||
<div className="card">
|
||||
<div className="card-header"><h3 className="text-sm font-semibold">Projects</h3>
|
||||
<Link to="/projects" className="text-xs text-blue-400 hover:underline flex items-center gap-1">Manage <ArrowUpRight size={12} /></Link></div>
|
||||
{loading ? <div className="card-body space-y-3">{Array(3).fill(0).map((_, i) => <div key={i} className="skeleton h-14 rounded-lg" />)}</div> : (
|
||||
<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 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>
|
||||
</Card>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,128 +1,32 @@
|
|||
import { useState } from 'react'
|
||||
import { Card, Button, Input, Modal, Badge, Tabs, TabsList, TabsTrigger, TabsContent } from '../components/ui'
|
||||
import { cn } from '../lib/utils'
|
||||
import { Plus, CheckCircle2 } from 'lucide-react'
|
||||
|
||||
const integrations = {
|
||||
connected: [
|
||||
{ id: '1', name: 'JIRA AI Fixer', type: 'ai', icon: '🤖', status: 'active', url: 'https://jira-fixer.startdata.com.br' },
|
||||
],
|
||||
available: [
|
||||
{ id: 'github', name: 'GitHub', type: 'repo', icon: '🐙', description: 'Link tickets to commits and PRs' },
|
||||
{ id: 'gitlab', name: 'GitLab', type: 'repo', icon: '🦊', description: 'Connect GitLab issues and MRs' },
|
||||
{ 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' },
|
||||
],
|
||||
}
|
||||
const platforms = [
|
||||
{ key: 'github', name: 'GitHub', desc: 'Issues and pull requests', color: 'from-gray-700 to-gray-800', icon: '🐙' },
|
||||
{ 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: '🔵' },
|
||||
{ key: 'servicenow', name: 'ServiceNow', desc: 'ITSM platform', color: 'from-emerald-600 to-emerald-700', icon: '⚙️' },
|
||||
{ key: 'slack', name: 'Slack', desc: 'Notifications & alerts', color: 'from-purple-600 to-purple-700', icon: '💬' },
|
||||
{ key: 'email', name: 'Email', desc: 'Ticket creation via email', color: 'from-red-600 to-red-700', icon: '📧' },
|
||||
]
|
||||
|
||||
export default function Integrations() {
|
||||
const [showConnect, setShowConnect] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Integrations</h1>
|
||||
<p className="text-gray-500">Connect TicketHub with your tools</p>
|
||||
<div className="p-6 animate-fade-in">
|
||||
<div className="page-header">
|
||||
<div><h1 className="page-title">Integrations</h1><p className="page-subtitle">Connect your tools</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">
|
||||
{integrations.available.map(int => (
|
||||
<Card key={int.id}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center text-2xl">
|
||||
{int.icon}
|
||||
{platforms.map(p => (
|
||||
<div key={p.key} className="card-hover p-5">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className={cn("w-10 h-10 rounded-xl bg-gradient-to-br flex items-center justify-center text-lg", p.color)}>{p.icon}</div>
|
||||
<div><h3 className="font-semibold text-white">{p.name}</h3><p className="text-xs text-gray-500">{p.desc}</p></div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<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>
|
||||
<button className="btn btn-secondary w-full btn-sm"><Plus size={14} /> Connect</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,80 +1,52 @@
|
|||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useQuery, useMutation } from '@tanstack/react-query'
|
||||
import { Card, Button, Input, Select } from '../components/ui'
|
||||
import { projectsApi, ticketsApi } from '../services/api'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ticketsApi, projectsApi } from '../services/api'
|
||||
import { ArrowLeft, Send, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function NewTicket() {
|
||||
const navigate = useNavigate()
|
||||
const { data: projects } = useQuery({ queryKey: ['projects'], queryFn: projectsApi.list })
|
||||
|
||||
const [form, setForm] = useState({
|
||||
project_id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
priority: 'medium',
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: ticketsApi.create,
|
||||
onSuccess: (ticket) => navigate(`/tickets/${ticket.id}`),
|
||||
const qc = useQueryClient()
|
||||
const [form, setForm] = useState({ project_id: '', title: '', description: '', priority: 'medium' })
|
||||
const { data: projects } = useQuery({ queryKey: ['projects'], queryFn: () => projectsApi.list() })
|
||||
const mut = useMutation({
|
||||
mutationFn: () => ticketsApi.create({ ...form, project_id: Number(form.project_id) }),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['tickets'] }); navigate('/tickets') }
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-3xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Create Ticket</h1>
|
||||
<p className="text-gray-500">Submit a new ticket for tracking</p>
|
||||
</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 className="p-6 animate-fade-in max-w-2xl">
|
||||
<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="page-title mb-6">New Ticket</h1>
|
||||
<form onSubmit={e => { e.preventDefault(); mut.mutate() }} className="card card-body space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||
<textarea
|
||||
rows={6}
|
||||
placeholder="Detailed description of the issue..."
|
||||
value={form.description}
|
||||
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
|
||||
/>
|
||||
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Project</label>
|
||||
<select value={form.project_id} onChange={e => setForm({ ...form, project_id: e.target.value })} className="input" required>
|
||||
<option value="">Select project</option>
|
||||
{(projects || []).map(p => <option key={p.id} value={p.id}>{p.key} - {p.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label="Priority"
|
||||
options={[
|
||||
{ value: 'low', label: '🟢 Low - Minor issue, can wait' },
|
||||
{ value: 'medium', label: '🟡 Medium - Standard priority' },
|
||||
{ value: 'high', label: '🟠 High - Important, needs attention soon' },
|
||||
{ value: 'critical', label: '🔴 Critical - Urgent, blocking work' },
|
||||
]}
|
||||
value={form.priority}
|
||||
onChange={e => setForm({ ...form, priority: e.target.value })}
|
||||
/>
|
||||
|
||||
<div className="flex gap-3 pt-4 border-t">
|
||||
<Button type="button" variant="secondary" onClick={() => navigate(-1)}>Cancel</Button>
|
||||
<Button type="submit" loading={createMutation.isPending}>Create Ticket</Button>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Title</label>
|
||||
<input value={form.title} onChange={e => setForm({ ...form, title: e.target.value })} className="input" placeholder="Brief summary" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Description</label>
|
||||
<textarea value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} className="input h-32 resize-none" placeholder="Describe the issue..." required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Priority</label>
|
||||
<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>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<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>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,172 +1,64 @@
|
|||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Card, Button, Input, Modal, Badge, Table, TableHeader, TableHead, TableBody, TableRow, TableCell } from '../components/ui'
|
||||
import { projectsApi, Project } from '../services/api'
|
||||
import { projectsApi } from '../services/api'
|
||||
import { cn } from '../lib/utils'
|
||||
import { Plus, FolderOpen, Settings, Trash2, Globe, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function Projects() {
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [editProject, setEditProject] = useState<Project | null>(null)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: projects } = useQuery({ queryKey: ['projects'], queryFn: projectsApi.list })
|
||||
|
||||
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'] }),
|
||||
})
|
||||
const qc = useQueryClient()
|
||||
const [showNew, setShowNew] = useState(false)
|
||||
const [form, setForm] = useState({ name: '', key: '', webhook_url: '' })
|
||||
const { data: projects, isLoading } = 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'] }) })
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Projects</h1>
|
||||
<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 className="p-6 animate-fade-in">
|
||||
<div className="page-header">
|
||||
<div><h1 className="page-title">Projects</h1><p className="page-subtitle">{projects?.length || 0} projects</p></div>
|
||||
<button onClick={() => setShowNew(!showNew)} className="btn btn-primary"><Plus size={16} /> New Project</button>
|
||||
</div>
|
||||
|
||||
{/* Projects Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{projects?.map(project => (
|
||||
<Card key={project.id}>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<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">
|
||||
{project.key.slice(0, 2)}
|
||||
</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>
|
||||
{showNew && (
|
||||
<form onSubmit={e => { e.preventDefault(); createMut.mutate() }} className="card card-body mb-6 animate-slide-up space-y-4 max-w-lg">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div><label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Name</label>
|
||||
<input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} className="input" placeholder="My Project" required /></div>
|
||||
<div><label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Key</label>
|
||||
<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><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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
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 (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Reports</h1>
|
||||
<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 className="p-6 animate-fade-in">
|
||||
<div className="page-header">
|
||||
<div><h1 className="page-title">Reports & Analytics</h1><p className="page-subtitle">Performance metrics</p></div>
|
||||
<button className="btn btn-secondary btn-sm"><Download size={14} /> Export</button>
|
||||
</div>
|
||||
|
||||
{/* KPIs */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<KPICard title="Tickets Created" value="127" change="+12%" trend="up" />
|
||||
<KPICard title="Tickets Resolved" value="98" change="+18%" trend="up" />
|
||||
<KPICard title="Avg Resolution Time" value="4.2h" change="-15%" trend="up" />
|
||||
<KPICard title="Open Tickets" value="29" change="-8%" trend="up" />
|
||||
<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><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>
|
||||
<div className={cn("w-11 h-11 rounded-xl flex items-center justify-center", s.bg)}><Icon size={20} className={s.color} /></div>
|
||||
</div></div>
|
||||
)})}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
{/* Status Distribution */}
|
||||
<Card>
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Status Distribution</h3>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ label: 'Open', value: 29, pct: 23, color: '#3b82f6' },
|
||||
{ label: 'In Progress', value: 18, pct: 14, color: '#eab308' },
|
||||
{ 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 className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="card"><div className="card-header"><h3 className="text-sm font-semibold">By Status</h3></div>
|
||||
<div className="card-body space-y-3">
|
||||
{byStatus.map(s => (
|
||||
<div key={s.name} className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-400 w-24 capitalize">{s.name}</span>
|
||||
<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>
|
||||
<span className="text-sm font-mono text-gray-300 w-8 text-right">{s.count}</span>
|
||||
</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 className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full" style={{ width: `${item.pct}%`, backgroundColor: item.color }} />
|
||||
<div className="card"><div className="card-header"><h3 className="text-sm font-semibold">By Priority</h3></div>
|
||||
<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>
|
||||
</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>
|
||||
</Card>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
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 (
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
|
||||
<p className="text-gray-500">Manage your workspace settings</p>
|
||||
<div className="p-6 animate-fade-in">
|
||||
<div className="page-header"><div><h1 className="page-title">Settings</h1><p className="page-subtitle">Workspace configuration</p></div></div>
|
||||
<div className="flex gap-6">
|
||||
<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>
|
||||
|
||||
<Tabs defaultValue="general">
|
||||
<TabsList>
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
<TabsTrigger value="notifications">Notifications</TabsTrigger>
|
||||
<TabsTrigger value="security">Security</TabsTrigger>
|
||||
<TabsTrigger value="api">API</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="general">
|
||||
<Card>
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Workspace Settings</h3>
|
||||
<div className="space-y-4 max-w-lg">
|
||||
<Input label="Workspace Name" defaultValue="StartData" />
|
||||
<Input label="Workspace URL" defaultValue="tickethub.startdata.com.br" disabled />
|
||||
<Select
|
||||
label="Default Timezone"
|
||||
options={[
|
||||
{ value: 'America/Sao_Paulo', label: 'America/Sao_Paulo (GMT-3)' },
|
||||
{ value: 'America/New_York', label: 'America/New_York (GMT-5)' },
|
||||
{ value: 'UTC', label: 'UTC' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Language"
|
||||
options={[
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'pt-BR', label: 'Português (Brasil)' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Date Format"
|
||||
options={[
|
||||
{ value: 'DD/MM/YYYY', label: 'DD/MM/YYYY' },
|
||||
{ value: 'MM/DD/YYYY', label: 'MM/DD/YYYY' },
|
||||
{ value: 'YYYY-MM-DD', label: 'YYYY-MM-DD' },
|
||||
]}
|
||||
/>
|
||||
<div className="pt-4">
|
||||
<Button>Save Changes</Button>
|
||||
<div className="flex-1 max-w-2xl">
|
||||
{activeTab === 'general' && (
|
||||
<div className="card animate-fade-in"><div className="card-header"><h3 className="text-sm font-semibold">Workspace Details</h3></div>
|
||||
<div className="card-body space-y-5">
|
||||
<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>
|
||||
<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>
|
||||
<div><label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Timezone</label>
|
||||
<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>
|
||||
</div></div>
|
||||
)}
|
||||
{activeTab === 'notifications' && (
|
||||
<div className="card animate-fade-in"><div className="card-header"><h3 className="text-sm font-semibold">Notification Preferences</h3></div>
|
||||
<div className="card-body space-y-4">
|
||||
{[{ 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 => (
|
||||
<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 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>
|
||||
))}</div></div>
|
||||
)}
|
||||
{activeTab === 'security' && (
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</div></div>
|
||||
)}
|
||||
{activeTab === 'api' && (
|
||||
<div className="card animate-fade-in"><div className="card-header"><h3 className="text-sm font-semibold">API Keys</h3><button className="btn btn-primary btn-sm"><Plus size={14} /> Create</button></div>
|
||||
<div className="card-body">
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-900/50 rounded-lg border border-gray-800/50">
|
||||
<Key size={16} className="text-gray-500" /><div className="flex-1 min-w-0"><p className="text-sm font-medium">Production Key</p>
|
||||
<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>
|
||||
<button onClick={() => setShowToken(!showToken)} className="text-gray-500 hover:text-gray-300">{showToken ? <EyeOff size={12} /> : <Eye size={12} />}</button>
|
||||
<button className="text-gray-500 hover:text-gray-300"><Copy size={12} /></button></div></div>
|
||||
<span className="badge badge-green text-[10px]">Active</span><button className="btn btn-danger btn-sm btn-icon"><Trash2 size={12} /></button></div>
|
||||
<div 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 \\
|
||||
-H "Authorization: Bearer YOUR_KEY" \\
|
||||
-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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,118 +1,53 @@
|
|||
import { useState } from 'react'
|
||||
import { Card, Button, Input, Modal, Badge, Avatar, Select } from '../components/ui'
|
||||
import { Users as UsersIcon, Plus, Shield, Crown, UserCog, Eye, Mail, MoreVertical } from 'lucide-react'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
interface TeamMember {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
role: 'admin' | 'member' | 'viewer'
|
||||
avatar: string
|
||||
tickets: number
|
||||
lastActive: string
|
||||
const roleConfig: Record<string, { label: string; badge: string; icon: any }> = {
|
||||
owner: { label: 'Owner', badge: 'badge-yellow', icon: Crown },
|
||||
admin: { label: 'Admin', badge: 'badge-red', icon: Shield },
|
||||
member: { label: 'Member', badge: 'badge-blue', icon: UserCog },
|
||||
viewer: { label: 'Viewer', badge: 'badge-gray', icon: Eye },
|
||||
}
|
||||
|
||||
const mockTeam: TeamMember[] = [
|
||||
{ id: '1', name: 'Ricel Leite', email: 'ricel.souza@gmail.com', role: 'admin', avatar: 'RL', tickets: 15, lastActive: '2026-02-18T18:00:00Z' },
|
||||
{ id: '2', name: 'AI Assistant', email: 'ai@tickethub.local', role: 'member', avatar: '🤖', tickets: 45, lastActive: '2026-02-18T18:30:00Z' },
|
||||
const mockMembers = [
|
||||
{ id: 1, name: 'Admin User', email: 'admin@company.com', role: 'owner', joined: '2024-01-15' },
|
||||
{ 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() {
|
||||
const [team] = useState<TeamMember[]>(mockTeam)
|
||||
const [showInvite, setShowInvite] = useState(false)
|
||||
|
||||
const roleColors: Record<string, 'error' | 'info' | 'default'> = {
|
||||
admin: 'error', member: 'info', viewer: 'default'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Team</h1>
|
||||
<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 className="p-6 animate-fade-in">
|
||||
<div className="page-header">
|
||||
<div><h1 className="page-title">Team</h1><p className="page-subtitle">{mockMembers.length} members</p></div>
|
||||
<button className="btn btn-primary"><Plus size={16} /> Invite Member</button>
|
||||
</div>
|
||||
|
||||
{/* Role Legend */}
|
||||
<div className="flex gap-6 mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="error">Admin</Badge>
|
||||
<span className="text-sm text-gray-500">Full access</span>
|
||||
</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 className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
{Object.entries(roleConfig).map(([key, cfg]) => {
|
||||
const Icon = cfg.icon; const count = mockMembers.filter(m => m.role === key).length
|
||||
return (<div key={key} className="stat-card"><div className="flex items-center justify-between relative z-10">
|
||||
<div><p className="text-xs font-medium text-gray-400 uppercase tracking-wide">{cfg.label}s</p><p className="text-2xl font-bold text-white mt-1">{count}</p></div>
|
||||
<Icon size={18} className="text-gray-600" /></div></div>)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Team Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{team.map(member => (
|
||||
<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 className="card overflow-hidden">
|
||||
<div className="flex items-center gap-4 px-5 py-3 border-b border-gray-800/50 text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
<div className="w-9" /><div className="flex-1">Member</div><div className="w-24">Role</div><div className="w-28">Joined</div><div className="w-8" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-900">{member.name}</h3>
|
||||
<Badge variant={roleColors[member.role]} size="sm">{member.role}</Badge>
|
||||
{mockMembers.map(m => {
|
||||
const r = roleConfig[m.role] || roleConfig.member; const Icon = r.icon
|
||||
return (
|
||||
<div key={m.id} className="flex items-center gap-4 px-5 py-3.5 table-row group">
|
||||
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-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>
|
||||
<p className="text-sm text-gray-500">{member.email}</p>
|
||||
)
|
||||
})}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,232 +1,131 @@
|
|||
import { useState } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Card, Button, Badge, Select, Input, Avatar, Modal } from '../components/ui'
|
||||
import { ticketsApi, Ticket } from '../services/api'
|
||||
import { ticketsApi } 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() {
|
||||
const { id } = useParams()
|
||||
const queryClient = useQueryClient()
|
||||
const qc = useQueryClient()
|
||||
const [comment, setComment] = useState('')
|
||||
const [showAssign, setShowAssign] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState('details')
|
||||
|
||||
const { data: ticket, isLoading } = useQuery({
|
||||
queryKey: ['ticket', id],
|
||||
queryFn: () => ticketsApi.get(Number(id)),
|
||||
enabled: !!id,
|
||||
const { data: ticket, isLoading } = useQuery({ queryKey: ['ticket', id], queryFn: () => ticketsApi.get(Number(id)) })
|
||||
const { data: comments } = useQuery({ queryKey: ['comments', id], queryFn: () => ticketsApi.getComments(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({
|
||||
queryKey: ['ticket-comments', id],
|
||||
queryFn: () => ticketsApi.getComments(Number(id)),
|
||||
enabled: !!id,
|
||||
})
|
||||
if (isLoading) return (
|
||||
<div className="p-6 animate-fade-in space-y-4">
|
||||
<div className="skeleton h-4 w-24" /><div className="skeleton h-7 w-96" /><div className="skeleton h-48 w-full rounded-xl" />
|
||||
</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({
|
||||
mutationFn: (data: Partial<Ticket>) => ticketsApi.update(Number(id), data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ticket', id] }),
|
||||
})
|
||||
|
||||
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'
|
||||
}
|
||||
const sc = statusCfg[ticket.status] || statusCfg.open
|
||||
const pc = priorityCfg[ticket.priority] || priorityCfg.medium
|
||||
const tabs = [{ id: 'details', label: 'Details', icon: FileText }, { id: 'comments', label: 'Comments', icon: MessageSquare, count: comments?.length }]
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Breadcrumb */}
|
||||
<div className="mb-4">
|
||||
<Link to="/tickets" className="text-blue-600 hover:text-blue-700 text-sm">← Back to Tickets</Link>
|
||||
</div>
|
||||
<div className="p-6 animate-fade-in">
|
||||
<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="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main Content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Header */}
|
||||
<Card>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-mono text-blue-600 text-lg">{ticket.key}</span>
|
||||
<Badge variant={statusColors[ticket.status]} size="md">{ticket.status.replace('_', ' ')}</Badge>
|
||||
<Badge variant={priorityColors[ticket.priority]} size="md">{ticket.priority}</Badge>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="font-mono text-lg text-blue-400 font-semibold">{ticket.key}</span>
|
||||
<span className={cn("badge", sc.badge)}>{sc.label}</span>
|
||||
<span className={cn("badge", pc.badge)}>{pc.label}</span>
|
||||
</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>
|
||||
<Button variant="secondary" size="sm">
|
||||
<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>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
<select value={ticket.status} onChange={e => updateMut.mutate({ status: e.target.value })} className="input h-9 w-36 text-xs">
|
||||
<option value="open">Open</option><option value="in_progress">In Progress</option>
|
||||
<option value="resolved">Resolved</option><option value="closed">Closed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="prose max-w-none">
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-2">Description</h4>
|
||||
<pre className="whitespace-pre-wrap font-sans text-gray-700 bg-gray-50 p-4 rounded-lg">
|
||||
{ticket.description}
|
||||
</pre>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Comments */}
|
||||
<Card>
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Activity</h3>
|
||||
|
||||
<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
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="card overflow-hidden">
|
||||
<div className="flex items-center gap-0 border-b border-gray-800/50 px-1">
|
||||
{tabs.map(tab => {
|
||||
const Icon = tab.icon
|
||||
return (
|
||||
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
|
||||
className={cn("flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-all -mb-px",
|
||||
activeTab === tab.id ? "border-blue-500 text-blue-400" : "border-transparent text-gray-500 hover:text-gray-300")}>
|
||||
<Icon size={14} />{tab.label}{tab.count !== undefined && <span className="badge badge-gray text-[10px]">{tab.count}</span>}
|
||||
</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>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Reporter</dt>
|
||||
<dd className="mt-1">{ticket.reporter || 'Unknown'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Created</dt>
|
||||
<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>
|
||||
{activeTab === 'comments' && (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{(comments || []).map(c => (
|
||||
<div key={c.id} className="flex gap-3">
|
||||
<div className="w-7 h-7 rounded-lg bg-gray-800 flex items-center justify-center flex-shrink-0 text-xs font-medium text-gray-400">{c.author[0]?.toUpperCase()}</div>
|
||||
<div className="flex-1">
|
||||
<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>
|
||||
<p className="text-sm text-gray-400">{c.content}</p>
|
||||
</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>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,141 +1,87 @@
|
|||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Card, Button, Select, Badge, Table, TableHeader, TableHead, TableBody, TableRow, TableCell, Avatar } from '../components/ui'
|
||||
import { ticketsApi, projectsApi, Ticket } from '../services/api'
|
||||
import { ticketsApi, projectsApi } 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() {
|
||||
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({
|
||||
queryKey: ['tickets', filters],
|
||||
queryFn: () => ticketsApi.list(filters.project ? Number(filters.project) : undefined, filters.status || undefined),
|
||||
})
|
||||
const { data: projects } = useQuery({ queryKey: ['projects'], queryFn: projectsApi.list })
|
||||
const { data: tickets, isLoading } = useQuery({ queryKey: ['tickets', projectId, status], queryFn: () => ticketsApi.list(projectId, status) })
|
||||
const { data: projects } = useQuery({ queryKey: ['projects'], queryFn: () => projectsApi.list() })
|
||||
|
||||
const filteredTickets = tickets?.filter(t => {
|
||||
if (filters.priority && t.priority !== filters.priority) return false
|
||||
return true
|
||||
}) || []
|
||||
|
||||
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'
|
||||
}
|
||||
const filtered = (tickets || []).filter(t => !search || t.title.toLowerCase().includes(search.toLowerCase()) || t.key.toLowerCase().includes(search.toLowerCase()))
|
||||
const counts: Record<string, number> = {}
|
||||
;(tickets || []).forEach(t => { counts[t.status] = (counts[t.status] || 0) + 1 })
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Tickets</h1>
|
||||
<p className="text-gray-500">{filteredTickets.length} tickets found</p>
|
||||
<div className="p-6 animate-fade-in">
|
||||
<div className="page-header">
|
||||
<div><h1 className="page-title">Tickets</h1><p className="page-subtitle">{tickets?.length || 0} total</p></div>
|
||||
<Link to="/tickets/new" className="btn btn-primary"><Plus size={16} /> New Ticket</Link>
|
||||
</div>
|
||||
<Link to="/tickets/new">
|
||||
<Button>
|
||||
<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 Ticket
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1 mb-4 overflow-x-auto pb-1">
|
||||
<button onClick={() => setStatus('')} className={cn("badge cursor-pointer", !status ? "badge-blue" : "badge-gray hover:bg-gray-700/50")}>All {tickets?.length || 0}</button>
|
||||
{Object.entries(statusCfg).map(([k, v]) => (
|
||||
<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>
|
||||
))}
|
||||
</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>
|
||||
)
|
||||
})}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,22 @@
|
|||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: { extend: {} },
|
||||
plugins: [],
|
||||
darkMode: 'class',
|
||||
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
|
|
@ -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 |
|
|
@ -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>
|
||||
Loading…
Reference in New Issue