diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..71a9a7e
--- /dev/null
+++ b/.gitignore
@@ -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
diff --git a/README.md b/README.md
index 774f1d0..55c4453 100644
--- a/README.md
+++ b/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
-- **Projects** - Organize tickets by project with unique keys (e.g., PROJ-123)
-- **Tickets** - Create, update, and track issues with status and priority
-- **Comments** - Add comments to tickets for collaboration
-- **Webhooks** - Trigger external systems on ticket events
-- **Simple** - SQLite database, no complex setup required
+### Work Management
+- π **Dashboard** - Overview with KPIs and recent activity
+- π« **Tickets** - Full CRUD with filters, search, and bulk actions
+- π **Kanban Board** - Drag-and-drop ticket management
+- π **Projects** - Organize tickets by project with unique keys
+
+### Team Collaboration
+- π₯ **Team Management** - Invite members with role-based access
+- π¬ **Comments** - Discussion threads on tickets
+- π **Notifications** - Email and Slack alerts
+
+### Enterprise Features
+- π **Reports & Analytics** - Performance metrics and insights
+- π **Integrations** - GitHub, GitLab, Jira, ServiceNow, Slack
+- β‘ **Automation** - Rules engine for repetitive tasks
+- π **Security** - SSO, 2FA, IP restrictions
+
+### API & Webhooks
+- RESTful API with full documentation
+- Incoming/outgoing webhooks
+- API key management
+
+## Tech Stack
+
+### Backend
+- Python 3.11 + FastAPI
+- SQLite (development) / PostgreSQL (production)
+- Async/await architecture
+
+### Frontend
+- React 18 + TypeScript
+- TailwindCSS
+- React Query
+- React Router
## Quick Start
-### Docker
-
-```bash
-docker-compose up -d
-```
-
-Access at http://localhost:8080
-
-### Manual
-
```bash
+# Backend
cd backend
pip install -r requirements.txt
-uvicorn app.main:app --host 0.0.0.0 --port 8000
+uvicorn app.main:app --reload
+
+# Frontend
+cd frontend
+npm install
+npm run dev
```
## API Endpoints
-### Projects
-- `GET /api/projects` - List all projects
-- `POST /api/projects` - Create project
-- `GET /api/projects/{id}` - Get project
-- `DELETE /api/projects/{id}` - Delete project
-
-### Tickets
-- `GET /api/tickets` - List tickets (filter by `project_id`, `status`)
-- `POST /api/tickets` - Create ticket
-- `GET /api/tickets/{id}` - Get ticket
-- `GET /api/tickets/key/{key}` - Get ticket by key (e.g., PROJ-123)
-- `PATCH /api/tickets/{id}` - Update ticket
-- `DELETE /api/tickets/{id}` - Delete ticket
-
-### Comments
-- `GET /api/tickets/{id}/comments` - List comments
-- `POST /api/tickets/{id}/comments` - Add comment
-
-### Webhooks
-- `GET /api/webhooks` - List webhooks
-- `POST /api/webhooks` - Create webhook
-- `DELETE /api/webhooks/{id}` - Delete webhook
-- `PATCH /api/webhooks/{id}/toggle` - Enable/disable webhook
-
-## Webhook Events
-
-When configured, TicketHub sends POST requests to your webhook URL:
-
-```json
-{
- "event": "ticket.created",
- "timestamp": "2026-02-18T12:00:00Z",
- "data": {
- "id": 1,
- "key": "PROJ-1",
- "title": "Issue title",
- "description": "...",
- "status": "open",
- "priority": "medium"
- }
-}
```
+GET /api/projects
+POST /api/projects
+GET /api/projects/:id
+PATCH /api/projects/:id
+DELETE /api/projects/:id
-Events: `ticket.created`, `ticket.updated`, `comment.added`
+GET /api/tickets
+POST /api/tickets
+GET /api/tickets/:id
+PATCH /api/tickets/:id
+DELETE /api/tickets/:id
-## Integration with JIRA AI Fixer
+GET /api/tickets/:id/comments
+POST /api/tickets/:id/comments
-Configure webhook URL in your project pointing to JIRA AI Fixer:
-
-```
-https://jira-fixer.example.com/api/webhook/tickethub
+POST /api/webhooks/incoming
```
## License
MIT
+
+## Credits
+
+Created by StartData
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..c79f63c
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ TicketHub
+
+
+
+
+
+
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..524557c
--- /dev/null
+++ b/frontend/package.json
@@ -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"
+ }
+}
diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js
new file mode 100644
index 0000000..be56e0e
--- /dev/null
+++ b/frontend/postcss.config.js
@@ -0,0 +1,3 @@
+export default {
+ plugins: { tailwindcss: {}, autoprefixer: {} },
+}
diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg
new file mode 100644
index 0000000..820929c
--- /dev/null
+++ b/frontend/public/favicon.svg
@@ -0,0 +1 @@
+
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
new file mode 100644
index 0000000..14efeea
--- /dev/null
+++ b/frontend/src/App.tsx
@@ -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 (
+
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ )
+}
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx
new file mode 100644
index 0000000..2dfaa2f
--- /dev/null
+++ b/frontend/src/components/Layout.tsx
@@ -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 (
+
+ {/* Sidebar */}
+
+
+
+ {/* Top Bar */}
+
+
+
+
+
+
+ RL
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/ui/Avatar.tsx b/frontend/src/components/ui/Avatar.tsx
new file mode 100644
index 0000000..d606278
--- /dev/null
+++ b/frontend/src/components/ui/Avatar.tsx
@@ -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 {initials}
+}
diff --git a/frontend/src/components/ui/Badge.tsx b/frontend/src/components/ui/Badge.tsx
new file mode 100644
index 0000000..64704a5
--- /dev/null
+++ b/frontend/src/components/ui/Badge.tsx
@@ -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 {children}
+}
diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx
new file mode 100644
index 0000000..4cdbfa2
--- /dev/null
+++ b/frontend/src/components/ui/Button.tsx
@@ -0,0 +1,31 @@
+import { ButtonHTMLAttributes, ReactNode } from 'react'
+
+interface ButtonProps extends ButtonHTMLAttributes {
+ 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 (
+
+ )
+}
diff --git a/frontend/src/components/ui/Card.tsx b/frontend/src/components/ui/Card.tsx
new file mode 100644
index 0000000..319203f
--- /dev/null
+++ b/frontend/src/components/ui/Card.tsx
@@ -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 {children}
+}
diff --git a/frontend/src/components/ui/Input.tsx b/frontend/src/components/ui/Input.tsx
new file mode 100644
index 0000000..a4c46b9
--- /dev/null
+++ b/frontend/src/components/ui/Input.tsx
@@ -0,0 +1,24 @@
+import { InputHTMLAttributes, forwardRef } from 'react'
+
+interface InputProps extends InputHTMLAttributes {
+ label?: string
+ error?: string
+ hint?: string
+}
+
+export const Input = forwardRef(
+ ({ label, error, hint, className = '', ...props }, ref) => (
+
+ {label &&
}
+
+ {hint && !error &&
{hint}
}
+ {error &&
{error}
}
+
+ )
+)
diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx
new file mode 100644
index 0000000..777d9a8
--- /dev/null
+++ b/frontend/src/components/ui/Modal.tsx
@@ -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 (
+
+ )
+}
diff --git a/frontend/src/components/ui/Select.tsx b/frontend/src/components/ui/Select.tsx
new file mode 100644
index 0000000..f05a292
--- /dev/null
+++ b/frontend/src/components/ui/Select.tsx
@@ -0,0 +1,17 @@
+import { SelectHTMLAttributes, forwardRef } from 'react'
+
+interface SelectProps extends SelectHTMLAttributes {
+ label?: string
+ options: { value: string; label: string }[]
+}
+
+export const Select = forwardRef(
+ ({ label, options, className = '', ...props }, ref) => (
+
+ {label && }
+
+
+ )
+)
diff --git a/frontend/src/components/ui/Table.tsx b/frontend/src/components/ui/Table.tsx
new file mode 100644
index 0000000..3dd7166
--- /dev/null
+++ b/frontend/src/components/ui/Table.tsx
@@ -0,0 +1,25 @@
+import { ReactNode } from 'react'
+
+export function Table({ children }: { children: ReactNode }) {
+ return
+}
+
+export function TableHeader({ children }: { children: ReactNode }) {
+ return {children}
+}
+
+export function TableHead({ children, className = '' }: { children: ReactNode; className?: string }) {
+ return {children} |
+}
+
+export function TableBody({ children }: { children: ReactNode }) {
+ return {children}
+}
+
+export function TableRow({ children, onClick, className = '' }: { children: ReactNode; onClick?: () => void; className?: string }) {
+ return {children}
+}
+
+export function TableCell({ children, className = '' }: { children: ReactNode; className?: string }) {
+ return {children} |
+}
diff --git a/frontend/src/components/ui/Tabs.tsx b/frontend/src/components/ui/Tabs.tsx
new file mode 100644
index 0000000..1b5fbd9
--- /dev/null
+++ b/frontend/src/components/ui/Tabs.tsx
@@ -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 {children}
+}
+
+export function TabsList({ children }: { children: ReactNode }) {
+ return {children}
+}
+
+export function TabsTrigger({ value, children }: { value: string; children: ReactNode }) {
+ const ctx = useContext(TabsContext)
+ if (!ctx) return null
+ return (
+
+ )
+}
+
+export function TabsContent({ value, children }: { value: string; children: ReactNode }) {
+ const ctx = useContext(TabsContext)
+ if (!ctx || ctx.activeTab !== value) return null
+ return {children}
+}
diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts
new file mode 100644
index 0000000..0cc04b5
--- /dev/null
+++ b/frontend/src/components/ui/index.ts
@@ -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'
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 0000000..90b5364
--- /dev/null
+++ b/frontend/src/index.css
@@ -0,0 +1,4 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+body { font-family: 'Inter', system-ui, sans-serif; }
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..cb66cea
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -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(
+
+
+
+
+
+
+ ,
+)
diff --git a/frontend/src/pages/Automation.tsx b/frontend/src/pages/Automation.tsx
new file mode 100644
index 0000000..97f7c74
--- /dev/null
+++ b/frontend/src/pages/Automation.tsx
@@ -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(mockRules)
+ const [showCreate, setShowCreate] = useState(false)
+
+ const toggleRule = (id: string) => {
+ setRules(rules.map(r => r.id === id ? { ...r, enabled: !r.enabled } : r))
+ }
+
+ return (
+
+
+
+
Automation
+
Automate repetitive tasks with rules
+
+
+
+
+ {/* Stats */}
+
+
+
+
{rules.length}
+
Total Rules
+
+
+
+
+
{rules.filter(r => r.enabled).length}
+
Active Rules
+
+
+
+
+
{rules.reduce((a, r) => a + r.runs, 0)}
+
Total Runs
+
+
+
+
+ {/* Rules List */}
+
+ {rules.map(rule => (
+
+
+
+
+
+
+
{rule.name}
+ {rule.enabled ? 'Active' : 'Disabled'}
+
+
{rule.trigger}
+
+ {rule.actions.map((action, i) => (
+ {action}
+ ))}
+
+
+
+
+
+
+ ))}
+
+
+ {/* Create Modal */}
+
setShowCreate(false)} title="Create Automation Rule" size="lg">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/Board.tsx b/frontend/src/pages/Board.tsx
new file mode 100644
index 0000000..489c972
--- /dev/null
+++ b/frontend/src/pages/Board.tsx
@@ -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('')
+ 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 = {
+ low: 'border-l-green-500',
+ medium: 'border-l-yellow-500',
+ high: 'border-l-orange-500',
+ critical: 'border-l-red-500',
+ }
+
+ return (
+
+
+
+
Board
+
Drag and drop to update status
+
+
+
+
+ {columns.map(col => (
+
+
+
+
{col.label}
+
({getTicketsByStatus(col.id).length})
+
+
+
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 => (
+
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`}
+ >
+
+ {ticket.key}
+
+
{ticket.title}
+
+
+ {ticket.priority}
+
+ {ticket.assignee &&
}
+
+
+ ))}
+ {getTicketsByStatus(col.id).length === 0 && (
+
No tickets
+ )}
+
+
+ ))}
+
+
+ )
+}
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx
new file mode 100644
index 0000000..4409dc9
--- /dev/null
+++ b/frontend/src/pages/Dashboard.tsx
@@ -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 (
+
+
+
+
Dashboard
+
Overview of your workspace
+
+
+
+
+
+
+ {/* Stats */}
+
+
+
+
+
+
+
+
+
+ {/* Recent Tickets */}
+
+
+
+
Recent Tickets
+ View all β
+
+
+ {recentTickets.length === 0 ? (
+
No tickets yet
+ ) : (
+ recentTickets.map(ticket =>
)
+ )}
+
+
+
+
+ {/* Projects */}
+
+
+
+
Projects
+ Manage β
+
+
+ {projects?.map(project => (
+
+
+
+
{project.name}
+
{project.key}
+
+
{project.ticket_count} tickets
+
+
+ ))}
+ {!projects?.length &&
No projects yet
}
+
+
+
+
+
+ )
+}
+
+function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
+ const colors: Record = {
+ 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 (
+
+
+
+ )
+}
+
+function TicketRow({ ticket }: { ticket: Ticket }) {
+ const statusColors: Record = {
+ 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 = {
+ low: 'π’', medium: 'π‘', high: 'π ', critical: 'π΄'
+ }
+ return (
+
+
+
+
{priorityIcons[ticket.priority]}
+
+
+ {ticket.key}
+
+ {ticket.status.replace('_', ' ')}
+
+
+
{ticket.title}
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/Integrations.tsx b/frontend/src/pages/Integrations.tsx
new file mode 100644
index 0000000..9bcbce0
--- /dev/null
+++ b/frontend/src/pages/Integrations.tsx
@@ -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(null)
+
+ return (
+
+
+
+
Integrations
+
Connect TicketHub with your tools
+
+
+
+
+
+ Connected ({integrations.connected.length})
+ Available ({integrations.available.length})
+
+
+
+ {integrations.connected.length === 0 ? (
+
+ π
+ No integrations yet
+ Connect your first integration to get started
+
+ ) : (
+
+ {integrations.connected.map(int => (
+
+
+
+
+ {int.icon}
+
+
+
+
{int.name}
+ Connected
+
+
{int.url}
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ {integrations.available.map(int => (
+
+
+
+ {int.icon}
+
+
+
{int.name}
+
{int.description}
+
+
+
+
+ ))}
+
+
+
+
+ {/* Webhook Section */}
+
+
Webhooks
+
+
+ Send ticket events to external services via webhooks. Configure webhook URLs per project in Project Settings.
+
+
+
+ POST
+ /api/webhooks/incoming
+ - Receive events from external services
+
+
+
+ Events sent: ticket.created, ticket.updated, ticket.resolved, comment.added
+
+
+
+
+ {/* Connect Modal */}
+
setShowConnect(null)} title={`Connect ${integrations.available.find(i => i.id === showConnect)?.name || ''}`}>
+
+
+
+ )
+}
diff --git a/frontend/src/pages/NewTicket.tsx b/frontend/src/pages/NewTicket.tsx
new file mode 100644
index 0000000..f4d5873
--- /dev/null
+++ b/frontend/src/pages/NewTicket.tsx
@@ -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 (
+
+
+
Create Ticket
+
Submit a new ticket for tracking
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/Projects.tsx b/frontend/src/pages/Projects.tsx
new file mode 100644
index 0000000..dfdd962
--- /dev/null
+++ b/frontend/src/pages/Projects.tsx
@@ -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(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) => projectsApi.update(id, data),
+ onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['projects'] }); setEditProject(null) },
+ })
+
+ const deleteMutation = useMutation({
+ mutationFn: projectsApi.delete,
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['projects'] }),
+ })
+
+ return (
+
+
+
+
Projects
+
Manage your projects and their settings
+
+
+
+
+ {/* Projects Grid */}
+
+ {projects?.map(project => (
+
+
+
+ {project.key.slice(0, 2)}
+
+
+
+
+
+
+ {project.name}
+ {project.key}
+
+ {project.description && (
+ {project.description}
+ )}
+
+
+
+
+ π« {project.ticket_count} tickets
+
+
+
+
+
+
+
+ {project.webhook_url && (
+
+ )}
+
+ ))}
+
+ {!projects?.length && (
+
+
π
+
No projects yet. Create your first project to get started.
+
+ )}
+
+
+ {/* Create Modal */}
+
setShowCreate(false)} title="Create Project">
+ createMutation.mutate(data)} loading={createMutation.isPending} />
+
+
+ {/* Edit Modal */}
+
setEditProject(null)} title="Edit Project">
+ {editProject && (
+ updateMutation.mutate({ id: editProject.id, ...data })}
+ loading={updateMutation.isPending}
+ onDelete={() => { deleteMutation.mutate(editProject.id); setEditProject(null) }}
+ />
+ )}
+
+
+ )
+}
+
+function ProjectForm({ initialData, onSubmit, loading, onDelete }: {
+ initialData?: Project
+ onSubmit: (data: Partial) => void
+ loading: boolean
+ onDelete?: () => void
+}) {
+ const [form, setForm] = useState({
+ name: initialData?.name || '',
+ key: initialData?.key || '',
+ description: initialData?.description || '',
+ webhook_url: initialData?.webhook_url || '',
+ })
+
+ return (
+
+ )
+}
diff --git a/frontend/src/pages/Reports.tsx b/frontend/src/pages/Reports.tsx
new file mode 100644
index 0000000..5f57cdd
--- /dev/null
+++ b/frontend/src/pages/Reports.tsx
@@ -0,0 +1,135 @@
+import { Card, Select } from '../components/ui'
+
+export default function Reports() {
+ return (
+
+
+
+
Reports
+
Analytics and insights
+
+
+
+
+ {/* KPIs */}
+
+
+
+
+
+
+
+
+ {/* Status Distribution */}
+
+ Status Distribution
+
+ {[
+ { 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 => (
+
+
+ {item.label}
+ {item.value} ({item.pct}%)
+
+
+
+ ))}
+
+
+
+ {/* Priority Distribution */}
+
+ Priority Distribution
+
+ {[
+ { 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 => (
+
+
+ {item.label}
+ {item.value} ({item.pct}%)
+
+
+
+ ))}
+
+
+
+
+ {/* Tickets Over Time */}
+
+ Tickets Over Time
+
+ {[15, 22, 18, 30, 25, 35, 28, 40, 32, 45, 38, 42].map((h, i) => (
+
+
+
{['J','F','M','A','M','J','J','A','S','O','N','D'][i]}
+
+ ))}
+
+
+
+ {/* Top Assignees */}
+
+ Top Performers
+
+ {[
+ { 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) => (
+
+
+
+ {i + 1}
+
+ {user.name}
+
+
+
+ Resolved:
+ {user.resolved}
+
+
+ Avg Time:
+ {user.time}
+
+
+
+ ))}
+
+
+
+ )
+}
+
+function KPICard({ title, value, change, trend }: { title: string; value: string; change: string; trend: 'up' | 'down' }) {
+ return (
+
+ {title}
+ {value}
+
+ {change} vs last period
+
+
+ )
+}
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx
new file mode 100644
index 0000000..7b1d637
--- /dev/null
+++ b/frontend/src/pages/Settings.tsx
@@ -0,0 +1,175 @@
+import { Card, Input, Select, Button, Tabs, TabsList, TabsTrigger, TabsContent } from '../components/ui'
+
+export default function Settings() {
+ return (
+
+
+
Settings
+
Manage your workspace settings
+
+
+
+
+ General
+ Notifications
+ Security
+ API
+
+
+
+
+ Workspace Settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Email Notifications
+
+ {[
+ '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 => (
+
+ ))}
+
+
+ Slack Notifications
+
+
+
+
+
+
+
+
+
+
+
+
+ Authentication
+
+
+
+
+
Session Settings
+
+
+
IP Restrictions
+
+
+
+
+
+
+
+
+
+
+
+ API Keys
+
+ Use API keys to authenticate with the TicketHub API.
+
+
+
+
+
+
tk_live_β’β’β’β’β’β’β’β’β’β’β’β’β’β’β’β’
+
Created Feb 18, 2026
+
+
+
+
+
+
+
+
+
+
+ API Documentation
+
+
+ GET
+ /api/projects
+
+
+ POST
+ /api/tickets
+
+
+ PATCH
+ /api/tickets/:id
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/Team.tsx b/frontend/src/pages/Team.tsx
new file mode 100644
index 0000000..854eeae
--- /dev/null
+++ b/frontend/src/pages/Team.tsx
@@ -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(mockTeam)
+ const [showInvite, setShowInvite] = useState(false)
+
+ const roleColors: Record = {
+ admin: 'error', member: 'info', viewer: 'default'
+ }
+
+ return (
+
+
+
+
Team
+
Manage team members and permissions
+
+
+
+
+ {/* Role Legend */}
+
+
+ Admin
+ Full access
+
+
+ Member
+ Create & edit tickets
+
+
+ Viewer
+ Read-only
+
+
+
+ {/* Team Grid */}
+
+ {team.map(member => (
+
+
+ {member.avatar.length <= 2 ? (
+
+ ) : (
+
+ {member.avatar}
+
+ )}
+
+
+
{member.name}
+ {member.role}
+
+
{member.email}
+
+
+
+
+
+
{member.tickets}
+
Tickets Assigned
+
+
+
{new Date(member.lastActive).toLocaleDateString()}
+
Last Active
+
+
+
+
+
+
+
+
+ ))}
+
+
+ {/* Invite Modal */}
+
setShowInvite(false)} title="Invite Team Member">
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/TicketDetail.tsx b/frontend/src/pages/TicketDetail.tsx
new file mode 100644
index 0000000..49efa12
--- /dev/null
+++ b/frontend/src/pages/TicketDetail.tsx
@@ -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) => 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 Loading...
+ if (!ticket) return Ticket not found
+
+ const statusColors: Record = {
+ open: 'info', in_progress: 'warning', resolved: 'success', closed: 'default'
+ }
+ const priorityColors: Record = {
+ low: 'success', medium: 'warning', high: 'error', critical: 'error'
+ }
+
+ return (
+
+ {/* Breadcrumb */}
+
+ β Back to Tickets
+
+
+
+ {/* Main Content */}
+
+ {/* Header */}
+
+
+
+
+ {ticket.key}
+ {ticket.status.replace('_', ' ')}
+ {ticket.priority}
+
+
{ticket.title}
+
+
+
+
+
+
Description
+
+ {ticket.description}
+
+
+
+
+ {/* Comments */}
+
+ Activity
+
+
+ {comments?.map(c => (
+
+
+
+
+ {c.author}
+ {new Date(c.created_at).toLocaleString()}
+
+
+ {c.content}
+
+
+
+ ))}
+ {!comments?.length &&
No comments yet
}
+
+
+
+
+
+
+
+ {/* Sidebar */}
+
+ {/* Actions */}
+
+ Actions
+
+
+
+
+ {/* Details */}
+
+ Details
+
+
+
- Assignee
+
-
+ {ticket.assignee ? (
+
+ ) : (
+
+ )}
+
+
+
+
- Reporter
+ - {ticket.reporter || 'Unknown'}
+
+
+
- Created
+ - {new Date(ticket.created_at).toLocaleString()}
+
+
+
- Updated
+ - {new Date(ticket.updated_at).toLocaleString()}
+
+
+
+
+ {/* Related */}
+
+ Integrations
+
+
+
+
+
+
+
+
+ {/* Assign Modal */}
+
setShowAssign(false)} title="Assign Ticket">
+
+
+
+ {['Ricel Leite', 'AI Assistant', 'Developer'].map(name => (
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/Tickets.tsx b/frontend/src/pages/Tickets.tsx
new file mode 100644
index 0000000..842a5e9
--- /dev/null
+++ b/frontend/src/pages/Tickets.tsx
@@ -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 = {
+ open: 'info', in_progress: 'warning', resolved: 'success', closed: 'default'
+ }
+ const priorityColors: Record = {
+ low: 'success', medium: 'warning', high: 'error', critical: 'error'
+ }
+
+ return (
+
+
+
+
Tickets
+
{filteredTickets.length} tickets found
+
+
+
+
+
+
+ {/* Filters */}
+
+
+ ({ value: String(p.id), label: p.name })) || [])]}
+ value={filters.project}
+ onChange={e => setFilters({ ...filters, project: e.target.value })}
+ />
+ setFilters({ ...filters, status: e.target.value })}
+ />
+ setFilters({ ...filters, priority: e.target.value })}
+ />
+
+
+
+
+ {/* Tickets Table */}
+
+
+
+ Key
+ Title
+ Status
+ Priority
+ Assignee
+ Created
+
+
+ {filteredTickets.length === 0 ? (
+
+
+ No tickets found
+
+
+ ) : (
+ filteredTickets.map(ticket => (
+ window.location.href = `/tickets/${ticket.id}`}>
+
+ {ticket.key}
+
+
+ {ticket.title}
+ {ticket.description}
+
+
+ {ticket.status.replace('_', ' ')}
+
+
+ {ticket.priority}
+
+
+ {ticket.assignee ? (
+
+ ) : (
+ Unassigned
+ )}
+
+
+
+ {new Date(ticket.created_at).toLocaleDateString()}
+
+
+
+ ))
+ )}
+
+
+
+
+ )
+}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
new file mode 100644
index 0000000..53d8fab
--- /dev/null
+++ b/frontend/src/services/api.ts
@@ -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 => (await api.get('/projects')).data,
+ get: async (id: number): Promise => (await api.get(`/projects/${id}`)).data,
+ create: async (data: Partial): Promise => (await api.post('/projects', data)).data,
+ update: async (id: number, data: Partial): Promise => (await api.patch(`/projects/${id}`, data)).data,
+ delete: async (id: number): Promise => api.delete(`/projects/${id}`),
+}
+
+export const ticketsApi = {
+ list: async (projectId?: number, status?: string): Promise => {
+ 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 => (await api.get(`/tickets/${id}`)).data,
+ create: async (data: Partial): Promise => (await api.post('/tickets', data)).data,
+ update: async (id: number, data: Partial): Promise => (await api.patch(`/tickets/${id}`, data)).data,
+ delete: async (id: number): Promise => api.delete(`/tickets/${id}`),
+ getComments: async (id: number): Promise => (await api.get(`/tickets/${id}/comments`)).data,
+ addComment: async (id: number, data: { author: string; content: string }): Promise =>
+ (await api.post(`/tickets/${id}/comments`, data)).data,
+}
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
new file mode 100644
index 0000000..6397b9c
--- /dev/null
+++ b/frontend/tailwind.config.js
@@ -0,0 +1,5 @@
+export default {
+ content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
+ theme: { extend: {} },
+ plugins: [],
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..42e0521
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -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"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..6318ceb
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -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'
+ }
+ }
+})