- Added API endpoints for tag management (create, read, update, delete). - Implemented entity tagging functionality to associate tags with various entities. - Created workflow management for tag-triggered actions. - Developed frontend views for tag administration using FastAPI and Jinja2. - Designed HTML template for tag management interface with Bootstrap styling. - Added JavaScript for tag picker component with keyboard shortcuts and dynamic tag filtering. - Created database migration scripts for tags, entity_tags, and tag_workflows tables. - Included default tags for initial setup in the database.
462 lines
15 KiB
Python
462 lines
15 KiB
Python
"""
|
|
Ticket System Frontend Views
|
|
HTML template routes for ticket management UI
|
|
"""
|
|
|
|
import logging
|
|
from fastapi import APIRouter, Request, HTTPException, Form
|
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
from fastapi.templating import Jinja2Templates
|
|
from typing import Optional
|
|
from datetime import date
|
|
|
|
from app.core.database import execute_query, execute_update, execute_query_single
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
templates = Jinja2Templates(directory="app")
|
|
|
|
|
|
# ============================================================================
|
|
# MOCKUP ROUTES (TEMPORARY)
|
|
# ============================================================================
|
|
|
|
@router.get("/mockups/1", response_class=HTMLResponse)
|
|
async def mockup_option1(request: Request):
|
|
"""Mockup: Split Screen Concept"""
|
|
return templates.TemplateResponse("ticket/frontend/mockups/option1_splitscreen.html", {"request": request})
|
|
|
|
@router.get("/mockups/2", response_class=HTMLResponse)
|
|
async def mockup_option2(request: Request):
|
|
"""Mockup: Kanban Board Concept"""
|
|
return templates.TemplateResponse("ticket/frontend/mockups/option2_kanban.html", {"request": request})
|
|
|
|
@router.get("/mockups/3", response_class=HTMLResponse)
|
|
async def mockup_option3(request: Request):
|
|
"""Mockup: Power Table Concept"""
|
|
return templates.TemplateResponse("ticket/frontend/mockups/option3_powertable.html", {"request": request})
|
|
|
|
|
|
@router.get("/worklog/review", response_class=HTMLResponse)
|
|
async def worklog_review_page(
|
|
request: Request,
|
|
customer_id: Optional[int] = None,
|
|
status: str = "draft"
|
|
):
|
|
"""
|
|
Worklog review page with single-entry approval
|
|
|
|
Query params:
|
|
customer_id: Filter by customer (optional)
|
|
status: Filter by status (default: draft)
|
|
"""
|
|
try:
|
|
# Build query with filters
|
|
query = """
|
|
SELECT
|
|
w.id,
|
|
w.ticket_id,
|
|
w.user_id,
|
|
w.work_date,
|
|
w.hours,
|
|
w.work_type,
|
|
w.description,
|
|
w.billing_method,
|
|
w.status,
|
|
w.prepaid_card_id,
|
|
w.created_at,
|
|
t.ticket_number,
|
|
t.subject AS ticket_subject,
|
|
t.customer_id,
|
|
t.status AS ticket_status,
|
|
c.name AS customer_name,
|
|
u.username AS user_name,
|
|
pc.card_number,
|
|
pc.remaining_hours AS card_remaining_hours
|
|
FROM tticket_worklog w
|
|
INNER JOIN tticket_tickets t ON t.id = w.ticket_id
|
|
LEFT JOIN customers c ON c.id = t.customer_id
|
|
LEFT JOIN users u ON u.user_id = w.user_id
|
|
LEFT JOIN tticket_prepaid_cards pc ON pc.id = w.prepaid_card_id
|
|
WHERE w.status = %s
|
|
"""
|
|
|
|
params = [status]
|
|
|
|
if customer_id:
|
|
query += " AND t.customer_id = %s"
|
|
params.append(customer_id)
|
|
|
|
query += " ORDER BY w.work_date DESC, w.created_at DESC"
|
|
|
|
worklogs = execute_query(query, tuple(params))
|
|
|
|
# Get customer list for filter dropdown
|
|
customers_query = """
|
|
SELECT DISTINCT c.id, c.name
|
|
FROM customers c
|
|
INNER JOIN tticket_tickets t ON t.customer_id = c.id
|
|
INNER JOIN tticket_worklog w ON w.ticket_id = t.id
|
|
WHERE w.status = %s
|
|
ORDER BY c.name
|
|
"""
|
|
customers = execute_query(customers_query, (status,))
|
|
|
|
# Calculate totals
|
|
total_hours = sum(float(w['hours']) for w in worklogs)
|
|
total_billable = sum(
|
|
float(w['hours'])
|
|
for w in worklogs
|
|
if w['billing_method'] == 'invoice'
|
|
)
|
|
|
|
return templates.TemplateResponse(
|
|
"ticket/frontend/worklog_review.html",
|
|
{
|
|
"request": request,
|
|
"worklogs": worklogs,
|
|
"customers": customers,
|
|
"selected_customer_id": customer_id,
|
|
"selected_status": status,
|
|
"total_hours": total_hours,
|
|
"total_billable_hours": total_billable,
|
|
"total_entries": len(worklogs)
|
|
}
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"❌ Failed to load worklog review page: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.post("/worklog/{worklog_id}/approve")
|
|
async def approve_worklog_entry(
|
|
worklog_id: int,
|
|
redirect_to: Optional[str] = Form(default="/ticket/worklog/review")
|
|
):
|
|
"""
|
|
Approve single worklog entry (change status draft → billable)
|
|
|
|
Form params:
|
|
redirect_to: URL to redirect after approval
|
|
"""
|
|
try:
|
|
# Check entry exists and is draft
|
|
check_query = """
|
|
SELECT id, status, billing_method
|
|
FROM tticket_worklog
|
|
WHERE id = %s
|
|
"""
|
|
entry = execute_query_single(check_query, (worklog_id,))
|
|
|
|
if not entry:
|
|
raise HTTPException(status_code=404, detail="Worklog entry not found")
|
|
|
|
if entry['status'] != 'draft':
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Cannot approve entry with status '{entry['status']}'"
|
|
)
|
|
|
|
# Approve entry
|
|
update_query = """
|
|
UPDATE tticket_worklog
|
|
SET status = 'billable',
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = %s
|
|
"""
|
|
execute_update(update_query, (worklog_id,))
|
|
|
|
logger.info(f"✅ Approved worklog entry {worklog_id}")
|
|
|
|
return RedirectResponse(url=redirect_to, status_code=303)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"❌ Failed to approve worklog entry: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.post("/worklog/{worklog_id}/reject")
|
|
async def reject_worklog_entry(
|
|
worklog_id: int,
|
|
reason: Optional[str] = Form(default=None),
|
|
redirect_to: Optional[str] = Form(default="/ticket/worklog/review")
|
|
):
|
|
"""
|
|
Reject single worklog entry (change status draft → rejected)
|
|
|
|
Form params:
|
|
reason: Rejection reason (optional)
|
|
redirect_to: URL to redirect after rejection
|
|
"""
|
|
try:
|
|
# Check entry exists and is draft
|
|
check_query = """
|
|
SELECT id, status
|
|
FROM tticket_worklog
|
|
WHERE id = %s
|
|
"""
|
|
entry = execute_query_single(check_query, (worklog_id,))
|
|
|
|
if not entry:
|
|
raise HTTPException(status_code=404, detail="Worklog entry not found")
|
|
|
|
if entry['status'] != 'draft':
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Cannot reject entry with status '{entry['status']}'"
|
|
)
|
|
|
|
# Reject entry (store reason in description)
|
|
update_query = """
|
|
UPDATE tticket_worklog
|
|
SET status = 'rejected',
|
|
description = COALESCE(description, '') ||
|
|
CASE WHEN %s IS NOT NULL
|
|
THEN E'\n\n[REJECTED: ' || %s || ']'
|
|
ELSE E'\n\n[REJECTED]'
|
|
END,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = %s
|
|
"""
|
|
execute_update(update_query, (reason, reason, worklog_id))
|
|
|
|
logger.info(f"❌ Rejected worklog entry {worklog_id}" + (f": {reason}" if reason else ""))
|
|
|
|
return RedirectResponse(url=redirect_to, status_code=303)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"❌ Failed to reject worklog entry: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/tickets/new", response_class=HTMLResponse)
|
|
async def new_ticket_page(request: Request):
|
|
"""
|
|
New ticket creation page with multi-step wizard
|
|
"""
|
|
return templates.TemplateResponse("ticket/frontend/ticket_new.html", {"request": request})
|
|
|
|
|
|
@router.get("/dashboard", response_class=HTMLResponse)
|
|
async def ticket_dashboard(request: Request):
|
|
"""
|
|
Ticket system dashboard with statistics
|
|
"""
|
|
try:
|
|
# Get ticket statistics
|
|
stats_query = """
|
|
SELECT
|
|
COUNT(*) FILTER (WHERE status = 'open') AS open_count,
|
|
COUNT(*) FILTER (WHERE status = 'in_progress') AS in_progress_count,
|
|
COUNT(*) FILTER (WHERE status = 'pending_customer') AS pending_count,
|
|
COUNT(*) FILTER (WHERE status = 'resolved') AS resolved_count,
|
|
COUNT(*) FILTER (WHERE status = 'closed') AS closed_count,
|
|
COUNT(*) AS total_count
|
|
FROM tticket_tickets
|
|
"""
|
|
stats_result = execute_query(stats_query)
|
|
stats = stats_result[0] if stats_result else {}
|
|
|
|
# Get recent tickets
|
|
recent_query = """
|
|
SELECT
|
|
t.id,
|
|
t.ticket_number,
|
|
t.subject,
|
|
t.status,
|
|
t.priority,
|
|
t.created_at,
|
|
c.name AS customer_name
|
|
FROM tticket_tickets t
|
|
LEFT JOIN customers c ON c.id = t.customer_id
|
|
ORDER BY t.created_at DESC
|
|
LIMIT 10
|
|
"""
|
|
recent_tickets = execute_query(recent_query)
|
|
|
|
# Get worklog statistics
|
|
worklog_stats_query = """
|
|
SELECT
|
|
COUNT(*) FILTER (WHERE status = 'draft') AS draft_count,
|
|
COALESCE(SUM(hours) FILTER (WHERE status = 'draft'), 0) AS draft_hours,
|
|
COUNT(*) FILTER (WHERE status = 'billable') AS billable_count,
|
|
COALESCE(SUM(hours) FILTER (WHERE status = 'billable'), 0) AS billable_hours
|
|
FROM tticket_worklog
|
|
"""
|
|
worklog_stats_result = execute_query(worklog_stats_query)
|
|
worklog_stats = worklog_stats_result[0] if worklog_stats_result else {}
|
|
|
|
return templates.TemplateResponse(
|
|
"ticket/frontend/dashboard.html",
|
|
{
|
|
"request": request,
|
|
"stats": stats,
|
|
"recent_tickets": recent_tickets or [],
|
|
"worklog_stats": worklog_stats
|
|
}
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"❌ Failed to load dashboard: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/tickets", response_class=HTMLResponse)
|
|
async def ticket_list_page(
|
|
request: Request,
|
|
status: Optional[str] = None,
|
|
priority: Optional[str] = None,
|
|
customer_id: Optional[int] = None,
|
|
search: Optional[str] = None
|
|
):
|
|
"""
|
|
Ticket list page with filters
|
|
"""
|
|
try:
|
|
# Build query with filters
|
|
query = """
|
|
SELECT
|
|
t.id,
|
|
t.ticket_number,
|
|
t.subject,
|
|
t.status,
|
|
t.priority,
|
|
t.created_at,
|
|
t.updated_at,
|
|
c.name AS customer_name,
|
|
u.username AS assigned_to_name,
|
|
(SELECT COUNT(*) FROM tticket_comments WHERE ticket_id = t.id) AS comment_count,
|
|
(SELECT COUNT(*) FROM tticket_worklog WHERE ticket_id = t.id) AS worklog_count
|
|
FROM tticket_tickets t
|
|
LEFT JOIN customers c ON c.id = t.customer_id
|
|
LEFT JOIN users u ON u.user_id = t.assigned_to_user_id
|
|
WHERE 1=1
|
|
"""
|
|
|
|
params = []
|
|
|
|
if status:
|
|
query += " AND t.status = %s"
|
|
params.append(status)
|
|
|
|
if priority:
|
|
query += " AND t.priority = %s"
|
|
params.append(priority)
|
|
|
|
if customer_id:
|
|
query += " AND t.customer_id = %s"
|
|
params.append(customer_id)
|
|
|
|
if search:
|
|
query += " AND (t.subject ILIKE %s OR t.description ILIKE %s OR t.ticket_number ILIKE %s)"
|
|
search_pattern = f"%{search}%"
|
|
params.extend([search_pattern, search_pattern, search_pattern])
|
|
|
|
query += " ORDER BY t.created_at DESC LIMIT 100"
|
|
|
|
tickets = execute_query(query, tuple(params)) if params else execute_query(query)
|
|
|
|
# Get filter options
|
|
customers = execute_query(
|
|
"""SELECT DISTINCT c.id, c.name
|
|
FROM customers c
|
|
INNER JOIN tticket_tickets t ON t.customer_id = c.id
|
|
ORDER BY c.name"""
|
|
)
|
|
|
|
return templates.TemplateResponse(
|
|
"ticket/frontend/ticket_list.html",
|
|
{
|
|
"request": request,
|
|
"tickets": tickets,
|
|
"customers": customers,
|
|
"selected_status": status,
|
|
"selected_priority": priority,
|
|
"selected_customer_id": customer_id,
|
|
"search_query": search
|
|
}
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"❌ Failed to load ticket list: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/tickets/{ticket_id}", response_class=HTMLResponse)
|
|
async def ticket_detail_page(request: Request, ticket_id: int):
|
|
"""
|
|
Ticket detail page with comments and worklog
|
|
"""
|
|
try:
|
|
# Get ticket details
|
|
ticket_query = """
|
|
SELECT
|
|
t.*,
|
|
c.name AS customer_name,
|
|
c.email AS customer_email,
|
|
u.username AS assigned_to_name
|
|
FROM tticket_tickets t
|
|
LEFT JOIN customers c ON c.id = t.customer_id
|
|
LEFT JOIN users u ON u.user_id = t.assigned_to_user_id
|
|
WHERE t.id = %s
|
|
"""
|
|
ticket = execute_query_single(ticket_query, (ticket_id,))
|
|
|
|
if not ticket:
|
|
raise HTTPException(status_code=404, detail="Ticket not found")
|
|
|
|
# Get comments
|
|
comments_query = """
|
|
SELECT
|
|
c.*,
|
|
u.username AS user_name
|
|
FROM tticket_comments c
|
|
LEFT JOIN users u ON u.user_id = c.user_id
|
|
WHERE c.ticket_id = %s
|
|
ORDER BY c.created_at ASC
|
|
"""
|
|
comments = execute_query(comments_query, (ticket_id,))
|
|
|
|
# Get worklog
|
|
worklog_query = """
|
|
SELECT
|
|
w.*,
|
|
u.username AS user_name
|
|
FROM tticket_worklog w
|
|
LEFT JOIN users u ON u.user_id = w.user_id
|
|
WHERE w.ticket_id = %s
|
|
ORDER BY w.work_date DESC, w.created_at DESC
|
|
"""
|
|
worklog = execute_query(worklog_query, (ticket_id,))
|
|
|
|
# Get attachments
|
|
attachments_query = """
|
|
SELECT * FROM tticket_attachments
|
|
WHERE ticket_id = %s
|
|
ORDER BY created_at DESC
|
|
"""
|
|
attachments = execute_query(attachments_query, (ticket_id,))
|
|
|
|
return templates.TemplateResponse(
|
|
"ticket/frontend/ticket_detail.html",
|
|
{
|
|
"request": request,
|
|
"ticket": ticket,
|
|
"comments": comments,
|
|
"worklog": worklog,
|
|
"attachments": attachments
|
|
}
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"❌ Failed to load ticket detail: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|