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:
parent
f695884784
commit
02407a31fb
|
|
@ -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
125
README.md
|
|
@ -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
|
## Features
|
||||||
|
|
||||||
- **Projects** - Organize tickets by project with unique keys (e.g., PROJ-123)
|
### Work Management
|
||||||
- **Tickets** - Create, update, and track issues with status and priority
|
- 📊 **Dashboard** - Overview with KPIs and recent activity
|
||||||
- **Comments** - Add comments to tickets for collaboration
|
- 🎫 **Tickets** - Full CRUD with filters, search, and bulk actions
|
||||||
- **Webhooks** - Trigger external systems on ticket events
|
- 📋 **Kanban Board** - Drag-and-drop ticket management
|
||||||
- **Simple** - SQLite database, no complex setup required
|
- 📁 **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
|
## Quick Start
|
||||||
|
|
||||||
### Docker
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Access at http://localhost:8080
|
|
||||||
|
|
||||||
### Manual
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Backend
|
||||||
cd backend
|
cd backend
|
||||||
pip install -r requirements.txt
|
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
|
## 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:
|
POST /api/webhooks/incoming
|
||||||
|
|
||||||
```
|
|
||||||
https://jira-fixer.example.com/api/webhook/tickethub
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
Created by StartData
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export default {
|
||||||
|
plugins: { tailwindcss: {}, autoprefixer: {} },
|
||||||
|
}
|
||||||
|
|
@ -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,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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
@ -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>
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
}
|
||||||
|
|
@ -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'
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
body { font-family: 'Inter', system-ui, sans-serif; }
|
||||||
|
|
@ -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>,
|
||||||
|
)
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: { extend: {} },
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
|
@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue