bmc_hub/app/ticket/frontend/views.py

1190 lines
41 KiB
Python
Raw Normal View History

"""
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, Dict, Any
from datetime import date
from app.core.database import execute_query, execute_update, execute_query_single, table_has_column
logger = logging.getLogger(__name__)
router = APIRouter()
templates = Jinja2Templates(directory="app")
def _case_start_date_sql(alias: str = "s") -> str:
"""Select start_date only when the live schema actually has it."""
if table_has_column("sag_sager", "start_date"):
return f"{alias}.start_date"
return "NULL::date AS start_date"
def _case_type_sql(alias: str = "s") -> str:
"""Select case type across old/new sag schemas."""
if table_has_column("sag_sager", "type"):
return f"COALESCE({alias}.template_key, {alias}.type, 'ticket') AS case_type"
return f"COALESCE({alias}.template_key, 'ticket') AS case_type"
@router.get("/", include_in_schema=False)
async def ticket_root_redirect():
return RedirectResponse(url="/sag", status_code=302)
def _format_long_text(value: Optional[str]) -> str:
if not value:
return ""
lines = value.splitlines()
blocks = []
buffer = []
in_list = False
def flush_paragraph():
nonlocal buffer
if buffer:
blocks.append("<p>" + " ".join(buffer).strip() + "</p>")
buffer = []
def close_list():
nonlocal in_list
if in_list:
blocks.append("</ul>")
in_list = False
for raw_line in lines:
line = raw_line.strip()
if not line:
flush_paragraph()
close_list()
continue
if line.startswith(("- ", "* ", "")):
flush_paragraph()
if not in_list:
blocks.append("<ul>")
in_list = True
item = line[2:].strip()
blocks.append(f"<li>{item}</li>")
continue
if line[0].isdigit() and "." in line[:4]:
flush_paragraph()
if not in_list:
blocks.append("<ul>")
in_list = True
item = line.split(".", 1)[1].strip()
blocks.append(f"<li>{item}</li>")
continue
close_list()
buffer.append(line)
flush_paragraph()
close_list()
return "\n".join(blocks)
templates.env.filters["format_long_text"] = _format_long_text
# ============================================================================
# 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 (UNION of Ticket Worklogs and Sag/Module Times)
# Ticket Worklogs (Positive IDs)
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,
false as is_sag_module
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)
# UNION ALL with Sag Module Times (Negative IDs)
# Map status: pending -> draft, approved -> billable
mapped_status = 'pending' if status == 'draft' else ('approved' if status == 'billable' else status)
query += """
UNION ALL
SELECT
(tm.id * -1) as id,
tm.sag_id as ticket_id,
NULL as user_id,
tm.worked_date as work_date,
tm.original_hours as hours,
'sag' as work_type,
tm.description,
COALESCE(tm.billing_method, 'invoice') as billing_method,
CASE
WHEN tm.status = 'pending' THEN 'draft'
WHEN tm.status = 'approved' THEN 'billable'
ELSE tm.status
END as status,
tm.prepaid_card_id,
tm.created_at,
CONCAT('SAG-', tm.sag_id) as ticket_number,
COALESCE(sol.title, 'Sagssarbejde') as ticket_subject,
cust.id as customer_id, -- Access core customer ID via Sag join
'Open' as ticket_status,
COALESCE(cust.name, 'Ukendt Kunde') as customer_name,
tm.user_name,
pc.card_number,
pc.remaining_hours as card_remaining_hours,
true as is_sag_module
FROM tmodule_times tm
LEFT JOIN sag_sager s ON tm.sag_id = s.id
LEFT JOIN sag_solutions sol ON tm.solution_id = sol.id
LEFT JOIN customers cust ON s.customer_id = cust.id
LEFT JOIN tticket_prepaid_cards pc ON tm.prepaid_card_id = pc.id
WHERE tm.status = %s
"""
params.append(mapped_status)
if customer_id:
query += " AND s.customer_id = %s"
params.append(customer_id)
query += " ORDER BY work_date DESC, 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:
# Handle Sag Module Entries (Negative IDs)
if worklog_id < 0:
tm_id = abs(worklog_id)
update_query = "UPDATE tmodule_times SET status = 'approved' WHERE id = %s AND status = 'pending'"
execute_update(update_query, (tm_id,))
logger.info(f"✅ Approved Sag time entry {tm_id}")
return RedirectResponse(url=redirect_to, status_code=303)
# 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})
def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
"""Collect live data slices for technician-focused dashboard variants."""
case_start_date_sql = _case_start_date_sql()
case_type_sql = _case_type_sql()
user_query = """
SELECT user_id, COALESCE(full_name, username, CONCAT('Bruger #', user_id::text)) AS display_name
FROM users
WHERE user_id = %s
LIMIT 1
"""
user_result = execute_query(user_query, (technician_user_id,))
technician_name = user_result[0]["display_name"] if user_result else f"Bruger #{technician_user_id}"
new_cases_query = f"""
SELECT
s.id,
s.titel,
s.beskrivelse,
s.priority,
s.status,
s.created_at,
{case_start_date_sql},
s.deferred_until,
s.deadline,
{case_type_sql},
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) AS kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN groups g ON g.id = s.assigned_group_id
LEFT JOIN LATERAL (
SELECT cc.contact_id
FROM contact_companies cc
WHERE cc.customer_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) cc_first ON true
LEFT JOIN contacts cont ON cont.id = cc_first.contact_id
WHERE s.deleted_at IS NULL
AND s.status = 'åben'
ORDER BY s.created_at DESC
LIMIT 12
"""
new_cases = execute_query(new_cases_query)
my_cases_query = f"""
SELECT
s.id,
s.titel,
s.beskrivelse,
s.priority,
s.status,
s.created_at,
{case_start_date_sql},
s.deferred_until,
s.deadline,
{case_type_sql},
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) AS kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN groups g ON g.id = s.assigned_group_id
LEFT JOIN LATERAL (
SELECT cc.contact_id
FROM contact_companies cc
WHERE cc.customer_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) cc_first ON true
LEFT JOIN contacts cont ON cont.id = cc_first.contact_id
WHERE s.deleted_at IS NULL
AND s.ansvarlig_bruger_id = %s
AND s.status <> 'lukket'
ORDER BY s.created_at DESC
LIMIT 12
"""
my_cases = execute_query(my_cases_query, (technician_user_id,))
today_tasks_query = f"""
SELECT
'case' AS item_type,
s.id AS item_id,
s.titel AS title,
s.beskrivelse,
s.status,
s.deadline AS due_at,
s.created_at,
{case_start_date_sql},
s.deferred_until,
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
{case_type_sql},
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) AS kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name,
COALESCE(s.priority::text, 'normal') AS priority,
'Sag deadline i dag' AS task_reason
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN groups g ON g.id = s.assigned_group_id
LEFT JOIN LATERAL (
SELECT cc.contact_id
FROM contact_companies cc
WHERE cc.customer_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) cc_first ON true
LEFT JOIN contacts cont ON cont.id = cc_first.contact_id
WHERE s.deleted_at IS NULL
AND s.ansvarlig_bruger_id = %s
AND s.status <> 'lukket'
AND s.deadline = CURRENT_DATE
UNION ALL
SELECT
'ticket' AS item_type,
t.id AS item_id,
t.subject AS title,
NULL::text AS beskrivelse,
t.status,
NULL::date AS due_at,
t.created_at,
NULL::date AS start_date,
NULL::date AS deferred_until,
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
'ticket' AS case_type,
NULL::text AS kontakt_navn,
COALESCE(uu.full_name, uu.username) AS ansvarlig_navn,
NULL::text AS assigned_group_name,
COALESCE(t.priority, 'normal') AS priority,
'Ticket oprettet i dag' AS task_reason
FROM tticket_tickets t
LEFT JOIN customers c ON c.id = t.customer_id
LEFT JOIN users uu ON uu.user_id = t.assigned_to_user_id
WHERE t.assigned_to_user_id = %s
AND t.status IN ('open', 'in_progress', 'pending_customer')
AND DATE(t.created_at) = CURRENT_DATE
ORDER BY created_at DESC
LIMIT 12
"""
today_tasks = execute_query(today_tasks_query, (technician_user_id, technician_user_id))
urgent_overdue_query = f"""
SELECT
'case' AS item_type,
s.id AS item_id,
s.titel AS title,
s.beskrivelse,
s.status,
s.deadline AS due_at,
s.created_at,
{case_start_date_sql},
s.deferred_until,
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
{case_type_sql},
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) AS kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name,
NULL::text AS priority,
'Over deadline' AS attention_reason
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN groups g ON g.id = s.assigned_group_id
LEFT JOIN LATERAL (
SELECT cc.contact_id
FROM contact_companies cc
WHERE cc.customer_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) cc_first ON true
LEFT JOIN contacts cont ON cont.id = cc_first.contact_id
WHERE s.deleted_at IS NULL
AND s.status <> 'lukket'
AND s.deadline IS NOT NULL
AND s.deadline < CURRENT_DATE
UNION ALL
SELECT
'ticket' AS item_type,
t.id AS item_id,
t.subject AS title,
NULL::text AS beskrivelse,
t.status,
NULL::date AS due_at,
t.created_at,
NULL::date AS start_date,
NULL::date AS deferred_until,
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
'ticket' AS case_type,
NULL::text AS kontakt_navn,
COALESCE(uu.full_name, uu.username) AS ansvarlig_navn,
NULL::text AS assigned_group_name,
COALESCE(t.priority, 'normal') AS priority,
CASE
WHEN t.priority = 'urgent' THEN 'Urgent prioritet'
ELSE 'Høj prioritet'
END AS attention_reason
FROM tticket_tickets t
LEFT JOIN customers c ON c.id = t.customer_id
LEFT JOIN users uu ON uu.user_id = t.assigned_to_user_id
WHERE t.status IN ('open', 'in_progress', 'pending_customer')
AND COALESCE(t.priority, '') IN ('urgent', 'high')
AND (t.assigned_to_user_id = %s OR t.assigned_to_user_id IS NULL)
ORDER BY created_at DESC
LIMIT 15
"""
urgent_overdue = execute_query(urgent_overdue_query, (technician_user_id,))
opportunities_query = """
SELECT
s.id,
s.titel,
s.status,
s.pipeline_amount,
s.pipeline_probability,
ps.name AS pipeline_stage,
s.deadline,
s.created_at,
COALESCE(c.name, 'Ukendt kunde') AS customer_name
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
LEFT JOIN pipeline_stages ps ON ps.id = s.pipeline_stage_id
WHERE s.deleted_at IS NULL
AND s.ansvarlig_bruger_id = %s
AND (
s.template_key = 'pipeline'
OR EXISTS (
SELECT 1
FROM entity_tags et
JOIN tags t ON t.id = et.tag_id
WHERE et.entity_type = 'case'
AND et.entity_id = s.id
AND LOWER(t.name) = 'pipeline'
)
OR EXISTS (
SELECT 1
FROM sag_tags st
WHERE st.sag_id = s.id
AND st.deleted_at IS NULL
AND LOWER(st.tag_navn) = 'pipeline'
)
)
ORDER BY s.created_at DESC
LIMIT 12
"""
my_opportunities = execute_query(opportunities_query, (technician_user_id,))
# Get user's group IDs
user_groups_query = """
SELECT group_id
FROM user_groups
WHERE user_id = %s
"""
user_groups = execute_query(user_groups_query, (technician_user_id,)) or []
user_group_ids = [g["group_id"] for g in user_groups]
# Get group cases (cases assigned to user's groups)
group_cases = []
if user_group_ids:
group_cases_query = f"""
SELECT
s.id,
s.titel,
s.beskrivelse,
s.priority,
s.status,
s.created_at,
{case_start_date_sql},
s.deferred_until,
s.deadline,
{case_type_sql},
s.assigned_group_id,
g.name AS group_name,
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) AS kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
LEFT JOIN groups g ON g.id = s.assigned_group_id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN LATERAL (
SELECT cc.contact_id
FROM contact_companies cc
WHERE cc.customer_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) cc_first ON true
LEFT JOIN contacts cont ON cont.id = cc_first.contact_id
WHERE s.deleted_at IS NULL
AND s.assigned_group_id = ANY(%s)
AND s.status <> 'lukket'
ORDER BY s.created_at DESC
LIMIT 20
"""
group_cases = execute_query(group_cases_query, (user_group_ids,))
return {
"technician_user_id": technician_user_id,
"technician_name": technician_name,
"new_cases": new_cases or [],
"my_cases": my_cases or [],
"today_tasks": today_tasks or [],
"urgent_overdue": urgent_overdue or [],
"my_opportunities": my_opportunities or [],
"group_cases": group_cases or [],
"kpis": {
"new_cases_count": len(new_cases or []),
"my_cases_count": len(my_cases or []),
"today_tasks_count": len(today_tasks or []),
"urgent_overdue_count": len(urgent_overdue or []),
"my_opportunities_count": len(my_opportunities or []),
"group_cases_count": len(group_cases or [])
}
}
def _is_user_in_technician_group(user_id: int) -> bool:
groups_query = """
SELECT LOWER(g.name) AS group_name
FROM user_groups ug
JOIN groups g ON g.id = ug.group_id
WHERE ug.user_id = %s
"""
groups = execute_query(groups_query, (user_id,)) or []
return any(
"teknik" in (g.get("group_name") or "") or "technician" in (g.get("group_name") or "")
for g in groups
)
@router.get("/dashboard/technician", response_class=HTMLResponse)
async def technician_dashboard_selector(
request: Request,
technician_user_id: int = 1
):
"""Technician dashboard landing page with 3 variants to choose from."""
try:
# Always use logged-in user, ignore query param
logged_in_user_id = getattr(request.state, "user_id", 1)
context = _get_technician_dashboard_data(logged_in_user_id)
return templates.TemplateResponse(
"ticket/frontend/technician_dashboard_selector.html",
{
"request": request,
**context
}
)
except Exception as e:
logger.error(f"❌ Failed to load technician dashboard selector: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/dashboard/technician/v1", response_class=HTMLResponse)
async def technician_dashboard_v1(
request: Request,
technician_user_id: int = 1
):
"""Variant 1: KPI + card overview layout."""
try:
# Always use logged-in user, ignore query param
logged_in_user_id = getattr(request.state, "user_id", 1)
context = _get_technician_dashboard_data(logged_in_user_id)
return templates.TemplateResponse(
"ticket/frontend/mockups/tech_v1_overview.html",
{
"request": request,
**context
}
)
except Exception as e:
logger.error(f"❌ Failed to load technician dashboard v1: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/dashboard/technician/v2", response_class=HTMLResponse)
async def technician_dashboard_v2(
request: Request,
technician_user_id: int = 1
):
"""Variant 2: Workboard columns by focus areas."""
try:
# Always use logged-in user, ignore query param
logged_in_user_id = getattr(request.state, "user_id", 1)
context = _get_technician_dashboard_data(logged_in_user_id)
return templates.TemplateResponse(
"ticket/frontend/mockups/tech_v2_workboard.html",
{
"request": request,
**context
}
)
except Exception as e:
logger.error(f"❌ Failed to load technician dashboard v2: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/dashboard/technician/v3", response_class=HTMLResponse)
async def technician_dashboard_v3(
request: Request,
technician_user_id: int = 1
):
"""Variant 3: Dense table-first layout for power users."""
try:
# Always use logged-in user, ignore query param
logged_in_user_id = getattr(request.state, "user_id", 1)
context = _get_technician_dashboard_data(logged_in_user_id)
return templates.TemplateResponse(
"ticket/frontend/mockups/tech_v3_table_focus.html",
{
"request": request,
**context
}
)
except Exception as e:
logger.error(f"❌ Failed to load technician dashboard v3: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/dashboard", response_class=HTMLResponse)
async def ticket_dashboard(request: Request):
"""
Ticket system dashboard with statistics
"""
try:
current_user_id = getattr(request.state, "user_id", None)
if current_user_id and _is_user_in_technician_group(int(current_user_id)):
return RedirectResponse(
url=f"/ticket/dashboard/technician/v1?technician_user_id={int(current_user_id)}",
status_code=302
)
# 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("/archived", response_class=HTMLResponse)
async def archived_ticket_list_page(
request: Request,
search: Optional[str] = None,
organization: Optional[str] = None,
contact: Optional[str] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None
):
"""
Archived ticket list page (Simply-CRM import)
"""
try:
query = """
SELECT
id,
ticket_number,
title,
organization_name,
contact_name,
email_from,
time_spent_hours,
status,
priority,
source_created_at,
description,
solution,
(
SELECT COUNT(*)
FROM tticket_archived_messages m
WHERE m.archived_ticket_id = t.id
) AS message_count
FROM tticket_archived_tickets t
WHERE 1=1
"""
params = []
if search:
query += """
AND (
t.ticket_number ILIKE %s
OR t.title ILIKE %s
OR t.description ILIKE %s
OR t.solution ILIKE %s
OR EXISTS (
SELECT 1
FROM tticket_archived_messages m
WHERE m.archived_ticket_id = t.id
AND m.body ILIKE %s
)
)
"""
search_pattern = f"%{search}%"
params.extend([search_pattern] * 5)
if organization:
query += " AND t.organization_name ILIKE %s"
params.append(f"%{organization}%")
if contact:
query += " AND t.contact_name ILIKE %s"
params.append(f"%{contact}%")
if date_from:
query += " AND t.source_created_at >= %s"
params.append(date_from)
if date_to:
query += " AND t.source_created_at <= %s"
params.append(date_to)
query += " ORDER BY t.source_created_at DESC NULLS LAST, t.imported_at DESC LIMIT 200"
tickets = execute_query(query, tuple(params)) if params else execute_query(query)
return templates.TemplateResponse(
"ticket/frontend/archived_ticket_list.html",
{
"request": request,
"tickets": tickets,
"search_query": search,
"organization_query": organization,
"contact_query": contact,
"date_from": date_from,
"date_to": date_to
}
)
except Exception as e:
logger.error(f"❌ Failed to load archived ticket list: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/archived/{archived_ticket_id}", response_class=HTMLResponse)
async def archived_ticket_detail_page(request: Request, archived_ticket_id: int):
"""
Archived ticket detail page with messages
"""
try:
ticket = execute_query_single(
"SELECT * FROM tticket_archived_tickets WHERE id = %s",
(archived_ticket_id,)
)
if not ticket:
raise HTTPException(status_code=404, detail="Archived ticket not found")
messages = execute_query(
"""
SELECT * FROM tticket_archived_messages
WHERE archived_ticket_id = %s
ORDER BY source_created_at ASC NULLS LAST, imported_at ASC
""",
(archived_ticket_id,)
)
formatted_messages = []
for message in messages or []:
formatted = dict(message)
body_value = message.get("body") or ""
body_html = _format_long_text(body_value)
if body_value and not body_html:
body_html = f"<p>{html.escape(body_value)}</p>"
formatted["body_html"] = body_html
formatted_messages.append(formatted)
description_value = ticket.get("description") or ""
description_html = _format_long_text(description_value)
if description_value and not description_html:
description_html = f"<p>{html.escape(description_value)}</p>"
solution_value = ticket.get("solution") or ""
solution_html = _format_long_text(solution_value)
if solution_value and not solution_html:
solution_html = f"<p>{html.escape(solution_value)}</p>"
return templates.TemplateResponse(
"ticket/frontend/archived_ticket_detail.html",
{
"request": request,
"ticket": ticket,
"messages": formatted_messages,
"description_html": description_html,
"solution_html": solution_html
}
)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Failed to load archived ticket detail: {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))
# Catch-all: other ticket routes redirect to /sag
@router.get("/{path:path}", include_in_schema=False)
async def ticket_catchall_redirect(path: str):
return RedirectResponse(url="/sag", status_code=302)