feat: Add bug tracker MVP (decoupled, feature-flagged)
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
- Backend: Ticket and TicketComment models (no FK to User) - API: /tickets/* endpoints for submit, view, comment, upvote - Admin: /tickets/admin/* for triage queue - Frontend: /bugs pages (submit, my-tickets, known-issues, detail) - Feature flag: ENABLE_BUG_TRACKER env var (default: true) To disable: Set ENABLE_BUG_TRACKER=false To remove: Delete models_tickets.py, routers/tickets.py, frontend/app/bugs/
This commit is contained in:
parent
2da46eaa16
commit
14a509ddb5
8 changed files with 1597 additions and 0 deletions
|
|
@ -1,8 +1,12 @@
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
import os
|
||||||
from routers import auth, shows, venues, songs, social, tours, artists, preferences, reviews, badges, nicknames, moderation, attendance, groups, users, search, performances, notifications, feed, leaderboards, stats, admin, chase, gamification, videos
|
from routers import auth, shows, venues, songs, social, tours, artists, preferences, reviews, badges, nicknames, moderation, attendance, groups, users, search, performances, notifications, feed, leaderboards, stats, admin, chase, gamification, videos
|
||||||
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
# Feature flags - set to False to disable features
|
||||||
|
ENABLE_BUG_TRACKER = os.getenv("ENABLE_BUG_TRACKER", "true").lower() == "true"
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
|
|
@ -39,6 +43,11 @@ app.include_router(chase.router)
|
||||||
app.include_router(gamification.router)
|
app.include_router(gamification.router)
|
||||||
app.include_router(videos.router)
|
app.include_router(videos.router)
|
||||||
|
|
||||||
|
# Optional features - can be disabled via env vars
|
||||||
|
if ENABLE_BUG_TRACKER:
|
||||||
|
from routers import tickets
|
||||||
|
app.include_router(tickets.router)
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def read_root():
|
def read_root():
|
||||||
return {"Hello": "World"}
|
return {"Hello": "World"}
|
||||||
|
|
|
||||||
140
backend/models_tickets.py
Normal file
140
backend/models_tickets.py
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
"""
|
||||||
|
Bug Tracker Models - ISOLATED MODULE
|
||||||
|
No dependencies on main Elmeg models.
|
||||||
|
Can be removed by: deleting this file + routes file + removing router import from main.py
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
from sqlmodel import SQLModel, Field, Relationship
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class TicketType(str, Enum):
|
||||||
|
BUG = "bug"
|
||||||
|
FEATURE = "feature"
|
||||||
|
QUESTION = "question"
|
||||||
|
OTHER = "other"
|
||||||
|
|
||||||
|
|
||||||
|
class TicketStatus(str, Enum):
|
||||||
|
OPEN = "open"
|
||||||
|
IN_PROGRESS = "in_progress"
|
||||||
|
RESOLVED = "resolved"
|
||||||
|
CLOSED = "closed"
|
||||||
|
|
||||||
|
|
||||||
|
class TicketPriority(str, Enum):
|
||||||
|
LOW = "low"
|
||||||
|
MEDIUM = "medium"
|
||||||
|
HIGH = "high"
|
||||||
|
CRITICAL = "critical"
|
||||||
|
|
||||||
|
|
||||||
|
class Ticket(SQLModel, table=True):
|
||||||
|
"""
|
||||||
|
Support ticket - fully decoupled from User model.
|
||||||
|
Stores reporter info as strings, not FKs.
|
||||||
|
"""
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
ticket_number: str = Field(unique=True, index=True) # ELM-001
|
||||||
|
|
||||||
|
type: TicketType = Field(default=TicketType.BUG)
|
||||||
|
status: TicketStatus = Field(default=TicketStatus.OPEN)
|
||||||
|
priority: TicketPriority = Field(default=TicketPriority.MEDIUM)
|
||||||
|
|
||||||
|
title: str = Field(max_length=200)
|
||||||
|
description: str = Field(default="")
|
||||||
|
|
||||||
|
# Reporter info - stored as strings, not FK
|
||||||
|
reporter_email: str = Field(index=True)
|
||||||
|
reporter_name: Optional[str] = None
|
||||||
|
reporter_user_id: Optional[int] = None # Reference only, not FK
|
||||||
|
|
||||||
|
# Assignment - stored as strings
|
||||||
|
assigned_to_email: Optional[str] = None
|
||||||
|
assigned_to_name: Optional[str] = None
|
||||||
|
|
||||||
|
is_public: bool = Field(default=False)
|
||||||
|
upvotes: int = Field(default=0)
|
||||||
|
|
||||||
|
# Environment info
|
||||||
|
browser: Optional[str] = None
|
||||||
|
os: Optional[str] = None
|
||||||
|
page_url: Optional[str] = None
|
||||||
|
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
resolved_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
# Relationships (within ticket system only)
|
||||||
|
comments: List["TicketComment"] = Relationship(back_populates="ticket")
|
||||||
|
|
||||||
|
|
||||||
|
class TicketComment(SQLModel, table=True):
|
||||||
|
"""Comment on a ticket - no FK to User"""
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
ticket_id: int = Field(foreign_key="ticket.id")
|
||||||
|
|
||||||
|
# Author info - stored as strings
|
||||||
|
author_email: str
|
||||||
|
author_name: str
|
||||||
|
author_user_id: Optional[int] = None # Reference only
|
||||||
|
|
||||||
|
content: str
|
||||||
|
is_internal: bool = Field(default=False) # Admin-only visibility
|
||||||
|
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationship
|
||||||
|
ticket: Optional[Ticket] = Relationship(back_populates="comments")
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Schemas ============
|
||||||
|
|
||||||
|
class TicketCreate(SQLModel):
|
||||||
|
type: TicketType = TicketType.BUG
|
||||||
|
priority: TicketPriority = TicketPriority.MEDIUM
|
||||||
|
title: str
|
||||||
|
description: str = ""
|
||||||
|
reporter_email: Optional[str] = None
|
||||||
|
reporter_name: Optional[str] = None
|
||||||
|
browser: Optional[str] = None
|
||||||
|
os: Optional[str] = None
|
||||||
|
page_url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TicketUpdate(SQLModel):
|
||||||
|
status: Optional[TicketStatus] = None
|
||||||
|
priority: Optional[TicketPriority] = None
|
||||||
|
assigned_to_email: Optional[str] = None
|
||||||
|
assigned_to_name: Optional[str] = None
|
||||||
|
is_public: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TicketCommentCreate(SQLModel):
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class TicketRead(SQLModel):
|
||||||
|
id: int
|
||||||
|
ticket_number: str
|
||||||
|
type: TicketType
|
||||||
|
status: TicketStatus
|
||||||
|
priority: TicketPriority
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
reporter_email: str
|
||||||
|
reporter_name: Optional[str]
|
||||||
|
is_public: bool
|
||||||
|
upvotes: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
resolved_at: Optional[datetime]
|
||||||
|
|
||||||
|
|
||||||
|
class TicketCommentRead(SQLModel):
|
||||||
|
id: int
|
||||||
|
author_name: str
|
||||||
|
content: str
|
||||||
|
is_internal: bool
|
||||||
|
created_at: datetime
|
||||||
371
backend/routers/tickets.py
Normal file
371
backend/routers/tickets.py
Normal file
|
|
@ -0,0 +1,371 @@
|
||||||
|
"""
|
||||||
|
Bug Tracker API Routes - ISOLATED MODULE
|
||||||
|
|
||||||
|
To remove this feature:
|
||||||
|
1. Delete this file
|
||||||
|
2. Delete models_tickets.py
|
||||||
|
3. Remove router import from main.py
|
||||||
|
4. Drop 'ticket' and 'ticketcomment' tables from database
|
||||||
|
"""
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlmodel import Session, select, func
|
||||||
|
|
||||||
|
from database import get_session
|
||||||
|
|
||||||
|
# Import auth helpers but don't create hard dependency
|
||||||
|
try:
|
||||||
|
from auth import get_current_user, get_current_user_optional
|
||||||
|
from models import User
|
||||||
|
AUTH_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
AUTH_AVAILABLE = False
|
||||||
|
User = None
|
||||||
|
def get_current_user():
|
||||||
|
return None
|
||||||
|
def get_current_user_optional():
|
||||||
|
return None
|
||||||
|
|
||||||
|
from models_tickets import (
|
||||||
|
Ticket, TicketComment, TicketType, TicketStatus, TicketPriority,
|
||||||
|
TicketCreate, TicketUpdate, TicketCommentCreate, TicketRead, TicketCommentRead
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/tickets", tags=["tickets"])
|
||||||
|
|
||||||
|
|
||||||
|
def generate_ticket_number(session: Session) -> str:
|
||||||
|
"""Generate next ticket number like ELM-001"""
|
||||||
|
result = session.exec(select(func.count(Ticket.id))).one()
|
||||||
|
return f"ELM-{(result + 1):03d}"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Public Endpoints ---
|
||||||
|
|
||||||
|
@router.post("/", response_model=TicketRead)
|
||||||
|
def create_ticket(
|
||||||
|
ticket: TicketCreate,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user = Depends(get_current_user_optional) if AUTH_AVAILABLE else None
|
||||||
|
):
|
||||||
|
"""Create a new support ticket"""
|
||||||
|
# Determine reporter info
|
||||||
|
if current_user:
|
||||||
|
reporter_email = current_user.email
|
||||||
|
reporter_name = current_user.email.split("@")[0]
|
||||||
|
reporter_user_id = current_user.id
|
||||||
|
elif ticket.reporter_email:
|
||||||
|
reporter_email = ticket.reporter_email
|
||||||
|
reporter_name = ticket.reporter_name or ticket.reporter_email.split("@")[0]
|
||||||
|
reporter_user_id = None
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail="Email required for anonymous submissions")
|
||||||
|
|
||||||
|
db_ticket = Ticket(
|
||||||
|
ticket_number=generate_ticket_number(session),
|
||||||
|
type=ticket.type,
|
||||||
|
priority=ticket.priority,
|
||||||
|
title=ticket.title,
|
||||||
|
description=ticket.description,
|
||||||
|
reporter_email=reporter_email,
|
||||||
|
reporter_name=reporter_name,
|
||||||
|
reporter_user_id=reporter_user_id,
|
||||||
|
browser=ticket.browser,
|
||||||
|
os=ticket.os,
|
||||||
|
page_url=ticket.page_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(db_ticket)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(db_ticket)
|
||||||
|
|
||||||
|
return db_ticket
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/known-issues", response_model=List[TicketRead])
|
||||||
|
def get_known_issues(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = Query(default=50, le=100)
|
||||||
|
):
|
||||||
|
"""Get public/known issues"""
|
||||||
|
tickets = session.exec(
|
||||||
|
select(Ticket)
|
||||||
|
.where(Ticket.is_public == True)
|
||||||
|
.where(Ticket.status.in_([TicketStatus.OPEN, TicketStatus.IN_PROGRESS]))
|
||||||
|
.order_by(Ticket.upvotes.desc(), Ticket.created_at.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
).all()
|
||||||
|
return tickets
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/my-tickets", response_model=List[TicketRead])
|
||||||
|
def get_my_tickets(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user = Depends(get_current_user) if AUTH_AVAILABLE else None
|
||||||
|
):
|
||||||
|
"""Get tickets submitted by current user"""
|
||||||
|
if not current_user:
|
||||||
|
raise HTTPException(status_code=401, detail="Login required")
|
||||||
|
|
||||||
|
tickets = session.exec(
|
||||||
|
select(Ticket)
|
||||||
|
.where(Ticket.reporter_user_id == current_user.id)
|
||||||
|
.order_by(Ticket.created_at.desc())
|
||||||
|
).all()
|
||||||
|
return tickets
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{ticket_number}", response_model=TicketRead)
|
||||||
|
def get_ticket(
|
||||||
|
ticket_number: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user = Depends(get_current_user_optional) if AUTH_AVAILABLE else None
|
||||||
|
):
|
||||||
|
"""Get ticket by number"""
|
||||||
|
ticket = session.exec(
|
||||||
|
select(Ticket).where(Ticket.ticket_number == ticket_number)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not ticket:
|
||||||
|
raise HTTPException(status_code=404, detail="Ticket not found")
|
||||||
|
|
||||||
|
# Check visibility
|
||||||
|
if not ticket.is_public:
|
||||||
|
if not current_user:
|
||||||
|
raise HTTPException(status_code=403, detail="Login required")
|
||||||
|
is_admin = getattr(current_user, 'is_superuser', False)
|
||||||
|
if ticket.reporter_user_id != current_user.id and not is_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
return ticket
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{ticket_number}/comments", response_model=List[TicketCommentRead])
|
||||||
|
def get_ticket_comments(
|
||||||
|
ticket_number: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user = Depends(get_current_user_optional) if AUTH_AVAILABLE else None
|
||||||
|
):
|
||||||
|
"""Get comments for a ticket"""
|
||||||
|
ticket = session.exec(
|
||||||
|
select(Ticket).where(Ticket.ticket_number == ticket_number)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not ticket:
|
||||||
|
raise HTTPException(status_code=404, detail="Ticket not found")
|
||||||
|
|
||||||
|
# Filter internal comments for non-admins
|
||||||
|
query = select(TicketComment).where(TicketComment.ticket_id == ticket.id)
|
||||||
|
is_admin = current_user and getattr(current_user, 'is_superuser', False)
|
||||||
|
if not is_admin:
|
||||||
|
query = query.where(TicketComment.is_internal == False)
|
||||||
|
|
||||||
|
comments = session.exec(query.order_by(TicketComment.created_at)).all()
|
||||||
|
return comments
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{ticket_number}/comments", response_model=TicketCommentRead)
|
||||||
|
def add_comment(
|
||||||
|
ticket_number: str,
|
||||||
|
comment: TicketCommentCreate,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user = Depends(get_current_user) if AUTH_AVAILABLE else None
|
||||||
|
):
|
||||||
|
"""Add a comment to a ticket"""
|
||||||
|
if not current_user:
|
||||||
|
raise HTTPException(status_code=401, detail="Login required")
|
||||||
|
|
||||||
|
ticket = session.exec(
|
||||||
|
select(Ticket).where(Ticket.ticket_number == ticket_number)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not ticket:
|
||||||
|
raise HTTPException(status_code=404, detail="Ticket not found")
|
||||||
|
|
||||||
|
# Check access
|
||||||
|
is_admin = getattr(current_user, 'is_superuser', False)
|
||||||
|
if ticket.reporter_user_id != current_user.id and not is_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
db_comment = TicketComment(
|
||||||
|
ticket_id=ticket.id,
|
||||||
|
author_user_id=current_user.id,
|
||||||
|
author_email=current_user.email,
|
||||||
|
author_name=current_user.email.split("@")[0],
|
||||||
|
content=comment.content,
|
||||||
|
is_internal=False
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(db_comment)
|
||||||
|
|
||||||
|
# Update ticket timestamp
|
||||||
|
ticket.updated_at = datetime.utcnow()
|
||||||
|
session.add(ticket)
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
session.refresh(db_comment)
|
||||||
|
|
||||||
|
return db_comment
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{ticket_number}/upvote")
|
||||||
|
def upvote_ticket(
|
||||||
|
ticket_number: str,
|
||||||
|
session: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
"""Upvote a public ticket"""
|
||||||
|
ticket = session.exec(
|
||||||
|
select(Ticket).where(Ticket.ticket_number == ticket_number)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not ticket:
|
||||||
|
raise HTTPException(status_code=404, detail="Ticket not found")
|
||||||
|
|
||||||
|
if not ticket.is_public:
|
||||||
|
raise HTTPException(status_code=403, detail="Not a public ticket")
|
||||||
|
|
||||||
|
ticket.upvotes += 1
|
||||||
|
session.add(ticket)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return {"upvotes": ticket.upvotes}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Admin Endpoints ---
|
||||||
|
|
||||||
|
@router.get("/admin/all", response_model=List[TicketRead])
|
||||||
|
def admin_get_all_tickets(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user = Depends(get_current_user) if AUTH_AVAILABLE else None,
|
||||||
|
status: Optional[TicketStatus] = None,
|
||||||
|
type: Optional[TicketType] = None,
|
||||||
|
priority: Optional[TicketPriority] = None,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = Query(default=50, le=100)
|
||||||
|
):
|
||||||
|
"""Admin: Get all tickets with filters"""
|
||||||
|
if not current_user or not getattr(current_user, 'is_superuser', False):
|
||||||
|
raise HTTPException(status_code=403, detail="Admin access required")
|
||||||
|
|
||||||
|
query = select(Ticket)
|
||||||
|
|
||||||
|
if status:
|
||||||
|
query = query.where(Ticket.status == status)
|
||||||
|
if type:
|
||||||
|
query = query.where(Ticket.type == type)
|
||||||
|
if priority:
|
||||||
|
query = query.where(Ticket.priority == priority)
|
||||||
|
|
||||||
|
tickets = session.exec(
|
||||||
|
query.order_by(Ticket.created_at.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
return tickets
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/admin/{ticket_number}", response_model=TicketRead)
|
||||||
|
def admin_update_ticket(
|
||||||
|
ticket_number: str,
|
||||||
|
update: TicketUpdate,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user = Depends(get_current_user) if AUTH_AVAILABLE else None
|
||||||
|
):
|
||||||
|
"""Admin: Update ticket status, priority, assignment"""
|
||||||
|
if not current_user or not getattr(current_user, 'is_superuser', False):
|
||||||
|
raise HTTPException(status_code=403, detail="Admin access required")
|
||||||
|
|
||||||
|
ticket = session.exec(
|
||||||
|
select(Ticket).where(Ticket.ticket_number == ticket_number)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not ticket:
|
||||||
|
raise HTTPException(status_code=404, detail="Ticket not found")
|
||||||
|
|
||||||
|
if update.status is not None:
|
||||||
|
ticket.status = update.status
|
||||||
|
if update.status == TicketStatus.RESOLVED:
|
||||||
|
ticket.resolved_at = datetime.utcnow()
|
||||||
|
|
||||||
|
if update.priority is not None:
|
||||||
|
ticket.priority = update.priority
|
||||||
|
|
||||||
|
if update.assigned_to_email is not None:
|
||||||
|
ticket.assigned_to_email = update.assigned_to_email
|
||||||
|
ticket.assigned_to_name = update.assigned_to_name
|
||||||
|
|
||||||
|
if update.is_public is not None:
|
||||||
|
ticket.is_public = update.is_public
|
||||||
|
|
||||||
|
ticket.updated_at = datetime.utcnow()
|
||||||
|
session.add(ticket)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(ticket)
|
||||||
|
|
||||||
|
return ticket
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/{ticket_number}/internal-note", response_model=TicketCommentRead)
|
||||||
|
def admin_add_internal_note(
|
||||||
|
ticket_number: str,
|
||||||
|
comment: TicketCommentCreate,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user = Depends(get_current_user) if AUTH_AVAILABLE else None
|
||||||
|
):
|
||||||
|
"""Admin: Add internal note (not visible to user)"""
|
||||||
|
if not current_user or not getattr(current_user, 'is_superuser', False):
|
||||||
|
raise HTTPException(status_code=403, detail="Admin access required")
|
||||||
|
|
||||||
|
ticket = session.exec(
|
||||||
|
select(Ticket).where(Ticket.ticket_number == ticket_number)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not ticket:
|
||||||
|
raise HTTPException(status_code=404, detail="Ticket not found")
|
||||||
|
|
||||||
|
db_comment = TicketComment(
|
||||||
|
ticket_id=ticket.id,
|
||||||
|
author_user_id=current_user.id,
|
||||||
|
author_email=current_user.email,
|
||||||
|
author_name=current_user.email.split("@")[0],
|
||||||
|
content=comment.content,
|
||||||
|
is_internal=True
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(db_comment)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(db_comment)
|
||||||
|
|
||||||
|
return db_comment
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/stats")
|
||||||
|
def admin_get_stats(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user = Depends(get_current_user) if AUTH_AVAILABLE else None
|
||||||
|
):
|
||||||
|
"""Admin: Get ticket statistics"""
|
||||||
|
if not current_user or not getattr(current_user, 'is_superuser', False):
|
||||||
|
raise HTTPException(status_code=403, detail="Admin access required")
|
||||||
|
|
||||||
|
total = session.exec(select(func.count(Ticket.id))).one()
|
||||||
|
open_count = session.exec(
|
||||||
|
select(func.count(Ticket.id)).where(Ticket.status == TicketStatus.OPEN)
|
||||||
|
).one()
|
||||||
|
in_progress = session.exec(
|
||||||
|
select(func.count(Ticket.id)).where(Ticket.status == TicketStatus.IN_PROGRESS)
|
||||||
|
).one()
|
||||||
|
resolved = session.exec(
|
||||||
|
select(func.count(Ticket.id)).where(Ticket.status == TicketStatus.RESOLVED)
|
||||||
|
).one()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"open": open_count,
|
||||||
|
"in_progress": in_progress,
|
||||||
|
"resolved": resolved
|
||||||
|
}
|
||||||
248
docs/BUGS_TRACKER_SPEC.md
Normal file
248
docs/BUGS_TRACKER_SPEC.md
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
# Elmeg Bug Tracker Spec
|
||||||
|
|
||||||
|
**Domain:** bugs.elmeg.xyz
|
||||||
|
**Style:** Zendesk-inspired support portal
|
||||||
|
**Date:** 2023-12-23
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A lightweight, self-hosted bug/feedback tracker with a clean, user-first interface. Users can submit issues, track status, and browse known issues. Admins can triage, respond, and resolve.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User-Facing Features
|
||||||
|
|
||||||
|
### 1. Submit a Report
|
||||||
|
|
||||||
|
- **Type:** Bug, Feature Request, Question, Other
|
||||||
|
- **Title:** Short description (required)
|
||||||
|
- **Description:** Rich text with markdown support
|
||||||
|
- **Attachments:** Screenshot upload (optional)
|
||||||
|
- **Priority:** (auto or user-selectable) Low, Medium, High, Critical
|
||||||
|
- **Email:** For anonymous users (logged-in users auto-fill)
|
||||||
|
- **Environment:** Auto-capture: Browser, OS, page URL
|
||||||
|
|
||||||
|
### 2. My Tickets
|
||||||
|
|
||||||
|
- View submitted tickets
|
||||||
|
- Track status: Open → In Progress → Resolved → Closed
|
||||||
|
- Add comments/updates to existing tickets
|
||||||
|
- Receive email notifications on updates
|
||||||
|
|
||||||
|
### 3. Knowledge Base / Known Issues
|
||||||
|
|
||||||
|
- Browse open bugs (public visibility toggle per ticket)
|
||||||
|
- Search functionality
|
||||||
|
- "Me too" upvote button
|
||||||
|
- FAQ section
|
||||||
|
|
||||||
|
### 4. Anonymous vs Authenticated
|
||||||
|
|
||||||
|
- Logged-in Elmeg users: auto-linked, no email required
|
||||||
|
- Anonymous: requires email for updates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin Features
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
|
||||||
|
- Ticket queue (filterable by status, type, priority, date)
|
||||||
|
- Unassigned tickets highlight
|
||||||
|
- Metrics: open count, avg response time, resolution rate
|
||||||
|
|
||||||
|
### Ticket Management
|
||||||
|
|
||||||
|
- Assign to self or team member
|
||||||
|
- Internal notes (not visible to user)
|
||||||
|
- Public reply
|
||||||
|
- Change status, priority, type
|
||||||
|
- Merge duplicate tickets
|
||||||
|
- Mark as known issue (public)
|
||||||
|
|
||||||
|
### Canned Responses
|
||||||
|
|
||||||
|
- Pre-written templates for common issues
|
||||||
|
- Variable substitution: `{user_name}`, `{ticket_id}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Architecture
|
||||||
|
|
||||||
|
### Stack
|
||||||
|
|
||||||
|
- **Frontend:** Next.js (standalone app, shares Elmeg styling)
|
||||||
|
- **Backend:** FastAPI (can share auth with main Elmeg backend)
|
||||||
|
- **Database:** PostgreSQL (new schema in same db or separate)
|
||||||
|
- **Storage:** S3/local for attachments
|
||||||
|
|
||||||
|
### Models
|
||||||
|
|
||||||
|
```
|
||||||
|
Ticket
|
||||||
|
├── id (uuid)
|
||||||
|
├── ticket_number (auto-increment display ID: ELM-001)
|
||||||
|
├── type (bug | feature | question | other)
|
||||||
|
├── status (open | in_progress | resolved | closed)
|
||||||
|
├── priority (low | medium | high | critical)
|
||||||
|
├── title
|
||||||
|
├── description
|
||||||
|
├── reporter_email
|
||||||
|
├── reporter_user_id (nullable, FK to elmeg user)
|
||||||
|
├── assigned_to (nullable, FK to admin user)
|
||||||
|
├── is_public (for known issues)
|
||||||
|
├── upvotes
|
||||||
|
├── environment (JSON: browser, os, url)
|
||||||
|
├── created_at
|
||||||
|
├── updated_at
|
||||||
|
├── resolved_at
|
||||||
|
|
||||||
|
TicketComment
|
||||||
|
├── id
|
||||||
|
├── ticket_id (FK)
|
||||||
|
├── author_id (FK)
|
||||||
|
├── author_email (for anonymous)
|
||||||
|
├── content
|
||||||
|
├── is_internal (admin-only visibility)
|
||||||
|
├── created_at
|
||||||
|
|
||||||
|
TicketAttachment
|
||||||
|
├── id
|
||||||
|
├── ticket_id (FK)
|
||||||
|
├── filename
|
||||||
|
├── url
|
||||||
|
├── created_at
|
||||||
|
|
||||||
|
CannedResponse
|
||||||
|
├── id
|
||||||
|
├── title
|
||||||
|
├── content
|
||||||
|
├── created_by
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
**Public:**
|
||||||
|
|
||||||
|
- `POST /tickets` - Create ticket
|
||||||
|
- `GET /tickets/{ticket_number}` - View ticket (if public or owned)
|
||||||
|
- `POST /tickets/{id}/comments` - Add comment
|
||||||
|
- `POST /tickets/{id}/upvote` - Upvote known issue
|
||||||
|
- `GET /known-issues` - List public tickets
|
||||||
|
|
||||||
|
**Authenticated (Elmeg user):**
|
||||||
|
|
||||||
|
- `GET /my-tickets` - User's tickets
|
||||||
|
|
||||||
|
**Admin:**
|
||||||
|
|
||||||
|
- `GET /admin/tickets` - All tickets (filtered)
|
||||||
|
- `PATCH /admin/tickets/{id}` - Update status, assign, etc.
|
||||||
|
- `POST /admin/tickets/{id}/internal-note` - Add internal note
|
||||||
|
- `GET /admin/canned-responses`
|
||||||
|
- `POST /admin/canned-responses`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI Pages
|
||||||
|
|
||||||
|
| Route | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `/` | Landing: "How can we help?" + Submit form |
|
||||||
|
| `/submit` | Full bug report form |
|
||||||
|
| `/my-tickets` | User's submitted tickets |
|
||||||
|
| `/ticket/[id]` | Ticket detail + comments |
|
||||||
|
| `/known-issues` | Public bug list with search |
|
||||||
|
| `/admin` | Admin dashboard |
|
||||||
|
| `/admin/ticket/[id]` | Admin ticket view |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Email Notifications
|
||||||
|
|
||||||
|
| Event | Recipient |
|
||||||
|
|-------|-----------|
|
||||||
|
| Ticket created | User (confirmation) |
|
||||||
|
| Admin replies | User |
|
||||||
|
| Status changed | User |
|
||||||
|
| User comment | Assigned admin |
|
||||||
|
|
||||||
|
Uses existing AWS SES integration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Docker Compose (separate service)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
bugs-frontend:
|
||||||
|
build: ./bugs-frontend
|
||||||
|
labels:
|
||||||
|
- "traefik.http.routers.bugs.rule=Host(`bugs.elmeg.xyz`)"
|
||||||
|
|
||||||
|
bugs-backend:
|
||||||
|
build: ./bugs-backend
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://...
|
||||||
|
- ELMEG_API_URL=https://elmeg.xyz/api
|
||||||
|
```
|
||||||
|
|
||||||
|
### DNS
|
||||||
|
|
||||||
|
Add A record or CNAME for `bugs.elmeg.xyz` → production server
|
||||||
|
|
||||||
|
### Auth Integration
|
||||||
|
|
||||||
|
- Share JWT validation with main Elmeg backend
|
||||||
|
- Or standalone with optional Elmeg SSO
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MVP Scope (Phase 1)
|
||||||
|
|
||||||
|
- [ ] Submit ticket form
|
||||||
|
- [ ] My tickets list
|
||||||
|
- [ ] Ticket detail with comments
|
||||||
|
- [ ] Admin ticket queue
|
||||||
|
- [ ] Email on ticket creation
|
||||||
|
- [ ] Basic styling matching Elmeg
|
||||||
|
|
||||||
|
## Phase 2
|
||||||
|
|
||||||
|
- [ ] Known issues page
|
||||||
|
- [ ] Upvoting
|
||||||
|
- [ ] Canned responses
|
||||||
|
- [ ] Internal notes
|
||||||
|
- [ ] Attachment uploads
|
||||||
|
- [ ] Search
|
||||||
|
|
||||||
|
## Phase 3
|
||||||
|
|
||||||
|
- [ ] Metrics dashboard
|
||||||
|
- [ ] Merge tickets
|
||||||
|
- [ ] Tags/labels
|
||||||
|
- [ ] Webhook integrations (Discord, Slack)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estimated Effort
|
||||||
|
|
||||||
|
| Phase | Effort |
|
||||||
|
|-------|--------|
|
||||||
|
| MVP | 2-3 days |
|
||||||
|
| Phase 2 | 2 days |
|
||||||
|
| Phase 3 | 2 days |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Create `/srv/containers/elmeg-bugs` directory structure
|
||||||
|
2. Initialize Next.js + FastAPI project
|
||||||
|
3. Set up Traefik routing for `bugs.elmeg.xyz`
|
||||||
|
4. Implement MVP models + endpoints
|
||||||
|
5. Deploy
|
||||||
143
frontend/app/bugs/known-issues/page.tsx
Normal file
143
frontend/app/bugs/known-issues/page.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Known Issues Page - Public bugs/feature requests
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { getApiUrl } from "@/lib/api-config"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { ArrowLeft, ThumbsUp, Bug, Lightbulb, Loader2 } from "lucide-react"
|
||||||
|
|
||||||
|
interface Ticket {
|
||||||
|
id: number
|
||||||
|
ticket_number: string
|
||||||
|
type: string
|
||||||
|
status: string
|
||||||
|
priority: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
upvotes: number
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_ICONS: Record<string, typeof Bug> = {
|
||||||
|
bug: Bug,
|
||||||
|
feature: Lightbulb,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KnownIssuesPage() {
|
||||||
|
const [tickets, setTickets] = useState<Ticket[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchKnownIssues()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchKnownIssues = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${getApiUrl()}/tickets/known-issues`)
|
||||||
|
if (res.ok) {
|
||||||
|
setTickets(await res.json())
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch known issues", e)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpvote = async (ticketNumber: string, e: React.MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`${getApiUrl()}/tickets/${ticketNumber}/upvote`, {
|
||||||
|
method: "POST",
|
||||||
|
})
|
||||||
|
// Optimistic update
|
||||||
|
setTickets(tickets.map(t =>
|
||||||
|
t.ticket_number === ticketNumber
|
||||||
|
? { ...t, upvotes: t.upvotes + 1 }
|
||||||
|
: t
|
||||||
|
))
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to upvote", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container max-w-4xl py-8">
|
||||||
|
<div className="flex items-center gap-4 mb-8">
|
||||||
|
<Link href="/bugs">
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Known Issues</h1>
|
||||||
|
<p className="text-muted-foreground">Active bugs and feature requests</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : tickets.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
|
<p className="text-muted-foreground">No known issues at this time.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{tickets.map((ticket) => {
|
||||||
|
const Icon = TYPE_ICONS[ticket.type] || Bug
|
||||||
|
return (
|
||||||
|
<Link key={ticket.id} href={`/bugs/ticket/${ticket.ticket_number}`}>
|
||||||
|
<Card className="hover:border-primary transition-colors cursor-pointer">
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="p-2 rounded-lg bg-muted">
|
||||||
|
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="font-mono text-sm text-muted-foreground">
|
||||||
|
{ticket.ticket_number}
|
||||||
|
</span>
|
||||||
|
<Badge variant={ticket.status === "in_progress" ? "secondary" : "default"}>
|
||||||
|
{ticket.status === "in_progress" ? "In Progress" : "Open"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold">{ticket.title}</h3>
|
||||||
|
{ticket.description && (
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-2 mt-1">
|
||||||
|
{ticket.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2 shrink-0"
|
||||||
|
onClick={(e) => handleUpvote(ticket.ticket_number, e)}
|
||||||
|
>
|
||||||
|
<ThumbsUp className="h-4 w-4" />
|
||||||
|
{ticket.upvotes}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
146
frontend/app/bugs/my-tickets/page.tsx
Normal file
146
frontend/app/bugs/my-tickets/page.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* My Tickets Page - View user's submitted tickets
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { useAuth } from "@/contexts/auth-context"
|
||||||
|
import { getApiUrl } from "@/lib/api-config"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { ArrowLeft, Clock, CheckCircle, AlertCircle, Loader2 } from "lucide-react"
|
||||||
|
|
||||||
|
interface Ticket {
|
||||||
|
id: number
|
||||||
|
ticket_number: string
|
||||||
|
type: string
|
||||||
|
status: string
|
||||||
|
priority: string
|
||||||
|
title: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_STYLES: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
|
||||||
|
open: { label: "Open", variant: "default" },
|
||||||
|
in_progress: { label: "In Progress", variant: "secondary" },
|
||||||
|
resolved: { label: "Resolved", variant: "outline" },
|
||||||
|
closed: { label: "Closed", variant: "outline" },
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIORITY_COLORS: Record<string, string> = {
|
||||||
|
low: "bg-gray-500",
|
||||||
|
medium: "bg-yellow-500",
|
||||||
|
high: "bg-orange-500",
|
||||||
|
critical: "bg-red-500",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MyTicketsPage() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [tickets, setTickets] = useState<Ticket[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
fetchTickets()
|
||||||
|
}
|
||||||
|
}, [user])
|
||||||
|
|
||||||
|
const fetchTickets = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("token")
|
||||||
|
const res = await fetch(`${getApiUrl()}/tickets/my-tickets`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setTickets(data)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch tickets", e)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="container max-w-2xl py-16 text-center">
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Please Log In</h1>
|
||||||
|
<p className="text-muted-foreground mb-6">
|
||||||
|
You need to be logged in to view your tickets.
|
||||||
|
</p>
|
||||||
|
<Link href="/login">
|
||||||
|
<Button>Log In</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container max-w-4xl py-8">
|
||||||
|
<div className="flex items-center gap-4 mb-8">
|
||||||
|
<Link href="/bugs">
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">My Tickets</h1>
|
||||||
|
<p className="text-muted-foreground">Track your submitted issues</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : tickets.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
|
<p className="text-muted-foreground mb-4">You haven't submitted any tickets yet.</p>
|
||||||
|
<Link href="/bugs">
|
||||||
|
<Button>Submit a Ticket</Button>
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{tickets.map((ticket) => (
|
||||||
|
<Link key={ticket.id} href={`/bugs/ticket/${ticket.ticket_number}`}>
|
||||||
|
<Card className="hover:border-primary transition-colors cursor-pointer">
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="font-mono text-sm text-muted-foreground">
|
||||||
|
{ticket.ticket_number}
|
||||||
|
</span>
|
||||||
|
<span className={`inline-block w-2 h-2 rounded-full ${PRIORITY_COLORS[ticket.priority]}`} />
|
||||||
|
<Badge variant={STATUS_STYLES[ticket.status]?.variant || "default"}>
|
||||||
|
{STATUS_STYLES[ticket.status]?.label || ticket.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold truncate">{ticket.title}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{new Date(ticket.created_at).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{ticket.status === "open" && <Clock className="h-4 w-4 text-muted-foreground" />}
|
||||||
|
{ticket.status === "resolved" && <CheckCircle className="h-4 w-4 text-green-500" />}
|
||||||
|
{ticket.status === "in_progress" && <AlertCircle className="h-4 w-4 text-yellow-500" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
299
frontend/app/bugs/page.tsx
Normal file
299
frontend/app/bugs/page.tsx
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bug Tracker - Main Page
|
||||||
|
* Submit bugs, feature requests, and questions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import {
|
||||||
|
Bug,
|
||||||
|
Lightbulb,
|
||||||
|
HelpCircle,
|
||||||
|
MessageSquare,
|
||||||
|
CheckCircle,
|
||||||
|
ArrowRight,
|
||||||
|
ExternalLink
|
||||||
|
} from "lucide-react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { getApiUrl } from "@/lib/api-config"
|
||||||
|
import { useAuth } from "@/contexts/auth-context"
|
||||||
|
|
||||||
|
const TICKET_TYPES = [
|
||||||
|
{ value: "bug", label: "Bug Report", icon: Bug, description: "Something isn't working" },
|
||||||
|
{ value: "feature", label: "Feature Request", icon: Lightbulb, description: "Suggest an improvement" },
|
||||||
|
{ value: "question", label: "Question", icon: HelpCircle, description: "Ask for help" },
|
||||||
|
{ value: "other", label: "Other", icon: MessageSquare, description: "General feedback" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const PRIORITIES = [
|
||||||
|
{ value: "low", label: "Low", color: "bg-gray-500" },
|
||||||
|
{ value: "medium", label: "Medium", color: "bg-yellow-500" },
|
||||||
|
{ value: "high", label: "High", color: "bg-orange-500" },
|
||||||
|
{ value: "critical", label: "Critical", color: "bg-red-500" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function BugsPage() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [step, setStep] = useState<"type" | "form" | "success">("type")
|
||||||
|
const [selectedType, setSelectedType] = useState<string>("bug")
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [ticketNumber, setTicketNumber] = useState<string>("")
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
priority: "medium",
|
||||||
|
reporter_email: "",
|
||||||
|
reporter_name: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleTypeSelect = (type: string) => {
|
||||||
|
setSelectedType(type)
|
||||||
|
setStep("form")
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSubmitting(true)
|
||||||
|
setError("")
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("token")
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${getApiUrl()}/tickets/`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: selectedType,
|
||||||
|
priority: formData.priority,
|
||||||
|
title: formData.title,
|
||||||
|
description: formData.description,
|
||||||
|
reporter_email: user?.email || formData.reporter_email,
|
||||||
|
reporter_name: formData.reporter_name,
|
||||||
|
page_url: window.location.href,
|
||||||
|
browser: navigator.userAgent,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
throw new Error(data.detail || "Failed to submit")
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticket = await res.json()
|
||||||
|
setTicketNumber(ticket.ticket_number)
|
||||||
|
setStep("success")
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || "Failed to submit ticket")
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success screen
|
||||||
|
if (step === "success") {
|
||||||
|
return (
|
||||||
|
<div className="container max-w-2xl py-16">
|
||||||
|
<Card className="text-center">
|
||||||
|
<CardContent className="pt-12 pb-8 space-y-6">
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-green-100 dark:bg-green-900">
|
||||||
|
<CheckCircle className="h-8 w-8 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Ticket Submitted!</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
Your ticket number is <span className="font-mono font-bold">{ticketNumber}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
We'll review your submission and get back to you soon.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-4 justify-center pt-4">
|
||||||
|
<Link href="/bugs/my-tickets">
|
||||||
|
<Button variant="outline">View My Tickets</Button>
|
||||||
|
</Link>
|
||||||
|
<Button onClick={() => { setStep("type"); setFormData({ title: "", description: "", priority: "medium", reporter_email: "", reporter_name: "" }) }}>
|
||||||
|
Submit Another
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container max-w-4xl py-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<h1 className="text-4xl font-bold tracking-tight mb-4">How can we help?</h1>
|
||||||
|
<p className="text-muted-foreground text-lg">
|
||||||
|
Report bugs, request features, or ask questions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step === "type" && (
|
||||||
|
<>
|
||||||
|
{/* Type Selection */}
|
||||||
|
<div className="grid md:grid-cols-2 gap-4 mb-8">
|
||||||
|
{TICKET_TYPES.map((type) => {
|
||||||
|
const Icon = type.icon
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={type.value}
|
||||||
|
onClick={() => handleTypeSelect(type.value)}
|
||||||
|
className="text-left p-6 rounded-xl border-2 border-border hover:border-primary transition-colors bg-card hover:bg-accent group"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="p-3 rounded-lg bg-primary/10 text-primary group-hover:bg-primary group-hover:text-primary-foreground transition-colors">
|
||||||
|
<Icon className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold text-lg">{type.label}</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">{type.description}</p>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="h-5 w-5 text-muted-foreground group-hover:text-primary transition-colors" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Links */}
|
||||||
|
<div className="flex flex-wrap gap-4 justify-center">
|
||||||
|
<Link href="/bugs/known-issues">
|
||||||
|
<Button variant="outline" className="gap-2">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
Known Issues
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
{user && (
|
||||||
|
<Link href="/bugs/my-tickets">
|
||||||
|
<Button variant="outline" className="gap-2">
|
||||||
|
My Tickets
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "form" && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setStep("type")}>
|
||||||
|
← Back
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
{TICKET_TYPES.find(t => t.value === selectedType)?.icon && (
|
||||||
|
(() => {
|
||||||
|
const Icon = TICKET_TYPES.find(t => t.value === selectedType)!.icon
|
||||||
|
return <Icon className="h-5 w-5" />
|
||||||
|
})()
|
||||||
|
)}
|
||||||
|
{TICKET_TYPES.find(t => t.value === selectedType)?.label}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Fill out the details below
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* Title */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title">Title *</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
placeholder="Brief summary of the issue"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||||
|
required
|
||||||
|
maxLength={200}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
placeholder="Provide more details. What happened? What did you expect?"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
rows={5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Priority */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Priority</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{PRIORITIES.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormData({ ...formData, priority: p.value })}
|
||||||
|
className={`px-4 py-2 rounded-lg border-2 transition-colors ${formData.priority === p.value
|
||||||
|
? "border-primary bg-primary/10"
|
||||||
|
: "border-border hover:border-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-block w-2 h-2 rounded-full ${p.color} mr-2`} />
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email (for anonymous) */}
|
||||||
|
{!user && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Your Email *</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={formData.reporter_email}
|
||||||
|
onChange={(e) => setFormData({ ...formData, reporter_email: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
We'll use this to send you updates
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-4 pt-4">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setStep("type")}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={submitting || !formData.title}>
|
||||||
|
{submitting ? "Submitting..." : "Submit Ticket"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
241
frontend/app/bugs/ticket/[id]/page.tsx
Normal file
241
frontend/app/bugs/ticket/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ticket Detail Page
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useParams } from "next/navigation"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { useAuth } from "@/contexts/auth-context"
|
||||||
|
import { getApiUrl } from "@/lib/api-config"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { ArrowLeft, Send, Loader2, Clock, User } from "lucide-react"
|
||||||
|
|
||||||
|
interface Ticket {
|
||||||
|
id: number
|
||||||
|
ticket_number: string
|
||||||
|
type: string
|
||||||
|
status: string
|
||||||
|
priority: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
reporter_email: string
|
||||||
|
reporter_name: string
|
||||||
|
is_public: boolean
|
||||||
|
upvotes: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
resolved_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Comment {
|
||||||
|
id: number
|
||||||
|
author_name: string
|
||||||
|
content: string
|
||||||
|
is_internal: boolean
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_STYLES: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
|
||||||
|
open: { label: "Open", variant: "default" },
|
||||||
|
in_progress: { label: "In Progress", variant: "secondary" },
|
||||||
|
resolved: { label: "Resolved", variant: "outline" },
|
||||||
|
closed: { label: "Closed", variant: "outline" },
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIORITY_COLORS: Record<string, string> = {
|
||||||
|
low: "bg-gray-500",
|
||||||
|
medium: "bg-yellow-500",
|
||||||
|
high: "bg-orange-500",
|
||||||
|
critical: "bg-red-500",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TicketDetailPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const ticketNumber = params.id as string
|
||||||
|
const { user } = useAuth()
|
||||||
|
|
||||||
|
const [ticket, setTicket] = useState<Ticket | null>(null)
|
||||||
|
const [comments, setComments] = useState<Comment[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [newComment, setNewComment] = useState("")
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ticketNumber) {
|
||||||
|
fetchTicket()
|
||||||
|
fetchComments()
|
||||||
|
}
|
||||||
|
}, [ticketNumber])
|
||||||
|
|
||||||
|
const fetchTicket = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("token")
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
if (token) headers["Authorization"] = `Bearer ${token}`
|
||||||
|
|
||||||
|
const res = await fetch(`${getApiUrl()}/tickets/${ticketNumber}`, { headers })
|
||||||
|
if (res.ok) {
|
||||||
|
setTicket(await res.json())
|
||||||
|
} else if (res.status === 404) {
|
||||||
|
setError("Ticket not found")
|
||||||
|
} else if (res.status === 403) {
|
||||||
|
setError("You don't have access to this ticket")
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError("Failed to load ticket")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchComments = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("token")
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
if (token) headers["Authorization"] = `Bearer ${token}`
|
||||||
|
|
||||||
|
const res = await fetch(`${getApiUrl()}/tickets/${ticketNumber}/comments`, { headers })
|
||||||
|
if (res.ok) {
|
||||||
|
setComments(await res.json())
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load comments", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmitComment = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!newComment.trim()) return
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("token")
|
||||||
|
const res = await fetch(`${getApiUrl()}/tickets/${ticketNumber}/comments`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ content: newComment }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const comment = await res.json()
|
||||||
|
setComments([...comments, comment])
|
||||||
|
setNewComment("")
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to add comment", e)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="container max-w-3xl py-16 flex items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !ticket) {
|
||||||
|
return (
|
||||||
|
<div className="container max-w-3xl py-16 text-center">
|
||||||
|
<h1 className="text-2xl font-bold mb-4">{error || "Ticket not found"}</h1>
|
||||||
|
<Link href="/bugs">
|
||||||
|
<Button>Back to Bug Tracker</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container max-w-3xl py-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<Link href="/bugs/my-tickets">
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-muted-foreground">{ticket.ticket_number}</span>
|
||||||
|
<span className={`inline-block w-2 h-2 rounded-full ${PRIORITY_COLORS[ticket.priority]}`} />
|
||||||
|
<Badge variant={STATUS_STYLES[ticket.status]?.variant || "default"}>
|
||||||
|
{STATUS_STYLES[ticket.status]?.label || ticket.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ticket Content */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{ticket.title}</CardTitle>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<User className="h-3 w-3" />
|
||||||
|
{ticket.reporter_name || "Anonymous"}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{new Date(ticket.created_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
{ticket.description && (
|
||||||
|
<CardContent>
|
||||||
|
<p className="whitespace-pre-wrap">{ticket.description}</p>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Comments */}
|
||||||
|
<div className="space-y-4 mb-6">
|
||||||
|
<h3 className="font-semibold">Comments ({comments.length})</h3>
|
||||||
|
|
||||||
|
{comments.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No comments yet</p>
|
||||||
|
) : (
|
||||||
|
comments.map((comment) => (
|
||||||
|
<Card key={comment.id}>
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="font-medium">{comment.author_name}</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{new Date(comment.created_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="whitespace-pre-wrap">{comment.content}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Comment Form */}
|
||||||
|
{user && (
|
||||||
|
<form onSubmit={handleSubmitComment} className="space-y-4">
|
||||||
|
<Textarea
|
||||||
|
placeholder="Add a comment..."
|
||||||
|
value={newComment}
|
||||||
|
onChange={(e) => setNewComment(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={submitting || !newComment.trim()} className="gap-2">
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
{submitting ? "Sending..." : "Send Comment"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue