feat: Enterprise-grade TicketHub portal

FEATURES:
- Dashboard with KPIs and recent activity
- Tickets list with filters (project, status, priority)
- Ticket detail with comments, assignee, status management
- Kanban board with drag-and-drop
- Projects management (CRUD, webhooks)
- Team management (invite, roles)
- Reports & Analytics (charts, KPIs, top performers)
- Integrations (GitHub, GitLab, Slack, JIRA AI Fixer)
- Automation rules engine
- Settings (general, notifications, security, API)

UI:
- Professional light theme
- Reusable component library
- Responsive sidebar navigation
- Search functionality
- Modal dialogs

TECH:
- React 18 + TypeScript
- TailwindCSS
- React Query
- React Router
This commit is contained in:
Ricel Leite 2026-02-18 18:37:29 -03:00
parent f695884784
commit 02407a31fb
35 changed files with 2190 additions and 65 deletions

23
.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
# Python
__pycache__/
*.py[cod]
.env
venv/
# Node
node_modules/
dist/
.env.local
# IDE
.idea/
.vscode/
*.swp
# OS
.DS_Store
Thumbs.db
# Database
*.db
*.sqlite

125
README.md
View File

@ -1,88 +1,83 @@
# 🎫 TicketHub
# TicketHub
Lightweight open-source ticket/issue tracking system with webhook support.
Enterprise-grade open-source ticket and issue tracking system.
## Features
- **Projects** - Organize tickets by project with unique keys (e.g., PROJ-123)
- **Tickets** - Create, update, and track issues with status and priority
- **Comments** - Add comments to tickets for collaboration
- **Webhooks** - Trigger external systems on ticket events
- **Simple** - SQLite database, no complex setup required
### Work Management
- 📊 **Dashboard** - Overview with KPIs and recent activity
- 🎫 **Tickets** - Full CRUD with filters, search, and bulk actions
- 📋 **Kanban Board** - Drag-and-drop ticket management
- 📁 **Projects** - Organize tickets by project with unique keys
### Team Collaboration
- 👥 **Team Management** - Invite members with role-based access
- 💬 **Comments** - Discussion threads on tickets
- 🔔 **Notifications** - Email and Slack alerts
### Enterprise Features
- 📈 **Reports & Analytics** - Performance metrics and insights
- 🔌 **Integrations** - GitHub, GitLab, Jira, ServiceNow, Slack
- ⚡ **Automation** - Rules engine for repetitive tasks
- 🔐 **Security** - SSO, 2FA, IP restrictions
### API & Webhooks
- RESTful API with full documentation
- Incoming/outgoing webhooks
- API key management
## Tech Stack
### Backend
- Python 3.11 + FastAPI
- SQLite (development) / PostgreSQL (production)
- Async/await architecture
### Frontend
- React 18 + TypeScript
- TailwindCSS
- React Query
- React Router
## Quick Start
### Docker
```bash
docker-compose up -d
```
Access at http://localhost:8080
### Manual
```bash
# Backend
cd backend
pip install -r requirements.txt
uvicorn app.main:app --host 0.0.0.0 --port 8000
uvicorn app.main:app --reload
# Frontend
cd frontend
npm install
npm run dev
```
## API Endpoints
### Projects
- `GET /api/projects` - List all projects
- `POST /api/projects` - Create project
- `GET /api/projects/{id}` - Get project
- `DELETE /api/projects/{id}` - Delete project
### Tickets
- `GET /api/tickets` - List tickets (filter by `project_id`, `status`)
- `POST /api/tickets` - Create ticket
- `GET /api/tickets/{id}` - Get ticket
- `GET /api/tickets/key/{key}` - Get ticket by key (e.g., PROJ-123)
- `PATCH /api/tickets/{id}` - Update ticket
- `DELETE /api/tickets/{id}` - Delete ticket
### Comments
- `GET /api/tickets/{id}/comments` - List comments
- `POST /api/tickets/{id}/comments` - Add comment
### Webhooks
- `GET /api/webhooks` - List webhooks
- `POST /api/webhooks` - Create webhook
- `DELETE /api/webhooks/{id}` - Delete webhook
- `PATCH /api/webhooks/{id}/toggle` - Enable/disable webhook
## Webhook Events
When configured, TicketHub sends POST requests to your webhook URL:
```json
{
"event": "ticket.created",
"timestamp": "2026-02-18T12:00:00Z",
"data": {
"id": 1,
"key": "PROJ-1",
"title": "Issue title",
"description": "...",
"status": "open",
"priority": "medium"
}
}
```
GET /api/projects
POST /api/projects
GET /api/projects/:id
PATCH /api/projects/:id
DELETE /api/projects/:id
Events: `ticket.created`, `ticket.updated`, `comment.added`
GET /api/tickets
POST /api/tickets
GET /api/tickets/:id
PATCH /api/tickets/:id
DELETE /api/tickets/:id
## Integration with JIRA AI Fixer
GET /api/tickets/:id/comments
POST /api/tickets/:id/comments
Configure webhook URL in your project pointing to JIRA AI Fixer:
```
https://jira-fixer.example.com/api/webhook/tickethub
POST /api/webhooks/incoming
```
## License
MIT
## Credits
Created by StartData

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!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>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

30
frontend/package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "tickethub-portal",
"version": "1.0.0",
"description": "TicketHub - Enterprise Issue Tracking System",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"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"
},
"devDependencies": {
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^5.0.12"
}
}

View File

@ -0,0 +1,3 @@
export default {
plugins: { tailwindcss: {}, autoprefixer: {} },
}

View File

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

After

Width:  |  Height:  |  Size: 110 B

33
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,33 @@
import { Routes, Route } from 'react-router-dom'
import Layout from './components/Layout'
import Dashboard from './pages/Dashboard'
import Tickets from './pages/Tickets'
import TicketDetail from './pages/TicketDetail'
import NewTicket from './pages/NewTicket'
import Board from './pages/Board'
import Projects from './pages/Projects'
import Team from './pages/Team'
import Reports from './pages/Reports'
import Integrations from './pages/Integrations'
import Automation from './pages/Automation'
import Settings from './pages/Settings'
export default function App() {
return (
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="tickets" element={<Tickets />} />
<Route path="tickets/new" element={<NewTicket />} />
<Route path="tickets/:id" element={<TicketDetail />} />
<Route path="board" element={<Board />} />
<Route path="projects" element={<Projects />} />
<Route path="team" element={<Team />} />
<Route path="reports" element={<Reports />} />
<Route path="integrations" element={<Integrations />} />
<Route path="automation" element={<Automation />} />
<Route path="settings" element={<Settings />} />
</Route>
</Routes>
)
}

View File

@ -0,0 +1,135 @@
import { Outlet, NavLink, useLocation } from 'react-router-dom'
import { useState } from 'react'
const navSections = [
{
title: 'Work',
items: [
{ to: '/', label: 'Dashboard', icon: '📊' },
{ to: '/tickets', label: 'All Tickets', icon: '🎫' },
{ to: '/board', label: 'Board', icon: '📋' },
],
},
{
title: 'Management',
items: [
{ to: '/projects', label: 'Projects', icon: '📁' },
{ to: '/team', label: 'Team', icon: '👥' },
{ to: '/reports', label: 'Reports', icon: '📈' },
],
},
{
title: 'Configuration',
items: [
{ to: '/integrations', label: 'Integrations', icon: '🔌' },
{ to: '/automation', label: 'Automation', icon: '⚡' },
{ to: '/settings', label: 'Settings', icon: '⚙️' },
],
},
]
export default function Layout() {
const [collapsed, setCollapsed] = useState(false)
const location = useLocation()
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>
{!collapsed && (
<div>
<h1 className="font-bold text-gray-900">TicketHub</h1>
<p className="text-xs text-gray-500">Enterprise</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>
)}
<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>
{!collapsed && <span>{item.label}</span>}
</NavLink>
</li>
))}
</ul>
</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>
</button>
</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>
</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>
</header>
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
</div>
)
}

View File

@ -0,0 +1,6 @@
export function Avatar({ name, size = 'md', color }: { name: string; size?: 'sm' | 'md' | 'lg'; color?: string }) {
const initials = name.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase()
const sizes = { sm: 'w-8 h-8 text-xs', md: 'w-10 h-10 text-sm', lg: 'w-12 h-12 text-base' }
const bg = color || `bg-blue-600`
return <div className={`${sizes[size]} ${bg} rounded-full flex items-center justify-center text-white font-semibold`}>{initials}</div>
}

View File

@ -0,0 +1,16 @@
import { ReactNode } from 'react'
export function Badge({ children, variant = 'default', size = 'sm' }: {
children: ReactNode; variant?: 'success' | 'warning' | 'error' | 'info' | 'default' | 'purple'; size?: 'sm' | 'md'
}) {
const variants = {
success: 'bg-green-100 text-green-700 border-green-200',
warning: 'bg-yellow-100 text-yellow-700 border-yellow-200',
error: 'bg-red-100 text-red-700 border-red-200',
info: 'bg-blue-100 text-blue-700 border-blue-200',
default: 'bg-gray-100 text-gray-700 border-gray-200',
purple: 'bg-purple-100 text-purple-700 border-purple-200',
}
const sizes = { sm: 'px-2 py-0.5 text-xs', md: 'px-3 py-1 text-sm' }
return <span className={`inline-flex items-center rounded-full border font-medium ${variants[variant]} ${sizes[size]}`}>{children}</span>
}

View File

@ -0,0 +1,31 @@
import { ButtonHTMLAttributes, ReactNode } from 'react'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'success'
size?: 'sm' | 'md' | 'lg'
children: ReactNode
loading?: 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' }
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}
{...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>
)
}

View File

@ -0,0 +1,8 @@
import { ReactNode } from 'react'
export function Card({ children, className = '', padding = 'md' }: {
children: ReactNode; className?: string; padding?: 'none' | 'sm' | 'md' | 'lg'
}) {
const p = { none: '', sm: 'p-4', md: 'p-6', lg: 'p-8' }
return <div className={`bg-white rounded-xl border border-gray-200 shadow-sm ${p[padding]} ${className}`}>{children}</div>
}

View File

@ -0,0 +1,24 @@
import { InputHTMLAttributes, forwardRef } from 'react'
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
hint?: string
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, hint, className = '', ...props }, ref) => (
<div className="space-y-1">
{label && <label className="block text-sm font-medium text-gray-700">{label}</label>}
<input
ref={ref}
className={`w-full border rounded-lg px-4 py-2.5 text-gray-900 placeholder-gray-400
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
${error ? 'border-red-500' : 'border-gray-300'} ${className}`}
{...props}
/>
{hint && !error && <p className="text-xs text-gray-500">{hint}</p>}
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
)
)

View File

@ -0,0 +1,23 @@
import { ReactNode, useEffect } from 'react'
export function Modal({ open, onClose, title, children, size = 'md' }: {
open: boolean; onClose: () => void; title: string; children: ReactNode; size?: 'sm' | 'md' | 'lg' | 'xl'
}) {
useEffect(() => { document.body.style.overflow = open ? 'hidden' : ''; return () => { document.body.style.overflow = '' } }, [open])
if (!open) return null
const sizes = { sm: 'max-w-md', md: 'max-w-lg', lg: 'max-w-2xl', xl: 'max-w-4xl' }
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<div className={`relative bg-white rounded-xl w-full ${sizes[size]} max-h-[90vh] flex flex-col shadow-xl`}>
<div className="flex items-center justify-between px-6 py-4 border-b">
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 p-1">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div className="flex-1 overflow-y-auto p-6">{children}</div>
</div>
</div>
)
}

View File

@ -0,0 +1,17 @@
import { SelectHTMLAttributes, forwardRef } from 'react'
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label?: string
options: { value: string; label: string }[]
}
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ label, options, className = '', ...props }, ref) => (
<div className="space-y-1">
{label && <label className="block text-sm font-medium text-gray-700">{label}</label>}
<select ref={ref} 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 ${className}`} {...props}>
{options.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
</select>
</div>
)
)

View File

@ -0,0 +1,25 @@
import { ReactNode } from 'react'
export function Table({ children }: { children: ReactNode }) {
return <table className="w-full">{children}</table>
}
export function TableHeader({ children }: { children: ReactNode }) {
return <thead className="bg-gray-50 border-b border-gray-200"><tr>{children}</tr></thead>
}
export function TableHead({ children, className = '' }: { children: ReactNode; className?: string }) {
return <th className={`px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider ${className}`}>{children}</th>
}
export function TableBody({ children }: { children: ReactNode }) {
return <tbody className="divide-y divide-gray-200">{children}</tbody>
}
export function TableRow({ children, onClick, className = '' }: { children: ReactNode; onClick?: () => void; className?: string }) {
return <tr className={`hover:bg-gray-50 ${onClick ? 'cursor-pointer' : ''} ${className}`} onClick={onClick}>{children}</tr>
}
export function TableCell({ children, className = '' }: { children: ReactNode; className?: string }) {
return <td className={`px-4 py-4 text-sm text-gray-900 ${className}`}>{children}</td>
}

View File

@ -0,0 +1,30 @@
import { ReactNode, useState, createContext, useContext } from 'react'
const TabsContext = createContext<{ activeTab: string; setActiveTab: (tab: string) => void } | null>(null)
export function Tabs({ defaultValue, children }: { defaultValue: string; children: ReactNode }) {
const [activeTab, setActiveTab] = useState(defaultValue)
return <TabsContext.Provider value={{ activeTab, setActiveTab }}>{children}</TabsContext.Provider>
}
export function TabsList({ children }: { children: ReactNode }) {
return <div className="flex border-b border-gray-200 gap-1">{children}</div>
}
export function TabsTrigger({ value, children }: { value: string; children: ReactNode }) {
const ctx = useContext(TabsContext)
if (!ctx) return null
return (
<button
onClick={() => ctx.setActiveTab(value)}
className={`px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px
${ctx.activeTab === value ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
>{children}</button>
)
}
export function TabsContent({ value, children }: { value: string; children: ReactNode }) {
const ctx = useContext(TabsContext)
if (!ctx || ctx.activeTab !== value) return null
return <div className="pt-6">{children}</div>
}

View File

@ -0,0 +1,9 @@
export * from './Card'
export * from './Button'
export * from './Input'
export * from './Select'
export * from './Badge'
export * from './Modal'
export * from './Tabs'
export * from './Avatar'
export * from './Table'

4
frontend/src/index.css Normal file
View File

@ -0,0 +1,4 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body { font-family: 'Inter', system-ui, sans-serif; }

18
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,18 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
const queryClient = new QueryClient()
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
)

View File

@ -0,0 +1,163 @@
import { useState } from 'react'
import { Card, Button, Input, Select, Modal, Badge } from '../components/ui'
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 },
]
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>
<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>
{/* 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>
</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>
</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>
)
}

View File

@ -0,0 +1,99 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { Card, Badge, Avatar, Select } from '../components/ui'
import { ticketsApi, projectsApi, Ticket } from '../services/api'
import { useState } from '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' },
]
export default function Board() {
const [projectId, setProjectId] = useState<string>('')
const queryClient = useQueryClient()
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',
}
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>
<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">
{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>
<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>
)}
</div>
</div>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,143 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { Card } from '../components/ui'
import { projectsApi, ticketsApi, Ticket } from '../services/api'
export default function Dashboard() {
const { data: projects } = useQuery({ queryKey: ['projects'], queryFn: projectsApi.list })
const { data: tickets } = useQuery({ queryKey: ['tickets'], queryFn: () => ticketsApi.list() })
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) || []
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<p className="text-gray-500">Overview of your workspace</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>
</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>
</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>
<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>
)
}

View File

@ -0,0 +1,128 @@
import { useState } from 'react'
import { Card, Button, Input, Modal, Badge, Tabs, TabsList, TabsTrigger, TabsContent } from '../components/ui'
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' },
],
}
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>
</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}
</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>
</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>
)
}

View File

@ -0,0 +1,80 @@
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'
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}`),
})
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>
<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
/>
</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>
</form>
</Card>
</div>
)
}

View File

@ -0,0 +1,172 @@
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'
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'] }),
})
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>
{/* 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>
</div>
</form>
)
}

View File

@ -0,0 +1,135 @@
import { Card, Select } from '../components/ui'
export default function Reports() {
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>
{/* 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>
<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>
))}
</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>
</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>
)
}

View File

@ -0,0 +1,175 @@
import { Card, Input, Select, Button, Tabs, TabsList, TabsTrigger, TabsContent } from '../components/ui'
export default function Settings() {
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>
<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>
</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>
)
}

118
frontend/src/pages/Team.tsx Normal file
View File

@ -0,0 +1,118 @@
import { useState } from 'react'
import { Card, Button, Input, Modal, Badge, Avatar, Select } from '../components/ui'
interface TeamMember {
id: string
name: string
email: string
role: 'admin' | 'member' | 'viewer'
avatar: string
tickets: number
lastActive: string
}
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' },
]
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>
{/* 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>
{/* 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>
)}
<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>
</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>
)
}

View File

@ -0,0 +1,232 @@
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'
export default function TicketDetail() {
const { id } = useParams()
const queryClient = useQueryClient()
const [comment, setComment] = useState('')
const [showAssign, setShowAssign] = useState(false)
const { data: ticket, isLoading } = useQuery({
queryKey: ['ticket', id],
queryFn: () => ticketsApi.get(Number(id)),
enabled: !!id,
})
const { data: comments } = useQuery({
queryKey: ['ticket-comments', id],
queryFn: () => ticketsApi.getComments(Number(id)),
enabled: !!id,
})
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'
}
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="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>
<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>
<h1 className="text-2xl font-bold text-gray-900">{ticket.title}</h1>
</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>
<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
</button>
)}
</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>
</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>
))}
</div>
</div>
</Modal>
</div>
)
}

View File

@ -0,0 +1,141 @@
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'
export default function Tickets() {
const [filters, setFilters] = useState({ project: '', status: '', priority: '' })
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 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'
}
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>
<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>
</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>
)
}

View File

@ -0,0 +1,62 @@
import axios from 'axios'
const API_URL = import.meta.env.VITE_API_URL || '/api'
export const api = axios.create({ baseURL: API_URL })
export interface Project {
id: number
name: string
key: string
description?: string
webhook_url?: string
ticket_count: number
created_at: string
}
export interface Ticket {
id: number
key: string
project_id: number
title: string
description: string
status: 'open' | 'in_progress' | 'resolved' | 'closed'
priority: 'low' | 'medium' | 'high' | 'critical'
assignee?: string
reporter?: string
labels?: string[]
created_at: string
updated_at: string
}
export interface Comment {
id: number
ticket_id: number
author: string
content: string
created_at: string
}
export const projectsApi = {
list: async (): Promise<Project[]> => (await api.get('/projects')).data,
get: async (id: number): Promise<Project> => (await api.get(`/projects/${id}`)).data,
create: async (data: Partial<Project>): Promise<Project> => (await api.post('/projects', data)).data,
update: async (id: number, data: Partial<Project>): Promise<Project> => (await api.patch(`/projects/${id}`, data)).data,
delete: async (id: number): Promise<void> => api.delete(`/projects/${id}`),
}
export const ticketsApi = {
list: async (projectId?: number, status?: string): Promise<Ticket[]> => {
const params: any = {}
if (projectId) params.project_id = projectId
if (status) params.status = status
return (await api.get('/tickets', { params })).data
},
get: async (id: number): Promise<Ticket> => (await api.get(`/tickets/${id}`)).data,
create: async (data: Partial<Ticket>): Promise<Ticket> => (await api.post('/tickets', data)).data,
update: async (id: number, data: Partial<Ticket>): Promise<Ticket> => (await api.patch(`/tickets/${id}`, data)).data,
delete: async (id: number): Promise<void> => api.delete(`/tickets/${id}`),
getComments: async (id: number): Promise<Comment[]> => (await api.get(`/tickets/${id}/comments`)).data,
addComment: async (id: number, data: { author: string; content: string }): Promise<Comment> =>
(await api.post(`/tickets/${id}/comments`, data)).data,
}

View File

@ -0,0 +1,5 @@
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: { extend: {} },
plugins: [],
}

17
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}

11
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:8000'
}
}
})