This commit is contained in:
HotSwapp
2025-08-08 19:06:39 -05:00
parent b257a06787
commit 04edc636f8
12 changed files with 1824 additions and 52 deletions

View File

@@ -89,6 +89,26 @@ async def read_users_me(current_user: User = Depends(get_current_user)):
return current_user return current_user
@router.post("/refresh", response_model=Token)
async def refresh_token(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Refresh access token for current user"""
# Update last login timestamp
current_user.last_login = datetime.utcnow()
db.commit()
# Create new token with full expiration time
access_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
access_token = create_access_token(
data={"sub": current_user.username},
expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}
@router.get("/users", response_model=List[UserResponse]) @router.get("/users", response_model=List[UserResponse])
async def list_users( async def list_users(
db: Session = Depends(get_db), db: Session = Depends(get_db),

452
app/api/support.py Normal file
View File

@@ -0,0 +1,452 @@
"""
Support ticket API endpoints
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import func, desc, and_, or_
from datetime import datetime
import secrets
from app.database.base import get_db
from app.models import User, SupportTicket, TicketResponse, TicketStatus, TicketPriority, TicketCategory
from app.auth.security import get_current_user, get_admin_user
router = APIRouter()
# Pydantic models for API
from pydantic import BaseModel, Field, EmailStr
class TicketCreate(BaseModel):
"""Create new support ticket"""
subject: str = Field(..., min_length=5, max_length=200)
description: str = Field(..., min_length=10)
category: TicketCategory = TicketCategory.BUG_REPORT
priority: TicketPriority = TicketPriority.MEDIUM
contact_name: str = Field(..., min_length=1, max_length=100)
contact_email: EmailStr
current_page: Optional[str] = None
browser_info: Optional[str] = None
class TicketUpdate(BaseModel):
"""Update existing ticket"""
subject: Optional[str] = Field(None, min_length=5, max_length=200)
description: Optional[str] = Field(None, min_length=10)
category: Optional[TicketCategory] = None
priority: Optional[TicketPriority] = None
status: Optional[TicketStatus] = None
assigned_to: Optional[int] = None
class ResponseCreate(BaseModel):
"""Create ticket response"""
message: str = Field(..., min_length=1)
is_internal: bool = False
class TicketResponse(BaseModel):
"""Ticket response model"""
id: int
ticket_id: int
message: str
is_internal: bool
author_name: Optional[str]
author_email: Optional[str]
created_at: datetime
class Config:
from_attributes = True
class TicketDetail(BaseModel):
"""Detailed ticket information"""
id: int
ticket_number: str
subject: str
description: str
category: TicketCategory
priority: TicketPriority
status: TicketStatus
contact_name: str
contact_email: str
current_page: Optional[str]
browser_info: Optional[str]
ip_address: Optional[str]
created_at: datetime
updated_at: Optional[datetime]
resolved_at: Optional[datetime]
assigned_to: Optional[int]
assigned_admin_name: Optional[str]
submitter_name: Optional[str]
responses: List[TicketResponse] = []
class Config:
from_attributes = True
def generate_ticket_number() -> str:
"""Generate unique ticket number like ST-2024-001"""
year = datetime.now().year
random_suffix = secrets.token_hex(2).upper()
return f"ST-{year}-{random_suffix}"
@router.post("/tickets", response_model=dict)
async def create_support_ticket(
ticket_data: TicketCreate,
request: Request,
db: Session = Depends(get_db),
current_user: Optional[User] = Depends(lambda: None) # Allow anonymous submissions
):
"""Create new support ticket (public endpoint)"""
# current_user is already set from dependency injection above
# No need to call get_current_user again
# Generate unique ticket number
ticket_number = generate_ticket_number()
while db.query(SupportTicket).filter(SupportTicket.ticket_number == ticket_number).first():
ticket_number = generate_ticket_number()
# Get client info from request
client_ip = request.client.host
user_agent = request.headers.get("User-Agent", "")
# Create ticket
new_ticket = SupportTicket(
ticket_number=ticket_number,
subject=ticket_data.subject,
description=ticket_data.description,
category=ticket_data.category,
priority=ticket_data.priority,
contact_name=ticket_data.contact_name,
contact_email=ticket_data.contact_email,
current_page=ticket_data.current_page,
browser_info=ticket_data.browser_info or user_agent,
ip_address=client_ip,
user_id=current_user.id if current_user else None,
status=TicketStatus.OPEN,
created_at=datetime.utcnow()
)
db.add(new_ticket)
db.commit()
db.refresh(new_ticket)
return {
"message": "Support ticket created successfully",
"ticket_number": new_ticket.ticket_number,
"ticket_id": new_ticket.id,
"status": "created"
}
@router.get("/tickets", response_model=List[TicketDetail])
async def list_tickets(
status: Optional[TicketStatus] = None,
priority: Optional[TicketPriority] = None,
category: Optional[TicketCategory] = None,
assigned_to_me: bool = False,
skip: int = 0,
limit: int = 50,
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user)
):
"""List support tickets (admin only)"""
query = db.query(SupportTicket).options(
joinedload(SupportTicket.submitter),
joinedload(SupportTicket.assigned_admin),
joinedload(SupportTicket.responses)
)
# Apply filters
if status:
query = query.filter(SupportTicket.status == status)
if priority:
query = query.filter(SupportTicket.priority == priority)
if category:
query = query.filter(SupportTicket.category == category)
if assigned_to_me:
query = query.filter(SupportTicket.assigned_to == current_user.id)
tickets = query.order_by(desc(SupportTicket.created_at)).offset(skip).limit(limit).all()
# Format response
result = []
for ticket in tickets:
ticket_dict = {
"id": ticket.id,
"ticket_number": ticket.ticket_number,
"subject": ticket.subject,
"description": ticket.description,
"category": ticket.category,
"priority": ticket.priority,
"status": ticket.status,
"contact_name": ticket.contact_name,
"contact_email": ticket.contact_email,
"current_page": ticket.current_page,
"browser_info": ticket.browser_info,
"ip_address": ticket.ip_address,
"created_at": ticket.created_at,
"updated_at": ticket.updated_at,
"resolved_at": ticket.resolved_at,
"assigned_to": ticket.assigned_to,
"assigned_admin_name": f"{ticket.assigned_admin.first_name} {ticket.assigned_admin.last_name}" if ticket.assigned_admin else None,
"submitter_name": f"{ticket.submitter.first_name} {ticket.submitter.last_name}" if ticket.submitter else ticket.contact_name,
"responses": [
{
"id": resp.id,
"ticket_id": resp.ticket_id,
"message": resp.message,
"is_internal": resp.is_internal,
"author_name": f"{resp.author.first_name} {resp.author.last_name}" if resp.author else resp.author_name,
"author_email": resp.author.email if resp.author else resp.author_email,
"created_at": resp.created_at
}
for resp in ticket.responses if not resp.is_internal # Only show public responses
]
}
result.append(ticket_dict)
return result
@router.get("/tickets/{ticket_id}", response_model=TicketDetail)
async def get_ticket(
ticket_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user)
):
"""Get ticket details (admin only)"""
ticket = db.query(SupportTicket).options(
joinedload(SupportTicket.submitter),
joinedload(SupportTicket.assigned_admin),
joinedload(SupportTicket.responses).joinedload(TicketResponse.author)
).filter(SupportTicket.id == ticket_id).first()
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ticket not found"
)
# Format response (include internal responses for admins)
return {
"id": ticket.id,
"ticket_number": ticket.ticket_number,
"subject": ticket.subject,
"description": ticket.description,
"category": ticket.category,
"priority": ticket.priority,
"status": ticket.status,
"contact_name": ticket.contact_name,
"contact_email": ticket.contact_email,
"current_page": ticket.current_page,
"browser_info": ticket.browser_info,
"ip_address": ticket.ip_address,
"created_at": ticket.created_at,
"updated_at": ticket.updated_at,
"resolved_at": ticket.resolved_at,
"assigned_to": ticket.assigned_to,
"assigned_admin_name": f"{ticket.assigned_admin.first_name} {ticket.assigned_admin.last_name}" if ticket.assigned_admin else None,
"submitter_name": f"{ticket.submitter.first_name} {ticket.submitter.last_name}" if ticket.submitter else ticket.contact_name,
"responses": [
{
"id": resp.id,
"ticket_id": resp.ticket_id,
"message": resp.message,
"is_internal": resp.is_internal,
"author_name": f"{resp.author.first_name} {resp.author.last_name}" if resp.author else resp.author_name,
"author_email": resp.author.email if resp.author else resp.author_email,
"created_at": resp.created_at
}
for resp in ticket.responses
]
}
@router.put("/tickets/{ticket_id}")
async def update_ticket(
ticket_id: int,
ticket_data: TicketUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user)
):
"""Update ticket (admin only)"""
ticket = db.query(SupportTicket).filter(SupportTicket.id == ticket_id).first()
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ticket not found"
)
# Track changes for audit
changes = {}
update_data = ticket_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
if hasattr(ticket, field) and getattr(ticket, field) != value:
changes[field] = {"from": getattr(ticket, field), "to": value}
setattr(ticket, field, value)
# Set resolved timestamp if status changed to resolved
if ticket_data.status == TicketStatus.RESOLVED and ticket.resolved_at is None:
ticket.resolved_at = datetime.utcnow()
changes["resolved_at"] = {"from": None, "to": ticket.resolved_at}
ticket.updated_at = datetime.utcnow()
db.commit()
# Log the update (audit logging can be added later)
# TODO: Add audit logging for ticket updates
return {"message": "Ticket updated successfully"}
@router.post("/tickets/{ticket_id}/responses")
async def add_response(
ticket_id: int,
response_data: ResponseCreate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user)
):
"""Add response to ticket (admin only)"""
ticket = db.query(SupportTicket).filter(SupportTicket.id == ticket_id).first()
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ticket not found"
)
# Create response
response = TicketResponse(
ticket_id=ticket_id,
message=response_data.message,
is_internal=response_data.is_internal,
user_id=current_user.id,
created_at=datetime.utcnow()
)
db.add(response)
# Update ticket timestamp
ticket.updated_at = datetime.utcnow()
db.commit()
db.refresh(response)
# Log the response (audit logging can be added later)
# TODO: Add audit logging for ticket responses
return {"message": "Response added successfully", "response_id": response.id}
@router.get("/my-tickets", response_model=List[TicketDetail])
async def get_my_tickets(
status: Optional[TicketStatus] = None,
skip: int = 0,
limit: int = 20,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get current user's tickets"""
query = db.query(SupportTicket).options(
joinedload(SupportTicket.responses)
).filter(SupportTicket.user_id == current_user.id)
if status:
query = query.filter(SupportTicket.status == status)
tickets = query.order_by(desc(SupportTicket.created_at)).offset(skip).limit(limit).all()
# Format response (exclude internal responses for regular users)
result = []
for ticket in tickets:
ticket_dict = {
"id": ticket.id,
"ticket_number": ticket.ticket_number,
"subject": ticket.subject,
"description": ticket.description,
"category": ticket.category,
"priority": ticket.priority,
"status": ticket.status,
"contact_name": ticket.contact_name,
"contact_email": ticket.contact_email,
"current_page": ticket.current_page,
"browser_info": None, # Don't expose to user
"ip_address": None, # Don't expose to user
"created_at": ticket.created_at,
"updated_at": ticket.updated_at,
"resolved_at": ticket.resolved_at,
"assigned_to": None, # Don't expose to user
"assigned_admin_name": None,
"submitter_name": f"{current_user.first_name} {current_user.last_name}",
"responses": [
{
"id": resp.id,
"ticket_id": resp.ticket_id,
"message": resp.message,
"is_internal": False, # Always show as public to user
"author_name": f"{resp.author.first_name} {resp.author.last_name}" if resp.author else "Support Team",
"author_email": None, # Don't expose admin emails
"created_at": resp.created_at
}
for resp in ticket.responses if not resp.is_internal # Only show public responses
]
}
result.append(ticket_dict)
return result
@router.get("/stats")
async def get_ticket_stats(
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user)
):
"""Get support ticket statistics (admin only)"""
total_tickets = db.query(func.count(SupportTicket.id)).scalar()
open_tickets = db.query(func.count(SupportTicket.id)).filter(
SupportTicket.status == TicketStatus.OPEN
).scalar()
in_progress_tickets = db.query(func.count(SupportTicket.id)).filter(
SupportTicket.status == TicketStatus.IN_PROGRESS
).scalar()
resolved_tickets = db.query(func.count(SupportTicket.id)).filter(
SupportTicket.status == TicketStatus.RESOLVED
).scalar()
# Tickets by priority
high_priority = db.query(func.count(SupportTicket.id)).filter(
and_(SupportTicket.priority == TicketPriority.HIGH, SupportTicket.status != TicketStatus.RESOLVED)
).scalar()
urgent_tickets = db.query(func.count(SupportTicket.id)).filter(
and_(SupportTicket.priority == TicketPriority.URGENT, SupportTicket.status != TicketStatus.RESOLVED)
).scalar()
# Recent tickets (last 7 days)
from datetime import timedelta
week_ago = datetime.utcnow() - timedelta(days=7)
recent_tickets = db.query(func.count(SupportTicket.id)).filter(
SupportTicket.created_at >= week_ago
).scalar()
return {
"total_tickets": total_tickets,
"open_tickets": open_tickets,
"in_progress_tickets": in_progress_tickets,
"resolved_tickets": resolved_tickets,
"high_priority_tickets": high_priority,
"urgent_tickets": urgent_tickets,
"recent_tickets": recent_tickets
}

View File

@@ -19,7 +19,7 @@ class Settings(BaseSettings):
# Authentication # Authentication
secret_key: str = "your-secret-key-change-in-production" secret_key: str = "your-secret-key-change-in-production"
algorithm: str = "HS256" algorithm: str = "HS256"
access_token_expire_minutes: int = 30 access_token_expire_minutes: int = 240 # 4 hours
# Admin account settings # Admin account settings
admin_username: str = "admin" admin_username: str = "admin"

View File

@@ -45,6 +45,7 @@ from app.api.documents import router as documents_router
from app.api.search import router as search_router from app.api.search import router as search_router
from app.api.admin import router as admin_router from app.api.admin import router as admin_router
from app.api.import_data import router as import_router from app.api.import_data import router as import_router
from app.api.support import router as support_router
app.include_router(auth_router, prefix="/api/auth", tags=["authentication"]) app.include_router(auth_router, prefix="/api/auth", tags=["authentication"])
app.include_router(customers_router, prefix="/api/customers", tags=["customers"]) app.include_router(customers_router, prefix="/api/customers", tags=["customers"])
@@ -54,6 +55,7 @@ app.include_router(documents_router, prefix="/api/documents", tags=["documents"]
app.include_router(search_router, prefix="/api/search", tags=["search"]) app.include_router(search_router, prefix="/api/search", tags=["search"])
app.include_router(admin_router, prefix="/api/admin", tags=["admin"]) app.include_router(admin_router, prefix="/api/admin", tags=["admin"])
app.include_router(import_router, tags=["import"]) app.include_router(import_router, tags=["import"])
app.include_router(support_router, prefix="/api/support", tags=["support"])
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)

View File

@@ -9,6 +9,7 @@ from .ledger import Ledger
from .qdro import QDRO from .qdro import QDRO
from .audit import AuditLog, LoginAttempt from .audit import AuditLog, LoginAttempt
from .additional import Deposit, Payment, FileNote, FormVariable, ReportVariable from .additional import Deposit, Payment, FileNote, FormVariable, ReportVariable
from .support import SupportTicket, TicketResponse, TicketStatus, TicketPriority, TicketCategory
from .pensions import ( from .pensions import (
Pension, PensionSchedule, MarriageHistory, DeathBenefit, Pension, PensionSchedule, MarriageHistory, DeathBenefit,
SeparationAgreement, LifeTable, NumberTable SeparationAgreement, LifeTable, NumberTable
@@ -23,6 +24,7 @@ __all__ = [
"BaseModel", "User", "Rolodex", "Phone", "File", "Ledger", "QDRO", "BaseModel", "User", "Rolodex", "Phone", "File", "Ledger", "QDRO",
"AuditLog", "LoginAttempt", "AuditLog", "LoginAttempt",
"Deposit", "Payment", "FileNote", "FormVariable", "ReportVariable", "Deposit", "Payment", "FileNote", "FormVariable", "ReportVariable",
"SupportTicket", "TicketResponse", "TicketStatus", "TicketPriority", "TicketCategory",
"Pension", "PensionSchedule", "MarriageHistory", "DeathBenefit", "Pension", "PensionSchedule", "MarriageHistory", "DeathBenefit",
"SeparationAgreement", "LifeTable", "NumberTable", "SeparationAgreement", "LifeTable", "NumberTable",
"Employee", "FileType", "FileStatus", "TransactionType", "TransactionCode", "Employee", "FileType", "FileStatus", "TransactionType", "TransactionCode",

102
app/models/support.py Normal file
View File

@@ -0,0 +1,102 @@
"""
Support ticket models for help desk functionality
"""
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, ForeignKey, Enum
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
from app.models.base import BaseModel
class TicketStatus(enum.Enum):
OPEN = "open"
IN_PROGRESS = "in_progress"
RESOLVED = "resolved"
CLOSED = "closed"
class TicketPriority(enum.Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
URGENT = "urgent"
class TicketCategory(enum.Enum):
BUG_REPORT = "bug_report"
QA_ISSUE = "qa_issue"
FEATURE_REQUEST = "feature_request"
DATABASE_ISSUE = "database_issue"
SYSTEM_ERROR = "system_error"
USER_ACCESS = "user_access"
PERFORMANCE = "performance"
DOCUMENTATION = "documentation"
CONFIGURATION = "configuration"
TESTING = "testing"
class SupportTicket(BaseModel):
"""
Support ticket for user help requests
"""
__tablename__ = "support_tickets"
id = Column(Integer, primary_key=True, autoincrement=True)
ticket_number = Column(String(20), unique=True, nullable=False, index=True) # Auto-generated like ST-2024-001
# Ticket details
subject = Column(String(200), nullable=False)
description = Column(Text, nullable=False)
category = Column(Enum(TicketCategory), default=TicketCategory.BUG_REPORT)
priority = Column(Enum(TicketPriority), default=TicketPriority.MEDIUM)
status = Column(Enum(TicketStatus), default=TicketStatus.OPEN)
# User information
user_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Null for anonymous submissions
contact_name = Column(String(100), nullable=False)
contact_email = Column(String(100), nullable=False)
# System information (auto-detected)
current_page = Column(String(100)) # Which page they were on
browser_info = Column(String(200)) # User agent
ip_address = Column(String(45)) # IP address
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
resolved_at = Column(DateTime)
# Admin assignment
assigned_to = Column(Integer, ForeignKey("users.id"))
# Relationships
submitter = relationship("User", foreign_keys=[user_id], back_populates="submitted_tickets")
assigned_admin = relationship("User", foreign_keys=[assigned_to])
responses = relationship("TicketResponse", back_populates="ticket", cascade="all, delete-orphan")
class TicketResponse(BaseModel):
"""
Responses/comments on support tickets
"""
__tablename__ = "ticket_responses"
id = Column(Integer, primary_key=True, autoincrement=True)
ticket_id = Column(Integer, ForeignKey("support_tickets.id"), nullable=False)
# Response details
message = Column(Text, nullable=False)
is_internal = Column(Boolean, default=False) # Internal admin notes vs public responses
# Author information
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
author_name = Column(String(100)) # For non-user responses
author_email = Column(String(100)) # For non-user responses
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
# Relationships
ticket = relationship("SupportTicket", back_populates="responses")
author = relationship("User")

View File

@@ -32,6 +32,7 @@ class User(BaseModel):
# Relationships # Relationships
audit_logs = relationship("AuditLog", back_populates="user") audit_logs = relationship("AuditLog", back_populates="user")
submitted_tickets = relationship("SupportTicket", foreign_keys="SupportTicket.user_id", back_populates="submitter")
def __repr__(self): def __repr__(self):
return f"<User(username='{self.username}', email='{self.email}')>" return f"<User(username='{self.username}', email='{self.email}')>"

View File

@@ -2,33 +2,33 @@
# Python 3.12+ FastAPI Backend Requirements # Python 3.12+ FastAPI Backend Requirements
# Core Web Framework # Core Web Framework
fastapi==0.104.1 fastapi==0.115.6
uvicorn[standard]==0.24.0 uvicorn[standard]==0.32.1
gunicorn==21.2.0 gunicorn==23.0.0
# Database # Database
sqlalchemy==2.0.23 sqlalchemy==2.0.36
alembic==1.13.1 alembic==1.14.0
# Authentication & Security # Authentication & Security
python-multipart==0.0.6 python-multipart==0.0.12
python-jose[cryptography]==3.3.0 python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4 passlib[bcrypt]==1.7.4
bcrypt==4.1.2 bcrypt==4.0.1
# Data Validation # Data Validation
pydantic==2.5.2 pydantic==2.10.3
pydantic-settings==2.1.0 pydantic-settings==2.7.0
email-validator==2.1.0 email-validator==2.2.0
# Templates & Static Files # Templates & Static Files
jinja2==3.1.2 jinja2==3.1.4
aiofiles==23.2.1 aiofiles==24.1.0
# Testing # Testing
pytest==7.4.3 pytest==8.3.4
pytest-asyncio==0.21.1 pytest-asyncio==0.24.0
httpx==0.25.2 httpx==0.28.1
# Development # Development
python-dotenv==1.0.0 python-dotenv==1.0.1

View File

@@ -94,6 +94,18 @@
<i class="fas fa-tools"></i> Maintenance <i class="fas fa-tools"></i> Maintenance
</button> </button>
</li> </li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="issues-tab" data-bs-toggle="tab" data-bs-target="#issues"
type="button" role="tab">
<i class="fas fa-bug"></i> Issue Tracking
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="import-tab" data-bs-toggle="tab" data-bs-target="#import"
type="button" role="tab">
<i class="bi bi-upload"></i> Data Import
</button>
</li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="backup-tab" data-bs-toggle="tab" data-bs-target="#backup" <button class="nav-link" id="backup-tab" data-bs-toggle="tab" data-bs-target="#backup"
type="button" role="tab"> type="button" role="tab">
@@ -373,6 +385,283 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Issues Tab -->
<div class="tab-pane fade" id="issues" role="tabpanel">
<div class="row mb-4">
<!-- Issue Statistics Cards -->
<div class="col-md-3">
<div class="card border-danger">
<div class="card-body text-center">
<h2 class="text-danger" id="high-priority-count">0</h2>
<h6>High Priority</h6>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-warning">
<div class="card-body text-center">
<h2 class="text-warning" id="open-issues-count">0</h2>
<h6>Open Issues</h6>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-info">
<div class="card-body text-center">
<h2 class="text-info" id="in-progress-count">0</h2>
<h6>In Progress</h6>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-success">
<div class="card-body text-center">
<h2 class="text-success" id="resolved-count">0</h2>
<h6>Resolved</h6>
</div>
</div>
</div>
</div>
<!-- Issue Management -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="fas fa-bug"></i> Internal Issues & Bugs</h5>
<div>
<button type="button" class="btn btn-outline-primary btn-sm me-2" onclick="loadIssues()">
<i class="fas fa-sync"></i> Refresh
</button>
<button type="button" class="btn btn-primary btn-sm" onclick="openSupportModal()">
<i class="fas fa-plus"></i> New Issue
</button>
</div>
</div>
<div class="card-body">
<!-- Filters -->
<div class="row mb-3">
<div class="col-md-3">
<select class="form-select form-select-sm" id="issueStatusFilter" onchange="filterIssues()">
<option value="">All Statuses</option>
<option value="open">Open</option>
<option value="in_progress">In Progress</option>
<option value="resolved">Resolved</option>
<option value="closed">Closed</option>
</select>
</div>
<div class="col-md-3">
<select class="form-select form-select-sm" id="issuePriorityFilter" onchange="filterIssues()">
<option value="">All Priorities</option>
<option value="urgent">Urgent</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div>
<div class="col-md-3">
<select class="form-select form-select-sm" id="issueCategoryFilter" onchange="filterIssues()">
<option value="">All Categories</option>
<option value="bug_report">Bug Reports</option>
<option value="qa_issue">QA Issues</option>
<option value="feature_request">Feature Requests</option>
<option value="database_issue">Database Issues</option>
<option value="system_error">System Errors</option>
<option value="performance">Performance</option>
<option value="user_access">User Access</option>
<option value="configuration">Configuration</option>
<option value="documentation">Documentation</option>
<option value="testing">Testing</option>
</select>
</div>
<div class="col-md-3">
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="assignedToMeFilter" onchange="filterIssues()">
<label class="form-check-label" for="assignedToMeFilter">
Assigned to me
</label>
</div>
</div>
</div>
<!-- Issues Table -->
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead class="table-dark">
<tr>
<th>ID</th>
<th>Type</th>
<th>Priority</th>
<th>Subject</th>
<th>Reporter</th>
<th>Status</th>
<th>Assigned</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="issues-table-body">
<tr>
<td colspan="9" class="text-center">Loading issues...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Issue Detail Modal -->
<div class="modal fade" id="issueDetailModal" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="issueDetailModalTitle">
<i class="fas fa-bug me-2"></i>Issue Details
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row">
<!-- Left Column - Issue Details -->
<div class="col-md-8">
<div class="card mb-3">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="badge bg-primary me-2" id="issueDetailNumber">ST-2025-XXXX</span>
<span class="badge" id="issueDetailCategory">category</span>
<span class="badge ms-2" id="issueDetailPriority">priority</span>
</div>
<span class="badge fs-6" id="issueDetailStatus">status</span>
</div>
</div>
<div class="card-body">
<h5 id="issueDetailSubject">Issue subject</h5>
<div class="mt-3">
<h6>Description:</h6>
<div id="issueDetailDescription" style="white-space: pre-wrap; background: #f8f9fa; padding: 1rem; border-radius: 0.375rem;"></div>
</div>
<div class="mt-3">
<h6>Context Information:</h6>
<div class="row">
<div class="col-md-6">
<small><strong>Page:</strong> <span id="issueDetailPage">-</span></small>
</div>
<div class="col-md-6">
<small><strong>Browser:</strong> <span id="issueDetailBrowser">-</span></small>
</div>
</div>
</div>
</div>
</div>
<!-- Responses Section -->
<div class="card">
<div class="card-header">
<h6 class="mb-0">Comments & Updates</h6>
</div>
<div class="card-body">
<div id="issueResponses">
<p class="text-muted">No comments yet.</p>
</div>
<!-- Add Response Form -->
<div class="mt-3 border-top pt-3">
<textarea class="form-control mb-2" id="newResponseText" rows="3" placeholder="Add a comment..."></textarea>
<div class="d-flex justify-content-between align-items-center">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="internalResponseCheck">
<label class="form-check-label" for="internalResponseCheck">
Internal note (not visible to reporter)
</label>
</div>
<button type="button" class="btn btn-primary btn-sm" onclick="addResponse()">
<i class="fas fa-comment"></i> Add Comment
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Right Column - Issue Management -->
<div class="col-md-4">
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0">Issue Management</h6>
</div>
<div class="card-body">
<form id="issueUpdateForm">
<input type="hidden" id="currentIssueId">
<div class="mb-3">
<label class="form-label">Status</label>
<select class="form-select form-select-sm" id="updateStatus">
<option value="open">Open</option>
<option value="in_progress">In Progress</option>
<option value="resolved">Resolved</option>
<option value="closed">Closed</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Priority</label>
<select class="form-select form-select-sm" id="updatePriority">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Assign To</label>
<select class="form-select form-select-sm" id="updateAssignee">
<option value="">Unassigned</option>
<!-- Will be populated with users -->
</select>
</div>
<button type="button" class="btn btn-success btn-sm w-100" onclick="updateIssue()">
<i class="fas fa-save"></i> Update Issue
</button>
</form>
</div>
</div>
<div class="card">
<div class="card-header">
<h6 class="mb-0">Issue Info</h6>
</div>
<div class="card-body">
<div class="mb-2">
<small class="text-muted">Reporter:</small><br>
<span id="issueDetailReporter">-</span>
</div>
<div class="mb-2">
<small class="text-muted">Email:</small><br>
<span id="issueDetailEmail">-</span>
</div>
<div class="mb-2">
<small class="text-muted">Created:</small><br>
<span id="issueDetailCreated">-</span>
</div>
<div class="mb-2">
<small class="text-muted">Last Updated:</small><br>
<span id="issueDetailUpdated">-</span>
</div>
<div class="mb-2" id="issueResolvedInfo" style="display: none;">
<small class="text-muted">Resolved:</small><br>
<span id="issueDetailResolved">-</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
@@ -515,6 +804,15 @@ let currentUsers = [];
let currentSettings = []; let currentSettings = [];
let userPagination = { page: 1, limit: 10 }; let userPagination = { page: 1, limit: 10 };
// Helper function for authenticated API calls
function getAuthHeaders() {
const token = localStorage.getItem('auth_token');
return {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
};
}
// Initialize admin dashboard // Initialize admin dashboard
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
loadSystemHealth(); loadSystemHealth();
@@ -523,15 +821,25 @@ document.addEventListener('DOMContentLoaded', function() {
loadSettings(); loadSettings();
loadLookupTables(); loadLookupTables();
loadBackups(); loadBackups();
loadIssues();
loadIssueStats();
// Auto-refresh every 5 minutes // Auto-refresh every 5 minutes
setInterval(loadSystemHealth, 300000); setInterval(loadSystemHealth, 300000);
// Load issue stats when Issue Tracking tab is clicked
document.getElementById('issues-tab').addEventListener('shown.bs.tab', function() {
loadIssues();
loadIssueStats();
});
}); });
// System Health Functions // System Health Functions
async function loadSystemHealth() { async function loadSystemHealth() {
try { try {
const response = await fetch('/api/admin/health'); const response = await fetch('/api/admin/health', {
headers: getAuthHeaders()
});
const data = await response.json(); const data = await response.json();
// Update status indicator // Update status indicator
@@ -567,7 +875,9 @@ async function loadSystemHealth() {
async function loadSystemStats() { async function loadSystemStats() {
try { try {
const response = await fetch('/api/admin/stats'); const response = await fetch('/api/admin/stats', {
headers: getAuthHeaders()
});
const data = await response.json(); const data = await response.json();
// Update dashboard cards // Update dashboard cards
@@ -611,7 +921,9 @@ async function loadUsers() {
if (search) url += 'search=' + encodeURIComponent(search) + '&'; if (search) url += 'search=' + encodeURIComponent(search) + '&';
if (filter === 'active') url += 'active_only=true&'; if (filter === 'active') url += 'active_only=true&';
const response = await fetch(url); const response = await fetch(url, {
headers: getAuthHeaders()
});
const users = await response.json(); const users = await response.json();
currentUsers = users; currentUsers = users;
@@ -675,7 +987,9 @@ function showCreateUserModal() {
async function editUser(userId) { async function editUser(userId) {
try { try {
const response = await fetch('/api/admin/users/' + userId); const response = await fetch('/api/admin/users/' + userId, {
headers: getAuthHeaders()
});
const user = await response.json(); const user = await response.json();
document.getElementById('userModalTitle').textContent = 'Edit User'; document.getElementById('userModalTitle').textContent = 'Edit User';
@@ -719,9 +1033,7 @@ async function saveUser() {
const response = await fetch(url, { const response = await fetch(url, {
method: method, method: method,
headers: { headers: getAuthHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify(userData) body: JSON.stringify(userData)
}); });
@@ -759,9 +1071,7 @@ async function resetPassword() {
try { try {
const response = await fetch('/api/admin/users/' + userId + '/reset-password', { const response = await fetch('/api/admin/users/' + userId + '/reset-password', {
method: 'POST', method: 'POST',
headers: { headers: getAuthHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify({ body: JSON.stringify({
new_password: newPassword, new_password: newPassword,
confirm_password: confirmPassword confirm_password: confirmPassword
@@ -787,7 +1097,8 @@ async function deactivateUser(userId) {
try { try {
const response = await fetch('/api/admin/users/' + userId, { const response = await fetch('/api/admin/users/' + userId, {
method: 'DELETE' method: 'DELETE',
headers: getAuthHeaders()
}); });
if (response.ok) { if (response.ok) {
@@ -807,7 +1118,9 @@ async function deactivateUser(userId) {
// Settings Management Functions // Settings Management Functions
async function loadSettings() { async function loadSettings() {
try { try {
const response = await fetch('/api/admin/settings'); const response = await fetch('/api/admin/settings', {
headers: getAuthHeaders()
});
const data = await response.json(); const data = await response.json();
currentSettings = data.settings; currentSettings = data.settings;
@@ -856,7 +1169,9 @@ function showCreateSettingModal() {
async function editSetting(settingKey) { async function editSetting(settingKey) {
try { try {
const response = await fetch('/api/admin/settings/' + encodeURIComponent(settingKey)); const response = await fetch('/api/admin/settings/' + encodeURIComponent(settingKey), {
headers: getAuthHeaders()
});
const setting = await response.json(); const setting = await response.json();
document.getElementById('settingModalTitle').textContent = 'Edit Setting'; document.getElementById('settingModalTitle').textContent = 'Edit Setting';
@@ -891,9 +1206,7 @@ async function saveSetting() {
const response = await fetch(url, { const response = await fetch(url, {
method: method, method: method,
headers: { headers: getAuthHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify(isEdit ? { body: JSON.stringify(isEdit ? {
setting_value: settingData.setting_value, setting_value: settingData.setting_value,
description: settingData.description description: settingData.description
@@ -920,7 +1233,8 @@ async function deleteSetting(settingKey) {
try { try {
const response = await fetch('/api/admin/settings/' + encodeURIComponent(settingKey), { const response = await fetch('/api/admin/settings/' + encodeURIComponent(settingKey), {
method: 'DELETE' method: 'DELETE',
headers: getAuthHeaders()
}); });
if (response.ok) { if (response.ok) {
@@ -940,7 +1254,9 @@ async function deleteSetting(settingKey) {
// Maintenance Functions // Maintenance Functions
async function loadLookupTables() { async function loadLookupTables() {
try { try {
const response = await fetch('/api/admin/lookups/tables'); const response = await fetch('/api/admin/lookups/tables', {
headers: getAuthHeaders()
});
const data = await response.json(); const data = await response.json();
const element = document.getElementById('lookup-tables'); const element = document.getElementById('lookup-tables');
@@ -964,7 +1280,10 @@ async function vacuumDatabase() {
if (!confirm('This will optimize the database. Continue?')) return; if (!confirm('This will optimize the database. Continue?')) return;
try { try {
const response = await fetch('/api/admin/maintenance/vacuum', { method: 'POST' }); const response = await fetch('/api/admin/maintenance/vacuum', {
method: 'POST',
headers: getAuthHeaders()
});
const result = await response.json(); const result = await response.json();
if (response.ok) { if (response.ok) {
@@ -984,7 +1303,10 @@ async function analyzeDatabase() {
if (!confirm('This will analyze database statistics. Continue?')) return; if (!confirm('This will analyze database statistics. Continue?')) return;
try { try {
const response = await fetch('/api/admin/maintenance/analyze', { method: 'POST' }); const response = await fetch('/api/admin/maintenance/analyze', {
method: 'POST',
headers: getAuthHeaders()
});
const result = await response.json(); const result = await response.json();
if (response.ok) { if (response.ok) {
@@ -1020,7 +1342,9 @@ function addMaintenanceLog(operation, message) {
// Backup Functions // Backup Functions
async function loadBackups() { async function loadBackups() {
try { try {
const response = await fetch('/api/admin/backup/list'); const response = await fetch('/api/admin/backup/list', {
headers: getAuthHeaders()
});
const data = await response.json(); const data = await response.json();
const tbody = document.getElementById('backup-list'); const tbody = document.getElementById('backup-list');
@@ -1059,7 +1383,10 @@ async function createBackup() {
if (!confirm('Create a new database backup?')) return; if (!confirm('Create a new database backup?')) return;
try { try {
const response = await fetch('/api/admin/backup/create', { method: 'POST' }); const response = await fetch('/api/admin/backup/create', {
method: 'POST',
headers: getAuthHeaders()
});
const result = await response.json(); const result = await response.json();
if (response.ok) { if (response.ok) {
@@ -1087,9 +1414,360 @@ function refreshDashboard() {
loadSettings(); loadSettings();
loadLookupTables(); loadLookupTables();
loadBackups(); loadBackups();
loadIssues();
showAlert('Dashboard refreshed', 'info'); showAlert('Dashboard refreshed', 'info');
} }
// Issue Tracking Functions
let currentIssues = [];
let allUsers = [];
async function loadIssues() {
try {
const statusFilter = document.getElementById('issueStatusFilter').value;
const priorityFilter = document.getElementById('issuePriorityFilter').value;
const categoryFilter = document.getElementById('issueCategoryFilter').value;
const assignedToMe = document.getElementById('assignedToMeFilter').checked;
let url = '/api/support/tickets?';
if (statusFilter) url += 'status=' + encodeURIComponent(statusFilter) + '&';
if (priorityFilter) url += 'priority=' + encodeURIComponent(priorityFilter) + '&';
if (categoryFilter) url += 'category=' + encodeURIComponent(categoryFilter) + '&';
if (assignedToMe) url += 'assigned_to_me=true&';
const response = await fetch(url, {
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error('Failed to load issues');
}
const issues = await response.json();
currentIssues = issues;
renderIssuesTable(issues);
} catch (error) {
console.error('Failed to load issues:', error);
document.getElementById('issues-table-body').innerHTML = '<tr><td colspan="9" class="text-center text-danger">Failed to load issues</td></tr>';
showAlert('Failed to load issues', 'error');
}
}
async function loadIssueStats() {
try {
const response = await fetch('/api/support/stats', {
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error('Failed to load issue stats');
}
const stats = await response.json();
// Update dashboard cards
document.getElementById('high-priority-count').textContent = stats.high_priority_tickets + stats.urgent_tickets;
document.getElementById('open-count').textContent = stats.open_tickets;
document.getElementById('in-progress-count').textContent = stats.in_progress_tickets;
document.getElementById('resolved-count').textContent = stats.resolved_tickets;
} catch (error) {
console.error('Failed to load issue stats:', error);
showAlert('Failed to load issue statistics', 'error');
}
}
function renderIssuesTable(issues) {
const tbody = document.getElementById('issues-table-body');
if (issues.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="text-center">No issues found</td></tr>';
return;
}
tbody.innerHTML = issues.map(issue => {
const priorityClass = {
'urgent': 'bg-danger',
'high': 'bg-warning',
'medium': 'bg-info',
'low': 'bg-secondary'
}[issue.priority] || 'bg-secondary';
const statusClass = {
'open': 'bg-danger',
'in_progress': 'bg-warning',
'resolved': 'bg-success',
'closed': 'bg-secondary'
}[issue.status] || 'bg-secondary';
const categoryDisplay = {
'bug_report': 'Bug Report',
'qa_issue': 'QA Issue',
'feature_request': 'Feature Request',
'database_issue': 'Database Issue',
'system_error': 'System Error',
'user_access': 'User Access',
'performance': 'Performance',
'documentation': 'Documentation',
'configuration': 'Configuration',
'testing': 'Testing'
}[issue.category] || issue.category;
return `
<tr>
<td><strong>${issue.ticket_number}</strong></td>
<td>
<span class="badge bg-primary">${categoryDisplay}</span>
</td>
<td>
<span class="badge ${priorityClass}">${issue.priority.toUpperCase()}</span>
</td>
<td>
<div style="max-width: 200px; overflow: hidden; text-overflow: ellipsis;">
${issue.subject}
</div>
</td>
<td>${issue.contact_name}</td>
<td>
<span class="badge ${statusClass}">${issue.status.replace('_', ' ').toUpperCase()}</span>
</td>
<td>${issue.assigned_admin_name || 'Unassigned'}</td>
<td>${new Date(issue.created_at).toLocaleDateString()}</td>
<td>
<button class="btn btn-outline-primary btn-sm" onclick="viewIssue(${issue.id})" title="View Details">
<i class="fas fa-eye"></i>
</button>
</td>
</tr>
`;
}).join('');
}
function filterIssues() {
loadIssues();
}
async function viewIssue(issueId) {
try {
const response = await fetch('/api/support/tickets/' + issueId, {
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error('Failed to load issue details');
}
const issue = await response.json();
// Populate issue detail modal
document.getElementById('issueDetailNumber').textContent = issue.ticket_number;
document.getElementById('issueDetailSubject').textContent = issue.subject;
document.getElementById('issueDetailDescription').textContent = issue.description;
// Update badges
const categoryDisplay = {
'bug_report': 'Bug Report',
'qa_issue': 'QA Issue',
'feature_request': 'Feature Request',
'database_issue': 'Database Issue',
'system_error': 'System Error',
'user_access': 'User Access',
'performance': 'Performance',
'documentation': 'Documentation',
'configuration': 'Configuration',
'testing': 'Testing'
}[issue.category] || issue.category;
document.getElementById('issueDetailCategory').textContent = categoryDisplay;
document.getElementById('issueDetailCategory').className = 'badge bg-primary';
document.getElementById('issueDetailPriority').textContent = issue.priority.toUpperCase();
document.getElementById('issueDetailPriority').className = 'badge ms-2 ' + ({
'urgent': 'bg-danger',
'high': 'bg-warning',
'medium': 'bg-info',
'low': 'bg-secondary'
}[issue.priority] || 'bg-secondary');
document.getElementById('issueDetailStatus').textContent = issue.status.replace('_', ' ').toUpperCase();
document.getElementById('issueDetailStatus').className = 'badge fs-6 ' + ({
'open': 'bg-danger',
'in_progress': 'bg-warning',
'resolved': 'bg-success',
'closed': 'bg-secondary'
}[issue.status] || 'bg-secondary');
// Update context info
document.getElementById('issueCurrentPage').textContent = issue.current_page || 'Unknown';
document.getElementById('issueBrowserInfo').textContent = issue.browser_info || 'Unknown';
document.getElementById('issueIpAddress').textContent = issue.ip_address || 'Unknown';
// Update sidebar info
document.getElementById('issueDetailReporter').textContent = issue.contact_name;
document.getElementById('issueDetailEmail').textContent = issue.contact_email;
document.getElementById('issueDetailCreated').textContent = new Date(issue.created_at).toLocaleString();
document.getElementById('issueDetailUpdated').textContent = issue.updated_at ? new Date(issue.updated_at).toLocaleString() : 'Never';
if (issue.resolved_at) {
document.getElementById('issueDetailResolved').textContent = new Date(issue.resolved_at).toLocaleString();
document.getElementById('issueResolvedInfo').style.display = 'block';
} else {
document.getElementById('issueResolvedInfo').style.display = 'none';
}
// Update form fields for editing
document.getElementById('updateStatus').value = issue.status;
document.getElementById('updatePriority').value = issue.priority;
document.getElementById('updateAssignee').value = issue.assigned_to || '';
// Store current issue ID for updates
window.currentIssueId = issue.id;
// Load users for assignment dropdown
await loadUsersForAssignment();
// Load and display responses
displayIssueResponses(issue.responses);
// Show modal
new bootstrap.Modal(document.getElementById('issueDetailModal')).show();
} catch (error) {
console.error('Failed to load issue details:', error);
showAlert('Failed to load issue details', 'error');
}
}
async function loadUsersForAssignment() {
try {
if (allUsers.length === 0) {
const response = await fetch('/api/admin/users', {
headers: getAuthHeaders()
});
allUsers = await response.json();
}
const select = document.getElementById('updateAssignee');
select.innerHTML = '<option value="">Unassigned</option>';
allUsers.filter(user => user.is_admin && user.is_active).forEach(user => {
select.innerHTML += `<option value="${user.id}">${user.first_name} ${user.last_name} (${user.username})</option>`;
});
} catch (error) {
console.error('Failed to load users for assignment:', error);
}
}
function displayIssueResponses(responses) {
const container = document.getElementById('issueResponsesList');
if (responses.length === 0) {
container.innerHTML = '<p class="text-muted">No responses yet.</p>';
return;
}
container.innerHTML = responses.map(response => {
const isInternal = response.is_internal;
const badgeClass = isInternal ? 'bg-warning' : 'bg-primary';
const badgeText = isInternal ? 'Internal' : 'Public';
return `
<div class="border rounded p-3 mb-3">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<strong>${response.author_name}</strong>
<span class="badge ${badgeClass} ms-2">${badgeText}</span>
</div>
<small class="text-muted">${new Date(response.created_at).toLocaleString()}</small>
</div>
<div style="white-space: pre-wrap;">${response.message}</div>
</div>
`;
}).join('');
}
async function updateIssue() {
if (!window.currentIssueId) {
showAlert('No issue selected for update', 'error');
return;
}
try {
const updateData = {
status: document.getElementById('updateStatus').value,
priority: document.getElementById('updatePriority').value,
assigned_to: document.getElementById('updateAssignee').value || null
};
const response = await fetch('/api/support/tickets/' + window.currentIssueId, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(updateData)
});
if (!response.ok) {
throw new Error('Failed to update issue');
}
showAlert('Issue updated successfully', 'success');
// Refresh the issue details and table
await viewIssue(window.currentIssueId);
await loadIssues();
await loadIssueStats();
} catch (error) {
console.error('Failed to update issue:', error);
showAlert('Failed to update issue', 'error');
}
}
async function addResponse() {
const message = document.getElementById('newResponseMessage').value.trim();
const isInternal = document.getElementById('newResponseInternal').checked;
if (!message) {
showAlert('Please enter a response message', 'error');
return;
}
if (!window.currentIssueId) {
showAlert('No issue selected for response', 'error');
return;
}
try {
const response = await fetch('/api/support/tickets/' + window.currentIssueId + '/responses', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
message: message,
is_internal: isInternal
})
});
if (!response.ok) {
throw new Error('Failed to add response');
}
showAlert('Response added successfully', 'success');
// Clear form
document.getElementById('newResponseMessage').value = '';
document.getElementById('newResponseInternal').checked = false;
// Refresh the issue details
await viewIssue(window.currentIssueId);
} catch (error) {
console.error('Failed to add response:', error);
showAlert('Failed to add response', 'error');
}
}
function searchUsers() { function searchUsers() {
const searchTerm = document.getElementById('user-search').value.toLowerCase(); const searchTerm = document.getElementById('user-search').value.toLowerCase();
const filteredUsers = currentUsers.filter(user => const filteredUsers = currentUsers.filter(user =>

View File

@@ -8,15 +8,51 @@
<!-- Bootstrap 5.3 CDN --> <!-- Bootstrap 5.3 CDN -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<!-- Custom CSS --> <!-- Custom CSS -->
<link href="/static/css/main.css" rel="stylesheet"> <link href="/static/css/main.css" rel="stylesheet">
<link href="/static/css/themes.css" rel="stylesheet"> <link href="/static/css/themes.css" rel="stylesheet">
<link href="/static/css/components.css" rel="stylesheet"> <link href="/static/css/components.css" rel="stylesheet">
<style>
/* Footer Enhancements */
footer .btn-outline-primary:hover {
background-color: #0d6efd;
border-color: #0d6efd;
color: white;
transform: translateY(-1px);
transition: all 0.2s ease;
}
footer .text-primary:hover {
color: #0056b3 !important;
transition: color 0.2s ease;
}
footer small {
color: #6c757d !important;
}
#currentPageDisplay {
color: #495057 !important;
font-weight: 500;
}
/* Responsive footer adjustments */
@media (max-width: 768px) {
footer .row {
text-align: center !important;
}
footer .col-md-6:first-child {
margin-bottom: 0.5rem;
}
}
</style>
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
</head> </head>
<body> <body class="d-flex flex-column min-vh-100">
<!-- Navigation --> <!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary"> <nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container"> <div class="container">
@@ -50,11 +86,6 @@
<i class="bi bi-file-text"></i> Documents <small>(Alt+D)</small> <i class="bi bi-file-text"></i> Documents <small>(Alt+D)</small>
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="/import" data-shortcut="Alt+I">
<i class="bi bi-upload"></i> Import <small>(Alt+I)</small>
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/search" data-shortcut="Ctrl+F"> <a class="nav-link" href="/search" data-shortcut="Ctrl+F">
<i class="bi bi-search"></i> Search <small>(Ctrl+F)</small> <i class="bi bi-search"></i> Search <small>(Ctrl+F)</small>
@@ -79,9 +110,37 @@
</nav> </nav>
<!-- Main Content --> <!-- Main Content -->
<div class="container-fluid mt-3"> <main class="flex-grow-1">
<div class="container-fluid mt-3 mb-4">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
</main>
<!-- Footer -->
<footer class="mt-auto py-3 border-top shadow-sm" style="background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); border-color: #dee2e6 !important;">
<div class="container">
<div class="row align-items-center">
<div class="col-md-6">
<small class="text-muted">
&copy; 2024 Delphi Consulting Group Database System
<span class="mx-2">|</span>
<span id="currentPageDisplay">Loading...</span>
</small>
</div>
<div class="col-md-6 text-end">
<button type="button" class="btn btn-outline-primary btn-sm me-3" onclick="openSupportModal()">
<i class="fas fa-bug me-1"></i>Report Issue
</button>
<small class="text-muted">
Found a bug? <a href="#" onclick="openSupportModal()" class="text-primary text-decoration-none">Report Issue</a>
</small>
</div>
</div>
</div>
</footer>
<!-- Include Support Modal -->
{% include 'support_modal.html' %}
<!-- Keyboard Shortcuts Help Modal --> <!-- Keyboard Shortcuts Help Modal -->
<div class="modal fade" id="shortcutsModal" tabindex="-1" aria-labelledby="shortcutsModalLabel" aria-hidden="true"> <div class="modal fade" id="shortcutsModal" tabindex="-1" aria-labelledby="shortcutsModalLabel" aria-hidden="true">
@@ -158,13 +217,153 @@
// Initialize keyboard shortcuts on page load // Initialize keyboard shortcuts on page load
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
initializeKeyboardShortcuts(); initializeKeyboardShortcuts();
updateCurrentPageDisplay();
initializeAuthManager();
}); });
// Logout function // Update current page display in footer
function logout() { function updateCurrentPageDisplay() {
const path = window.location.pathname;
const pageNames = {
'/': 'Dashboard',
'/login': 'Login',
'/customers': 'Customer Management',
'/files': 'File Cabinet',
'/financial': 'Financial/Ledger',
'/documents': 'Document Management',
'/import': 'Data Import',
'/search': 'Advanced Search',
'/admin': 'System Administration'
};
const currentPage = pageNames[path] || `Page: ${path}`;
const displayElement = document.getElementById('currentPageDisplay');
if (displayElement) {
displayElement.textContent = `Current: ${currentPage}`;
}
}
// Authentication Manager
function initializeAuthManager() {
// Check if we have a valid token on page load
const token = localStorage.getItem('auth_token');
if (token && !isLoginPage()) {
// Verify token is still valid
checkTokenValidity();
// Set up periodic token refresh (every hour)
setInterval(refreshTokenIfNeeded, 3600000); // 1 hour
// Set up activity monitoring for auto-refresh
setupActivityMonitoring();
} else if (!isLoginPage() && !token) {
// No token and not on login page - redirect to login
window.location.href = '/login';
}
}
function isLoginPage() {
return window.location.pathname === '/login' || window.location.pathname === '/';
}
async function checkTokenValidity() {
const token = localStorage.getItem('auth_token');
if (!token) return false;
try {
const response = await fetch('/api/auth/me', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
// Token is invalid, remove it and redirect to login
localStorage.removeItem('auth_token');
if (!isLoginPage()) {
window.location.href = '/login';
}
return false;
}
return true;
} catch (error) {
console.error('Error checking token validity:', error);
return false;
}
}
async function refreshTokenIfNeeded() {
const token = localStorage.getItem('auth_token');
if (!token) return;
try {
// Try to get a new token
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('auth_token', data.access_token);
console.log('Token refreshed successfully');
} else {
// If refresh fails, check if current token is still valid
const isValid = await checkTokenValidity();
if (!isValid) {
localStorage.removeItem('auth_token'); localStorage.removeItem('auth_token');
window.location.href = '/login'; window.location.href = '/login';
} }
}
} catch (error) {
console.error('Error refreshing token:', error);
}
}
function setupActivityMonitoring() {
let lastActivity = Date.now();
// Track user activity
const activityEvents = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart'];
activityEvents.forEach(event => {
document.addEventListener(event, () => {
lastActivity = Date.now();
});
});
// Check every 30 minutes if user has been inactive for more than 4 hours
setInterval(() => {
const now = Date.now();
const fourHours = 4 * 60 * 60 * 1000;
if (now - lastActivity > fourHours) {
// User has been inactive for 4+ hours, logout
logout('Session expired due to inactivity');
}
}, 30 * 60 * 1000); // Check every 30 minutes
}
// Enhanced logout function
function logout(reason = null) {
localStorage.removeItem('auth_token');
if (reason) {
// Store logout reason to show on login page
sessionStorage.setItem('logout_reason', reason);
}
window.location.href = '/login';
}
// Make functions globally available
window.authManager = {
checkTokenValidity,
refreshTokenIfNeeded,
logout
};
</script> </script>
</body> </body>
</html> </html>

View File

@@ -78,10 +78,18 @@
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Check if already logged in // Check for logout reason
const logoutReason = sessionStorage.getItem('logout_reason');
if (logoutReason) {
showAlert(logoutReason, 'warning');
sessionStorage.removeItem('logout_reason');
}
// Check if already logged in with valid token
const token = localStorage.getItem('auth_token'); const token = localStorage.getItem('auth_token');
if (token) { if (token) {
window.location.href = '/customers'; // Verify token is still valid before redirecting
checkTokenAndRedirect(token);
return; return;
} }
@@ -150,6 +158,28 @@
document.getElementById('username').focus(); document.getElementById('username').focus();
}); });
async function checkTokenAndRedirect(token) {
try {
const response = await fetch('/api/auth/me', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
// Token is valid, redirect to customers page
window.location.href = '/customers';
} else {
// Token is invalid, remove it
localStorage.removeItem('auth_token');
}
} catch (error) {
// Error checking token, remove it
localStorage.removeItem('auth_token');
console.error('Error checking token:', error);
}
}
function showAlert(message, type = 'info') { function showAlert(message, type = 'info') {
// Remove existing alerts // Remove existing alerts
const existingAlerts = document.querySelectorAll('.alert'); const existingAlerts = document.querySelectorAll('.alert');

View File

@@ -0,0 +1,286 @@
<!-- Support Ticket Modal -->
<div class="modal fade" id="supportModal" tabindex="-1" aria-labelledby="supportModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title" id="supportModalLabel">
<i class="fas fa-bug me-2"></i>Submit Internal Issue
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="supportForm">
<div class="row">
<div class="col-md-6 mb-3">
<label for="contactName" class="form-label">Reporter Name *</label>
<input type="text" class="form-control" id="contactName" required>
</div>
<div class="col-md-6 mb-3">
<label for="contactEmail" class="form-label">Reporter Email *</label>
<input type="email" class="form-control" id="contactEmail" required>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="ticketCategory" class="form-label">Issue Type *</label>
<select class="form-select" id="ticketCategory" required>
<option value="">Select issue type...</option>
<option value="bug_report" selected>Bug Report</option>
<option value="qa_issue">QA Issue</option>
<option value="feature_request">Feature Request</option>
<option value="database_issue">Database Issue</option>
<option value="system_error">System Error</option>
<option value="user_access">User Access</option>
<option value="performance">Performance Issue</option>
<option value="documentation">Documentation</option>
<option value="configuration">Configuration</option>
<option value="testing">Testing Request</option>
</select>
</div>
<div class="col-md-6 mb-3">
<label for="ticketPriority" class="form-label">Priority</label>
<select class="form-select" id="ticketPriority">
<option value="low">Low</option>
<option value="medium" selected>Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
</div>
<div class="mb-3">
<label for="ticketSubject" class="form-label">Issue Summary *</label>
<input type="text" class="form-control" id="ticketSubject" maxlength="200" required>
<div class="form-text">Brief summary of the bug/issue</div>
</div>
<div class="mb-3">
<label for="ticketDescription" class="form-label">Detailed Description *</label>
<textarea class="form-control" id="ticketDescription" rows="5" required placeholder="Steps to reproduce:&#10;1. &#10;2. &#10;3. &#10;&#10;Expected behavior:&#10;&#10;Actual behavior:&#10;&#10;Additional context:"></textarea>
<div class="form-text">Include steps to reproduce, expected vs actual behavior, error messages, etc.</div>
</div>
<!-- System Information (auto-populated) -->
<div class="card bg-light mb-3">
<div class="card-body">
<h6 class="card-title">
<i class="fas fa-info-circle me-1"></i>System Information
<small class="text-muted">(automatically included)</small>
</h6>
<div class="row">
<div class="col-md-6">
<small><strong>Current Page:</strong> <span id="currentPageInfo">Loading...</span></small>
</div>
<div class="col-md-6">
<small><strong>Browser:</strong> <span id="browserInfo">Loading...</span></small>
</div>
</div>
</div>
</div>
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
<strong>Note:</strong> Your issue will be assigned a tracking number and the development team will be notified automatically.
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="submitSupportTicket">
<i class="fas fa-bug me-2"></i>Submit Issue
</button>
</div>
</div>
</div>
</div>
<!-- Support Ticket Success Modal -->
<div class="modal fade" id="supportSuccessModal" tabindex="-1" aria-labelledby="supportSuccessLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-success text-white">
<h5 class="modal-title" id="supportSuccessLabel">
<i class="fas fa-check-circle me-2"></i>Issue Submitted Successfully
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-center">
<div class="mb-3">
<i class="fas fa-bug fa-3x text-success mb-3"></i>
<h4>Issue logged successfully!</h4>
</div>
<div class="alert alert-success">
<strong>Issue ID:</strong> <span id="newTicketNumber"></span>
</div>
<p>Your issue has been logged and the development team has been notified. You'll receive updates on the resolution progress.</p>
<div class="mt-4">
<h6>What happens next?</h6>
<ul class="list-unstyled text-start">
<li><i class="fas fa-check text-success me-2"></i>Issue logged in tracking system</li>
<li><i class="fas fa-users text-warning me-2"></i>Development team has been notified</li>
<li><i class="fas fa-code text-info me-2"></i>Issue will be triaged and prioritized</li>
<li><i class="fas fa-bell text-primary me-2"></i>You'll get status updates via email</li>
</ul>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-success" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<script>
// Support ticket functionality
let supportSystem = {
currentPageInfo: 'Unknown',
browserInfo: 'Unknown',
init: function() {
this.detectSystemInfo();
this.setupEventListeners();
},
detectSystemInfo: function() {
// Get current page information
const path = window.location.pathname;
const pageNames = {
'/': 'Dashboard',
'/login': 'Login Page',
'/customers': 'Customer Management',
'/files': 'File Cabinet',
'/financial': 'Financial/Ledger',
'/documents': 'Document Management',
'/import': 'Data Import',
'/search': 'Advanced Search',
'/admin': 'System Administration'
};
this.currentPageInfo = pageNames[path] || `Page: ${path}`;
// Get browser information
const userAgent = navigator.userAgent;
let browserName = 'Unknown';
if (userAgent.includes('Chrome') && !userAgent.includes('Edg')) {
browserName = 'Chrome';
} else if (userAgent.includes('Firefox')) {
browserName = 'Firefox';
} else if (userAgent.includes('Safari') && !userAgent.includes('Chrome')) {
browserName = 'Safari';
} else if (userAgent.includes('Edg')) {
browserName = 'Edge';
}
this.browserInfo = `${browserName} (${navigator.platform})`;
// Update modal display
document.getElementById('currentPageInfo').textContent = this.currentPageInfo;
document.getElementById('browserInfo').textContent = this.browserInfo;
},
setupEventListeners: function() {
// Auto-populate user info if logged in
const supportModal = document.getElementById('supportModal');
supportModal.addEventListener('show.bs.modal', this.populateUserInfo.bind(this));
// Submit button
document.getElementById('submitSupportTicket').addEventListener('click', this.submitTicket.bind(this));
// Form validation
const form = document.getElementById('supportForm');
form.addEventListener('submit', function(e) {
e.preventDefault();
supportSystem.submitTicket();
});
},
populateUserInfo: function() {
// Try to get current user info from the global app state
if (window.app && window.app.user) {
const user = window.app.user;
document.getElementById('contactName').value = `${user.first_name || ''} ${user.last_name || ''}`.trim() || user.username;
document.getElementById('contactEmail').value = user.email;
}
},
submitTicket: async function() {
const form = document.getElementById('supportForm');
if (!form.checkValidity()) {
form.classList.add('was-validated');
return;
}
const submitBtn = document.getElementById('submitSupportTicket');
const originalText = submitBtn.innerHTML;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Submitting...';
submitBtn.disabled = true;
try {
const ticketData = {
contact_name: document.getElementById('contactName').value,
contact_email: document.getElementById('contactEmail').value,
category: document.getElementById('ticketCategory').value,
priority: document.getElementById('ticketPriority').value,
subject: document.getElementById('ticketSubject').value,
description: document.getElementById('ticketDescription').value,
current_page: this.currentPageInfo,
browser_info: this.browserInfo
};
const response = await fetch('/api/support/tickets', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(ticketData)
});
const result = await response.json();
if (response.ok) {
// Hide support modal
bootstrap.Modal.getInstance(document.getElementById('supportModal')).hide();
// Show success modal
document.getElementById('newTicketNumber').textContent = result.ticket_number;
new bootstrap.Modal(document.getElementById('supportSuccessModal')).show();
// Reset form
form.reset();
form.classList.remove('was-validated');
} else {
throw new Error(result.detail || 'Failed to submit ticket');
}
} catch (error) {
console.error('Error submitting support ticket:', error);
this.showAlert('Failed to submit support ticket: ' + error.message, 'error');
} finally {
submitBtn.innerHTML = originalText;
submitBtn.disabled = false;
}
},
showAlert: function(message, type = 'info') {
// Use existing notification system if available
if (window.showNotification) {
window.showNotification(message, type);
} else {
alert(message);
}
}
};
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
supportSystem.init();
});
// Global function to open support modal
window.openSupportModal = function() {
new bootstrap.Modal(document.getElementById('supportModal')).show();
};
</script>