feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- Added migration 025 for the Ticket System, creating tables for tickets, comments, attachments, worklogs, prepaid cards, and audit logs. - Introduced migration 026 to add ticket-related permissions to the auth system and assign them to user groups. - Developed a test suite for the Ticket Module, validating database schema, ticket number generation, prepaid card constraints, service logic, worklog creation, audit logging, and views.
This commit is contained in:
parent
3fb43783a6
commit
3806c7d011
@ -67,6 +67,19 @@ class Settings(BaseSettings):
|
||||
OLLAMA_ENDPOINT: str = "http://ai_direct.cs.blaahund.dk"
|
||||
OLLAMA_MODEL: str = "qwen2.5-coder:7b" # qwen2.5-coder fungerer bedre til JSON udtrækning
|
||||
|
||||
# Ticket System Module
|
||||
TICKET_ENABLED: bool = True
|
||||
TICKET_EMAIL_INTEGRATION: bool = False # 🚨 SAFETY: Disable email-to-ticket until configured
|
||||
TICKET_AUTO_ASSIGN: bool = False # Auto-assign tickets based on rules
|
||||
TICKET_DEFAULT_PRIORITY: str = "normal" # low|normal|high|urgent
|
||||
TICKET_REQUIRE_CUSTOMER: bool = False # Allow tickets without customer link
|
||||
TICKET_NOTIFICATION_ENABLED: bool = False # Notify on status changes
|
||||
|
||||
# Ticket System - e-conomic Integration
|
||||
TICKET_ECONOMIC_READ_ONLY: bool = True # 🚨 SAFETY: Block all writes to e-conomic
|
||||
TICKET_ECONOMIC_DRY_RUN: bool = True # 🚨 SAFETY: Log without executing
|
||||
TICKET_ECONOMIC_AUTO_EXPORT: bool = False # Auto-export billable worklog
|
||||
|
||||
# Email System Configuration
|
||||
EMAIL_TO_TICKET_ENABLED: bool = False # 🚨 SAFETY: Disable auto-processing until configured
|
||||
|
||||
|
||||
@ -266,7 +266,8 @@ class EmailWorkflowService:
|
||||
try:
|
||||
# Dispatch to specific action handler
|
||||
handler_map = {
|
||||
'create_ticket': self._action_create_ticket,
|
||||
'create_ticket': self._action_create_ticket_system,
|
||||
'link_email_to_ticket': self._action_link_email_to_ticket,
|
||||
'create_time_entry': self._action_create_time_entry,
|
||||
'link_to_vendor': self._action_link_to_vendor,
|
||||
'link_to_customer': self._action_link_to_customer,
|
||||
@ -302,19 +303,81 @@ class EmailWorkflowService:
|
||||
|
||||
# Action Handlers
|
||||
|
||||
async def _action_create_ticket(self, params: Dict, email_data: Dict) -> Dict:
|
||||
"""Create a ticket/case from email"""
|
||||
module = params.get('module', 'support_cases')
|
||||
priority = params.get('priority', 'normal')
|
||||
async def _action_create_ticket_system(self, params: Dict, email_data: Dict) -> Dict:
|
||||
"""Create a ticket from email using new ticket system"""
|
||||
from app.ticket.backend.email_integration import EmailTicketIntegration
|
||||
|
||||
# TODO: Integrate with actual case/ticket system
|
||||
logger.info(f"🎫 Would create ticket in module '{module}' with priority '{priority}'")
|
||||
# Build email_data dict for ticket integration
|
||||
ticket_email_data = {
|
||||
'message_id': email_data.get('message_id'),
|
||||
'subject': email_data.get('subject'),
|
||||
'from_address': email_data.get('sender_email'),
|
||||
'body': email_data.get('body_text', ''),
|
||||
'html_body': email_data.get('body_html'),
|
||||
'received_at': email_data.get('received_date').isoformat() if email_data.get('received_date') else None,
|
||||
'in_reply_to': None, # TODO: Extract from email headers
|
||||
'references': None # TODO: Extract from email headers
|
||||
}
|
||||
|
||||
# Get params from workflow
|
||||
customer_id = params.get('customer_id') or email_data.get('customer_id')
|
||||
assigned_to_user_id = params.get('assigned_to_user_id')
|
||||
|
||||
logger.info(f"🎫 Creating ticket from email: {email_data.get('message_id')}")
|
||||
|
||||
result = await EmailTicketIntegration.process_email_for_ticket(
|
||||
email_data=ticket_email_data,
|
||||
customer_id=customer_id,
|
||||
assigned_to_user_id=assigned_to_user_id
|
||||
)
|
||||
|
||||
logger.info(f"✅ Created ticket {result.get('ticket_number')} from email")
|
||||
|
||||
return {
|
||||
'action': 'create_ticket',
|
||||
'module': module,
|
||||
'priority': priority,
|
||||
'note': 'Ticket creation not yet implemented'
|
||||
'ticket_id': result.get('ticket_id'),
|
||||
'ticket_number': result.get('ticket_number'),
|
||||
'created': result.get('created', False),
|
||||
'linked': result.get('linked', False)
|
||||
}
|
||||
|
||||
async def _action_link_email_to_ticket(self, params: Dict, email_data: Dict) -> Dict:
|
||||
"""Link email to existing ticket"""
|
||||
from app.ticket.backend.email_integration import EmailTicketIntegration
|
||||
|
||||
ticket_number = params.get('ticket_number')
|
||||
|
||||
if not ticket_number:
|
||||
logger.warning("⚠️ No ticket_number provided for link_email_to_ticket action")
|
||||
return {
|
||||
'action': 'link_email_to_ticket',
|
||||
'status': 'failed',
|
||||
'error': 'ticket_number required'
|
||||
}
|
||||
|
||||
ticket_email_data = {
|
||||
'message_id': email_data.get('message_id'),
|
||||
'subject': email_data.get('subject'),
|
||||
'from_address': email_data.get('sender_email'),
|
||||
'body': email_data.get('body_text', ''),
|
||||
'html_body': email_data.get('body_html'),
|
||||
'received_at': email_data.get('received_date').isoformat() if email_data.get('received_date') else None,
|
||||
}
|
||||
|
||||
logger.info(f"🔗 Linking email to ticket {ticket_number}")
|
||||
|
||||
result = await EmailTicketIntegration.link_email_to_ticket(
|
||||
ticket_number=ticket_number,
|
||||
email_data=ticket_email_data
|
||||
)
|
||||
|
||||
logger.info(f"✅ Linked email to ticket {ticket_number}")
|
||||
|
||||
return {
|
||||
'action': 'link_email_to_ticket',
|
||||
'ticket_id': result.get('ticket_id'),
|
||||
'ticket_number': result.get('ticket_number'),
|
||||
'linked': True
|
||||
}
|
||||
|
||||
async def _action_create_time_entry(self, params: Dict, email_data: Dict) -> Dict:
|
||||
|
||||
@ -231,8 +231,12 @@
|
||||
<i class="bi bi-headset me-2"></i>Support
|
||||
</a>
|
||||
<ul class="dropdown-menu mt-2">
|
||||
<li><a class="dropdown-item py-2" href="#">Sager</a></li>
|
||||
<li><a class="dropdown-item py-2" href="#">Ny Sag</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/ticket/dashboard"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/ticket/tickets"><i class="bi bi-ticket-detailed me-2"></i>Alle Tickets</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/ticket/worklog/review"><i class="bi bi-clock-history me-2"></i>Godkend Worklog</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item py-2" href="#">Ny Ticket</a></li>
|
||||
<li><a class="dropdown-item py-2" href="#">Klippekort</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item py-2" href="#">Knowledge Base</a></li>
|
||||
</ul>
|
||||
@ -491,9 +495,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% block content_wrapper %}
|
||||
<div class="container-fluid px-4 py-4">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
|
||||
10
app/ticket/__init__.py
Normal file
10
app/ticket/__init__.py
Normal file
@ -0,0 +1,10 @@
|
||||
"""
|
||||
Ticket System & Klippekort Module
|
||||
==================================
|
||||
|
||||
Isoleret modul til ticket management og klippekort (prepaid time cards).
|
||||
|
||||
Alle tabeller bruger 'tticket_' prefix og kan uninstalleres uden at påvirke core data.
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
6
app/ticket/backend/__init__.py
Normal file
6
app/ticket/backend/__init__.py
Normal file
@ -0,0 +1,6 @@
|
||||
"""
|
||||
Ticket Module Backend
|
||||
=====================
|
||||
|
||||
Business logic, API endpoints, og services for ticket-systemet.
|
||||
"""
|
||||
402
app/ticket/backend/economic_export.py
Normal file
402
app/ticket/backend/economic_export.py
Normal file
@ -0,0 +1,402 @@
|
||||
"""
|
||||
E-conomic Export Service for Ticket System
|
||||
Exports billable worklog entries to e-conomic as invoice lines
|
||||
|
||||
🚨 SAFETY MODES (inherited from EconomicService):
|
||||
- TICKET_ECONOMIC_READ_ONLY: Blocks ALL write operations when True
|
||||
- TICKET_ECONOMIC_DRY_RUN: Logs operations but doesn't send to e-conomic when True
|
||||
- TICKET_ECONOMIC_AUTO_EXPORT: Enable automatic export on worklog approval
|
||||
|
||||
Integration Pattern:
|
||||
1. Worklog entry marked "billable" → Export to e-conomic
|
||||
2. Create invoice draft with worklog line items
|
||||
3. Store economic_invoice_number in tticket_worklog
|
||||
4. Mark worklog as "billed" after successful export
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from app.core.database import execute_query, execute_update
|
||||
from app.core.config import settings
|
||||
from app.services.economic_service import EconomicService
|
||||
from psycopg2.extras import Json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TicketEconomicExportService:
|
||||
"""
|
||||
Handles export of billable worklog to e-conomic
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.economic = EconomicService()
|
||||
self.read_only = getattr(settings, 'TICKET_ECONOMIC_READ_ONLY', True)
|
||||
self.dry_run = getattr(settings, 'TICKET_ECONOMIC_DRY_RUN', True)
|
||||
self.auto_export = getattr(settings, 'TICKET_ECONOMIC_AUTO_EXPORT', False)
|
||||
|
||||
# Log safety status
|
||||
if self.read_only:
|
||||
logger.warning("🔒 TICKET E-CONOMIC READ-ONLY MODE ENABLED")
|
||||
elif self.dry_run:
|
||||
logger.warning("🏃 TICKET E-CONOMIC DRY-RUN MODE ENABLED")
|
||||
else:
|
||||
logger.warning("⚠️ TICKET E-CONOMIC WRITE MODE ACTIVE")
|
||||
|
||||
def _check_export_permission(self, operation: str) -> bool:
|
||||
"""Check if export operations are allowed"""
|
||||
if self.read_only:
|
||||
logger.error(f"🚫 BLOCKED: {operation} - TICKET_ECONOMIC_READ_ONLY=true")
|
||||
logger.error("To enable: Set TICKET_ECONOMIC_READ_ONLY=false in .env")
|
||||
return False
|
||||
|
||||
if self.dry_run:
|
||||
logger.warning(f"🏃 DRY-RUN: {operation} - TICKET_ECONOMIC_DRY_RUN=true")
|
||||
logger.warning("To execute: Set TICKET_ECONOMIC_DRY_RUN=false in .env")
|
||||
return False
|
||||
|
||||
logger.warning(f"⚠️ EXECUTING: {operation} - Will create in e-conomic")
|
||||
return True
|
||||
|
||||
async def export_billable_worklog_batch(
|
||||
self,
|
||||
customer_id: int,
|
||||
worklog_ids: Optional[List[int]] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Export billable worklog entries to e-conomic as draft invoice
|
||||
|
||||
Args:
|
||||
customer_id: Customer ID for the invoice
|
||||
worklog_ids: Specific worklog entry IDs to export (optional)
|
||||
date_from: Start date filter (optional)
|
||||
date_to: End date filter (optional)
|
||||
|
||||
Returns:
|
||||
Dict with export results
|
||||
"""
|
||||
try:
|
||||
logger.info(f"📦 Starting worklog export for customer {customer_id}")
|
||||
|
||||
# Get customer e-conomic info
|
||||
customer = await self._get_customer_economic_info(customer_id)
|
||||
if not customer:
|
||||
raise ValueError(f"Customer {customer_id} not found or missing e-conomic mapping")
|
||||
|
||||
# Get billable worklog entries
|
||||
worklogs = await self._get_billable_worklog(
|
||||
customer_id, worklog_ids, date_from, date_to
|
||||
)
|
||||
|
||||
if not worklogs:
|
||||
logger.info("✅ No billable worklog entries found")
|
||||
return {
|
||||
'status': 'no_entries',
|
||||
'exported_count': 0
|
||||
}
|
||||
|
||||
logger.info(f"📋 Found {len(worklogs)} billable entries")
|
||||
|
||||
# Safety check
|
||||
if not self._check_export_permission(f"Export {len(worklogs)} worklog entries"):
|
||||
return {
|
||||
'status': 'blocked',
|
||||
'reason': 'Safety mode enabled',
|
||||
'read_only': self.read_only,
|
||||
'dry_run': self.dry_run,
|
||||
'entries': len(worklogs)
|
||||
}
|
||||
|
||||
# Create invoice in e-conomic
|
||||
invoice_result = await self._create_economic_invoice(customer, worklogs)
|
||||
|
||||
# Update worklog entries with invoice number
|
||||
await self._mark_worklogs_as_billed(
|
||||
worklog_ids=[w['id'] for w in worklogs],
|
||||
economic_invoice_number=invoice_result.get('draftInvoiceNumber')
|
||||
)
|
||||
|
||||
logger.info(f"✅ Successfully exported {len(worklogs)} entries to e-conomic")
|
||||
|
||||
return {
|
||||
'status': 'exported',
|
||||
'exported_count': len(worklogs),
|
||||
'invoice_number': invoice_result.get('draftInvoiceNumber'),
|
||||
'total_hours': sum(float(w['hours']) for w in worklogs),
|
||||
'total_amount': sum(float(w['amount']) for w in worklogs),
|
||||
'entries': [
|
||||
{
|
||||
'id': w['id'],
|
||||
'ticket_number': w['ticket_number'],
|
||||
'hours': float(w['hours'])
|
||||
}
|
||||
for w in worklogs
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to export worklog: {e}")
|
||||
raise
|
||||
|
||||
async def _get_customer_economic_info(self, customer_id: int) -> Optional[Dict]:
|
||||
"""
|
||||
Get customer e-conomic mapping information
|
||||
|
||||
Returns:
|
||||
Dict with debtor_number, payment_terms, etc.
|
||||
"""
|
||||
query = """
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
email,
|
||||
economic_customer_number,
|
||||
payment_terms_number,
|
||||
address,
|
||||
postal_code,
|
||||
city
|
||||
FROM customers
|
||||
WHERE id = %s
|
||||
"""
|
||||
|
||||
customer = execute_query(query, (customer_id,), fetchone=True)
|
||||
|
||||
if not customer:
|
||||
logger.error(f"❌ Customer {customer_id} not found")
|
||||
return None
|
||||
|
||||
if not customer.get('economic_customer_number'):
|
||||
logger.error(f"❌ Customer {customer_id} missing economic_customer_number")
|
||||
return None
|
||||
|
||||
return customer
|
||||
|
||||
async def _get_billable_worklog(
|
||||
self,
|
||||
customer_id: int,
|
||||
worklog_ids: Optional[List[int]] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Get billable worklog entries ready for export
|
||||
|
||||
Returns:
|
||||
List of worklog dicts with ticket and customer info
|
||||
"""
|
||||
query = """
|
||||
SELECT
|
||||
w.id,
|
||||
w.ticket_id,
|
||||
w.work_date,
|
||||
w.hours,
|
||||
w.work_type,
|
||||
w.description,
|
||||
w.billing_method,
|
||||
t.ticket_number,
|
||||
t.subject AS ticket_subject,
|
||||
t.customer_id,
|
||||
c.economic_customer_number,
|
||||
(w.hours * 850) AS amount
|
||||
FROM tticket_worklog w
|
||||
INNER JOIN tticket_tickets t ON t.id = w.ticket_id
|
||||
INNER JOIN customers c ON c.id = t.customer_id
|
||||
WHERE w.status = 'billable'
|
||||
AND w.billing_method = 'invoice'
|
||||
AND w.billed_at IS NULL
|
||||
AND t.customer_id = %s
|
||||
"""
|
||||
|
||||
params = [customer_id]
|
||||
|
||||
if worklog_ids:
|
||||
query += " AND w.id = ANY(%s)"
|
||||
params.append(worklog_ids)
|
||||
|
||||
if date_from:
|
||||
query += " AND w.work_date >= %s"
|
||||
params.append(date_from)
|
||||
|
||||
if date_to:
|
||||
query += " AND w.work_date <= %s"
|
||||
params.append(date_to)
|
||||
|
||||
query += " ORDER BY w.work_date ASC, w.created_at ASC"
|
||||
|
||||
return execute_query(query, tuple(params))
|
||||
|
||||
async def _create_economic_invoice(self, customer: Dict, worklogs: List[Dict]) -> Dict:
|
||||
"""
|
||||
Create draft invoice in e-conomic with worklog line items
|
||||
|
||||
Args:
|
||||
customer: Customer dict with e-conomic mapping
|
||||
worklogs: List of billable worklog entries
|
||||
|
||||
Returns:
|
||||
e-conomic invoice response dict
|
||||
"""
|
||||
# Build invoice payload
|
||||
invoice_data = {
|
||||
"date": datetime.now().strftime("%Y-%m-%d"),
|
||||
"customer": {
|
||||
"customerNumber": customer['economic_customer_number']
|
||||
},
|
||||
"recipient": {
|
||||
"name": customer['name'],
|
||||
"address": customer.get('address', ''),
|
||||
"zip": customer.get('postal_code', ''),
|
||||
"city": customer.get('city', ''),
|
||||
"vatZone": {
|
||||
"vatZoneNumber": 1 # Denmark
|
||||
}
|
||||
},
|
||||
"paymentTerms": {
|
||||
"paymentTermsNumber": customer.get('payment_terms_number', 1)
|
||||
},
|
||||
"layout": {
|
||||
"layoutNumber": 19 # Default layout
|
||||
},
|
||||
"lines": []
|
||||
}
|
||||
|
||||
# Add worklog entries as invoice lines
|
||||
for worklog in worklogs:
|
||||
product_number = 'SUPPORT' # Default product number
|
||||
|
||||
# Build line description
|
||||
ticket_ref = f"[{worklog['ticket_number']}] {worklog['ticket_subject']}"
|
||||
work_desc = worklog.get('description', 'Support arbejde')
|
||||
line_text = f"{ticket_ref}\n{work_desc}\n{worklog['work_date']}"
|
||||
|
||||
line = {
|
||||
"product": {
|
||||
"productNumber": product_number[:25] # Max 25 chars
|
||||
},
|
||||
"description": line_text[:250], # Max 250 chars
|
||||
"quantity": float(worklog['hours']),
|
||||
"unitNetPrice": float(worklog['amount']) / float(worklog['hours']),
|
||||
"unit": {
|
||||
"unitNumber": 1 # Hours
|
||||
}
|
||||
}
|
||||
|
||||
invoice_data['lines'].append(line)
|
||||
|
||||
logger.info(f"📄 Creating e-conomic invoice with {len(invoice_data['lines'])} lines")
|
||||
logger.info(f"📊 Invoice payload: {invoice_data}")
|
||||
|
||||
# DRY-RUN: Just return mock response
|
||||
if self.dry_run:
|
||||
logger.warning("🏃 DRY-RUN: Would create invoice in e-conomic")
|
||||
return {
|
||||
'draftInvoiceNumber': 999999,
|
||||
'grossAmount': sum(float(w['amount']) for w in worklogs),
|
||||
'netAmount': sum(float(w['amount']) for w in worklogs),
|
||||
'vatAmount': 0,
|
||||
'dryRun': True
|
||||
}
|
||||
|
||||
# REAL EXECUTION: Create in e-conomic
|
||||
try:
|
||||
result = await self.economic.create_draft_invoice(invoice_data)
|
||||
logger.info(f"✅ Created e-conomic draft invoice: {result.get('draftInvoiceNumber')}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to create e-conomic invoice: {e}")
|
||||
raise
|
||||
|
||||
async def _mark_worklogs_as_billed(
|
||||
self,
|
||||
worklog_ids: List[int],
|
||||
economic_invoice_number: Optional[int]
|
||||
) -> None:
|
||||
"""
|
||||
Mark worklog entries as billed with e-conomic reference
|
||||
|
||||
Args:
|
||||
worklog_ids: List of worklog entry IDs
|
||||
economic_invoice_number: e-conomic invoice number
|
||||
"""
|
||||
if not worklog_ids:
|
||||
return
|
||||
|
||||
query = """
|
||||
UPDATE tticket_worklog
|
||||
SET billed_at = CURRENT_TIMESTAMP,
|
||||
economic_invoice_number = %s,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ANY(%s)
|
||||
"""
|
||||
|
||||
execute_update(query, (economic_invoice_number, worklog_ids))
|
||||
|
||||
logger.info(f"✅ Marked {len(worklog_ids)} worklog entries as billed")
|
||||
|
||||
async def get_export_preview(
|
||||
self,
|
||||
customer_id: int,
|
||||
worklog_ids: Optional[List[int]] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Preview what would be exported without actually exporting
|
||||
|
||||
Args:
|
||||
Same as export_billable_worklog_batch
|
||||
|
||||
Returns:
|
||||
Dict with preview information
|
||||
"""
|
||||
try:
|
||||
customer = await self._get_customer_economic_info(customer_id)
|
||||
if not customer:
|
||||
return {
|
||||
'status': 'error',
|
||||
'error': 'Customer not found or missing e-conomic mapping'
|
||||
}
|
||||
|
||||
worklogs = await self._get_billable_worklog(
|
||||
customer_id, worklog_ids, date_from, date_to
|
||||
)
|
||||
|
||||
total_hours = sum(float(w['hours']) for w in worklogs)
|
||||
total_amount = sum(float(w['amount']) for w in worklogs)
|
||||
|
||||
return {
|
||||
'status': 'preview',
|
||||
'customer_id': customer_id,
|
||||
'customer_name': customer['name'],
|
||||
'economic_customer_number': customer['economic_customer_number'],
|
||||
'entry_count': len(worklogs),
|
||||
'total_hours': float(total_hours),
|
||||
'total_amount': float(total_amount),
|
||||
'entries': [
|
||||
{
|
||||
'id': w['id'],
|
||||
'ticket_number': w['ticket_number'],
|
||||
'work_date': w['work_date'].strftime('%Y-%m-%d'),
|
||||
'hours': float(w['hours']),
|
||||
'amount': float(w['amount']),
|
||||
'description': w['description']
|
||||
}
|
||||
for w in worklogs
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to generate export preview: {e}")
|
||||
return {
|
||||
'status': 'error',
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
|
||||
# Singleton instance
|
||||
ticket_economic_service = TicketEconomicExportService()
|
||||
472
app/ticket/backend/email_integration.py
Normal file
472
app/ticket/backend/email_integration.py
Normal file
@ -0,0 +1,472 @@
|
||||
"""
|
||||
Email-to-Ticket Integration for BMC Hub Ticket System
|
||||
|
||||
Implements Option B: Message-ID Threading
|
||||
- Parse email message_id from workflow context
|
||||
- Create tickets with email metadata
|
||||
- Link emails to existing tickets using In-Reply-To header
|
||||
- Extract ticket tags from email body
|
||||
- Store email threading info in tticket_email_log
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Dict, Any, Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.database import execute_query, execute_insert
|
||||
from app.ticket.backend.ticket_service import TicketService
|
||||
from app.ticket.backend.models import TTicketCreate, TicketPriority
|
||||
from psycopg2.extras import Json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmailTicketIntegration:
|
||||
"""Handles email-to-ticket conversion and threading"""
|
||||
|
||||
# Regex patterns for ticket detection
|
||||
TICKET_NUMBER_PATTERN = r'TKT-\d{8}-\d{3}'
|
||||
TAG_PATTERN = r'#(\w+)'
|
||||
|
||||
@staticmethod
|
||||
async def create_ticket_from_email(
|
||||
email_data: Dict[str, Any],
|
||||
customer_id: Optional[int] = None,
|
||||
assigned_to_user_id: Optional[int] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create ticket from email workflow action
|
||||
|
||||
Args:
|
||||
email_data: Dict with keys:
|
||||
- message_id: Email Message-ID header
|
||||
- subject: Email subject
|
||||
- from_address: Sender email
|
||||
- body: Email body text
|
||||
- html_body: Email HTML body (optional)
|
||||
- received_at: Email timestamp (ISO format)
|
||||
- in_reply_to: In-Reply-To header (optional)
|
||||
- references: References header (optional)
|
||||
customer_id: BMC customer ID (if known)
|
||||
assigned_to_user_id: User to assign ticket to
|
||||
|
||||
Returns:
|
||||
Dict with ticket_id and ticket_number
|
||||
"""
|
||||
try:
|
||||
logger.info(f"🎫 Creating ticket from email: {email_data.get('message_id')}")
|
||||
|
||||
# Extract tags from email body
|
||||
tags = EmailTicketIntegration._extract_tags(email_data.get('body', ''))
|
||||
|
||||
# Determine priority from subject/tags
|
||||
priority = EmailTicketIntegration._determine_priority(
|
||||
email_data.get('subject', ''),
|
||||
tags
|
||||
)
|
||||
|
||||
# Create ticket
|
||||
ticket_create = TTicketCreate(
|
||||
subject=email_data.get('subject', 'Email without subject'),
|
||||
description=EmailTicketIntegration._format_description(email_data),
|
||||
customer_id=customer_id,
|
||||
assigned_to_user_id=assigned_to_user_id,
|
||||
priority=priority,
|
||||
tags=tags,
|
||||
custom_fields={
|
||||
'email_from': email_data.get('from_address'),
|
||||
'email_message_id': email_data.get('message_id'),
|
||||
'created_from_email': True
|
||||
}
|
||||
)
|
||||
|
||||
# Create via service (handles auto-number generation)
|
||||
ticket = await TicketService.create_ticket(ticket_create, created_by_user_id=None)
|
||||
|
||||
# Log email-ticket linkage
|
||||
await EmailTicketIntegration._log_email_linkage(
|
||||
ticket_id=ticket['id'],
|
||||
email_data=email_data
|
||||
)
|
||||
|
||||
logger.info(f"✅ Created ticket {ticket['ticket_number']} from email {email_data.get('message_id')}")
|
||||
|
||||
return {
|
||||
'ticket_id': ticket['id'],
|
||||
'ticket_number': ticket['ticket_number'],
|
||||
'created': True
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to create ticket from email: {e}")
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
async def link_email_to_ticket(
|
||||
ticket_number: str,
|
||||
email_data: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Link email to existing ticket (reply threading)
|
||||
|
||||
Args:
|
||||
ticket_number: TKT-YYYYMMDD-XXX format
|
||||
email_data: Same format as create_ticket_from_email
|
||||
|
||||
Returns:
|
||||
Dict with ticket_id and linked=True
|
||||
"""
|
||||
try:
|
||||
logger.info(f"🔗 Linking email to ticket {ticket_number}")
|
||||
|
||||
# Find ticket by ticket_number
|
||||
query = "SELECT id FROM tticket_tickets WHERE ticket_number = %s"
|
||||
result = execute_query(query, (ticket_number,), fetchone=True)
|
||||
|
||||
if not result:
|
||||
logger.warning(f"⚠️ Ticket {ticket_number} not found - creating new ticket instead")
|
||||
return await EmailTicketIntegration.create_ticket_from_email(email_data)
|
||||
|
||||
ticket_id = result['id']
|
||||
|
||||
# Log email linkage
|
||||
await EmailTicketIntegration._log_email_linkage(
|
||||
ticket_id=ticket_id,
|
||||
email_data=email_data,
|
||||
is_reply=True
|
||||
)
|
||||
|
||||
# Add comment with email content
|
||||
from app.ticket.backend.models import TTicketCommentCreate
|
||||
comment_text = f"📧 Email from {email_data.get('from_address')}:\n\n{email_data.get('body', '')}"
|
||||
|
||||
await TicketService.add_comment(
|
||||
ticket_id=ticket_id,
|
||||
comment=TTicketCommentCreate(
|
||||
comment_text=comment_text,
|
||||
internal_note=False
|
||||
),
|
||||
user_id=None
|
||||
)
|
||||
|
||||
logger.info(f"✅ Linked email to ticket {ticket_number} (ID: {ticket_id})")
|
||||
|
||||
return {
|
||||
'ticket_id': ticket_id,
|
||||
'ticket_number': ticket_number,
|
||||
'linked': True
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to link email to ticket: {e}")
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
async def process_email_for_ticket(
|
||||
email_data: Dict[str, Any],
|
||||
customer_id: Optional[int] = None,
|
||||
assigned_to_user_id: Optional[int] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Smart processor: Creates new ticket OR links to existing based on threading
|
||||
|
||||
Args:
|
||||
email_data: Email metadata dict
|
||||
customer_id: Customer ID if known
|
||||
assigned_to_user_id: User to assign new tickets to
|
||||
|
||||
Returns:
|
||||
Dict with ticket_id, ticket_number, created/linked status
|
||||
"""
|
||||
try:
|
||||
# Check if email is reply to existing ticket
|
||||
ticket_number = EmailTicketIntegration._find_ticket_in_thread(email_data)
|
||||
|
||||
if ticket_number:
|
||||
# Reply to existing ticket
|
||||
return await EmailTicketIntegration.link_email_to_ticket(
|
||||
ticket_number=ticket_number,
|
||||
email_data=email_data
|
||||
)
|
||||
else:
|
||||
# New ticket
|
||||
return await EmailTicketIntegration.create_ticket_from_email(
|
||||
email_data=email_data,
|
||||
customer_id=customer_id,
|
||||
assigned_to_user_id=assigned_to_user_id
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to process email for ticket: {e}")
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _find_ticket_in_thread(email_data: Dict[str, Any]) -> Optional[str]:
|
||||
"""
|
||||
Find ticket number in email thread (In-Reply-To, References, Subject)
|
||||
|
||||
Returns:
|
||||
Ticket number (TKT-YYYYMMDD-XXX) or None
|
||||
"""
|
||||
# Check In-Reply-To header first
|
||||
in_reply_to = email_data.get('in_reply_to', '')
|
||||
if in_reply_to:
|
||||
match = re.search(EmailTicketIntegration.TICKET_NUMBER_PATTERN, in_reply_to)
|
||||
if match:
|
||||
return match.group(0)
|
||||
|
||||
# Check References header
|
||||
references = email_data.get('references', '')
|
||||
if references:
|
||||
match = re.search(EmailTicketIntegration.TICKET_NUMBER_PATTERN, references)
|
||||
if match:
|
||||
return match.group(0)
|
||||
|
||||
# Check subject line for [TKT-YYYYMMDD-XXX] or Re: TKT-YYYYMMDD-XXX
|
||||
subject = email_data.get('subject', '')
|
||||
match = re.search(EmailTicketIntegration.TICKET_NUMBER_PATTERN, subject)
|
||||
if match:
|
||||
return match.group(0)
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_tags(body: str) -> List[str]:
|
||||
"""
|
||||
Extract #hashtags from email body
|
||||
|
||||
Returns:
|
||||
List of lowercase tags without # prefix
|
||||
"""
|
||||
if not body:
|
||||
return []
|
||||
|
||||
tags = re.findall(EmailTicketIntegration.TAG_PATTERN, body)
|
||||
return [tag.lower() for tag in tags if len(tag) > 2][:10] # Max 10 tags
|
||||
|
||||
@staticmethod
|
||||
def _determine_priority(subject: str, tags: List[str]) -> TicketPriority:
|
||||
"""
|
||||
Determine ticket priority from subject/tags
|
||||
|
||||
Returns:
|
||||
TicketPriority enum value
|
||||
"""
|
||||
subject_lower = subject.lower()
|
||||
all_text = f"{subject_lower} {' '.join(tags)}"
|
||||
|
||||
# Critical keywords
|
||||
if any(word in all_text for word in ['kritisk', 'critical', 'down', 'nede', 'urgent', 'akut']):
|
||||
return TicketPriority.critical
|
||||
|
||||
# High priority keywords
|
||||
if any(word in all_text for word in ['høj', 'high', 'vigtig', 'important', 'haster']):
|
||||
return TicketPriority.high
|
||||
|
||||
# Low priority keywords
|
||||
if any(word in all_text for word in ['lav', 'low', 'spørgsmål', 'question', 'info']):
|
||||
return TicketPriority.low
|
||||
|
||||
return TicketPriority.normal
|
||||
|
||||
@staticmethod
|
||||
def _format_description(email_data: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Format email body as ticket description
|
||||
|
||||
Returns:
|
||||
Formatted description text
|
||||
"""
|
||||
body = email_data.get('body', '').strip()
|
||||
from_address = email_data.get('from_address', 'unknown')
|
||||
received_at = email_data.get('received_at', '')
|
||||
|
||||
description = f"📧 Email fra: {from_address}\n"
|
||||
description += f"📅 Modtaget: {received_at}\n"
|
||||
description += f"{'='*60}\n\n"
|
||||
description += body
|
||||
|
||||
return description
|
||||
|
||||
@staticmethod
|
||||
async def _log_email_linkage(
|
||||
ticket_id: int,
|
||||
email_data: Dict[str, Any],
|
||||
is_reply: bool = False
|
||||
) -> None:
|
||||
"""
|
||||
Store email-ticket linkage in tticket_email_log
|
||||
|
||||
Args:
|
||||
ticket_id: Ticket ID
|
||||
email_data: Email metadata
|
||||
is_reply: True if email is reply to existing ticket
|
||||
"""
|
||||
query = """
|
||||
INSERT INTO tticket_email_log (
|
||||
ticket_id,
|
||||
email_message_id,
|
||||
email_subject,
|
||||
email_from,
|
||||
email_received_at,
|
||||
is_reply,
|
||||
thread_data
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
"""
|
||||
|
||||
# Parse received_at timestamp
|
||||
received_at = email_data.get('received_at')
|
||||
if isinstance(received_at, str):
|
||||
try:
|
||||
received_at = datetime.fromisoformat(received_at.replace('Z', '+00:00'))
|
||||
except:
|
||||
received_at = None
|
||||
|
||||
thread_data = {
|
||||
'in_reply_to': email_data.get('in_reply_to'),
|
||||
'references': email_data.get('references'),
|
||||
'message_id': email_data.get('message_id')
|
||||
}
|
||||
|
||||
execute_insert(
|
||||
query,
|
||||
(
|
||||
ticket_id,
|
||||
email_data.get('message_id'),
|
||||
email_data.get('subject'),
|
||||
email_data.get('from_address'),
|
||||
received_at,
|
||||
is_reply,
|
||||
Json(thread_data)
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(f"📝 Logged email linkage for ticket {ticket_id}")
|
||||
|
||||
@staticmethod
|
||||
async def get_ticket_email_thread(ticket_id: int) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all emails linked to a ticket (chronological order)
|
||||
|
||||
Args:
|
||||
ticket_id: Ticket ID
|
||||
|
||||
Returns:
|
||||
List of email log entries
|
||||
"""
|
||||
query = """
|
||||
SELECT
|
||||
id,
|
||||
email_message_id,
|
||||
email_subject,
|
||||
email_from,
|
||||
email_received_at,
|
||||
is_reply,
|
||||
thread_data,
|
||||
created_at
|
||||
FROM tticket_email_log
|
||||
WHERE ticket_id = %s
|
||||
ORDER BY email_received_at ASC
|
||||
"""
|
||||
|
||||
return execute_query(query, (ticket_id,), fetchall=True)
|
||||
|
||||
@staticmethod
|
||||
async def find_tickets_by_email_address(email_address: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Find all tickets associated with an email address
|
||||
|
||||
Args:
|
||||
email_address: Email address to search for
|
||||
|
||||
Returns:
|
||||
List of ticket info dicts
|
||||
"""
|
||||
query = """
|
||||
SELECT DISTINCT
|
||||
t.id,
|
||||
t.ticket_number,
|
||||
t.subject,
|
||||
t.status,
|
||||
t.created_at
|
||||
FROM tticket_tickets t
|
||||
INNER JOIN tticket_email_log e ON e.ticket_id = t.id
|
||||
WHERE e.email_from = %s
|
||||
ORDER BY t.created_at DESC
|
||||
"""
|
||||
|
||||
return execute_query(query, (email_address,), fetchall=True)
|
||||
|
||||
|
||||
# Workflow action functions (called from email workflow engine)
|
||||
|
||||
async def workflow_action_create_ticket(
|
||||
context: Dict[str, Any],
|
||||
action_params: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Workflow action: Create ticket from email
|
||||
|
||||
Usage in workflow:
|
||||
{
|
||||
"action": "create_ticket",
|
||||
"params": {
|
||||
"customer_id": 123, # Optional
|
||||
"assigned_to_user_id": 5, # Optional
|
||||
"priority": "high" # Optional override
|
||||
}
|
||||
}
|
||||
|
||||
Args:
|
||||
context: Email workflow context with email_data
|
||||
action_params: Action parameters from workflow definition
|
||||
|
||||
Returns:
|
||||
Result dict with ticket info
|
||||
"""
|
||||
email_data = context.get('email_data', {})
|
||||
|
||||
result = await EmailTicketIntegration.process_email_for_ticket(
|
||||
email_data=email_data,
|
||||
customer_id=action_params.get('customer_id'),
|
||||
assigned_to_user_id=action_params.get('assigned_to_user_id')
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def workflow_action_link_email_to_ticket(
|
||||
context: Dict[str, Any],
|
||||
action_params: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Workflow action: Link email to existing ticket
|
||||
|
||||
Usage in workflow:
|
||||
{
|
||||
"action": "link_email_to_ticket",
|
||||
"params": {
|
||||
"ticket_number": "TKT-20251215-001"
|
||||
}
|
||||
}
|
||||
|
||||
Args:
|
||||
context: Email workflow context
|
||||
action_params: Must contain ticket_number
|
||||
|
||||
Returns:
|
||||
Result dict with link status
|
||||
"""
|
||||
email_data = context.get('email_data', {})
|
||||
ticket_number = action_params.get('ticket_number')
|
||||
|
||||
if not ticket_number:
|
||||
raise ValueError("ticket_number required in action params")
|
||||
|
||||
result = await EmailTicketIntegration.link_email_to_ticket(
|
||||
ticket_number=ticket_number,
|
||||
email_data=email_data
|
||||
)
|
||||
|
||||
return result
|
||||
504
app/ticket/backend/klippekort_service.py
Normal file
504
app/ticket/backend/klippekort_service.py
Normal file
@ -0,0 +1,504 @@
|
||||
"""
|
||||
Klippekort (Prepaid Time Card) Service
|
||||
=======================================
|
||||
|
||||
Business logic for prepaid time cards: purchase, balance, deduction.
|
||||
|
||||
CONSTRAINT: Only 1 active card per customer (enforced by database UNIQUE index).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
from app.core.database import execute_query, execute_insert, execute_update
|
||||
from app.ticket.backend.models import (
|
||||
TPrepaidCard,
|
||||
TPrepaidCardCreate,
|
||||
TPrepaidCardUpdate,
|
||||
TPrepaidCardWithStats,
|
||||
TPrepaidTransaction,
|
||||
TPrepaidTransactionCreate,
|
||||
PrepaidCardStatus,
|
||||
TransactionType
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class KlippekortService:
|
||||
"""Service for prepaid card operations"""
|
||||
|
||||
@staticmethod
|
||||
def purchase_card(
|
||||
card_data: TPrepaidCardCreate,
|
||||
user_id: Optional[int] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Purchase a new prepaid card
|
||||
|
||||
CONSTRAINT: Only 1 active card allowed per customer.
|
||||
This will fail if customer already has an active card.
|
||||
|
||||
Args:
|
||||
card_data: Card purchase data
|
||||
user_id: User making purchase
|
||||
|
||||
Returns:
|
||||
Created card dict
|
||||
|
||||
Raises:
|
||||
ValueError: If customer already has active card
|
||||
"""
|
||||
from psycopg2.extras import Json
|
||||
|
||||
# Check if customer already has an active card
|
||||
existing = execute_query(
|
||||
"""
|
||||
SELECT id, card_number FROM tticket_prepaid_cards
|
||||
WHERE customer_id = %s AND status = 'active'
|
||||
""",
|
||||
(card_data.customer_id,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
if existing:
|
||||
raise ValueError(
|
||||
f"Customer {card_data.customer_id} already has an active card: {existing['card_number']}. "
|
||||
"Please deactivate or deplete the existing card before purchasing a new one."
|
||||
)
|
||||
|
||||
logger.info(f"💳 Purchasing prepaid card for customer {card_data.customer_id}: {card_data.purchased_hours}h")
|
||||
|
||||
# Insert card (trigger will auto-generate card_number if NULL)
|
||||
card_id = execute_insert(
|
||||
"""
|
||||
INSERT INTO tticket_prepaid_cards (
|
||||
card_number, customer_id, purchased_hours, price_per_hour, total_amount,
|
||||
status, expires_at, notes, economic_invoice_number, economic_product_number,
|
||||
created_by_user_id
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
card_data.card_number,
|
||||
card_data.customer_id,
|
||||
card_data.purchased_hours,
|
||||
card_data.price_per_hour,
|
||||
card_data.total_amount,
|
||||
'active',
|
||||
card_data.expires_at,
|
||||
card_data.notes,
|
||||
card_data.economic_invoice_number,
|
||||
card_data.economic_product_number,
|
||||
user_id or card_data.created_by_user_id
|
||||
)
|
||||
)
|
||||
|
||||
# Create initial transaction
|
||||
execute_insert(
|
||||
"""
|
||||
INSERT INTO tticket_prepaid_transactions
|
||||
(card_id, transaction_type, hours, balance_after, description, created_by_user_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
card_id,
|
||||
'purchase',
|
||||
card_data.purchased_hours,
|
||||
card_data.purchased_hours,
|
||||
f"Initial purchase: {card_data.purchased_hours}h @ {card_data.price_per_hour} DKK/h",
|
||||
user_id
|
||||
)
|
||||
)
|
||||
|
||||
# Fetch created card
|
||||
card = execute_query(
|
||||
"SELECT * FROM tticket_prepaid_cards WHERE id = %s",
|
||||
(card_id,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
logger.info(f"✅ Created prepaid card {card['card_number']} (ID: {card_id})")
|
||||
return card
|
||||
|
||||
@staticmethod
|
||||
def get_card(card_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get prepaid card by ID"""
|
||||
return execute_query(
|
||||
"SELECT * FROM tticket_prepaid_cards WHERE id = %s",
|
||||
(card_id,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_card_with_stats(card_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get prepaid card with usage statistics"""
|
||||
return execute_query(
|
||||
"SELECT * FROM tticket_prepaid_balances WHERE id = %s",
|
||||
(card_id,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_active_card_for_customer(customer_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get active prepaid card for customer
|
||||
|
||||
Returns None if no active card exists.
|
||||
"""
|
||||
return execute_query(
|
||||
"""
|
||||
SELECT * FROM tticket_prepaid_cards
|
||||
WHERE customer_id = %s AND status = 'active'
|
||||
""",
|
||||
(customer_id,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def check_balance(customer_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Check prepaid card balance for customer
|
||||
|
||||
Args:
|
||||
customer_id: Customer ID
|
||||
|
||||
Returns:
|
||||
Dict with balance info: {
|
||||
"has_card": bool,
|
||||
"card_id": int or None,
|
||||
"card_number": str or None,
|
||||
"balance_hours": Decimal,
|
||||
"status": str or None,
|
||||
"expires_at": datetime or None
|
||||
}
|
||||
"""
|
||||
card = KlippekortService.get_active_card_for_customer(customer_id)
|
||||
|
||||
if not card:
|
||||
return {
|
||||
"has_card": False,
|
||||
"card_id": None,
|
||||
"card_number": None,
|
||||
"balance_hours": Decimal('0'),
|
||||
"status": None,
|
||||
"expires_at": None
|
||||
}
|
||||
|
||||
return {
|
||||
"has_card": True,
|
||||
"card_id": card['id'],
|
||||
"card_number": card['card_number'],
|
||||
"balance_hours": card['remaining_hours'],
|
||||
"status": card['status'],
|
||||
"expires_at": card['expires_at']
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def can_deduct(customer_id: int, hours: Decimal) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Check if customer has sufficient balance for deduction
|
||||
|
||||
Args:
|
||||
customer_id: Customer ID
|
||||
hours: Hours to deduct
|
||||
|
||||
Returns:
|
||||
(can_deduct, error_message)
|
||||
"""
|
||||
balance_info = KlippekortService.check_balance(customer_id)
|
||||
|
||||
if not balance_info['has_card']:
|
||||
return False, f"Customer {customer_id} has no active prepaid card"
|
||||
|
||||
if balance_info['status'] != 'active':
|
||||
return False, f"Prepaid card is not active (status: {balance_info['status']})"
|
||||
|
||||
if balance_info['balance_hours'] < hours:
|
||||
return False, (
|
||||
f"Insufficient balance: {balance_info['balance_hours']}h available, "
|
||||
f"{hours}h required"
|
||||
)
|
||||
|
||||
# Check expiration
|
||||
if balance_info['expires_at']:
|
||||
if balance_info['expires_at'] < datetime.now():
|
||||
return False, f"Prepaid card expired on {balance_info['expires_at']}"
|
||||
|
||||
return True, None
|
||||
|
||||
@staticmethod
|
||||
def deduct_hours(
|
||||
customer_id: int,
|
||||
hours: Decimal,
|
||||
worklog_id: int,
|
||||
user_id: Optional[int] = None,
|
||||
description: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Deduct hours from customer's active prepaid card
|
||||
|
||||
Args:
|
||||
customer_id: Customer ID
|
||||
hours: Hours to deduct
|
||||
worklog_id: Worklog entry consuming the hours
|
||||
user_id: User performing deduction
|
||||
description: Optional description
|
||||
|
||||
Returns:
|
||||
Transaction dict
|
||||
|
||||
Raises:
|
||||
ValueError: If insufficient balance or no active card
|
||||
"""
|
||||
# Check if deduction is possible
|
||||
can_deduct, error = KlippekortService.can_deduct(customer_id, hours)
|
||||
if not can_deduct:
|
||||
raise ValueError(error)
|
||||
|
||||
# Get active card
|
||||
card = KlippekortService.get_active_card_for_customer(customer_id)
|
||||
|
||||
logger.info(f"⏱️ Deducting {hours}h from card {card['card_number']} for worklog {worklog_id}")
|
||||
|
||||
# Update card usage
|
||||
new_used = Decimal(str(card['used_hours'])) + hours
|
||||
execute_update(
|
||||
"UPDATE tticket_prepaid_cards SET used_hours = %s WHERE id = %s",
|
||||
(new_used, card['id'])
|
||||
)
|
||||
|
||||
# Calculate new balance
|
||||
new_balance = Decimal(str(card['remaining_hours'])) - hours
|
||||
|
||||
# Create transaction
|
||||
transaction_id = execute_insert(
|
||||
"""
|
||||
INSERT INTO tticket_prepaid_transactions
|
||||
(card_id, worklog_id, transaction_type, hours, balance_after, description, created_by_user_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
card['id'],
|
||||
worklog_id,
|
||||
'usage',
|
||||
-hours, # Negative for deduction
|
||||
new_balance,
|
||||
description or f"Worklog #{worklog_id}: {hours}h",
|
||||
user_id
|
||||
)
|
||||
)
|
||||
|
||||
# Check if card is now depleted
|
||||
if new_balance <= Decimal('0'):
|
||||
execute_update(
|
||||
"UPDATE tticket_prepaid_cards SET status = 'depleted' WHERE id = %s",
|
||||
(card['id'],)
|
||||
)
|
||||
logger.warning(f"💳 Card {card['card_number']} is now depleted")
|
||||
|
||||
# Fetch transaction
|
||||
transaction = execute_query(
|
||||
"SELECT * FROM tticket_prepaid_transactions WHERE id = %s",
|
||||
(transaction_id,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
logger.info(f"✅ Deducted {hours}h from card {card['card_number']}, new balance: {new_balance}h")
|
||||
return transaction
|
||||
|
||||
@staticmethod
|
||||
def top_up_card(
|
||||
card_id: int,
|
||||
hours: Decimal,
|
||||
user_id: Optional[int] = None,
|
||||
note: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Add hours to existing prepaid card
|
||||
|
||||
Args:
|
||||
card_id: Card ID
|
||||
hours: Hours to add
|
||||
user_id: User performing top-up
|
||||
note: Optional note
|
||||
|
||||
Returns:
|
||||
Transaction dict
|
||||
|
||||
Raises:
|
||||
ValueError: If card not found or not active
|
||||
"""
|
||||
# Get card
|
||||
card = KlippekortService.get_card(card_id)
|
||||
|
||||
if not card:
|
||||
raise ValueError(f"Prepaid card {card_id} not found")
|
||||
|
||||
if card['status'] not in ['active', 'depleted']:
|
||||
raise ValueError(f"Cannot top up card with status: {card['status']}")
|
||||
|
||||
logger.info(f"💰 Topping up card {card['card_number']} with {hours}h")
|
||||
|
||||
# Update purchased hours
|
||||
new_purchased = Decimal(str(card['purchased_hours'])) + hours
|
||||
execute_update(
|
||||
"UPDATE tticket_prepaid_cards SET purchased_hours = %s, status = 'active' WHERE id = %s",
|
||||
(new_purchased, card_id)
|
||||
)
|
||||
|
||||
# Calculate new balance
|
||||
new_balance = Decimal(str(card['remaining_hours'])) + hours
|
||||
|
||||
# Create transaction
|
||||
transaction_id = execute_insert(
|
||||
"""
|
||||
INSERT INTO tticket_prepaid_transactions
|
||||
(card_id, transaction_type, hours, balance_after, description, created_by_user_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
card_id,
|
||||
'top_up',
|
||||
hours, # Positive for addition
|
||||
new_balance,
|
||||
note or f"Top-up: {hours}h added",
|
||||
user_id
|
||||
)
|
||||
)
|
||||
|
||||
transaction = execute_query(
|
||||
"SELECT * FROM tticket_prepaid_transactions WHERE id = %s",
|
||||
(transaction_id,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
logger.info(f"✅ Topped up card {card['card_number']} with {hours}h, new balance: {new_balance}h")
|
||||
return transaction
|
||||
|
||||
@staticmethod
|
||||
def get_transactions(
|
||||
card_id: int,
|
||||
limit: int = 100
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get transaction history for card
|
||||
|
||||
Args:
|
||||
card_id: Card ID
|
||||
limit: Max number of transactions
|
||||
|
||||
Returns:
|
||||
List of transaction dicts
|
||||
"""
|
||||
transactions = execute_query(
|
||||
"""
|
||||
SELECT * FROM tticket_prepaid_transactions
|
||||
WHERE card_id = %s
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(card_id, limit)
|
||||
)
|
||||
|
||||
return transactions or []
|
||||
|
||||
@staticmethod
|
||||
def list_cards(
|
||||
customer_id: Optional[int] = None,
|
||||
status: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List prepaid cards with optional filters
|
||||
|
||||
Args:
|
||||
customer_id: Filter by customer
|
||||
status: Filter by status
|
||||
limit: Number of results
|
||||
offset: Offset for pagination
|
||||
|
||||
Returns:
|
||||
List of card dicts
|
||||
"""
|
||||
query = "SELECT * FROM tticket_prepaid_cards WHERE 1=1"
|
||||
params = []
|
||||
|
||||
if customer_id:
|
||||
query += " AND customer_id = %s"
|
||||
params.append(customer_id)
|
||||
|
||||
if status:
|
||||
query += " AND status = %s"
|
||||
params.append(status)
|
||||
|
||||
query += " ORDER BY purchased_at DESC LIMIT %s OFFSET %s"
|
||||
params.extend([limit, offset])
|
||||
|
||||
cards = execute_query(query, tuple(params))
|
||||
return cards or []
|
||||
|
||||
@staticmethod
|
||||
def cancel_card(
|
||||
card_id: int,
|
||||
user_id: Optional[int] = None,
|
||||
reason: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Cancel/deactivate a prepaid card
|
||||
|
||||
Args:
|
||||
card_id: Card ID
|
||||
user_id: User cancelling card
|
||||
reason: Cancellation reason
|
||||
|
||||
Returns:
|
||||
Updated card dict
|
||||
|
||||
Raises:
|
||||
ValueError: If card not found or already cancelled
|
||||
"""
|
||||
card = KlippekortService.get_card(card_id)
|
||||
|
||||
if not card:
|
||||
raise ValueError(f"Prepaid card {card_id} not found")
|
||||
|
||||
if card['status'] == 'cancelled':
|
||||
raise ValueError(f"Card {card['card_number']} is already cancelled")
|
||||
|
||||
logger.info(f"❌ Cancelling card {card['card_number']}")
|
||||
|
||||
# Update status
|
||||
execute_update(
|
||||
"UPDATE tticket_prepaid_cards SET status = 'cancelled' WHERE id = %s",
|
||||
(card_id,)
|
||||
)
|
||||
|
||||
# Log transaction
|
||||
execute_insert(
|
||||
"""
|
||||
INSERT INTO tticket_prepaid_transactions
|
||||
(card_id, transaction_type, hours, balance_after, description, created_by_user_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
card_id,
|
||||
'cancellation',
|
||||
Decimal('0'),
|
||||
card['remaining_hours'],
|
||||
reason or "Card cancelled",
|
||||
user_id
|
||||
)
|
||||
)
|
||||
|
||||
# Fetch updated card
|
||||
updated = execute_query(
|
||||
"SELECT * FROM tticket_prepaid_cards WHERE id = %s",
|
||||
(card_id,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
logger.info(f"✅ Cancelled card {card['card_number']}")
|
||||
return updated
|
||||
497
app/ticket/backend/models.py
Normal file
497
app/ticket/backend/models.py
Normal file
@ -0,0 +1,497 @@
|
||||
"""
|
||||
Pydantic Models for Ticket System & Klippekort Module
|
||||
======================================================
|
||||
|
||||
Alle models repræsenterer data fra tticket_* tabeller.
|
||||
Modulet er isoleret og har ingen afhængigheder til core Hub-models.
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from enum import Enum
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ENUMS
|
||||
# ============================================================================
|
||||
|
||||
class TicketStatus(str, Enum):
|
||||
"""Ticket status workflow"""
|
||||
OPEN = "open"
|
||||
IN_PROGRESS = "in_progress"
|
||||
WAITING_CUSTOMER = "waiting_customer"
|
||||
WAITING_INTERNAL = "waiting_internal"
|
||||
RESOLVED = "resolved"
|
||||
CLOSED = "closed"
|
||||
|
||||
|
||||
class TicketPriority(str, Enum):
|
||||
"""Ticket prioritet"""
|
||||
LOW = "low"
|
||||
NORMAL = "normal"
|
||||
HIGH = "high"
|
||||
URGENT = "urgent"
|
||||
|
||||
|
||||
class TicketSource(str, Enum):
|
||||
"""Hvor ticket blev oprettet fra"""
|
||||
EMAIL = "email"
|
||||
PORTAL = "portal"
|
||||
PHONE = "phone"
|
||||
MANUAL = "manual"
|
||||
API = "api"
|
||||
|
||||
|
||||
class WorkType(str, Enum):
|
||||
"""Type af arbejde"""
|
||||
SUPPORT = "support"
|
||||
DEVELOPMENT = "development"
|
||||
TROUBLESHOOTING = "troubleshooting"
|
||||
ON_SITE = "on_site"
|
||||
MEETING = "meeting"
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
class BillingMethod(str, Enum):
|
||||
"""Afregningsmetode"""
|
||||
PREPAID_CARD = "prepaid_card"
|
||||
INVOICE = "invoice"
|
||||
INTERNAL = "internal"
|
||||
WARRANTY = "warranty"
|
||||
|
||||
|
||||
class WorklogStatus(str, Enum):
|
||||
"""Worklog status"""
|
||||
DRAFT = "draft"
|
||||
BILLABLE = "billable"
|
||||
BILLED = "billed"
|
||||
NON_BILLABLE = "non_billable"
|
||||
|
||||
|
||||
class PrepaidCardStatus(str, Enum):
|
||||
"""Klippekort status"""
|
||||
ACTIVE = "active"
|
||||
DEPLETED = "depleted"
|
||||
EXPIRED = "expired"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class TransactionType(str, Enum):
|
||||
"""Klippekort transaction type"""
|
||||
PURCHASE = "purchase"
|
||||
TOP_UP = "top_up"
|
||||
USAGE = "usage"
|
||||
REFUND = "refund"
|
||||
EXPIRATION = "expiration"
|
||||
CANCELLATION = "cancellation"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TICKET MODELS
|
||||
# ============================================================================
|
||||
|
||||
class TTicketBase(BaseModel):
|
||||
"""Base model for ticket"""
|
||||
subject: str = Field(..., min_length=1, max_length=500)
|
||||
description: Optional[str] = None
|
||||
status: TicketStatus = Field(default=TicketStatus.OPEN)
|
||||
priority: TicketPriority = Field(default=TicketPriority.NORMAL)
|
||||
category: Optional[str] = Field(None, max_length=100)
|
||||
customer_id: Optional[int] = Field(None, description="Reference til customers.id")
|
||||
contact_id: Optional[int] = Field(None, description="Reference til contacts.id")
|
||||
assigned_to_user_id: Optional[int] = Field(None, description="Reference til users.user_id")
|
||||
source: TicketSource = Field(default=TicketSource.MANUAL)
|
||||
tags: Optional[List[str]] = Field(default_factory=list)
|
||||
custom_fields: Optional[dict] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class TTicketCreate(TTicketBase):
|
||||
"""Model for creating a ticket"""
|
||||
ticket_number: Optional[str] = Field(None, description="Auto-generated hvis ikke angivet")
|
||||
created_by_user_id: Optional[int] = Field(None, description="User der opretter ticket")
|
||||
|
||||
|
||||
class TTicketUpdate(BaseModel):
|
||||
"""Model for updating a ticket (partial updates)"""
|
||||
subject: Optional[str] = Field(None, min_length=1, max_length=500)
|
||||
description: Optional[str] = None
|
||||
status: Optional[TicketStatus] = None
|
||||
priority: Optional[TicketPriority] = None
|
||||
category: Optional[str] = Field(None, max_length=100)
|
||||
customer_id: Optional[int] = None
|
||||
contact_id: Optional[int] = None
|
||||
assigned_to_user_id: Optional[int] = None
|
||||
tags: Optional[List[str]] = None
|
||||
custom_fields: Optional[dict] = None
|
||||
|
||||
|
||||
class TTicket(TTicketBase):
|
||||
"""Full ticket model with DB fields"""
|
||||
id: int
|
||||
ticket_number: str
|
||||
created_by_user_id: Optional[int] = None
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
first_response_at: Optional[datetime] = None
|
||||
resolved_at: Optional[datetime] = None
|
||||
closed_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TTicketWithStats(TTicket):
|
||||
"""Ticket med statistik (fra view)"""
|
||||
comment_count: Optional[int] = 0
|
||||
attachment_count: Optional[int] = 0
|
||||
pending_hours: Optional[Decimal] = None
|
||||
billed_hours: Optional[Decimal] = None
|
||||
last_comment_at: Optional[datetime] = None
|
||||
age_hours: Optional[float] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# COMMENT MODELS
|
||||
# ============================================================================
|
||||
|
||||
class TTicketCommentBase(BaseModel):
|
||||
"""Base model for comment"""
|
||||
comment_text: str = Field(..., min_length=1)
|
||||
is_internal: bool = Field(default=False, description="Intern note (ikke kunde-synlig)")
|
||||
|
||||
|
||||
class TTicketCommentCreate(TTicketCommentBase):
|
||||
"""Model for creating a comment"""
|
||||
ticket_id: int = Field(..., gt=0)
|
||||
user_id: Optional[int] = Field(None, description="User der opretter kommentar")
|
||||
|
||||
|
||||
class TTicketComment(TTicketCommentBase):
|
||||
"""Full comment model with DB fields"""
|
||||
id: int
|
||||
ticket_id: int
|
||||
user_id: Optional[int] = None
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ATTACHMENT MODELS
|
||||
# ============================================================================
|
||||
|
||||
class TTicketAttachmentBase(BaseModel):
|
||||
"""Base model for attachment"""
|
||||
file_name: str = Field(..., min_length=1, max_length=255)
|
||||
file_path: str = Field(..., min_length=1, max_length=500)
|
||||
file_size: Optional[int] = Field(None, ge=0)
|
||||
mime_type: Optional[str] = Field(None, max_length=100)
|
||||
|
||||
|
||||
class TTicketAttachmentCreate(TTicketAttachmentBase):
|
||||
"""Model for creating an attachment"""
|
||||
ticket_id: int = Field(..., gt=0)
|
||||
uploaded_by_user_id: Optional[int] = None
|
||||
|
||||
|
||||
class TTicketAttachment(TTicketAttachmentBase):
|
||||
"""Full attachment model with DB fields"""
|
||||
id: int
|
||||
ticket_id: int
|
||||
uploaded_by_user_id: Optional[int] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# WORKLOG MODELS
|
||||
# ============================================================================
|
||||
|
||||
class TTicketWorklogBase(BaseModel):
|
||||
"""Base model for worklog entry"""
|
||||
work_date: date = Field(..., description="Dato arbejdet blev udført")
|
||||
hours: Decimal = Field(..., gt=0, le=24, description="Timer brugt")
|
||||
work_type: WorkType = Field(default=WorkType.SUPPORT)
|
||||
description: Optional[str] = None
|
||||
billing_method: BillingMethod = Field(default=BillingMethod.INVOICE)
|
||||
|
||||
@field_validator('hours')
|
||||
@classmethod
|
||||
def validate_hours(cls, v):
|
||||
"""Validate hours is reasonable (max 24 hours per day)"""
|
||||
if v <= 0:
|
||||
raise ValueError('Hours must be greater than 0')
|
||||
if v > 24:
|
||||
raise ValueError('Hours cannot exceed 24 per entry')
|
||||
return v
|
||||
|
||||
|
||||
class TTicketWorklogCreate(TTicketWorklogBase):
|
||||
"""Model for creating a worklog entry"""
|
||||
ticket_id: int = Field(..., gt=0)
|
||||
user_id: Optional[int] = Field(None, description="User der opretter worklog")
|
||||
prepaid_card_id: Optional[int] = Field(None, description="Klippekort ID hvis billing_method=prepaid_card")
|
||||
|
||||
|
||||
class TTicketWorklogUpdate(BaseModel):
|
||||
"""Model for updating a worklog entry (partial updates)"""
|
||||
work_date: Optional[date] = None
|
||||
hours: Optional[Decimal] = Field(None, gt=0, le=24)
|
||||
work_type: Optional[WorkType] = None
|
||||
description: Optional[str] = None
|
||||
billing_method: Optional[BillingMethod] = None
|
||||
status: Optional[WorklogStatus] = None
|
||||
prepaid_card_id: Optional[int] = None
|
||||
|
||||
|
||||
class TTicketWorklog(TTicketWorklogBase):
|
||||
"""Full worklog model with DB fields"""
|
||||
id: int
|
||||
ticket_id: int
|
||||
user_id: Optional[int] = None
|
||||
status: WorklogStatus = Field(default=WorklogStatus.DRAFT)
|
||||
prepaid_card_id: Optional[int] = None
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
billed_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TTicketWorklogWithDetails(TTicketWorklog):
|
||||
"""Worklog med ticket detaljer (til review UI)"""
|
||||
ticket_number: Optional[str] = None
|
||||
ticket_subject: Optional[str] = None
|
||||
customer_id: Optional[int] = None
|
||||
ticket_status: Optional[str] = None
|
||||
has_sufficient_balance: Optional[bool] = True
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PREPAID CARD (KLIPPEKORT) MODELS
|
||||
# ============================================================================
|
||||
|
||||
class TPrepaidCardBase(BaseModel):
|
||||
"""Base model for prepaid card"""
|
||||
customer_id: int = Field(..., gt=0, description="Reference til customers.id")
|
||||
purchased_hours: Decimal = Field(..., gt=0, description="Timer købt")
|
||||
price_per_hour: Decimal = Field(..., gt=0, description="DKK pr. time")
|
||||
total_amount: Decimal = Field(..., gt=0, description="Total pris DKK")
|
||||
expires_at: Optional[datetime] = Field(None, description="Udløbsdato (NULL = ingen udløb)")
|
||||
notes: Optional[str] = None
|
||||
|
||||
@field_validator('total_amount')
|
||||
@classmethod
|
||||
def validate_total(cls, v, info):
|
||||
"""Validate that total_amount matches purchased_hours * price_per_hour"""
|
||||
# Note: This validator runs after all fields are set in Pydantic v2
|
||||
# If we need cross-field validation, use model_validator instead
|
||||
if v <= 0:
|
||||
raise ValueError('Total amount must be greater than 0')
|
||||
return v
|
||||
|
||||
|
||||
class TPrepaidCardCreate(TPrepaidCardBase):
|
||||
"""Model for creating a prepaid card"""
|
||||
card_number: Optional[str] = Field(None, description="Auto-generated hvis ikke angivet")
|
||||
created_by_user_id: Optional[int] = Field(None, description="User der opretter kort")
|
||||
economic_invoice_number: Optional[str] = Field(None, max_length=50)
|
||||
economic_product_number: Optional[str] = Field(None, max_length=50)
|
||||
|
||||
|
||||
class TPrepaidCardUpdate(BaseModel):
|
||||
"""Model for updating a prepaid card (partial updates)"""
|
||||
status: Optional[PrepaidCardStatus] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
notes: Optional[str] = None
|
||||
economic_invoice_number: Optional[str] = Field(None, max_length=50)
|
||||
economic_product_number: Optional[str] = Field(None, max_length=50)
|
||||
|
||||
|
||||
class TPrepaidCard(TPrepaidCardBase):
|
||||
"""Full prepaid card model with DB fields"""
|
||||
id: int
|
||||
card_number: str
|
||||
used_hours: Decimal = Field(default=Decimal('0'))
|
||||
remaining_hours: Decimal # Generated column
|
||||
status: PrepaidCardStatus = Field(default=PrepaidCardStatus.ACTIVE)
|
||||
purchased_at: datetime
|
||||
economic_invoice_number: Optional[str] = None
|
||||
economic_product_number: Optional[str] = None
|
||||
created_by_user_id: Optional[int] = None
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TPrepaidCardWithStats(TPrepaidCard):
|
||||
"""Prepaid card med statistik (fra view)"""
|
||||
usage_count: Optional[int] = 0
|
||||
total_hours_used: Optional[Decimal] = None
|
||||
billed_usage_count: Optional[int] = 0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PREPAID TRANSACTION MODELS
|
||||
# ============================================================================
|
||||
|
||||
class TPrepaidTransactionBase(BaseModel):
|
||||
"""Base model for prepaid transaction"""
|
||||
transaction_type: TransactionType
|
||||
hours: Decimal = Field(..., description="Timer (positiv=tilføj, negativ=træk)")
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class TPrepaidTransactionCreate(TPrepaidTransactionBase):
|
||||
"""Model for creating a transaction"""
|
||||
card_id: int = Field(..., gt=0)
|
||||
worklog_id: Optional[int] = Field(None, description="Reference til worklog (NULL for køb/top-up)")
|
||||
balance_after: Decimal = Field(..., ge=0, description="Saldo efter transaction")
|
||||
created_by_user_id: Optional[int] = None
|
||||
|
||||
|
||||
class TPrepaidTransaction(TPrepaidTransactionBase):
|
||||
"""Full transaction model with DB fields"""
|
||||
id: int
|
||||
card_id: int
|
||||
worklog_id: Optional[int] = None
|
||||
balance_after: Decimal
|
||||
created_at: datetime
|
||||
created_by_user_id: Optional[int] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# EMAIL INTEGRATION MODELS
|
||||
# ============================================================================
|
||||
|
||||
class TTicketEmailLogBase(BaseModel):
|
||||
"""Base model for email log"""
|
||||
action: str = Field(..., max_length=50)
|
||||
|
||||
|
||||
class TTicketEmailLogCreate(TTicketEmailLogBase):
|
||||
"""Model for creating an email log entry"""
|
||||
ticket_id: Optional[int] = None
|
||||
email_id: Optional[int] = Field(None, description="Reference til email_messages.id")
|
||||
email_message_id: Optional[str] = Field(None, max_length=500, description="Email Message-ID header")
|
||||
|
||||
|
||||
class TTicketEmailLog(TTicketEmailLogBase):
|
||||
"""Full email log model with DB fields"""
|
||||
id: int
|
||||
ticket_id: Optional[int] = None
|
||||
email_id: Optional[int] = None
|
||||
email_message_id: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# AUDIT LOG MODELS
|
||||
# ============================================================================
|
||||
|
||||
class TTicketAuditLogBase(BaseModel):
|
||||
"""Base model for audit log"""
|
||||
entity_type: str = Field(..., max_length=50)
|
||||
action: str = Field(..., max_length=50)
|
||||
old_value: Optional[str] = None
|
||||
new_value: Optional[str] = None
|
||||
details: Optional[dict] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class TTicketAuditLogCreate(TTicketAuditLogBase):
|
||||
"""Model for creating an audit log entry"""
|
||||
ticket_id: Optional[int] = None
|
||||
entity_id: Optional[int] = None
|
||||
user_id: Optional[int] = None
|
||||
|
||||
|
||||
class TTicketAuditLog(TTicketAuditLogBase):
|
||||
"""Full audit log model with DB fields"""
|
||||
id: int
|
||||
ticket_id: Optional[int] = None
|
||||
entity_id: Optional[int] = None
|
||||
user_id: Optional[int] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# RESPONSE MODELS (for API responses)
|
||||
# ============================================================================
|
||||
|
||||
class TicketListResponse(BaseModel):
|
||||
"""Response model for ticket lists"""
|
||||
tickets: List[TTicketWithStats]
|
||||
total: int
|
||||
page: int = 1
|
||||
page_size: int = 50
|
||||
|
||||
|
||||
class WorklogReviewResponse(BaseModel):
|
||||
"""Response model for worklog review page"""
|
||||
worklogs: List[TTicketWorklogWithDetails]
|
||||
total: int
|
||||
total_hours: Decimal
|
||||
total_billable_hours: Decimal
|
||||
|
||||
|
||||
class PrepaidCardBalanceResponse(BaseModel):
|
||||
"""Response model for prepaid card balance check"""
|
||||
card: TPrepaidCardWithStats
|
||||
can_deduct: bool
|
||||
required_hours: Optional[Decimal] = None
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# REQUEST MODELS (for specific actions)
|
||||
# ============================================================================
|
||||
|
||||
class TicketStatusUpdateRequest(BaseModel):
|
||||
"""Request model for updating ticket status"""
|
||||
status: TicketStatus
|
||||
note: Optional[str] = Field(None, description="Note til audit log")
|
||||
|
||||
|
||||
class WorklogBillingRequest(BaseModel):
|
||||
"""Request model for marking worklogs as billable"""
|
||||
worklog_ids: List[int] = Field(..., min_length=1)
|
||||
note: Optional[str] = Field(None, description="Note til audit log")
|
||||
|
||||
|
||||
class PrepaidCardTopUpRequest(BaseModel):
|
||||
"""Request model for topping up prepaid card"""
|
||||
hours: Decimal = Field(..., gt=0, description="Timer at tilføje")
|
||||
note: Optional[str] = Field(None, description="Beskrivelse af top-up")
|
||||
|
||||
|
||||
class PrepaidCardDeductRequest(BaseModel):
|
||||
"""Request model for deducting hours from prepaid card"""
|
||||
worklog_id: int = Field(..., gt=0, description="Worklog ID der skal trækkes fra kort")
|
||||
hours: Decimal = Field(..., gt=0, description="Timer at trække")
|
||||
818
app/ticket/backend/router.py
Normal file
818
app/ticket/backend/router.py
Normal file
@ -0,0 +1,818 @@
|
||||
"""
|
||||
Ticket Module API Router
|
||||
=========================
|
||||
|
||||
REST API endpoints for ticket system.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.ticket.backend.ticket_service import TicketService
|
||||
from app.ticket.backend.economic_export import ticket_economic_service
|
||||
from app.ticket.backend.models import (
|
||||
TTicket,
|
||||
TTicketCreate,
|
||||
TTicketUpdate,
|
||||
TTicketWithStats,
|
||||
TTicketComment,
|
||||
TTicketCommentCreate,
|
||||
TTicketWorklog,
|
||||
TTicketWorklogCreate,
|
||||
TTicketWorklogUpdate,
|
||||
TTicketWorklogWithDetails,
|
||||
TicketListResponse,
|
||||
TicketStatusUpdateRequest,
|
||||
WorklogReviewResponse,
|
||||
WorklogBillingRequest
|
||||
)
|
||||
from app.core.database import execute_query, execute_insert, execute_update
|
||||
from datetime import date
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TICKET ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/tickets", response_model=TicketListResponse, tags=["Tickets"])
|
||||
async def list_tickets(
|
||||
status: Optional[str] = Query(None, description="Filter by status"),
|
||||
priority: Optional[str] = Query(None, description="Filter by priority"),
|
||||
customer_id: Optional[int] = Query(None, description="Filter by customer"),
|
||||
assigned_to_user_id: Optional[int] = Query(None, description="Filter by assigned user"),
|
||||
search: Optional[str] = Query(None, description="Search in subject/description"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Number of results"),
|
||||
offset: int = Query(0, ge=0, description="Offset for pagination")
|
||||
):
|
||||
"""
|
||||
List tickets with optional filters
|
||||
|
||||
- **status**: Filter by ticket status
|
||||
- **priority**: Filter by priority level
|
||||
- **customer_id**: Show tickets for specific customer
|
||||
- **assigned_to_user_id**: Show tickets assigned to user
|
||||
- **search**: Search in subject and description
|
||||
"""
|
||||
try:
|
||||
tickets = TicketService.list_tickets(
|
||||
status=status,
|
||||
priority=priority,
|
||||
customer_id=customer_id,
|
||||
assigned_to_user_id=assigned_to_user_id,
|
||||
search=search,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
# Get total count for pagination
|
||||
total_query = "SELECT COUNT(*) as count FROM tticket_tickets WHERE 1=1"
|
||||
params = []
|
||||
|
||||
if status:
|
||||
total_query += " AND status = %s"
|
||||
params.append(status)
|
||||
if customer_id:
|
||||
total_query += " AND customer_id = %s"
|
||||
params.append(customer_id)
|
||||
|
||||
total_result = execute_query(total_query, tuple(params), fetchone=True)
|
||||
total = total_result['count'] if total_result else 0
|
||||
|
||||
return TicketListResponse(
|
||||
tickets=tickets,
|
||||
total=total,
|
||||
page=offset // limit + 1,
|
||||
page_size=limit
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error listing tickets: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/tickets/{ticket_id}", response_model=TTicketWithStats, tags=["Tickets"])
|
||||
async def get_ticket(ticket_id: int):
|
||||
"""
|
||||
Get single ticket with statistics
|
||||
|
||||
Returns ticket with comment count, worklog hours, etc.
|
||||
"""
|
||||
try:
|
||||
ticket = TicketService.get_ticket_with_stats(ticket_id)
|
||||
|
||||
if not ticket:
|
||||
raise HTTPException(status_code=404, detail=f"Ticket {ticket_id} not found")
|
||||
|
||||
return ticket
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error getting ticket {ticket_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/tickets", response_model=TTicket, status_code=status.HTTP_201_CREATED, tags=["Tickets"])
|
||||
async def create_ticket(
|
||||
ticket_data: TTicketCreate,
|
||||
user_id: Optional[int] = Query(None, description="User creating ticket")
|
||||
):
|
||||
"""
|
||||
Create new ticket
|
||||
|
||||
Ticket number will be auto-generated if not provided.
|
||||
"""
|
||||
try:
|
||||
ticket = TicketService.create_ticket(ticket_data, user_id=user_id)
|
||||
logger.info(f"✅ Created ticket {ticket['ticket_number']}")
|
||||
return ticket
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error creating ticket: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/tickets/{ticket_id}", response_model=TTicket, tags=["Tickets"])
|
||||
async def update_ticket(
|
||||
ticket_id: int,
|
||||
update_data: TTicketUpdate,
|
||||
user_id: Optional[int] = Query(None, description="User making update")
|
||||
):
|
||||
"""
|
||||
Update ticket (partial update)
|
||||
|
||||
Only provided fields will be updated.
|
||||
"""
|
||||
try:
|
||||
ticket = TicketService.update_ticket(ticket_id, update_data, user_id=user_id)
|
||||
return ticket
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error updating ticket {ticket_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/tickets/{ticket_id}/status", response_model=TTicket, tags=["Tickets"])
|
||||
async def update_ticket_status(
|
||||
ticket_id: int,
|
||||
request: TicketStatusUpdateRequest,
|
||||
user_id: Optional[int] = Query(None, description="User changing status")
|
||||
):
|
||||
"""
|
||||
Update ticket status with validation
|
||||
|
||||
Status transitions are validated according to workflow rules.
|
||||
"""
|
||||
try:
|
||||
ticket = TicketService.update_ticket_status(
|
||||
ticket_id,
|
||||
request.status.value,
|
||||
user_id=user_id,
|
||||
note=request.note
|
||||
)
|
||||
return ticket
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error updating status for ticket {ticket_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/tickets/{ticket_id}/assign", response_model=TTicket, tags=["Tickets"])
|
||||
async def assign_ticket(
|
||||
ticket_id: int,
|
||||
assigned_to_user_id: int = Query(..., description="User to assign to"),
|
||||
user_id: Optional[int] = Query(None, description="User making assignment")
|
||||
):
|
||||
"""
|
||||
Assign ticket to a user
|
||||
"""
|
||||
try:
|
||||
ticket = TicketService.assign_ticket(ticket_id, assigned_to_user_id, user_id=user_id)
|
||||
return ticket
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error assigning ticket {ticket_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# COMMENT ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/tickets/{ticket_id}/comments", response_model=List[TTicketComment], tags=["Comments"])
|
||||
async def list_comments(ticket_id: int):
|
||||
"""
|
||||
List all comments for a ticket
|
||||
"""
|
||||
try:
|
||||
comments = execute_query(
|
||||
"SELECT * FROM tticket_comments WHERE ticket_id = %s ORDER BY created_at ASC",
|
||||
(ticket_id,)
|
||||
)
|
||||
return comments or []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error listing comments for ticket {ticket_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/tickets/{ticket_id}/comments", response_model=TTicketComment, status_code=status.HTTP_201_CREATED, tags=["Comments"])
|
||||
async def add_comment(
|
||||
ticket_id: int,
|
||||
comment_text: str = Query(..., min_length=1, description="Comment text"),
|
||||
is_internal: bool = Query(False, description="Is internal note"),
|
||||
user_id: Optional[int] = Query(None, description="User adding comment")
|
||||
):
|
||||
"""
|
||||
Add comment to ticket
|
||||
|
||||
- **is_internal**: If true, comment is only visible to staff
|
||||
"""
|
||||
try:
|
||||
comment = TicketService.add_comment(
|
||||
ticket_id,
|
||||
comment_text,
|
||||
user_id=user_id,
|
||||
is_internal=is_internal
|
||||
)
|
||||
return comment
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error adding comment to ticket {ticket_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# WORKLOG ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/tickets/{ticket_id}/worklog", response_model=List[TTicketWorklog], tags=["Worklog"])
|
||||
async def list_worklog(ticket_id: int):
|
||||
"""
|
||||
List all worklog entries for a ticket
|
||||
"""
|
||||
try:
|
||||
worklog = execute_query(
|
||||
"SELECT * FROM tticket_worklog WHERE ticket_id = %s ORDER BY work_date DESC",
|
||||
(ticket_id,)
|
||||
)
|
||||
return worklog or []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error listing worklog for ticket {ticket_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/tickets/{ticket_id}/worklog", response_model=TTicketWorklog, status_code=status.HTTP_201_CREATED, tags=["Worklog"])
|
||||
async def create_worklog(
|
||||
ticket_id: int,
|
||||
worklog_data: TTicketWorklogCreate,
|
||||
user_id: Optional[int] = Query(None, description="User creating worklog")
|
||||
):
|
||||
"""
|
||||
Create worklog entry for ticket
|
||||
|
||||
Creates time entry in draft status.
|
||||
"""
|
||||
try:
|
||||
from psycopg2.extras import Json
|
||||
|
||||
worklog_id = execute_insert(
|
||||
"""
|
||||
INSERT INTO tticket_worklog
|
||||
(ticket_id, work_date, hours, work_type, description, billing_method, status, user_id, prepaid_card_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
ticket_id,
|
||||
worklog_data.work_date,
|
||||
worklog_data.hours,
|
||||
worklog_data.work_type.value,
|
||||
worklog_data.description,
|
||||
worklog_data.billing_method.value,
|
||||
'draft',
|
||||
user_id or worklog_data.user_id,
|
||||
worklog_data.prepaid_card_id
|
||||
)
|
||||
)
|
||||
|
||||
# Log audit
|
||||
TicketService.log_audit(
|
||||
ticket_id=ticket_id,
|
||||
entity_type="worklog",
|
||||
entity_id=worklog_id,
|
||||
user_id=user_id,
|
||||
action="created",
|
||||
details={"hours": float(worklog_data.hours), "work_type": worklog_data.work_type.value}
|
||||
)
|
||||
|
||||
worklog = execute_query(
|
||||
"SELECT * FROM tticket_worklog WHERE id = %s",
|
||||
(worklog_id,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
logger.info(f"✅ Created worklog entry {worklog_id} for ticket {ticket_id}")
|
||||
return worklog
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error creating worklog: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/worklog/{worklog_id}", response_model=TTicketWorklog, tags=["Worklog"])
|
||||
async def update_worklog(
|
||||
worklog_id: int,
|
||||
update_data: TTicketWorklogUpdate,
|
||||
user_id: Optional[int] = Query(None, description="User updating worklog")
|
||||
):
|
||||
"""
|
||||
Update worklog entry (partial update)
|
||||
|
||||
Only draft entries can be fully edited.
|
||||
"""
|
||||
try:
|
||||
# Get current worklog
|
||||
current = execute_query(
|
||||
"SELECT * FROM tticket_worklog WHERE id = %s",
|
||||
(worklog_id,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
if not current:
|
||||
raise HTTPException(status_code=404, detail=f"Worklog {worklog_id} not found")
|
||||
|
||||
# Build update query
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
update_dict = update_data.model_dump(exclude_unset=True)
|
||||
|
||||
for field, value in update_dict.items():
|
||||
if hasattr(value, 'value'):
|
||||
value = value.value
|
||||
updates.append(f"{field} = %s")
|
||||
params.append(value)
|
||||
|
||||
if updates:
|
||||
params.append(worklog_id)
|
||||
query = f"UPDATE tticket_worklog SET {', '.join(updates)} WHERE id = %s"
|
||||
execute_update(query, tuple(params))
|
||||
|
||||
# Log audit
|
||||
TicketService.log_audit(
|
||||
ticket_id=current['ticket_id'],
|
||||
entity_type="worklog",
|
||||
entity_id=worklog_id,
|
||||
user_id=user_id,
|
||||
action="updated",
|
||||
details=update_dict
|
||||
)
|
||||
|
||||
# Fetch updated
|
||||
worklog = execute_query(
|
||||
"SELECT * FROM tticket_worklog WHERE id = %s",
|
||||
(worklog_id,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
return worklog
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error updating worklog {worklog_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/worklog/review", response_model=WorklogReviewResponse, tags=["Worklog"])
|
||||
async def review_worklog(
|
||||
customer_id: Optional[int] = Query(None, description="Filter by customer"),
|
||||
status: str = Query("draft", description="Filter by status (default: draft)")
|
||||
):
|
||||
"""
|
||||
Get worklog entries for review/billing
|
||||
|
||||
Returns entries ready for review with ticket context.
|
||||
"""
|
||||
try:
|
||||
from decimal import Decimal
|
||||
|
||||
query = """
|
||||
SELECT w.*, t.ticket_number, t.subject AS ticket_subject,
|
||||
t.customer_id, t.status AS ticket_status
|
||||
FROM tticket_worklog w
|
||||
JOIN tticket_tickets t ON w.ticket_id = t.id
|
||||
WHERE w.status = %s
|
||||
"""
|
||||
params = [status]
|
||||
|
||||
if customer_id:
|
||||
query += " AND t.customer_id = %s"
|
||||
params.append(customer_id)
|
||||
|
||||
query += " ORDER BY w.work_date DESC, t.customer_id"
|
||||
|
||||
worklogs = execute_query(query, tuple(params))
|
||||
|
||||
# Calculate totals
|
||||
total_hours = Decimal('0')
|
||||
total_billable_hours = Decimal('0')
|
||||
|
||||
for w in worklogs or []:
|
||||
total_hours += Decimal(str(w['hours']))
|
||||
if w['status'] in ['draft', 'billable']:
|
||||
total_billable_hours += Decimal(str(w['hours']))
|
||||
|
||||
return WorklogReviewResponse(
|
||||
worklogs=worklogs or [],
|
||||
total=len(worklogs) if worklogs else 0,
|
||||
total_hours=total_hours,
|
||||
total_billable_hours=total_billable_hours
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error getting worklog review: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/worklog/mark-billable", tags=["Worklog"])
|
||||
async def mark_worklog_billable(
|
||||
request: WorklogBillingRequest,
|
||||
user_id: Optional[int] = Query(None, description="User marking as billable")
|
||||
):
|
||||
"""
|
||||
Mark worklog entries as billable
|
||||
|
||||
Changes status from draft to billable for selected entries.
|
||||
"""
|
||||
try:
|
||||
updated_count = 0
|
||||
|
||||
for worklog_id in request.worklog_ids:
|
||||
# Get worklog
|
||||
worklog = execute_query(
|
||||
"SELECT * FROM tticket_worklog WHERE id = %s",
|
||||
(worklog_id,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
if not worklog:
|
||||
logger.warning(f"⚠️ Worklog {worklog_id} not found, skipping")
|
||||
continue
|
||||
|
||||
if worklog['status'] != 'draft':
|
||||
logger.warning(f"⚠️ Worklog {worklog_id} not in draft status, skipping")
|
||||
continue
|
||||
|
||||
# Update to billable
|
||||
execute_update(
|
||||
"UPDATE tticket_worklog SET status = 'billable' WHERE id = %s",
|
||||
(worklog_id,)
|
||||
)
|
||||
|
||||
# Log audit
|
||||
TicketService.log_audit(
|
||||
ticket_id=worklog['ticket_id'],
|
||||
entity_type="worklog",
|
||||
entity_id=worklog_id,
|
||||
user_id=user_id,
|
||||
action="marked_billable",
|
||||
old_value="draft",
|
||||
new_value="billable",
|
||||
details={"note": request.note} if request.note else None
|
||||
)
|
||||
|
||||
updated_count += 1
|
||||
|
||||
logger.info(f"✅ Marked {updated_count} worklog entries as billable")
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": True,
|
||||
"updated_count": updated_count,
|
||||
"message": f"Marked {updated_count} entries as billable"
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error marking worklog as billable: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PREPAID CARD (KLIPPEKORT) ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
from app.ticket.backend.klippekort_service import KlippekortService
|
||||
from app.ticket.backend.models import (
|
||||
TPrepaidCard,
|
||||
TPrepaidCardCreate,
|
||||
TPrepaidCardUpdate,
|
||||
TPrepaidCardWithStats,
|
||||
TPrepaidTransaction,
|
||||
PrepaidCardBalanceResponse,
|
||||
PrepaidCardTopUpRequest
|
||||
)
|
||||
|
||||
|
||||
@router.get("/prepaid-cards", response_model=List[TPrepaidCard], tags=["Prepaid Cards"])
|
||||
async def list_prepaid_cards(
|
||||
customer_id: Optional[int] = Query(None, description="Filter by customer"),
|
||||
status: Optional[str] = Query(None, description="Filter by status"),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0)
|
||||
):
|
||||
"""
|
||||
List prepaid cards with optional filters
|
||||
"""
|
||||
try:
|
||||
cards = KlippekortService.list_cards(
|
||||
customer_id=customer_id,
|
||||
status=status,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
return cards
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error listing prepaid cards: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/prepaid-cards/{card_id}", response_model=TPrepaidCardWithStats, tags=["Prepaid Cards"])
|
||||
async def get_prepaid_card(card_id: int):
|
||||
"""
|
||||
Get prepaid card with usage statistics
|
||||
"""
|
||||
try:
|
||||
card = KlippekortService.get_card_with_stats(card_id)
|
||||
|
||||
if not card:
|
||||
raise HTTPException(status_code=404, detail=f"Prepaid card {card_id} not found")
|
||||
|
||||
return card
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error getting prepaid card {card_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/prepaid-cards", response_model=TPrepaidCard, status_code=status.HTTP_201_CREATED, tags=["Prepaid Cards"])
|
||||
async def purchase_prepaid_card(
|
||||
card_data: TPrepaidCardCreate,
|
||||
user_id: Optional[int] = Query(None, description="User purchasing card")
|
||||
):
|
||||
"""
|
||||
Purchase new prepaid card
|
||||
|
||||
CONSTRAINT: Only 1 active card allowed per customer.
|
||||
Will fail if customer already has an active card.
|
||||
"""
|
||||
try:
|
||||
card = KlippekortService.purchase_card(card_data, user_id=user_id)
|
||||
logger.info(f"✅ Purchased prepaid card {card['card_number']}")
|
||||
return card
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error purchasing prepaid card: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/prepaid-cards/customer/{customer_id}/balance", response_model=PrepaidCardBalanceResponse, tags=["Prepaid Cards"])
|
||||
async def check_customer_balance(customer_id: int):
|
||||
"""
|
||||
Check prepaid card balance for customer
|
||||
|
||||
Returns balance info for customer's active card.
|
||||
"""
|
||||
try:
|
||||
balance_info = KlippekortService.check_balance(customer_id)
|
||||
|
||||
if not balance_info['has_card']:
|
||||
return PrepaidCardBalanceResponse(
|
||||
card=None,
|
||||
can_deduct=False,
|
||||
message=f"Customer {customer_id} has no active prepaid card"
|
||||
)
|
||||
|
||||
# Get card details
|
||||
card = KlippekortService.get_card_with_stats(balance_info['card_id'])
|
||||
|
||||
return PrepaidCardBalanceResponse(
|
||||
card=card,
|
||||
can_deduct=balance_info['balance_hours'] > 0,
|
||||
message=f"Balance: {balance_info['balance_hours']}h"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error checking balance for customer {customer_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/prepaid-cards/{card_id}/top-up", response_model=TPrepaidTransaction, tags=["Prepaid Cards"])
|
||||
async def top_up_prepaid_card(
|
||||
card_id: int,
|
||||
request: PrepaidCardTopUpRequest,
|
||||
user_id: Optional[int] = Query(None, description="User performing top-up")
|
||||
):
|
||||
"""
|
||||
Top up prepaid card with additional hours
|
||||
"""
|
||||
try:
|
||||
transaction = KlippekortService.top_up_card(
|
||||
card_id,
|
||||
request.hours,
|
||||
user_id=user_id,
|
||||
note=request.note
|
||||
)
|
||||
return transaction
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error topping up card {card_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/prepaid-cards/{card_id}/transactions", response_model=List[TPrepaidTransaction], tags=["Prepaid Cards"])
|
||||
async def get_card_transactions(
|
||||
card_id: int,
|
||||
limit: int = Query(100, ge=1, le=500)
|
||||
):
|
||||
"""
|
||||
Get transaction history for prepaid card
|
||||
"""
|
||||
try:
|
||||
transactions = KlippekortService.get_transactions(card_id, limit=limit)
|
||||
return transactions
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error getting transactions for card {card_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/prepaid-cards/{card_id}", response_model=TPrepaidCard, tags=["Prepaid Cards"])
|
||||
async def cancel_prepaid_card(
|
||||
card_id: int,
|
||||
reason: Optional[str] = Query(None, description="Cancellation reason"),
|
||||
user_id: Optional[int] = Query(None, description="User cancelling card")
|
||||
):
|
||||
"""
|
||||
Cancel/deactivate prepaid card
|
||||
"""
|
||||
try:
|
||||
card = KlippekortService.cancel_card(card_id, user_id=user_id, reason=reason)
|
||||
return card
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error cancelling card {card_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# STATISTICS ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/tickets/stats/by-status", tags=["Statistics"])
|
||||
async def get_stats_by_status():
|
||||
"""
|
||||
Get ticket statistics grouped by status
|
||||
"""
|
||||
try:
|
||||
stats = execute_query(
|
||||
"SELECT * FROM tticket_stats_by_status ORDER BY status"
|
||||
)
|
||||
return stats or []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error getting stats: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/tickets/stats/open", tags=["Statistics"])
|
||||
async def get_open_tickets_stats():
|
||||
"""
|
||||
Get statistics for open tickets
|
||||
"""
|
||||
try:
|
||||
stats = execute_query(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(*) as total_open,
|
||||
COUNT(*) FILTER (WHERE status = 'open') as new_tickets,
|
||||
COUNT(*) FILTER (WHERE status = 'in_progress') as in_progress,
|
||||
COUNT(*) FILTER (WHERE priority = 'urgent') as urgent_count,
|
||||
AVG(age_hours) as avg_age_hours
|
||||
FROM tticket_open_tickets
|
||||
""",
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
return stats or {}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error getting open tickets stats: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# E-CONOMIC EXPORT ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/worklog/export/preview", tags=["E-conomic Export"])
|
||||
async def preview_economic_export(
|
||||
customer_id: int = Query(..., description="Customer ID"),
|
||||
worklog_ids: Optional[List[int]] = Query(None, description="Specific worklog IDs to export"),
|
||||
date_from: Optional[date] = Query(None, description="Start date filter"),
|
||||
date_to: Optional[date] = Query(None, description="End date filter")
|
||||
):
|
||||
"""
|
||||
Preview what would be exported to e-conomic without actually exporting
|
||||
|
||||
**Safety**: This is read-only and safe to call
|
||||
"""
|
||||
try:
|
||||
preview = await ticket_economic_service.get_export_preview(
|
||||
customer_id=customer_id,
|
||||
worklog_ids=worklog_ids,
|
||||
date_from=date_from,
|
||||
date_to=date_to
|
||||
)
|
||||
|
||||
return preview
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error generating export preview: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/worklog/export/execute", tags=["E-conomic Export"])
|
||||
async def execute_economic_export(
|
||||
customer_id: int = Query(..., description="Customer ID"),
|
||||
worklog_ids: Optional[List[int]] = Query(None, description="Specific worklog IDs to export"),
|
||||
date_from: Optional[date] = Query(None, description="Start date filter"),
|
||||
date_to: Optional[date] = Query(None, description="End date filter")
|
||||
):
|
||||
"""
|
||||
Export billable worklog entries to e-conomic as draft invoice
|
||||
|
||||
**⚠️ WARNING**: This creates invoices in e-conomic (subject to safety switches)
|
||||
|
||||
**Safety Switches**:
|
||||
- `TICKET_ECONOMIC_READ_ONLY=true`: Blocks execution
|
||||
- `TICKET_ECONOMIC_DRY_RUN=true`: Logs but doesn't execute
|
||||
- Both must be `false` to actually export
|
||||
|
||||
**Process**:
|
||||
1. Validates customer has e-conomic mapping
|
||||
2. Collects billable worklog entries
|
||||
3. Creates draft invoice in e-conomic
|
||||
4. Marks worklog entries as "billed"
|
||||
"""
|
||||
try:
|
||||
result = await ticket_economic_service.export_billable_worklog_batch(
|
||||
customer_id=customer_id,
|
||||
worklog_ids=worklog_ids,
|
||||
date_from=date_from,
|
||||
date_to=date_to
|
||||
)
|
||||
|
||||
if result['status'] == 'blocked':
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={
|
||||
'error': 'Export blocked by safety switches',
|
||||
'read_only': result.get('read_only'),
|
||||
'dry_run': result.get('dry_run'),
|
||||
'message': 'Set TICKET_ECONOMIC_READ_ONLY=false and TICKET_ECONOMIC_DRY_RUN=false to enable'
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error executing export: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
543
app/ticket/backend/ticket_service.py
Normal file
543
app/ticket/backend/ticket_service.py
Normal file
@ -0,0 +1,543 @@
|
||||
"""
|
||||
Ticket Service
|
||||
==============
|
||||
|
||||
Business logic for ticket operations: status transitions, validation, audit logging.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, List
|
||||
from decimal import Decimal
|
||||
|
||||
from app.core.database import execute_query, execute_insert, execute_update
|
||||
from app.ticket.backend.models import (
|
||||
TicketStatus,
|
||||
TicketPriority,
|
||||
TTicketCreate,
|
||||
TTicketUpdate,
|
||||
TTicket
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TicketService:
|
||||
"""Service for ticket business logic"""
|
||||
|
||||
# Status transition rules (which transitions are allowed)
|
||||
ALLOWED_TRANSITIONS = {
|
||||
TicketStatus.OPEN: [TicketStatus.IN_PROGRESS, TicketStatus.CLOSED],
|
||||
TicketStatus.IN_PROGRESS: [
|
||||
TicketStatus.WAITING_CUSTOMER,
|
||||
TicketStatus.WAITING_INTERNAL,
|
||||
TicketStatus.RESOLVED
|
||||
],
|
||||
TicketStatus.WAITING_CUSTOMER: [TicketStatus.IN_PROGRESS, TicketStatus.CLOSED],
|
||||
TicketStatus.WAITING_INTERNAL: [TicketStatus.IN_PROGRESS, TicketStatus.CLOSED],
|
||||
TicketStatus.RESOLVED: [TicketStatus.CLOSED, TicketStatus.IN_PROGRESS], # Can reopen
|
||||
TicketStatus.CLOSED: [] # Cannot transition from closed
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def validate_status_transition(current_status: str, new_status: str) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate if status transition is allowed
|
||||
|
||||
Returns:
|
||||
(is_valid, error_message)
|
||||
"""
|
||||
try:
|
||||
current = TicketStatus(current_status)
|
||||
new = TicketStatus(new_status)
|
||||
except ValueError as e:
|
||||
return False, f"Invalid status: {e}"
|
||||
|
||||
if current == new:
|
||||
return True, None # Same status is OK
|
||||
|
||||
allowed = TicketService.ALLOWED_TRANSITIONS.get(current, [])
|
||||
if new not in allowed:
|
||||
return False, f"Cannot transition from {current.value} to {new.value}"
|
||||
|
||||
return True, None
|
||||
|
||||
@staticmethod
|
||||
def create_ticket(
|
||||
ticket_data: TTicketCreate,
|
||||
user_id: Optional[int] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a new ticket with auto-generated ticket_number if not provided
|
||||
|
||||
Args:
|
||||
ticket_data: Ticket creation data
|
||||
user_id: User creating the ticket
|
||||
|
||||
Returns:
|
||||
Created ticket dict
|
||||
"""
|
||||
logger.info(f"🎫 Creating ticket: {ticket_data.subject}")
|
||||
|
||||
# Prepare data for PostgreSQL (convert Python types to SQL types)
|
||||
import json
|
||||
from psycopg2.extras import Json
|
||||
|
||||
# Insert ticket (trigger will auto-generate ticket_number if NULL)
|
||||
ticket_id = execute_insert(
|
||||
"""
|
||||
INSERT INTO tticket_tickets (
|
||||
ticket_number, subject, description, status, priority, category,
|
||||
customer_id, contact_id, assigned_to_user_id, created_by_user_id,
|
||||
source, tags, custom_fields
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
ticket_data.ticket_number,
|
||||
ticket_data.subject,
|
||||
ticket_data.description,
|
||||
ticket_data.status.value,
|
||||
ticket_data.priority.value,
|
||||
ticket_data.category,
|
||||
ticket_data.customer_id,
|
||||
ticket_data.contact_id,
|
||||
ticket_data.assigned_to_user_id,
|
||||
user_id or ticket_data.created_by_user_id,
|
||||
ticket_data.source.value,
|
||||
ticket_data.tags or [], # PostgreSQL array
|
||||
Json(ticket_data.custom_fields or {}) # PostgreSQL JSONB
|
||||
)
|
||||
)
|
||||
|
||||
# Log creation
|
||||
TicketService.log_audit(
|
||||
ticket_id=ticket_id,
|
||||
entity_type="ticket",
|
||||
entity_id=ticket_id,
|
||||
user_id=user_id,
|
||||
action="created",
|
||||
details={"subject": ticket_data.subject, "status": ticket_data.status.value}
|
||||
)
|
||||
|
||||
# Fetch created ticket
|
||||
ticket = execute_query(
|
||||
"SELECT * FROM tticket_tickets WHERE id = %s",
|
||||
(ticket_id,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
logger.info(f"✅ Created ticket {ticket['ticket_number']} (ID: {ticket_id})")
|
||||
return ticket
|
||||
|
||||
@staticmethod
|
||||
def update_ticket(
|
||||
ticket_id: int,
|
||||
update_data: TTicketUpdate,
|
||||
user_id: Optional[int] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Update ticket with partial data
|
||||
|
||||
Args:
|
||||
ticket_id: Ticket ID to update
|
||||
update_data: Fields to update
|
||||
user_id: User making the update
|
||||
|
||||
Returns:
|
||||
Updated ticket dict
|
||||
"""
|
||||
# Get current ticket
|
||||
current = execute_query(
|
||||
"SELECT * FROM tticket_tickets WHERE id = %s",
|
||||
(ticket_id,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
if not current:
|
||||
raise ValueError(f"Ticket {ticket_id} not found")
|
||||
|
||||
# Build UPDATE query dynamically
|
||||
import json
|
||||
from psycopg2.extras import Json
|
||||
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
update_dict = update_data.model_dump(exclude_unset=True)
|
||||
|
||||
for field, value in update_dict.items():
|
||||
# Convert enums to values
|
||||
if hasattr(value, 'value'):
|
||||
value = value.value
|
||||
# Convert dict to JSON for PostgreSQL JSONB
|
||||
elif field == 'custom_fields' and isinstance(value, dict):
|
||||
value = Json(value)
|
||||
# Arrays are handled automatically by psycopg2
|
||||
|
||||
updates.append(f"{field} = %s")
|
||||
params.append(value)
|
||||
|
||||
if not updates:
|
||||
logger.warning(f"No fields to update for ticket {ticket_id}")
|
||||
return current
|
||||
|
||||
# Add ticket_id to params
|
||||
params.append(ticket_id)
|
||||
|
||||
query = f"UPDATE tticket_tickets SET {', '.join(updates)} WHERE id = %s"
|
||||
execute_update(query, tuple(params))
|
||||
|
||||
# Log update
|
||||
TicketService.log_audit(
|
||||
ticket_id=ticket_id,
|
||||
entity_type="ticket",
|
||||
entity_id=ticket_id,
|
||||
user_id=user_id,
|
||||
action="updated",
|
||||
details=update_dict
|
||||
)
|
||||
|
||||
# Fetch updated ticket
|
||||
updated = execute_query(
|
||||
"SELECT * FROM tticket_tickets WHERE id = %s",
|
||||
(ticket_id,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
logger.info(f"✅ Updated ticket {updated['ticket_number']}")
|
||||
return updated
|
||||
|
||||
@staticmethod
|
||||
def update_ticket_status(
|
||||
ticket_id: int,
|
||||
new_status: str,
|
||||
user_id: Optional[int] = None,
|
||||
note: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Update ticket status with validation
|
||||
|
||||
Args:
|
||||
ticket_id: Ticket ID
|
||||
new_status: New status value
|
||||
user_id: User making the change
|
||||
note: Optional note for audit log
|
||||
|
||||
Returns:
|
||||
Updated ticket dict
|
||||
|
||||
Raises:
|
||||
ValueError: If transition is not allowed
|
||||
"""
|
||||
# Get current ticket
|
||||
current = execute_query(
|
||||
"SELECT * FROM tticket_tickets WHERE id = %s",
|
||||
(ticket_id,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
if not current:
|
||||
raise ValueError(f"Ticket {ticket_id} not found")
|
||||
|
||||
current_status = current['status']
|
||||
|
||||
# Validate transition
|
||||
is_valid, error = TicketService.validate_status_transition(current_status, new_status)
|
||||
if not is_valid:
|
||||
logger.error(f"❌ Invalid status transition: {error}")
|
||||
raise ValueError(error)
|
||||
|
||||
# Update status
|
||||
execute_update(
|
||||
"UPDATE tticket_tickets SET status = %s WHERE id = %s",
|
||||
(new_status, ticket_id)
|
||||
)
|
||||
|
||||
# Update resolved_at timestamp if transitioning to resolved
|
||||
if new_status == TicketStatus.RESOLVED.value and not current['resolved_at']:
|
||||
execute_update(
|
||||
"UPDATE tticket_tickets SET resolved_at = CURRENT_TIMESTAMP WHERE id = %s",
|
||||
(ticket_id,)
|
||||
)
|
||||
|
||||
# Update closed_at timestamp if transitioning to closed
|
||||
if new_status == TicketStatus.CLOSED.value and not current['closed_at']:
|
||||
execute_update(
|
||||
"UPDATE tticket_tickets SET closed_at = CURRENT_TIMESTAMP WHERE id = %s",
|
||||
(ticket_id,)
|
||||
)
|
||||
|
||||
# Log status change
|
||||
TicketService.log_audit(
|
||||
ticket_id=ticket_id,
|
||||
entity_type="ticket",
|
||||
entity_id=ticket_id,
|
||||
user_id=user_id,
|
||||
action="status_changed",
|
||||
old_value=current_status,
|
||||
new_value=new_status,
|
||||
details={"note": note} if note else None
|
||||
)
|
||||
|
||||
# Fetch updated ticket
|
||||
updated = execute_query(
|
||||
"SELECT * FROM tticket_tickets WHERE id = %s",
|
||||
(ticket_id,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
logger.info(f"✅ Updated ticket {updated['ticket_number']} status: {current_status} → {new_status}")
|
||||
return updated
|
||||
|
||||
@staticmethod
|
||||
def assign_ticket(
|
||||
ticket_id: int,
|
||||
assigned_to_user_id: int,
|
||||
user_id: Optional[int] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Assign ticket to a user
|
||||
|
||||
Args:
|
||||
ticket_id: Ticket ID
|
||||
assigned_to_user_id: User to assign to
|
||||
user_id: User making the assignment
|
||||
|
||||
Returns:
|
||||
Updated ticket dict
|
||||
"""
|
||||
# Get current assignment
|
||||
current = execute_query(
|
||||
"SELECT assigned_to_user_id FROM tticket_tickets WHERE id = %s",
|
||||
(ticket_id,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
if not current:
|
||||
raise ValueError(f"Ticket {ticket_id} not found")
|
||||
|
||||
# Update assignment
|
||||
execute_update(
|
||||
"UPDATE tticket_tickets SET assigned_to_user_id = %s WHERE id = %s",
|
||||
(assigned_to_user_id, ticket_id)
|
||||
)
|
||||
|
||||
# Log assignment
|
||||
TicketService.log_audit(
|
||||
ticket_id=ticket_id,
|
||||
entity_type="ticket",
|
||||
entity_id=ticket_id,
|
||||
user_id=user_id,
|
||||
action="assigned",
|
||||
old_value=str(current['assigned_to_user_id']) if current['assigned_to_user_id'] else None,
|
||||
new_value=str(assigned_to_user_id)
|
||||
)
|
||||
|
||||
# Fetch updated ticket
|
||||
updated = execute_query(
|
||||
"SELECT * FROM tticket_tickets WHERE id = %s",
|
||||
(ticket_id,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
logger.info(f"✅ Assigned ticket {updated['ticket_number']} to user {assigned_to_user_id}")
|
||||
return updated
|
||||
|
||||
@staticmethod
|
||||
def add_comment(
|
||||
ticket_id: int,
|
||||
comment_text: str,
|
||||
user_id: Optional[int] = None,
|
||||
is_internal: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Add a comment to a ticket
|
||||
|
||||
Args:
|
||||
ticket_id: Ticket ID
|
||||
comment_text: Comment content
|
||||
user_id: User adding comment
|
||||
is_internal: Whether comment is internal
|
||||
|
||||
Returns:
|
||||
Created comment dict
|
||||
"""
|
||||
# Verify ticket exists
|
||||
ticket = execute_query(
|
||||
"SELECT id FROM tticket_tickets WHERE id = %s",
|
||||
(ticket_id,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
if not ticket:
|
||||
raise ValueError(f"Ticket {ticket_id} not found")
|
||||
|
||||
# Insert comment
|
||||
comment_id = execute_insert(
|
||||
"""
|
||||
INSERT INTO tticket_comments (ticket_id, user_id, comment_text, is_internal)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
""",
|
||||
(ticket_id, user_id, comment_text, is_internal)
|
||||
)
|
||||
|
||||
# Update ticket's updated_at timestamp
|
||||
execute_update(
|
||||
"UPDATE tticket_tickets SET updated_at = CURRENT_TIMESTAMP WHERE id = %s",
|
||||
(ticket_id,)
|
||||
)
|
||||
|
||||
# Update first_response_at if this is the first non-internal comment
|
||||
if not is_internal:
|
||||
ticket = execute_query(
|
||||
"SELECT first_response_at FROM tticket_tickets WHERE id = %s",
|
||||
(ticket_id,),
|
||||
fetchone=True
|
||||
)
|
||||
if not ticket['first_response_at']:
|
||||
execute_update(
|
||||
"UPDATE tticket_tickets SET first_response_at = CURRENT_TIMESTAMP WHERE id = %s",
|
||||
(ticket_id,)
|
||||
)
|
||||
|
||||
# Log comment
|
||||
TicketService.log_audit(
|
||||
ticket_id=ticket_id,
|
||||
entity_type="comment",
|
||||
entity_id=comment_id,
|
||||
user_id=user_id,
|
||||
action="comment_added",
|
||||
details={"is_internal": is_internal, "length": len(comment_text)}
|
||||
)
|
||||
|
||||
# Fetch created comment
|
||||
comment = execute_query(
|
||||
"SELECT * FROM tticket_comments WHERE id = %s",
|
||||
(comment_id,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
logger.info(f"💬 Added comment to ticket {ticket_id} (internal: {is_internal})")
|
||||
return comment
|
||||
|
||||
@staticmethod
|
||||
def log_audit(
|
||||
ticket_id: Optional[int] = None,
|
||||
entity_type: str = "ticket",
|
||||
entity_id: Optional[int] = None,
|
||||
user_id: Optional[int] = None,
|
||||
action: str = "updated",
|
||||
old_value: Optional[str] = None,
|
||||
new_value: Optional[str] = None,
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""
|
||||
Log an action to audit log
|
||||
|
||||
Args:
|
||||
ticket_id: Related ticket ID (optional)
|
||||
entity_type: Type of entity (ticket, comment, worklog, etc.)
|
||||
entity_id: ID of the entity
|
||||
user_id: User performing action
|
||||
action: Action performed
|
||||
old_value: Old value (for updates)
|
||||
new_value: New value (for updates)
|
||||
details: Additional details as JSON
|
||||
"""
|
||||
try:
|
||||
from psycopg2.extras import Json
|
||||
|
||||
execute_insert(
|
||||
"""
|
||||
INSERT INTO tticket_audit_log
|
||||
(ticket_id, entity_type, entity_id, user_id, action, old_value, new_value, details)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(ticket_id, entity_type, entity_id, user_id, action, old_value, new_value,
|
||||
Json(details) if details else None)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to log audit entry: {e}")
|
||||
# Don't raise - audit logging should not break the main operation
|
||||
|
||||
@staticmethod
|
||||
def get_ticket_with_stats(ticket_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get ticket with statistics from view
|
||||
|
||||
Args:
|
||||
ticket_id: Ticket ID
|
||||
|
||||
Returns:
|
||||
Ticket dict with stats or None if not found
|
||||
"""
|
||||
ticket = execute_query(
|
||||
"SELECT * FROM tticket_open_tickets WHERE id = %s",
|
||||
(ticket_id,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
# If not in open_tickets view, fetch from main table
|
||||
if not ticket:
|
||||
ticket = execute_query(
|
||||
"SELECT * FROM tticket_tickets WHERE id = %s",
|
||||
(ticket_id,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
return ticket
|
||||
|
||||
@staticmethod
|
||||
def list_tickets(
|
||||
status: Optional[str] = None,
|
||||
priority: Optional[str] = None,
|
||||
customer_id: Optional[int] = None,
|
||||
assigned_to_user_id: Optional[int] = None,
|
||||
search: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List tickets with filters
|
||||
|
||||
Args:
|
||||
status: Filter by status
|
||||
priority: Filter by priority
|
||||
customer_id: Filter by customer
|
||||
assigned_to_user_id: Filter by assigned user
|
||||
search: Search in subject/description
|
||||
limit: Number of results
|
||||
offset: Offset for pagination
|
||||
|
||||
Returns:
|
||||
List of ticket dicts
|
||||
"""
|
||||
query = "SELECT * FROM tticket_tickets WHERE 1=1"
|
||||
params = []
|
||||
|
||||
if status:
|
||||
query += " AND status = %s"
|
||||
params.append(status)
|
||||
|
||||
if priority:
|
||||
query += " AND priority = %s"
|
||||
params.append(priority)
|
||||
|
||||
if customer_id:
|
||||
query += " AND customer_id = %s"
|
||||
params.append(customer_id)
|
||||
|
||||
if assigned_to_user_id:
|
||||
query += " AND assigned_to_user_id = %s"
|
||||
params.append(assigned_to_user_id)
|
||||
|
||||
if search:
|
||||
query += " AND (subject ILIKE %s OR description ILIKE %s)"
|
||||
search_pattern = f"%{search}%"
|
||||
params.extend([search_pattern, search_pattern])
|
||||
|
||||
query += " ORDER BY created_at DESC LIMIT %s OFFSET %s"
|
||||
params.extend([limit, offset])
|
||||
|
||||
tickets = execute_query(query, tuple(params))
|
||||
return tickets or []
|
||||
6
app/ticket/frontend/__init__.py
Normal file
6
app/ticket/frontend/__init__.py
Normal file
@ -0,0 +1,6 @@
|
||||
"""
|
||||
Ticket Module Frontend
|
||||
======================
|
||||
|
||||
HTML templates og view handlers for ticket-systemet.
|
||||
"""
|
||||
361
app/ticket/frontend/dashboard.html
Normal file
361
app/ticket/frontend/dashboard.html
Normal file
@ -0,0 +1,361 @@
|
||||
{% extends "shared/frontend/base.html" %}
|
||||
|
||||
{% block title %}Ticket Dashboard - BMC Hub{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.stat-card {
|
||||
text-align: center;
|
||||
padding: 2rem 1.5rem;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
background: var(--accent);
|
||||
transform: scaleX(0);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.stat-card:hover::before {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-card p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-card .icon {
|
||||
font-size: 2rem;
|
||||
opacity: 0.3;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.stat-card.status-open h3 { color: #17a2b8; }
|
||||
.stat-card.status-in-progress h3 { color: #ffc107; }
|
||||
.stat-card.status-resolved h3 { color: #28a745; }
|
||||
.stat-card.status-closed h3 { color: #6c757d; }
|
||||
|
||||
.ticket-list {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.ticket-list th {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 2px solid var(--accent-light);
|
||||
padding: 1rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.ticket-list td {
|
||||
padding: 1rem 0.75rem;
|
||||
vertical-align: middle;
|
||||
border-bottom: 1px solid var(--accent-light);
|
||||
}
|
||||
|
||||
.ticket-row {
|
||||
transition: background-color 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ticket-row:hover {
|
||||
background-color: var(--accent-light);
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.badge-status-open {
|
||||
background-color: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
.badge-status-in_progress {
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.badge-status-pending_customer {
|
||||
background-color: #e2e3e5;
|
||||
color: #383d41;
|
||||
}
|
||||
|
||||
.badge-status-resolved {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.badge-status-closed {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.badge-priority-low {
|
||||
background-color: var(--accent-light);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.badge-priority-normal {
|
||||
background-color: #e2e3e5;
|
||||
color: #383d41;
|
||||
}
|
||||
|
||||
.badge-priority-high {
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.badge-priority-urgent, .badge-priority-critical {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.ticket-number {
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
background: var(--accent-light);
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.worklog-stats {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 1.5rem;
|
||||
background: linear-gradient(135deg, var(--accent-light) 0%, var(--bg-card) 100%);
|
||||
border-radius: var(--border-radius);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.worklog-stat {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.worklog-stat h4 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.worklog-stat p {
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-4">
|
||||
<!-- Page Header -->
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h1 class="mb-2">
|
||||
<i class="bi bi-speedometer2"></i> Ticket Dashboard
|
||||
</h1>
|
||||
<p class="text-muted">Oversigt over alle tickets og worklog aktivitet</p>
|
||||
</div>
|
||||
<div class="quick-actions">
|
||||
<a href="/ticket/tickets/new" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Ny Ticket
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ticket Statistics -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card status-open" onclick="filterTickets('open')">
|
||||
<div class="icon"><i class="bi bi-inbox"></i></div>
|
||||
<h3>{{ stats.open_count or 0 }}</h3>
|
||||
<p>Nye Tickets</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card status-in-progress" onclick="filterTickets('in_progress')">
|
||||
<div class="icon"><i class="bi bi-arrow-repeat"></i></div>
|
||||
<h3>{{ stats.in_progress_count or 0 }}</h3>
|
||||
<p>I Gang</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card status-resolved" onclick="filterTickets('resolved')">
|
||||
<div class="icon"><i class="bi bi-check-circle"></i></div>
|
||||
<h3>{{ stats.resolved_count or 0 }}</h3>
|
||||
<p>Løst</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card status-closed" onclick="filterTickets('closed')">
|
||||
<div class="icon"><i class="bi bi-archive"></i></div>
|
||||
<h3>{{ stats.closed_count or 0 }}</h3>
|
||||
<p>Lukket</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Worklog Statistics -->
|
||||
<div class="worklog-stats">
|
||||
<div class="worklog-stat">
|
||||
<h4>{{ worklog_stats.draft_count or 0 }}</h4>
|
||||
<p>Draft Worklog</p>
|
||||
</div>
|
||||
<div class="worklog-stat">
|
||||
<h4>{{ "%.1f"|format(worklog_stats.draft_hours or 0) }}t</h4>
|
||||
<p>Udraft Timer</p>
|
||||
</div>
|
||||
<div class="worklog-stat">
|
||||
<h4>{{ worklog_stats.billable_count or 0 }}</h4>
|
||||
<p>Billable Entries</p>
|
||||
</div>
|
||||
<div class="worklog-stat">
|
||||
<h4>{{ "%.1f"|format(worklog_stats.billable_hours or 0) }}t</h4>
|
||||
<p>Billable Timer</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Tickets -->
|
||||
<div class="section-header">
|
||||
<h2>
|
||||
<i class="bi bi-clock-history"></i> Seneste Tickets
|
||||
</h2>
|
||||
<a href="/ticket/tickets" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-list-ul"></i> Se Alle
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if recent_tickets %}
|
||||
<div class="card">
|
||||
<div class="table-responsive">
|
||||
<table class="table ticket-list mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ticket</th>
|
||||
<th>Kunde</th>
|
||||
<th>Status</th>
|
||||
<th>Prioritet</th>
|
||||
<th>Oprettet</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ticket in recent_tickets %}
|
||||
<tr class="ticket-row" onclick="window.location='/ticket/tickets/{{ ticket.id }}'">
|
||||
<td>
|
||||
<span class="ticket-number">{{ ticket.ticket_number }}</span>
|
||||
<br>
|
||||
<strong>{{ ticket.subject }}</strong>
|
||||
</td>
|
||||
<td>
|
||||
{% if ticket.customer_name %}
|
||||
{{ ticket.customer_name }}
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-status-{{ ticket.status }}">
|
||||
{{ ticket.status.replace('_', ' ').title() }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-priority-{{ ticket.priority }}">
|
||||
{{ ticket.priority.title() }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{{ ticket.created_at.strftime('%d-%m-%Y %H:%M') if ticket.created_at else '-' }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-inbox"></i>
|
||||
<h3>Ingen tickets endnu</h3>
|
||||
<p>Opret din første ticket for at komme i gang</p>
|
||||
<a href="/ticket/tickets/new" class="btn btn-primary mt-3">
|
||||
<i class="bi bi-plus-circle"></i> Opret Ticket
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Filter tickets by status
|
||||
function filterTickets(status) {
|
||||
window.location.href = `/ticket/tickets?status=${status}`;
|
||||
}
|
||||
|
||||
// Auto-refresh every 5 minutes
|
||||
setTimeout(() => {
|
||||
location.reload();
|
||||
}, 300000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
265
app/ticket/frontend/mockups/option1_splitscreen.html
Normal file
265
app/ticket/frontend/mockups/option1_splitscreen.html
Normal file
@ -0,0 +1,265 @@
|
||||
{% extends "shared/frontend/base.html" %}
|
||||
|
||||
{% block title %}Concept 1: Split Screen - BMC Hub{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
/* Hide default footer if exists to maximize height */
|
||||
footer { display: none; }
|
||||
|
||||
.split-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Left Sidebar: Ticket List */
|
||||
.ticket-list-sidebar {
|
||||
width: 350px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid rgba(0,0,0,0.1);
|
||||
background: var(--bg-card);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.list-header {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.ticket-list-scroll {
|
||||
overflow-y: auto;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.ticket-item {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.05);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ticket-item:hover {
|
||||
background-color: var(--accent-light);
|
||||
}
|
||||
|
||||
.ticket-item.active {
|
||||
background-color: var(--accent-light);
|
||||
border-left: 4px solid var(--accent);
|
||||
}
|
||||
|
||||
.ticket-item.unread::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Right Content: Detail View */
|
||||
.ticket-detail-main {
|
||||
flex-grow: 1;
|
||||
background: var(--bg-body);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
background: var(--bg-card);
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-scroll {
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.detail-footer {
|
||||
background: var(--bg-card);
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* Chat bubbles */
|
||||
.message-bubble {
|
||||
background: var(--bg-card);
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.message-bubble.internal {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeeba;
|
||||
}
|
||||
|
||||
.message-bubble.me {
|
||||
background: var(--accent-light);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.meta-text {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content_wrapper %}
|
||||
<div class="container-fluid p-0" style="height: calc(100vh - 80px); overflow: hidden;">
|
||||
<div class="split-layout">
|
||||
<!-- Left Sidebar -->
|
||||
<div class="ticket-list-sidebar">
|
||||
<div class="list-header">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-transparent border-end-0"><i class="bi bi-search"></i></span>
|
||||
<input type="text" class="form-control border-start-0" placeholder="Søg tickets...">
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-2 overflow-auto pb-1">
|
||||
<span class="badge bg-primary rounded-pill">Alle</span>
|
||||
<span class="badge bg-light text-dark border rounded-pill">Mine</span>
|
||||
<span class="badge bg-light text-dark border rounded-pill">Uløste</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ticket-list-scroll">
|
||||
<!-- Active Item -->
|
||||
<div class="ticket-item active">
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<span class="badge bg-warning text-dark">Høj</span>
|
||||
<small class="text-muted">14:32</small>
|
||||
</div>
|
||||
<h6 class="mb-1 text-truncate">Netværksproblem i hovedkontoret</h6>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<img src="https://ui-avatars.com/api/?name=Tech+Corp&background=random" class="rounded-circle" width="20">
|
||||
<small class="text-muted">Tech Corp A/S</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Other Items -->
|
||||
<div class="ticket-item unread">
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<span class="badge bg-info text-white">Ny</span>
|
||||
<small class="text-muted">12:15</small>
|
||||
</div>
|
||||
<h6 class="mb-1 text-truncate">Printer løbet tør for toner</h6>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<img src="https://ui-avatars.com/api/?name=Advokat+Huset&background=random" class="rounded-circle" width="20">
|
||||
<small class="text-muted">Advokathuset</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ticket-item">
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<span class="badge bg-success">Løst</span>
|
||||
<small class="text-muted">I går</small>
|
||||
</div>
|
||||
<h6 class="mb-1 text-truncate">Opsætning af ny bruger (Mette)</h6>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<img src="https://ui-avatars.com/api/?name=Byg+Aps&background=random" class="rounded-circle" width="20">
|
||||
<small class="text-muted">Byg ApS</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- More dummy items -->
|
||||
{% for i in range(5) %}
|
||||
<div class="ticket-item">
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<span class="badge bg-secondary">Venter</span>
|
||||
<small class="text-muted">2 dage siden</small>
|
||||
</div>
|
||||
<h6 class="mb-1 text-truncate">Licens fornyelse Office 365</h6>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<img src="https://ui-avatars.com/api/?name=Kunde+{{i}}&background=random" class="rounded-circle" width="20">
|
||||
<small class="text-muted">Kunde {{i}}</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Content -->
|
||||
<div class="ticket-detail-main">
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
<div class="d-flex align-items-center gap-2 mb-1">
|
||||
<span class="badge bg-light text-dark border">#TKT-20251215-005</span>
|
||||
<span class="badge bg-warning text-dark">I Gang</span>
|
||||
</div>
|
||||
<h5 class="mb-0">Netværksproblem i hovedkontoret</h5>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-outline-secondary btn-sm"><i class="bi bi-person-plus"></i> Tildel</button>
|
||||
<button class="btn btn-outline-secondary btn-sm"><i class="bi bi-clock"></i> Log Tid</button>
|
||||
<button class="btn btn-primary btn-sm"><i class="bi bi-check-lg"></i> Løs Sag</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-scroll">
|
||||
<!-- Original Request -->
|
||||
<div class="message-bubble">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<img src="https://ui-avatars.com/api/?name=Jens+Jensen&background=random" class="rounded-circle" width="24">
|
||||
<span class="fw-bold">Jens Jensen</span>
|
||||
<span class="text-muted small">via Email</span>
|
||||
</div>
|
||||
<small class="text-muted">15. dec 14:32</small>
|
||||
</div>
|
||||
<p class="mb-0">Hej Support,<br><br>Vi har problemer med internettet på hovedkontoret. Ingen kan komme på Wi-Fi, og kablet forbindelse virker heller ikke. Det haster meget!</p>
|
||||
</div>
|
||||
|
||||
<!-- Internal Note -->
|
||||
<div class="message-bubble internal">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="bi bi-shield-lock text-warning"></i>
|
||||
<span class="fw-bold text-warning-emphasis">Intern Note</span>
|
||||
</div>
|
||||
<small class="text-muted">15. dec 14:45</small>
|
||||
</div>
|
||||
<p class="mb-0">Jeg har tjekket Unifi controlleren. Switch #3 svarer ikke. Det er nok den der er nede.</p>
|
||||
</div>
|
||||
|
||||
<!-- Reply -->
|
||||
<div class="message-bubble me">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<img src="https://ui-avatars.com/api/?name=Christian&background=0f4c75&color=fff" class="rounded-circle" width="24">
|
||||
<span class="fw-bold">Christian (Support)</span>
|
||||
</div>
|
||||
<small class="text-muted">15. dec 14:50</small>
|
||||
</div>
|
||||
<p class="mb-0">Hej Jens,<br><br>Jeg kigger på det med det samme. Jeg kan se vi har mistet forbindelsen til en af jeres switche. Jeg prøver at genstarte den remote.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-footer">
|
||||
<div class="input-group">
|
||||
<button class="btn btn-outline-secondary" type="button"><i class="bi bi-paperclip"></i></button>
|
||||
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">Svar Kunde</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="#">Svar Kunde</a></li>
|
||||
<li><a class="dropdown-item" href="#"><i class="bi bi-shield-lock text-warning"></i> Intern Note</a></li>
|
||||
</ul>
|
||||
<input type="text" class="form-control" placeholder="Skriv et svar...">
|
||||
<button class="btn btn-primary" type="button"><i class="bi bi-send"></i> Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
251
app/ticket/frontend/mockups/option2_kanban.html
Normal file
251
app/ticket/frontend/mockups/option2_kanban.html
Normal file
@ -0,0 +1,251 @@
|
||||
{% extends "shared/frontend/base.html" %}
|
||||
|
||||
{% block title %}Concept 2: Kanban Board - BMC Hub{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.kanban-board {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
padding: 1.5rem;
|
||||
gap: 1.5rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.kanban-column {
|
||||
width: 320px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: transparent;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.column-header {
|
||||
padding: 0.5rem 0.5rem 1rem 0.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.column-content {
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.5rem; /* Space for scrollbar */
|
||||
}
|
||||
|
||||
.kanban-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
cursor: grab;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
border-left: 4px solid transparent;
|
||||
}
|
||||
|
||||
.kanban-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.kanban-card:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Priority Borders */
|
||||
.priority-high { border-left-color: var(--warning); }
|
||||
.priority-critical { border-left-color: var(--danger); }
|
||||
.priority-normal { border-left-color: var(--info); }
|
||||
.priority-low { border-left-color: var(--text-secondary); }
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.card-tags {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: var(--bg-body);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.add-card-btn {
|
||||
background: transparent;
|
||||
border: 2px dashed rgba(0,0,0,0.1);
|
||||
color: var(--text-secondary);
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
margin-top: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.add-card-btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: rgba(15, 76, 117, 0.05);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content_wrapper %}
|
||||
<div class="container-fluid p-0" style="height: calc(100vh - 80px); overflow: hidden;">
|
||||
<div class="kanban-board">
|
||||
<!-- Column: Ny -->
|
||||
<div class="kanban-column">
|
||||
<div class="column-header">
|
||||
<span><i class="bi bi-circle text-info me-2"></i>Ny</span>
|
||||
<span class="badge bg-secondary bg-opacity-10 text-secondary">3</span>
|
||||
</div>
|
||||
<div class="column-content">
|
||||
<div class="kanban-card priority-critical">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<small class="text-muted">#TKT-005</small>
|
||||
<i class="bi bi-three-dots text-muted"></i>
|
||||
</div>
|
||||
<h6 class="mb-2">Server nede i produktion</h6>
|
||||
<div class="card-tags">
|
||||
<span class="tag">Server</span>
|
||||
<span class="tag">Kritisk</span>
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<img src="https://ui-avatars.com/api/?name=Fabrik+A/S&background=random" class="rounded-circle" width="16">
|
||||
<span>Fabrik A/S</span>
|
||||
</div>
|
||||
<span>10m</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kanban-card priority-normal">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<small class="text-muted">#TKT-008</small>
|
||||
<i class="bi bi-three-dots text-muted"></i>
|
||||
</div>
|
||||
<h6 class="mb-2">Ny medarbejder oprettelse</h6>
|
||||
<div class="card-meta">
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<img src="https://ui-avatars.com/api/?name=Kontor+ApS&background=random" class="rounded-circle" width="16">
|
||||
<span>Kontor ApS</span>
|
||||
</div>
|
||||
<span>2t</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="add-card-btn"><i class="bi bi-plus"></i> Tilføj kort</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Column: I Gang -->
|
||||
<div class="kanban-column">
|
||||
<div class="column-header">
|
||||
<span><i class="bi bi-play-circle text-warning me-2"></i>I Gang</span>
|
||||
<span class="badge bg-secondary bg-opacity-10 text-secondary">2</span>
|
||||
</div>
|
||||
<div class="column-content">
|
||||
<div class="kanban-card priority-high">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<small class="text-muted">#TKT-002</small>
|
||||
<img src="https://ui-avatars.com/api/?name=Christian&background=0f4c75&color=fff" class="rounded-circle" width="20" title="Assigned to Christian">
|
||||
</div>
|
||||
<h6 class="mb-2">Netværksproblem hovedkontor</h6>
|
||||
<div class="card-tags">
|
||||
<span class="tag">Netværk</span>
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<img src="https://ui-avatars.com/api/?name=Tech+Corp&background=random" class="rounded-circle" width="16">
|
||||
<span>Tech Corp</span>
|
||||
</div>
|
||||
<span>1d</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kanban-card priority-normal">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<small class="text-muted">#TKT-004</small>
|
||||
<img src="https://ui-avatars.com/api/?name=Morten&background=random" class="rounded-circle" width="20" title="Assigned to Morten">
|
||||
</div>
|
||||
<h6 class="mb-2">Opdatering af firewall</h6>
|
||||
<div class="card-meta">
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<img src="https://ui-avatars.com/api/?name=Sikkerhed+A/S&background=random" class="rounded-circle" width="16">
|
||||
<span>Sikkerhed A/S</span>
|
||||
</div>
|
||||
<span>3d</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Column: Venter på Kunde -->
|
||||
<div class="kanban-column">
|
||||
<div class="column-header">
|
||||
<span><i class="bi bi-pause-circle text-secondary me-2"></i>Venter</span>
|
||||
<span class="badge bg-secondary bg-opacity-10 text-secondary">4</span>
|
||||
</div>
|
||||
<div class="column-content">
|
||||
{% for i in range(4) %}
|
||||
<div class="kanban-card priority-low">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<small class="text-muted">#TKT-01{{i}}</small>
|
||||
<i class="bi bi-three-dots text-muted"></i>
|
||||
</div>
|
||||
<h6 class="mb-2">Bestilling af hardware {{i}}</h6>
|
||||
<div class="card-meta">
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<img src="https://ui-avatars.com/api/?name=Kunde+{{i}}&background=random" class="rounded-circle" width="16">
|
||||
<span>Kunde {{i}}</span>
|
||||
</div>
|
||||
<span>5d</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Column: Løst -->
|
||||
<div class="kanban-column">
|
||||
<div class="column-header">
|
||||
<span><i class="bi bi-check-circle text-success me-2"></i>Løst</span>
|
||||
<span class="badge bg-secondary bg-opacity-10 text-secondary">12</span>
|
||||
</div>
|
||||
<div class="column-content">
|
||||
<div class="kanban-card priority-normal opacity-75">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<small class="text-muted">#TKT-001</small>
|
||||
<i class="bi bi-check-lg text-success"></i>
|
||||
</div>
|
||||
<h6 class="mb-2 text-decoration-line-through">Printer installation</h6>
|
||||
<div class="card-meta">
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<img src="https://ui-avatars.com/api/?name=Advokat&background=random" class="rounded-circle" width="16">
|
||||
<span>Advokat</span>
|
||||
</div>
|
||||
<span>1u</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
239
app/ticket/frontend/mockups/option3_powertable.html
Normal file
239
app/ticket/frontend/mockups/option3_powertable.html
Normal file
@ -0,0 +1,239 @@
|
||||
{% extends "shared/frontend/base.html" %}
|
||||
|
||||
{% block title %}Concept 3: Power Table - BMC Hub{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.power-table-container {
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 15px rgba(0,0,0,0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.1);
|
||||
background: var(--bg-body);
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.power-table {
|
||||
width: 100%;
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.power-table th {
|
||||
background: var(--bg-body);
|
||||
padding: 0.75rem 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 2px solid rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.power-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.05);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.power-table tr:hover td {
|
||||
background-color: var(--accent-light);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.col-checkbox { width: 40px; text-align: center; }
|
||||
.col-id { width: 120px; font-family: monospace; color: var(--accent); }
|
||||
.col-status { width: 100px; }
|
||||
.col-priority { width: 100px; }
|
||||
.col-subject { min-width: 300px; font-weight: 500; }
|
||||
.col-customer { width: 200px; }
|
||||
.col-assigned { width: 150px; }
|
||||
.col-updated { width: 150px; color: var(--text-secondary); }
|
||||
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.status-open { background-color: var(--info); }
|
||||
.status-in-progress { background-color: var(--warning); }
|
||||
.status-resolved { background-color: var(--success); }
|
||||
.status-closed { background-color: var(--text-secondary); }
|
||||
|
||||
.priority-badge {
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.priority-high { background: #fff3cd; color: #856404; }
|
||||
.priority-critical { background: #f8d7da; color: #721c24; }
|
||||
.priority-normal { background: #e2e3e5; color: #383d41; }
|
||||
|
||||
.avatar-sm {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
vertical-align: middle;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.bulk-actions {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.bulk-actions.show {
|
||||
display: flex;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-4 py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="h4 mb-0">Alle Tickets</h2>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-outline-secondary"><i class="bi bi-download"></i> Eksportér</button>
|
||||
<button class="btn btn-primary"><i class="bi bi-plus-lg"></i> Ny Ticket</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="power-table-container">
|
||||
<!-- Filter Bar -->
|
||||
<div class="filter-bar">
|
||||
<div class="input-group" style="width: 300px;">
|
||||
<span class="input-group-text bg-white border-end-0"><i class="bi bi-search"></i></span>
|
||||
<input type="text" class="form-control border-start-0" placeholder="Søg...">
|
||||
</div>
|
||||
|
||||
<select class="form-select" style="width: 150px;">
|
||||
<option selected>Status: Alle</option>
|
||||
<option>Ny</option>
|
||||
<option>I Gang</option>
|
||||
<option>Løst</option>
|
||||
</select>
|
||||
|
||||
<select class="form-select" style="width: 150px;">
|
||||
<option selected>Prioritet: Alle</option>
|
||||
<option>Kritisk</option>
|
||||
<option>Høj</option>
|
||||
<option>Normal</option>
|
||||
</select>
|
||||
|
||||
<select class="form-select" style="width: 150px;">
|
||||
<option selected>Tildelt: Alle</option>
|
||||
<option>Mig</option>
|
||||
<option>Ufordelt</option>
|
||||
</select>
|
||||
|
||||
<div class="ms-auto text-muted small">
|
||||
Viser 1-50 af 142 tickets
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions Bar (Hidden by default) -->
|
||||
<div class="bg-light p-2 border-bottom d-none" id="bulkActions">
|
||||
<div class="d-flex align-items-center gap-3 px-2">
|
||||
<span class="fw-bold text-primary">3 valgt</span>
|
||||
<div class="vr"></div>
|
||||
<button class="btn btn-sm btn-outline-secondary">Sæt Status</button>
|
||||
<button class="btn btn-sm btn-outline-secondary">Tildel</button>
|
||||
<button class="btn btn-sm btn-outline-danger">Slet</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="table-responsive">
|
||||
<table class="power-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-checkbox"><input type="checkbox" class="form-check-input"></th>
|
||||
<th class="col-id">ID</th>
|
||||
<th class="col-subject">Emne</th>
|
||||
<th class="col-customer">Kunde</th>
|
||||
<th class="col-status">Status</th>
|
||||
<th class="col-priority">Prioritet</th>
|
||||
<th class="col-assigned">Tildelt</th>
|
||||
<th class="col-updated">Opdateret</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="col-checkbox"><input type="checkbox" class="form-check-input"></td>
|
||||
<td class="col-id">#TKT-005</td>
|
||||
<td class="col-subject">Netværksproblem i hovedkontoret</td>
|
||||
<td class="col-customer">
|
||||
<img src="https://ui-avatars.com/api/?name=Tech+Corp&background=random" class="avatar-sm">
|
||||
Tech Corp A/S
|
||||
</td>
|
||||
<td class="col-status"><span class="status-dot status-in-progress"></span>I Gang</td>
|
||||
<td class="col-priority"><span class="priority-badge priority-high">Høj</span></td>
|
||||
<td class="col-assigned">
|
||||
<img src="https://ui-avatars.com/api/?name=Christian&background=0f4c75&color=fff" class="avatar-sm">
|
||||
Christian
|
||||
</td>
|
||||
<td class="col-updated">10 min siden</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="col-checkbox"><input type="checkbox" class="form-check-input"></td>
|
||||
<td class="col-id">#TKT-004</td>
|
||||
<td class="col-subject">Server nede i produktion</td>
|
||||
<td class="col-customer">
|
||||
<img src="https://ui-avatars.com/api/?name=Fabrik+A/S&background=random" class="avatar-sm">
|
||||
Fabrik A/S
|
||||
</td>
|
||||
<td class="col-status"><span class="status-dot status-open"></span>Ny</td>
|
||||
<td class="col-priority"><span class="priority-badge priority-critical">Kritisk</span></td>
|
||||
<td class="col-assigned"><span class="text-muted fst-italic">Ufordelt</span></td>
|
||||
<td class="col-updated">1 time siden</td>
|
||||
</tr>
|
||||
|
||||
{% for i in range(10) %}
|
||||
<tr>
|
||||
<td class="col-checkbox"><input type="checkbox" class="form-check-input"></td>
|
||||
<td class="col-id">#TKT-00{{i}}</td>
|
||||
<td class="col-subject">Support sag vedrørende faktura {{i}}</td>
|
||||
<td class="col-customer">
|
||||
<img src="https://ui-avatars.com/api/?name=Kunde+{{i}}&background=random" class="avatar-sm">
|
||||
Kunde {{i}} ApS
|
||||
</td>
|
||||
<td class="col-status"><span class="status-dot status-resolved"></span>Løst</td>
|
||||
<td class="col-priority"><span class="priority-badge priority-normal">Normal</span></td>
|
||||
<td class="col-assigned">
|
||||
<img src="https://ui-avatars.com/api/?name=Morten&background=random" class="avatar-sm">
|
||||
Morten
|
||||
</td>
|
||||
<td class="col-updated">{{i}} dage siden</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
409
app/ticket/frontend/ticket_detail.html
Normal file
409
app/ticket/frontend/ticket_detail.html
Normal file
@ -0,0 +1,409 @@
|
||||
{% extends "shared/frontend/base.html" %}
|
||||
|
||||
{% block title %}{{ ticket.ticket_number }} - BMC Hub{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.ticket-header {
|
||||
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-light) 100%);
|
||||
padding: 2rem;
|
||||
border-radius: var(--border-radius);
|
||||
color: white;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.ticket-number {
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
font-size: 1rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.ticket-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.badge-status-open { background-color: #d1ecf1; color: #0c5460; }
|
||||
.badge-status-in_progress { background-color: #fff3cd; color: #856404; }
|
||||
.badge-status-pending_customer { background-color: #e2e3e5; color: #383d41; }
|
||||
.badge-status-resolved { background-color: #d4edda; color: #155724; }
|
||||
.badge-status-closed { background-color: #f8d7da; color: #721c24; }
|
||||
|
||||
.badge-priority-low { background-color: var(--accent-light); color: var(--accent); }
|
||||
.badge-priority-normal { background-color: #e2e3e5; color: #383d41; }
|
||||
.badge-priority-high { background-color: #fff3cd; color: #856404; }
|
||||
.badge-priority-urgent, .badge-priority-critical { background-color: #f8d7da; color: #721c24; }
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
background: var(--bg-card);
|
||||
padding: 1rem;
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid var(--accent-light);
|
||||
}
|
||||
|
||||
.info-item label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.info-item .value {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.comment {
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
background: var(--accent-light);
|
||||
border-radius: var(--border-radius);
|
||||
border-left: 4px solid var(--accent);
|
||||
}
|
||||
|
||||
.comment.internal {
|
||||
background: #fff3cd;
|
||||
border-left-color: #ffc107;
|
||||
}
|
||||
|
||||
.comment-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.comment-author {
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.comment-date {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.worklog-table th {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 2px solid var(--accent-light);
|
||||
padding: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.worklog-table td {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid var(--accent-light);
|
||||
}
|
||||
|
||||
.attachment {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--accent-light);
|
||||
border-radius: var(--border-radius);
|
||||
margin-right: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--accent);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.attachment:hover {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.attachment i {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.description-box {
|
||||
background: var(--bg-body);
|
||||
padding: 1.5rem;
|
||||
border-radius: var(--border-radius);
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-4">
|
||||
<!-- Ticket Header -->
|
||||
<div class="ticket-header">
|
||||
<div class="ticket-number">{{ ticket.ticket_number }}</div>
|
||||
<div class="ticket-title">{{ ticket.subject }}</div>
|
||||
<div class="mt-3">
|
||||
<span class="badge badge-status-{{ ticket.status }}">
|
||||
{{ ticket.status.replace('_', ' ').title() }}
|
||||
</span>
|
||||
<span class="badge badge-priority-{{ ticket.priority }}">
|
||||
{{ ticket.priority.title() }} Priority
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="action-buttons mb-4">
|
||||
<a href="/api/v1/tickets/{{ ticket.id }}" class="btn btn-outline-primary">
|
||||
<i class="bi bi-pencil"></i> Rediger
|
||||
</a>
|
||||
<button class="btn btn-outline-secondary" onclick="addComment()">
|
||||
<i class="bi bi-chat"></i> Tilføj Kommentar
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" onclick="addWorklog()">
|
||||
<i class="bi bi-clock"></i> Log Tid
|
||||
</button>
|
||||
<a href="/ticket/tickets" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Tilbage
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Main Content -->
|
||||
<div class="col-lg-8">
|
||||
<!-- Description -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="section-title">
|
||||
<i class="bi bi-file-text"></i> Beskrivelse
|
||||
</div>
|
||||
<div class="description-box">
|
||||
{{ ticket.description or 'Ingen beskrivelse' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comments -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="section-title">
|
||||
<i class="bi bi-chat-dots"></i> Kommentarer ({{ comments|length }})
|
||||
</div>
|
||||
{% if comments %}
|
||||
{% for comment in comments %}
|
||||
<div class="comment {% if comment.internal_note %}internal{% endif %}">
|
||||
<div class="comment-header">
|
||||
<span class="comment-author">
|
||||
<i class="bi bi-person-circle"></i>
|
||||
{{ comment.user_name or 'System' }}
|
||||
{% if comment.internal_note %}
|
||||
<span class="badge bg-warning text-dark ms-2">Internal</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="comment-date">
|
||||
{{ comment.created_at.strftime('%d-%m-%Y %H:%M') if comment.created_at else '-' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="comment-text">
|
||||
{{ comment.comment_text }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-chat"></i>
|
||||
<p>Ingen kommentarer endnu</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Worklog -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="section-title">
|
||||
<i class="bi bi-clock-history"></i> Worklog ({{ worklog|length }})
|
||||
</div>
|
||||
{% if worklog %}
|
||||
<div class="table-responsive">
|
||||
<table class="table worklog-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Dato</th>
|
||||
<th>Timer</th>
|
||||
<th>Type</th>
|
||||
<th>Beskrivelse</th>
|
||||
<th>Status</th>
|
||||
<th>Medarbejder</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in worklog %}
|
||||
<tr>
|
||||
<td>{{ entry.work_date.strftime('%d-%m-%Y') if entry.work_date else '-' }}</td>
|
||||
<td><strong>{{ "%.2f"|format(entry.hours) }}t</strong></td>
|
||||
<td>{{ entry.work_type }}</td>
|
||||
<td>{{ entry.description or '-' }}</td>
|
||||
<td>
|
||||
<span class="badge {% if entry.status == 'billable' %}bg-success{% elif entry.status == 'draft' %}bg-warning{% else %}bg-secondary{% endif %}">
|
||||
{{ entry.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ entry.user_name or '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-clock"></i>
|
||||
<p>Ingen worklog entries endnu</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Attachments -->
|
||||
{% if attachments %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="section-title">
|
||||
<i class="bi bi-paperclip"></i> Vedhæftninger ({{ attachments|length }})
|
||||
</div>
|
||||
{% for attachment in attachments %}
|
||||
<a href="/api/v1/attachments/{{ attachment.id }}/download" class="attachment">
|
||||
<i class="bi bi-file-earmark"></i>
|
||||
{{ attachment.filename }}
|
||||
<small class="ms-2">({{ (attachment.file_size / 1024)|round(1) }} KB)</small>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="col-lg-4">
|
||||
<!-- Ticket Info -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="section-title">
|
||||
<i class="bi bi-info-circle"></i> Ticket Information
|
||||
</div>
|
||||
<div class="info-item mb-3">
|
||||
<label>Kunde</label>
|
||||
<div class="value">
|
||||
{% if ticket.customer_name %}
|
||||
<a href="/customers/{{ ticket.customer_id }}" style="text-decoration: none; color: var(--accent);">
|
||||
{{ ticket.customer_name }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-muted">Ikke angivet</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item mb-3">
|
||||
<label>Tildelt til</label>
|
||||
<div class="value">
|
||||
{{ ticket.assigned_to_name or 'Ikke tildelt' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item mb-3">
|
||||
<label>Oprettet</label>
|
||||
<div class="value">
|
||||
{{ ticket.created_at.strftime('%d-%m-%Y %H:%M') if ticket.created_at else '-' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item mb-3">
|
||||
<label>Senest opdateret</label>
|
||||
<div class="value">
|
||||
{{ ticket.updated_at.strftime('%d-%m-%Y %H:%M') if ticket.updated_at else '-' }}
|
||||
</div>
|
||||
</div>
|
||||
{% if ticket.resolved_at %}
|
||||
<div class="info-item mb-3">
|
||||
<label>Løst</label>
|
||||
<div class="value">
|
||||
{{ ticket.resolved_at.strftime('%d-%m-%Y %H:%M') }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if ticket.first_response_at %}
|
||||
<div class="info-item mb-3">
|
||||
<label>Første svar</label>
|
||||
<div class="value">
|
||||
{{ ticket.first_response_at.strftime('%d-%m-%Y %H:%M') }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
{% if ticket.tags %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="section-title">
|
||||
<i class="bi bi-tags"></i> Tags
|
||||
</div>
|
||||
{% for tag in ticket.tags %}
|
||||
<span class="badge bg-secondary me-1 mb-1">#{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Add comment (placeholder - integrate with API)
|
||||
function addComment() {
|
||||
alert('Add comment functionality - integrate with POST /api/v1/tickets/{{ ticket.id }}/comments');
|
||||
}
|
||||
|
||||
// Add worklog (placeholder - integrate with API)
|
||||
function addWorklog() {
|
||||
alert('Add worklog functionality - integrate with POST /api/v1/tickets/{{ ticket.id }}/worklog');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
272
app/ticket/frontend/ticket_list.html
Normal file
272
app/ticket/frontend/ticket_list.html
Normal file
@ -0,0 +1,272 @@
|
||||
{% extends "shared/frontend/base.html" %}
|
||||
|
||||
{% block title %}Alle Tickets - BMC Hub{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.filter-bar {
|
||||
background: var(--bg-card);
|
||||
padding: 1.5rem;
|
||||
border-radius: var(--border-radius);
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 2px 15px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.ticket-table th {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 2px solid var(--accent-light);
|
||||
padding: 1rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.ticket-table td {
|
||||
padding: 1rem 0.75rem;
|
||||
vertical-align: middle;
|
||||
border-bottom: 1px solid var(--accent-light);
|
||||
}
|
||||
|
||||
.ticket-row {
|
||||
transition: background-color 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ticket-row:hover {
|
||||
background-color: var(--accent-light);
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.badge-status-open { background-color: #d1ecf1; color: #0c5460; }
|
||||
.badge-status-in_progress { background-color: #fff3cd; color: #856404; }
|
||||
.badge-status-pending_customer { background-color: #e2e3e5; color: #383d41; }
|
||||
.badge-status-resolved { background-color: #d4edda; color: #155724; }
|
||||
.badge-status-closed { background-color: #f8d7da; color: #721c24; }
|
||||
|
||||
.badge-priority-low { background-color: var(--accent-light); color: var(--accent); }
|
||||
.badge-priority-normal { background-color: #e2e3e5; color: #383d41; }
|
||||
.badge-priority-high { background-color: #fff3cd; color: #856404; }
|
||||
.badge-priority-urgent, .badge-priority-critical { background-color: #f8d7da; color: #721c24; }
|
||||
|
||||
.ticket-number {
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
background: var(--accent-light);
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
padding-left: 2.5rem;
|
||||
background: var(--bg-body);
|
||||
border: 1px solid var(--accent-light);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.search-box i {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.meta-info {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.meta-info i {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-4">
|
||||
<!-- Page Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="mb-2">
|
||||
<i class="bi bi-ticket-detailed"></i> Alle Tickets
|
||||
</h1>
|
||||
<p class="text-muted">Oversigt over alle tickets i systemet</p>
|
||||
</div>
|
||||
<a href="/api/v1/tickets" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Ny Ticket
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<div class="filter-bar">
|
||||
<form method="get" action="/ticket/tickets" class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label for="status" class="form-label">Status:</label>
|
||||
<select name="status" id="status" class="form-select" onchange="this.form.submit()">
|
||||
<option value="">Alle</option>
|
||||
<option value="open" {% if selected_status == 'open' %}selected{% endif %}>Open</option>
|
||||
<option value="in_progress" {% if selected_status == 'in_progress' %}selected{% endif %}>In Progress</option>
|
||||
<option value="pending_customer" {% if selected_status == 'pending_customer' %}selected{% endif %}>Pending Customer</option>
|
||||
<option value="resolved" {% if selected_status == 'resolved' %}selected{% endif %}>Resolved</option>
|
||||
<option value="closed" {% if selected_status == 'closed' %}selected{% endif %}>Closed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="priority" class="form-label">Prioritet:</label>
|
||||
<select name="priority" id="priority" class="form-select" onchange="this.form.submit()">
|
||||
<option value="">Alle</option>
|
||||
<option value="low" {% if selected_priority == 'low' %}selected{% endif %}>Low</option>
|
||||
<option value="normal" {% if selected_priority == 'normal' %}selected{% endif %}>Normal</option>
|
||||
<option value="high" {% if selected_priority == 'high' %}selected{% endif %}>High</option>
|
||||
<option value="urgent" {% if selected_priority == 'urgent' %}selected{% endif %}>Urgent</option>
|
||||
<option value="critical" {% if selected_priority == 'critical' %}selected{% endif %}>Critical</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="customer_id" class="form-label">Kunde:</label>
|
||||
<select name="customer_id" id="customer_id" class="form-select" onchange="this.form.submit()">
|
||||
<option value="">Alle kunder</option>
|
||||
{% for customer in customers %}
|
||||
<option value="{{ customer.id }}" {% if customer.id == selected_customer_id %}selected{% endif %}>
|
||||
{{ customer.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="search" class="form-label">Søg:</label>
|
||||
<div class="search-box">
|
||||
<i class="bi bi-search"></i>
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
id="search"
|
||||
class="form-control"
|
||||
placeholder="Ticket nummer eller emne..."
|
||||
value="{{ search_query or '' }}">
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Tickets Table -->
|
||||
{% if tickets %}
|
||||
<div class="card">
|
||||
<div class="table-responsive">
|
||||
<table class="table ticket-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ticket</th>
|
||||
<th>Kunde</th>
|
||||
<th>Status</th>
|
||||
<th>Prioritet</th>
|
||||
<th>Tildelt</th>
|
||||
<th>Aktivitet</th>
|
||||
<th>Oprettet</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ticket in tickets %}
|
||||
<tr class="ticket-row" onclick="window.location='/ticket/tickets/{{ ticket.id }}'">
|
||||
<td>
|
||||
<span class="ticket-number">{{ ticket.ticket_number }}</span>
|
||||
<br>
|
||||
<strong>{{ ticket.subject }}</strong>
|
||||
</td>
|
||||
<td>
|
||||
{% if ticket.customer_name %}
|
||||
{{ ticket.customer_name }}
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-status-{{ ticket.status }}">
|
||||
{{ ticket.status.replace('_', ' ').title() }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-priority-{{ ticket.priority }}">
|
||||
{{ ticket.priority.title() }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if ticket.assigned_to_name %}
|
||||
{{ ticket.assigned_to_name }}
|
||||
{% else %}
|
||||
<span class="text-muted">Ikke tildelt</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="meta-info">
|
||||
<i class="bi bi-chat"></i> {{ ticket.comment_count }}
|
||||
<i class="bi bi-clock ms-2"></i> {{ ticket.worklog_count }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{{ ticket.created_at.strftime('%d-%m-%Y') if ticket.created_at else '-' }}
|
||||
<br>
|
||||
<small class="text-muted">{{ ticket.created_at.strftime('%H:%M') if ticket.created_at else '' }}</small>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-muted mt-3">
|
||||
<small>Viser {{ tickets|length }} tickets</small>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-inbox"></i>
|
||||
<h3>Ingen tickets fundet</h3>
|
||||
<p>Prøv at justere dine filtre eller opret en ny ticket</p>
|
||||
{% if selected_status or selected_priority or selected_customer_id or search_query %}
|
||||
<a href="/ticket/tickets" class="btn btn-outline-secondary mt-3">
|
||||
<i class="bi bi-x-circle"></i> Ryd filtre
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Submit search on Enter
|
||||
document.getElementById('search').addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.form.submit();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
451
app/ticket/frontend/views.py
Normal file
451
app/ticket/frontend/views.py
Normal file
@ -0,0 +1,451 @@
|
||||
"""
|
||||
Ticket System Frontend Views
|
||||
HTML template routes for ticket management UI
|
||||
"""
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, Request, HTTPException, Form
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from typing import Optional
|
||||
from datetime import date
|
||||
|
||||
from app.core.database import execute_query, execute_update
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MOCKUP ROUTES (TEMPORARY)
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/mockups/1", response_class=HTMLResponse)
|
||||
async def mockup_option1(request: Request):
|
||||
"""Mockup: Split Screen Concept"""
|
||||
return templates.TemplateResponse("ticket/frontend/mockups/option1_splitscreen.html", {"request": request})
|
||||
|
||||
@router.get("/mockups/2", response_class=HTMLResponse)
|
||||
async def mockup_option2(request: Request):
|
||||
"""Mockup: Kanban Board Concept"""
|
||||
return templates.TemplateResponse("ticket/frontend/mockups/option2_kanban.html", {"request": request})
|
||||
|
||||
@router.get("/mockups/3", response_class=HTMLResponse)
|
||||
async def mockup_option3(request: Request):
|
||||
"""Mockup: Power Table Concept"""
|
||||
return templates.TemplateResponse("ticket/frontend/mockups/option3_powertable.html", {"request": request})
|
||||
|
||||
|
||||
@router.get("/worklog/review", response_class=HTMLResponse)
|
||||
async def worklog_review_page(
|
||||
request: Request,
|
||||
customer_id: Optional[int] = None,
|
||||
status: str = "draft"
|
||||
):
|
||||
"""
|
||||
Worklog review page with single-entry approval
|
||||
|
||||
Query params:
|
||||
customer_id: Filter by customer (optional)
|
||||
status: Filter by status (default: draft)
|
||||
"""
|
||||
try:
|
||||
# Build query with filters
|
||||
query = """
|
||||
SELECT
|
||||
w.id,
|
||||
w.ticket_id,
|
||||
w.user_id,
|
||||
w.work_date,
|
||||
w.hours,
|
||||
w.work_type,
|
||||
w.description,
|
||||
w.billing_method,
|
||||
w.status,
|
||||
w.prepaid_card_id,
|
||||
w.created_at,
|
||||
t.ticket_number,
|
||||
t.subject AS ticket_subject,
|
||||
t.customer_id,
|
||||
t.status AS ticket_status,
|
||||
c.name AS customer_name,
|
||||
u.username AS user_name,
|
||||
pc.card_number,
|
||||
pc.remaining_hours AS card_remaining_hours
|
||||
FROM tticket_worklog w
|
||||
INNER JOIN tticket_tickets t ON t.id = w.ticket_id
|
||||
LEFT JOIN customers c ON c.id = t.customer_id
|
||||
LEFT JOIN users u ON u.user_id = w.user_id
|
||||
LEFT JOIN tticket_prepaid_cards pc ON pc.id = w.prepaid_card_id
|
||||
WHERE w.status = %s
|
||||
"""
|
||||
|
||||
params = [status]
|
||||
|
||||
if customer_id:
|
||||
query += " AND t.customer_id = %s"
|
||||
params.append(customer_id)
|
||||
|
||||
query += " ORDER BY w.work_date DESC, w.created_at DESC"
|
||||
|
||||
worklogs = execute_query(query, tuple(params))
|
||||
|
||||
# Get customer list for filter dropdown
|
||||
customers_query = """
|
||||
SELECT DISTINCT c.id, c.name
|
||||
FROM customers c
|
||||
INNER JOIN tticket_tickets t ON t.customer_id = c.id
|
||||
INNER JOIN tticket_worklog w ON w.ticket_id = t.id
|
||||
WHERE w.status = %s
|
||||
ORDER BY c.name
|
||||
"""
|
||||
customers = execute_query(customers_query, (status,))
|
||||
|
||||
# Calculate totals
|
||||
total_hours = sum(float(w['hours']) for w in worklogs)
|
||||
total_billable = sum(
|
||||
float(w['hours'])
|
||||
for w in worklogs
|
||||
if w['billing_method'] == 'invoice'
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"ticket/frontend/worklog_review.html",
|
||||
{
|
||||
"request": request,
|
||||
"worklogs": worklogs,
|
||||
"customers": customers,
|
||||
"selected_customer_id": customer_id,
|
||||
"selected_status": status,
|
||||
"total_hours": total_hours,
|
||||
"total_billable_hours": total_billable,
|
||||
"total_entries": len(worklogs)
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to load worklog review page: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/worklog/{worklog_id}/approve")
|
||||
async def approve_worklog_entry(
|
||||
worklog_id: int,
|
||||
redirect_to: Optional[str] = Form(default="/ticket/worklog/review")
|
||||
):
|
||||
"""
|
||||
Approve single worklog entry (change status draft → billable)
|
||||
|
||||
Form params:
|
||||
redirect_to: URL to redirect after approval
|
||||
"""
|
||||
try:
|
||||
# Check entry exists and is draft
|
||||
check_query = """
|
||||
SELECT id, status, billing_method
|
||||
FROM tticket_worklog
|
||||
WHERE id = %s
|
||||
"""
|
||||
entry = execute_query(check_query, (worklog_id,), fetchone=True)
|
||||
|
||||
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(check_query, (worklog_id,), fetchone=True)
|
||||
|
||||
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("/dashboard", response_class=HTMLResponse)
|
||||
async def ticket_dashboard(request: Request):
|
||||
"""
|
||||
Ticket system dashboard with statistics
|
||||
"""
|
||||
try:
|
||||
# Get ticket statistics
|
||||
stats_query = """
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE status = 'open') AS open_count,
|
||||
COUNT(*) FILTER (WHERE status = 'in_progress') AS in_progress_count,
|
||||
COUNT(*) FILTER (WHERE status = 'pending_customer') AS pending_count,
|
||||
COUNT(*) FILTER (WHERE status = 'resolved') AS resolved_count,
|
||||
COUNT(*) FILTER (WHERE status = 'closed') AS closed_count,
|
||||
COUNT(*) AS total_count
|
||||
FROM tticket_tickets
|
||||
"""
|
||||
stats = execute_query(stats_query, fetchone=True)
|
||||
|
||||
# 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 = execute_query(worklog_stats_query, fetchone=True)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"ticket/frontend/dashboard.html",
|
||||
{
|
||||
"request": request,
|
||||
"stats": stats,
|
||||
"recent_tickets": recent_tickets,
|
||||
"worklog_stats": worklog_stats
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to load dashboard: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/tickets", response_class=HTMLResponse)
|
||||
async def ticket_list_page(
|
||||
request: Request,
|
||||
status: Optional[str] = None,
|
||||
priority: Optional[str] = None,
|
||||
customer_id: Optional[int] = None,
|
||||
search: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
Ticket list page with filters
|
||||
"""
|
||||
try:
|
||||
# Build query with filters
|
||||
query = """
|
||||
SELECT
|
||||
t.id,
|
||||
t.ticket_number,
|
||||
t.subject,
|
||||
t.status,
|
||||
t.priority,
|
||||
t.created_at,
|
||||
t.updated_at,
|
||||
c.name AS customer_name,
|
||||
u.username AS assigned_to_name,
|
||||
(SELECT COUNT(*) FROM tticket_comments WHERE ticket_id = t.id) AS comment_count,
|
||||
(SELECT COUNT(*) FROM tticket_worklog WHERE ticket_id = t.id) AS worklog_count
|
||||
FROM tticket_tickets t
|
||||
LEFT JOIN customers c ON c.id = t.customer_id
|
||||
LEFT JOIN users u ON u.user_id = t.assigned_to_user_id
|
||||
WHERE 1=1
|
||||
"""
|
||||
|
||||
params = []
|
||||
|
||||
if status:
|
||||
query += " AND t.status = %s"
|
||||
params.append(status)
|
||||
|
||||
if priority:
|
||||
query += " AND t.priority = %s"
|
||||
params.append(priority)
|
||||
|
||||
if customer_id:
|
||||
query += " AND t.customer_id = %s"
|
||||
params.append(customer_id)
|
||||
|
||||
if search:
|
||||
query += " AND (t.subject ILIKE %s OR t.description ILIKE %s OR t.ticket_number ILIKE %s)"
|
||||
search_pattern = f"%{search}%"
|
||||
params.extend([search_pattern, search_pattern, search_pattern])
|
||||
|
||||
query += " ORDER BY t.created_at DESC LIMIT 100"
|
||||
|
||||
tickets = execute_query(query, tuple(params)) if params else execute_query(query)
|
||||
|
||||
# Get filter options
|
||||
customers = execute_query(
|
||||
"""SELECT DISTINCT c.id, c.name
|
||||
FROM customers c
|
||||
INNER JOIN tticket_tickets t ON t.customer_id = c.id
|
||||
ORDER BY c.name"""
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"ticket/frontend/ticket_list.html",
|
||||
{
|
||||
"request": request,
|
||||
"tickets": tickets,
|
||||
"customers": customers,
|
||||
"selected_status": status,
|
||||
"selected_priority": priority,
|
||||
"selected_customer_id": customer_id,
|
||||
"search_query": search
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to load ticket list: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/tickets/{ticket_id}", response_class=HTMLResponse)
|
||||
async def ticket_detail_page(request: Request, ticket_id: int):
|
||||
"""
|
||||
Ticket detail page with comments and worklog
|
||||
"""
|
||||
try:
|
||||
# Get ticket details
|
||||
ticket_query = """
|
||||
SELECT
|
||||
t.*,
|
||||
c.name AS customer_name,
|
||||
c.email AS customer_email,
|
||||
u.username AS assigned_to_name
|
||||
FROM tticket_tickets t
|
||||
LEFT JOIN customers c ON c.id = t.customer_id
|
||||
LEFT JOIN users u ON u.user_id = t.assigned_to_user_id
|
||||
WHERE t.id = %s
|
||||
"""
|
||||
ticket = execute_query(ticket_query, (ticket_id,), fetchone=True)
|
||||
|
||||
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))
|
||||
560
app/ticket/frontend/worklog_review.html
Normal file
560
app/ticket/frontend/worklog_review.html
Normal file
@ -0,0 +1,560 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="da">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Worklog Godkendelse - BMC Hub</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
||||
<style>
|
||||
:root {
|
||||
--bg-body: #f8f9fa;
|
||||
--bg-card: #ffffff;
|
||||
--text-primary: #2c3e50;
|
||||
--text-secondary: #6c757d;
|
||||
--accent: #0f4c75;
|
||||
--accent-light: #eef2f5;
|
||||
--border-radius: 12px;
|
||||
--success: #28a745;
|
||||
--danger: #dc3545;
|
||||
--warning: #ffc107;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg-body: #1a1d23;
|
||||
--bg-card: #252a31;
|
||||
--text-primary: #e4e6eb;
|
||||
--text-secondary: #b0b3b8;
|
||||
--accent: #4a9eff;
|
||||
--accent-light: #2d3748;
|
||||
--success: #48bb78;
|
||||
--danger: #f56565;
|
||||
--warning: #ed8936;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-body);
|
||||
color: var(--text-primary);
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
padding-top: 80px;
|
||||
transition: background-color 0.3s, color 0.3s;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background: var(--bg-card);
|
||||
box-shadow: 0 2px 15px rgba(0,0,0,0.08);
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.05);
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--text-secondary);
|
||||
padding: 0.6rem 1.2rem !important;
|
||||
border-radius: var(--border-radius);
|
||||
transition: all 0.2s;
|
||||
font-weight: 500;
|
||||
margin: 0 0.2rem;
|
||||
}
|
||||
|
||||
.nav-link:hover, .nav-link.active {
|
||||
background-color: var(--accent-light);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.card {
|
||||
border: none;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: 0 2px 15px rgba(0,0,0,0.05);
|
||||
background: var(--bg-card);
|
||||
margin-bottom: 1.5rem;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
text-align: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-card p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.worklog-table {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.worklog-table th {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 2px solid var(--accent-light);
|
||||
padding: 1rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.worklog-table td {
|
||||
padding: 1rem 0.75rem;
|
||||
vertical-align: middle;
|
||||
border-bottom: 1px solid var(--accent-light);
|
||||
}
|
||||
|
||||
.worklog-row {
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.worklog-row:hover {
|
||||
background-color: var(--accent-light);
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.badge-invoice {
|
||||
background-color: var(--accent-light);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.badge-prepaid {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.badge-support {
|
||||
background-color: #cce5ff;
|
||||
color: #004085;
|
||||
}
|
||||
|
||||
.badge-development {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.btn-approve {
|
||||
background-color: var(--success);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.4rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-approve:hover {
|
||||
background-color: #218838;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-reject {
|
||||
background-color: var(--danger);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.4rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-reject:hover {
|
||||
background-color: #c82333;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--border-radius);
|
||||
background: var(--accent-light);
|
||||
color: var(--accent);
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
background: var(--bg-card);
|
||||
padding: 1.5rem;
|
||||
border-radius: var(--border-radius);
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 2px 15px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.hours-display {
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.ticket-number {
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
background: var(--accent-light);
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.customer-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.work-description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-group-actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Navigation -->
|
||||
<nav class="navbar navbar-expand-lg fixed-top">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="/">
|
||||
<i class="bi bi-boxes"></i> BMC Hub
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/ticket/dashboard">
|
||||
<i class="bi bi-speedometer2"></i> Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/api/v1/tickets">
|
||||
<i class="bi bi-ticket-detailed"></i> Tickets
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" href="/ticket/worklog/review">
|
||||
<i class="bi bi-clock-history"></i> Worklog Godkendelse
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/api/v1/prepaid-cards">
|
||||
<i class="bi bi-credit-card"></i> Klippekort
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" title="Toggle Dark Mode">
|
||||
<i class="bi bi-moon-stars-fill"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container-fluid px-4">
|
||||
<!-- Page Header -->
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1 class="mb-2">
|
||||
<i class="bi bi-clock-history"></i> Worklog Godkendelse
|
||||
</h1>
|
||||
<p class="text-muted">Godkend eller afvis enkelt-entries fra draft worklog</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Row -->
|
||||
<div class="row stats-row">
|
||||
<div class="col-md-4">
|
||||
<div class="card stat-card">
|
||||
<h3>{{ total_entries }}</h3>
|
||||
<p>Entries til godkendelse</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card stat-card">
|
||||
<h3>{{ "%.2f"|format(total_hours) }}t</h3>
|
||||
<p>Total timer</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card stat-card">
|
||||
<h3>{{ "%.2f"|format(total_billable_hours) }}t</h3>
|
||||
<p>Fakturerbare timer</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<div class="filter-bar">
|
||||
<form method="get" action="/ticket/worklog/review" class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label for="customer_id" class="form-label">Filtrer efter kunde:</label>
|
||||
<select name="customer_id" id="customer_id" class="form-select" onchange="this.form.submit()">
|
||||
<option value="">Alle kunder</option>
|
||||
{% for customer in customers %}
|
||||
<option value="{{ customer.id }}" {% if customer.id == selected_customer_id %}selected{% endif %}>
|
||||
{{ customer.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="status" class="form-label">Status:</label>
|
||||
<select name="status" id="status" class="form-select" onchange="this.form.submit()">
|
||||
<option value="draft" {% if selected_status == 'draft' %}selected{% endif %}>Draft</option>
|
||||
<option value="billable" {% if selected_status == 'billable' %}selected{% endif %}>Billable</option>
|
||||
<option value="rejected" {% if selected_status == 'rejected' %}selected{% endif %}>Rejected</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-funnel"></i> Filtrer
|
||||
</button>
|
||||
<a href="/ticket/worklog/review" class="btn btn-outline-secondary ms-2">
|
||||
<i class="bi bi-x-circle"></i> Ryd filtre
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Worklog Table -->
|
||||
{% if worklogs %}
|
||||
<div class="card">
|
||||
<div class="table-responsive">
|
||||
<table class="table worklog-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ticket</th>
|
||||
<th>Kunde</th>
|
||||
<th>Dato</th>
|
||||
<th>Timer</th>
|
||||
<th>Type</th>
|
||||
<th>Fakturering</th>
|
||||
<th>Beskrivelse</th>
|
||||
<th>Medarbejder</th>
|
||||
<th style="text-align: right;">Handlinger</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for worklog in worklogs %}
|
||||
<tr class="worklog-row">
|
||||
<td>
|
||||
<span class="ticket-number">{{ worklog.ticket_number }}</span>
|
||||
<br>
|
||||
<small class="text-muted">{{ worklog.ticket_subject[:30] }}...</small>
|
||||
</td>
|
||||
<td>
|
||||
{% if worklog.customer_name %}
|
||||
<span class="customer-name">{{ worklog.customer_name }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ worklog.work_date.strftime('%d-%m-%Y') if worklog.work_date else '-' }}
|
||||
</td>
|
||||
<td>
|
||||
<span class="hours-display">{{ "%.2f"|format(worklog.hours) }}t</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if worklog.work_type == 'support' %}
|
||||
<span class="badge badge-support">Support</span>
|
||||
{% elif worklog.work_type == 'development' %}
|
||||
<span class="badge badge-development">Udvikling</span>
|
||||
{% else %}
|
||||
<span class="badge">{{ worklog.work_type }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if worklog.billing_method == 'invoice' %}
|
||||
<span class="badge badge-invoice">
|
||||
<i class="bi bi-file-earmark-text"></i> Faktura
|
||||
</span>
|
||||
{% elif worklog.billing_method == 'prepaid' %}
|
||||
<span class="badge badge-prepaid">
|
||||
<i class="bi bi-credit-card"></i> Klippekort
|
||||
</span>
|
||||
{% if worklog.card_number %}
|
||||
<br><small class="text-muted">{{ worklog.card_number }}</small>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="work-description" title="{{ worklog.description or '-' }}">
|
||||
{{ worklog.description or '-' }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{{ worklog.user_name or 'N/A' }}
|
||||
</td>
|
||||
<td>
|
||||
{% if worklog.status == 'draft' %}
|
||||
<div class="btn-group-actions">
|
||||
<form method="post" action="/ticket/worklog/{{ worklog.id }}/approve" style="display: inline;">
|
||||
<input type="hidden" name="redirect_to" value="{{ request.url }}">
|
||||
<button type="submit" class="btn btn-approve btn-sm">
|
||||
<i class="bi bi-check-circle"></i> Godkend
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-reject btn-sm ms-1"
|
||||
onclick="rejectWorklog({{ worklog.id }}, '{{ request.url }}')">
|
||||
<i class="bi bi-x-circle"></i> Afvis
|
||||
</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="badge">{{ worklog.status }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-inbox"></i>
|
||||
<h3>Ingen worklog entries</h3>
|
||||
<p>Der er ingen entries med status "{{ selected_status }}" {% if selected_customer_id %}for denne kunde{% endif %}.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Reject Modal -->
|
||||
<div class="modal fade" id="rejectModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content" style="background: var(--bg-card); color: var(--text-primary);">
|
||||
<div class="modal-header" style="border-bottom: 1px solid var(--accent-light);">
|
||||
<h5 class="modal-title">Afvis Worklog Entry</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form id="rejectForm" method="post">
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="rejectReason" class="form-label">Årsag til afvisning (valgfrit):</label>
|
||||
<textarea
|
||||
class="form-control"
|
||||
id="rejectReason"
|
||||
name="reason"
|
||||
rows="3"
|
||||
placeholder="Forklar hvorfor denne entry afvises..."
|
||||
style="background: var(--bg-body); color: var(--text-primary); border-color: var(--accent-light);"></textarea>
|
||||
</div>
|
||||
<input type="hidden" name="redirect_to" id="rejectRedirectTo">
|
||||
</div>
|
||||
<div class="modal-footer" style="border-top: 1px solid var(--accent-light);">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
||||
<button type="submit" class="btn btn-reject">
|
||||
<i class="bi bi-x-circle"></i> Afvis Entry
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
// Theme Toggle
|
||||
function toggleTheme() {
|
||||
const html = document.documentElement;
|
||||
const currentTheme = html.getAttribute('data-theme');
|
||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
html.setAttribute('data-theme', newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
|
||||
// Update icon
|
||||
const icon = document.querySelector('.theme-toggle i');
|
||||
if (newTheme === 'dark') {
|
||||
icon.className = 'bi bi-sun-fill';
|
||||
} else {
|
||||
icon.className = 'bi bi-moon-stars-fill';
|
||||
}
|
||||
}
|
||||
|
||||
// Load saved theme
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const savedTheme = localStorage.getItem('theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
|
||||
const icon = document.querySelector('.theme-toggle i');
|
||||
if (savedTheme === 'dark') {
|
||||
icon.className = 'bi bi-sun-fill';
|
||||
}
|
||||
});
|
||||
|
||||
// Reject worklog with modal
|
||||
function rejectWorklog(worklogId, redirectUrl) {
|
||||
const form = document.getElementById('rejectForm');
|
||||
form.action = `/ticket/worklog/${worklogId}/reject`;
|
||||
document.getElementById('rejectRedirectTo').value = redirectUrl;
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('rejectModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// Auto-refresh indicator (optional)
|
||||
let lastRefresh = Date.now();
|
||||
setInterval(() => {
|
||||
const elapsed = Math.floor((Date.now() - lastRefresh) / 1000);
|
||||
if (elapsed > 300) { // 5 minutes
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'badge bg-warning position-fixed';
|
||||
badge.style.bottom = '20px';
|
||||
badge.style.right = '20px';
|
||||
badge.style.cursor = 'pointer';
|
||||
badge.innerHTML = '<i class="bi bi-arrow-clockwise"></i> Opdater siden';
|
||||
badge.onclick = () => location.reload();
|
||||
document.body.appendChild(badge);
|
||||
}
|
||||
}, 60000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
423
docs/TICKET_EMAIL_INTEGRATION.md
Normal file
423
docs/TICKET_EMAIL_INTEGRATION.md
Normal file
@ -0,0 +1,423 @@
|
||||
# Ticket System - Email Integration
|
||||
|
||||
## Overview
|
||||
|
||||
The ticket system integrates with BMC Hub's email workflow engine to automatically create tickets from incoming emails. Uses **Option B: Message-ID Threading** for robust email-to-ticket linkage.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Incoming Email → Email Classifier → Email Workflow Engine → Ticket Integration
|
||||
↓
|
||||
create_ticket action
|
||||
↓
|
||||
EmailTicketIntegration.process_email_for_ticket()
|
||||
↓
|
||||
┌───────┴───────┐
|
||||
↓ ↓
|
||||
New Ticket Reply to Existing
|
||||
(creates TKT-XXX) (links via Message-ID)
|
||||
```
|
||||
|
||||
## Email Threading Logic
|
||||
|
||||
### Message-ID Based Threading (Option B)
|
||||
|
||||
The system uses email headers to detect if an email is part of an existing ticket thread:
|
||||
|
||||
1. **In-Reply-To Header**: Check if contains `TKT-YYYYMMDD-XXX` pattern
|
||||
2. **References Header**: Check all message IDs in chain for ticket number
|
||||
3. **Subject Line**: Check for `[TKT-YYYYMMDD-XXX]` or `Re: TKT-YYYYMMDD-XXX`
|
||||
|
||||
If ticket number found → **Link to existing ticket**
|
||||
If NOT found → **Create new ticket**
|
||||
|
||||
### Ticket Number Format
|
||||
|
||||
Pattern: `TKT-YYYYMMDD-XXX`
|
||||
- `TKT`: Prefix
|
||||
- `YYYYMMDD`: Date (e.g., 20251215)
|
||||
- `XXX`: Sequential number (001-999)
|
||||
|
||||
Example: `TKT-20251215-001`
|
||||
|
||||
## Workflow Actions
|
||||
|
||||
### 1. `create_ticket`
|
||||
|
||||
Creates new ticket OR links to existing ticket (smart routing).
|
||||
|
||||
**Workflow Definition:**
|
||||
```json
|
||||
{
|
||||
"name": "Support Request → Ticket",
|
||||
"classification_trigger": "support_request",
|
||||
"workflow_steps": [
|
||||
{
|
||||
"action": "create_ticket",
|
||||
"params": {
|
||||
"customer_id": 123,
|
||||
"assigned_to_user_id": 5
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `customer_id` (optional): BMC customer ID to link ticket to
|
||||
- `assigned_to_user_id` (optional): User ID to assign ticket to
|
||||
- `priority` (optional): Override priority detection (low/normal/high/critical)
|
||||
|
||||
**Returns:**
|
||||
```json
|
||||
{
|
||||
"ticket_id": 42,
|
||||
"ticket_number": "TKT-20251215-001",
|
||||
"created": true,
|
||||
"linked": false
|
||||
}
|
||||
```
|
||||
|
||||
### 2. `link_email_to_ticket`
|
||||
|
||||
Explicitly link email to a specific ticket (manual routing).
|
||||
|
||||
**Workflow Definition:**
|
||||
```json
|
||||
{
|
||||
"action": "link_email_to_ticket",
|
||||
"params": {
|
||||
"ticket_number": "TKT-20251215-001"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `ticket_number` (required): Target ticket number
|
||||
|
||||
**Returns:**
|
||||
```json
|
||||
{
|
||||
"ticket_id": 42,
|
||||
"ticket_number": "TKT-20251215-001",
|
||||
"linked": true
|
||||
}
|
||||
```
|
||||
|
||||
## Automatic Features
|
||||
|
||||
### Tag Extraction
|
||||
|
||||
Extracts `#hashtags` from email body and adds as ticket tags:
|
||||
|
||||
```
|
||||
Email body: "We need help with #billing #urgent issue"
|
||||
→ Ticket tags: ["billing", "urgent"]
|
||||
```
|
||||
|
||||
- Max 10 tags per ticket
|
||||
- Minimum 3 characters
|
||||
- Converted to lowercase
|
||||
|
||||
### Priority Detection
|
||||
|
||||
Automatically determines ticket priority from email content:
|
||||
|
||||
**Critical**: kritisk, critical, down, nede, urgent, akut
|
||||
**High**: høj, high, vigtig, important, haster
|
||||
**Low**: lav, low, spørgsmål, question, info
|
||||
**Normal**: Everything else (default)
|
||||
|
||||
Checks both subject line and extracted tags.
|
||||
|
||||
### Email Metadata
|
||||
|
||||
Stores rich metadata in ticket:
|
||||
|
||||
- **Description**: Formatted with sender email, received date, original body
|
||||
- **Custom Fields**: `email_from`, `email_message_id`, `created_from_email`
|
||||
- **Email Log**: Full threading data in `tticket_email_log` table
|
||||
|
||||
## Database Storage
|
||||
|
||||
### `tticket_email_log` Table
|
||||
|
||||
Stores all email-ticket linkages for audit trail:
|
||||
|
||||
```sql
|
||||
CREATE TABLE tticket_email_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id),
|
||||
email_message_id TEXT NOT NULL,
|
||||
email_subject TEXT,
|
||||
email_from TEXT NOT NULL,
|
||||
email_received_at TIMESTAMP,
|
||||
is_reply BOOLEAN DEFAULT FALSE,
|
||||
thread_data JSONB,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
**`thread_data` JSONB structure:**
|
||||
```json
|
||||
{
|
||||
"message_id": "<abc123@example.com>",
|
||||
"in_reply_to": "<TKT-20251215-001@bmcnetworks.dk>",
|
||||
"references": "<ref1@example.com> <ref2@example.com>"
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Example 1: Basic Support Request
|
||||
|
||||
**Incoming Email:**
|
||||
```
|
||||
From: customer@example.com
|
||||
Subject: Help with network issue
|
||||
Body: Our internet is down. Can you help?
|
||||
```
|
||||
|
||||
**Workflow Configuration:**
|
||||
```json
|
||||
{
|
||||
"name": "Support Email → Ticket",
|
||||
"classification_trigger": "support_request",
|
||||
"confidence_threshold": 0.7,
|
||||
"workflow_steps": [
|
||||
{
|
||||
"action": "create_ticket",
|
||||
"params": {
|
||||
"assigned_to_user_id": 5
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Result:**
|
||||
- Creates ticket `TKT-20251215-001`
|
||||
- Priority: `normal` (no urgent keywords)
|
||||
- Tags: [] (no hashtags found)
|
||||
- Assigned to user 5
|
||||
- Email logged in `tticket_email_log`
|
||||
|
||||
### Example 2: Email Reply to Ticket
|
||||
|
||||
**Incoming Email:**
|
||||
```
|
||||
From: customer@example.com
|
||||
Subject: Re: TKT-20251215-001
|
||||
In-Reply-To: <TKT-20251215-001@bmcnetworks.dk>
|
||||
Body: Thanks, issue is resolved now
|
||||
```
|
||||
|
||||
**Result:**
|
||||
- **Detects existing ticket** via In-Reply-To header
|
||||
- **Adds comment** to ticket `TKT-20251215-001`
|
||||
- Does NOT create new ticket
|
||||
- Logs as reply (`is_reply=true`)
|
||||
|
||||
### Example 3: Tagged Urgent Request
|
||||
|
||||
**Incoming Email:**
|
||||
```
|
||||
From: vip@example.com
|
||||
Subject: URGENT: Server down!
|
||||
Body: Production server is down #critical #server
|
||||
```
|
||||
|
||||
**Result:**
|
||||
- Creates ticket with priority `critical` (subject keyword)
|
||||
- Tags: `["critical", "server"]`
|
||||
- Custom field: `created_from_email=true`
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Get Ticket Email Thread
|
||||
|
||||
```http
|
||||
GET /api/v1/tickets/{ticket_id}/emails
|
||||
```
|
||||
|
||||
Returns chronological list of all emails linked to ticket.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"ticket_id": 42,
|
||||
"ticket_number": "TKT-20251215-001",
|
||||
"emails": [
|
||||
{
|
||||
"id": 1,
|
||||
"email_message_id": "<abc123@example.com>",
|
||||
"email_from": "customer@example.com",
|
||||
"email_subject": "Help with issue",
|
||||
"email_received_at": "2025-12-15T10:00:00Z",
|
||||
"is_reply": false,
|
||||
"created_at": "2025-12-15T10:01:00Z"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"email_message_id": "<def456@example.com>",
|
||||
"email_from": "customer@example.com",
|
||||
"email_subject": "Re: TKT-20251215-001",
|
||||
"email_received_at": "2025-12-15T11:00:00Z",
|
||||
"is_reply": true,
|
||||
"created_at": "2025-12-15T11:01:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Find Tickets by Email Address
|
||||
|
||||
```http
|
||||
GET /api/v1/tickets/by-email/{email_address}
|
||||
```
|
||||
|
||||
Returns all tickets associated with an email address.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"email_address": "customer@example.com",
|
||||
"tickets": [
|
||||
{
|
||||
"id": 42,
|
||||
"ticket_number": "TKT-20251215-001",
|
||||
"subject": "Network issue",
|
||||
"status": "open",
|
||||
"created_at": "2025-12-15T10:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Settings (`.env`)
|
||||
|
||||
```bash
|
||||
# Ticket system enabled
|
||||
TICKET_ENABLED=true
|
||||
|
||||
# Email integration enabled (requires email system)
|
||||
TICKET_EMAIL_INTEGRATION=true
|
||||
|
||||
# Auto-assign new tickets (requires user_id)
|
||||
TICKET_AUTO_ASSIGN=false
|
||||
TICKET_DEFAULT_ASSIGNEE_ID=5
|
||||
|
||||
# Default priority for new tickets
|
||||
TICKET_DEFAULT_PRIORITY=normal
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Email-to-Ticket Creation
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8001/api/v1/test/email-to-ticket \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email_data": {
|
||||
"message_id": "<test123@example.com>",
|
||||
"subject": "Test ticket",
|
||||
"from_address": "test@example.com",
|
||||
"body": "This is a test #testing",
|
||||
"received_at": "2025-12-15T10:00:00Z"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Test Email Reply Linking
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8001/api/v1/test/email-to-ticket \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email_data": {
|
||||
"message_id": "<reply456@example.com>",
|
||||
"subject": "Re: TKT-20251215-001",
|
||||
"from_address": "test@example.com",
|
||||
"body": "Reply to existing ticket",
|
||||
"received_at": "2025-12-15T11:00:00Z",
|
||||
"in_reply_to": "<TKT-20251215-001@bmcnetworks.dk>"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Tickets Not Created
|
||||
|
||||
**Check:**
|
||||
1. Email workflow engine enabled (`EMAIL_WORKFLOWS_ENABLED=true`)
|
||||
2. Workflow exists for classification trigger
|
||||
3. Confidence threshold met
|
||||
4. Workflow action is `create_ticket` (NOT old `create_ticket` stub)
|
||||
|
||||
**Debug:**
|
||||
```sql
|
||||
-- Check workflow executions
|
||||
SELECT * FROM email_workflow_executions
|
||||
WHERE status = 'failed'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
### Email Not Linked to Ticket
|
||||
|
||||
**Check:**
|
||||
1. Ticket number format correct (`TKT-YYYYMMDD-XXX`)
|
||||
2. Ticket exists in database
|
||||
3. Email headers contain ticket number (In-Reply-To, References, Subject)
|
||||
|
||||
**Debug:**
|
||||
```sql
|
||||
-- Check email logs
|
||||
SELECT * FROM tticket_email_log
|
||||
WHERE ticket_id = 42
|
||||
ORDER BY created_at DESC;
|
||||
```
|
||||
|
||||
### Duplicate Tickets Created
|
||||
|
||||
**Check:**
|
||||
1. Email reply headers missing ticket number
|
||||
2. Subject line doesn't match pattern (e.g., `Re: Ticket 123` instead of `Re: TKT-20251215-001`)
|
||||
|
||||
**Solution:**
|
||||
- Ensure outgoing ticket emails include ticket number in subject: `[TKT-20251215-001]`
|
||||
- Add ticket number to Message-ID: `<TKT-20251215-001-reply@bmcnetworks.dk>`
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Include Ticket Number in Replies**: Always include `[TKT-YYYYMMDD-XXX]` in subject line
|
||||
2. **Use Message-ID with Ticket Number**: Format: `<TKT-YYYYMMDD-XXX@bmcnetworks.dk>`
|
||||
3. **Set Customer ID in Workflow**: Improves ticket organization and reporting
|
||||
4. **Monitor Workflow Executions**: Check `email_workflow_executions` table regularly
|
||||
5. **Review Failed Actions**: Alert on repeated workflow failures
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **No Email Body Storage**: Only stores metadata and body in ticket description
|
||||
2. **Sender Validation**: Consider implementing sender verification (SPF/DKIM)
|
||||
3. **Spam Prevention**: Email classifier should filter spam before workflow execution
|
||||
4. **Customer Isolation**: Ensure `customer_id` properly set to prevent data leakage
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- **Attachment Handling**: Link email attachments to ticket attachments
|
||||
- **Email Templates**: Auto-reply with ticket number
|
||||
- **SLA Integration**: Start SLA timer on ticket creation from email
|
||||
- **Multi-Ticket Threading**: Support one email creating multiple tickets
|
||||
- **Smart Customer Detection**: Auto-detect customer from sender domain
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Ticket System Architecture](TICKET_SYSTEM_ARCHITECTURE.md)
|
||||
- [Email Workflow System](EMAIL_WORKFLOWS.md)
|
||||
- [Database Schema](../migrations/025_ticket_module.sql)
|
||||
4
main.py
4
main.py
@ -42,6 +42,8 @@ from app.emails.frontend import views as emails_views
|
||||
from app.backups.backend import router as backups_api
|
||||
from app.backups.frontend import views as backups_views
|
||||
from app.backups.backend.scheduler import backup_scheduler
|
||||
from app.ticket.backend import router as ticket_api
|
||||
from app.ticket.frontend import views as ticket_views
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
@ -131,6 +133,7 @@ app.include_router(devportal_api.router, prefix="/api/v1/devportal", tags=["DEV
|
||||
app.include_router(timetracking_api, prefix="/api/v1/timetracking", tags=["Time Tracking"])
|
||||
app.include_router(backups_api.router, prefix="/api/v1", tags=["Backup System"])
|
||||
app.include_router(emails_api.router, prefix="/api/v1", tags=["Email System"])
|
||||
app.include_router(ticket_api.router, prefix="/api/v1", tags=["Ticket System"])
|
||||
|
||||
# Frontend Routers
|
||||
app.include_router(auth_views.router, tags=["Frontend"])
|
||||
@ -144,6 +147,7 @@ app.include_router(devportal_views.router, tags=["Frontend"])
|
||||
app.include_router(backups_views.router, tags=["Frontend"])
|
||||
app.include_router(timetracking_views.router, tags=["Frontend"])
|
||||
app.include_router(emails_views.router, tags=["Frontend"])
|
||||
app.include_router(ticket_views.router, prefix="/ticket", tags=["Frontend - Tickets"])
|
||||
|
||||
# Serve static files (UI)
|
||||
app.mount("/static", StaticFiles(directory="static", html=True), name="static")
|
||||
|
||||
@ -106,6 +106,17 @@ INSERT INTO permissions (code, description, category) VALUES
|
||||
('billing.edit', 'Edit billing information', 'billing'),
|
||||
('billing.approve', 'Approve billing items', 'billing'),
|
||||
|
||||
-- Tickets
|
||||
('tickets.view', 'View tickets', 'tickets'),
|
||||
('tickets.create', 'Create tickets', 'tickets'),
|
||||
('tickets.edit', 'Edit tickets', 'tickets'),
|
||||
('tickets.delete', 'Delete tickets', 'tickets'),
|
||||
('tickets.assign', 'Assign tickets to users', 'tickets'),
|
||||
('tickets.worklog.create', 'Create worklog entries', 'tickets'),
|
||||
('tickets.worklog.approve', 'Approve worklog for billing', 'tickets'),
|
||||
('tickets.billing.export', 'Export billable worklog to e-conomic', 'tickets'),
|
||||
('tickets.prepaid.manage', 'Manage prepaid cards (klippekort)', 'tickets'),
|
||||
|
||||
-- System
|
||||
('system.view', 'View system settings', 'system'),
|
||||
('system.edit', 'Edit system settings', 'system'),
|
||||
@ -148,6 +159,8 @@ WHERE g.name = 'Managers' AND p.code IN (
|
||||
'customers.view', 'customers.edit', 'customers.create',
|
||||
'hardware.view',
|
||||
'billing.view', 'billing.edit', 'billing.approve',
|
||||
'tickets.view', 'tickets.create', 'tickets.edit', 'tickets.assign',
|
||||
'tickets.worklog.approve', 'tickets.billing.export', 'tickets.prepaid.manage',
|
||||
'reports.view', 'reports.export'
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
@ -160,6 +173,7 @@ CROSS JOIN permissions p
|
||||
WHERE g.name = 'Technicians' AND p.code IN (
|
||||
'customers.view',
|
||||
'hardware.view', 'hardware.edit', 'hardware.create', 'hardware.assign',
|
||||
'tickets.view', 'tickets.create', 'tickets.edit', 'tickets.worklog.create',
|
||||
'reports.view'
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
@ -173,6 +187,7 @@ WHERE g.name = 'Viewers' AND p.code IN (
|
||||
'customers.view',
|
||||
'hardware.view',
|
||||
'billing.view',
|
||||
'tickets.view',
|
||||
'reports.view'
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
484
migrations/025_ticket_module.sql
Normal file
484
migrations/025_ticket_module.sql
Normal file
@ -0,0 +1,484 @@
|
||||
-- ============================================================================
|
||||
-- Migration 025: Ticket System & Klippekort Modul (Isoleret)
|
||||
-- ============================================================================
|
||||
-- Dette modul er 100% isoleret og kan slettes uden at påvirke eksisterende data.
|
||||
-- Alle tabeller har prefix 'tticket_' for at markere tilhørsforhold til modulet.
|
||||
-- Ved uninstall køres DROP-scriptet i bunden af denne fil.
|
||||
-- ============================================================================
|
||||
|
||||
-- Metadata tabel til at tracke modulets tilstand
|
||||
CREATE TABLE IF NOT EXISTS tticket_metadata (
|
||||
id SERIAL PRIMARY KEY,
|
||||
module_version VARCHAR(20) NOT NULL DEFAULT '1.0.0',
|
||||
installed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
installed_by INTEGER, -- Reference til users.user_id (read-only, ingen FK)
|
||||
last_sync_at TIMESTAMP,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
settings JSONB DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
-- Indsæt initial metadata
|
||||
INSERT INTO tticket_metadata (module_version, is_active)
|
||||
VALUES ('1.0.0', true)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ============================================================================
|
||||
-- TICKETS (hovedtabel)
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS tticket_tickets (
|
||||
id SERIAL PRIMARY KEY,
|
||||
ticket_number VARCHAR(50) UNIQUE NOT NULL, -- Format: TKT-YYYYMMDD-XXX
|
||||
|
||||
-- Core felter
|
||||
subject VARCHAR(500) NOT NULL,
|
||||
description TEXT,
|
||||
status VARCHAR(20) DEFAULT 'open' CHECK (status IN ('open', 'in_progress', 'waiting_customer', 'waiting_internal', 'resolved', 'closed')),
|
||||
priority VARCHAR(20) DEFAULT 'normal' CHECK (priority IN ('low', 'normal', 'high', 'urgent')),
|
||||
category VARCHAR(100), -- support, bug, feature_request, question, etc.
|
||||
|
||||
-- Relationer (read-only references - INGEN FK til core tables)
|
||||
customer_id INTEGER, -- Reference til customers.id
|
||||
contact_id INTEGER, -- Reference til contacts.id
|
||||
assigned_to_user_id INTEGER, -- Reference til users.user_id
|
||||
created_by_user_id INTEGER, -- Reference til users.user_id
|
||||
|
||||
-- Kilde
|
||||
source VARCHAR(50) DEFAULT 'manual' CHECK (source IN ('email', 'portal', 'phone', 'manual', 'api')),
|
||||
|
||||
-- Metadata
|
||||
tags TEXT[],
|
||||
custom_fields JSONB,
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP,
|
||||
first_response_at TIMESTAMP, -- For SLA tracking (future)
|
||||
resolved_at TIMESTAMP,
|
||||
closed_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tticket_tickets_number ON tticket_tickets(ticket_number);
|
||||
CREATE INDEX idx_tticket_tickets_customer ON tticket_tickets(customer_id);
|
||||
CREATE INDEX idx_tticket_tickets_assigned ON tticket_tickets(assigned_to_user_id);
|
||||
CREATE INDEX idx_tticket_tickets_status ON tticket_tickets(status);
|
||||
CREATE INDEX idx_tticket_tickets_priority ON tticket_tickets(priority);
|
||||
CREATE INDEX idx_tticket_tickets_created ON tticket_tickets(created_at DESC);
|
||||
|
||||
-- ============================================================================
|
||||
-- COMMENTS (beskeder på tickets)
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS tticket_comments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE,
|
||||
user_id INTEGER, -- Reference til users.user_id (read-only, ingen FK)
|
||||
comment_text TEXT NOT NULL,
|
||||
is_internal BOOLEAN DEFAULT false, -- Intern note vs. kunde-synlig
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tticket_comments_ticket ON tticket_comments(ticket_id);
|
||||
CREATE INDEX idx_tticket_comments_created ON tticket_comments(created_at);
|
||||
|
||||
-- ============================================================================
|
||||
-- ATTACHMENTS (filer på tickets)
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS tticket_attachments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE,
|
||||
file_name VARCHAR(255) NOT NULL,
|
||||
file_path VARCHAR(500) NOT NULL,
|
||||
file_size INTEGER,
|
||||
mime_type VARCHAR(100),
|
||||
uploaded_by_user_id INTEGER, -- Reference til users.user_id (read-only)
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tticket_attachments_ticket ON tticket_attachments(ticket_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- WORKLOG (tidsregistrering på tickets)
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS tticket_worklog (
|
||||
id SERIAL PRIMARY KEY,
|
||||
ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE,
|
||||
|
||||
-- Arbejdsdata
|
||||
work_date DATE NOT NULL,
|
||||
hours DECIMAL(5,2) NOT NULL CHECK (hours > 0),
|
||||
work_type VARCHAR(50) DEFAULT 'support' CHECK (work_type IN ('support', 'development', 'troubleshooting', 'on_site', 'meeting', 'other')),
|
||||
description TEXT,
|
||||
|
||||
-- Fakturering
|
||||
billing_method VARCHAR(20) DEFAULT 'invoice' CHECK (billing_method IN ('prepaid_card', 'invoice', 'internal', 'warranty')),
|
||||
status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'billable', 'billed', 'non_billable')),
|
||||
prepaid_card_id INTEGER, -- Reference til tticket_prepaid_cards (read-only)
|
||||
|
||||
-- Metadata
|
||||
user_id INTEGER, -- Reference til users.user_id (read-only)
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP,
|
||||
billed_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tticket_worklog_ticket ON tticket_worklog(ticket_id);
|
||||
CREATE INDEX idx_tticket_worklog_status ON tticket_worklog(status);
|
||||
CREATE INDEX idx_tticket_worklog_date ON tticket_worklog(work_date);
|
||||
CREATE INDEX idx_tticket_worklog_card ON tticket_worklog(prepaid_card_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- PREPAID CARDS (klippekort - kun 1 aktivt per virksomhed)
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS tticket_prepaid_cards (
|
||||
id SERIAL PRIMARY KEY,
|
||||
card_number VARCHAR(50) UNIQUE NOT NULL, -- Format: CARD-YYYYMMDD-XXX
|
||||
|
||||
-- Tilknytning (kun 1 aktivt kort per customer)
|
||||
customer_id INTEGER NOT NULL, -- Reference til customers.id (read-only, ingen FK)
|
||||
|
||||
-- Saldo
|
||||
purchased_hours DECIMAL(8,2) NOT NULL CHECK (purchased_hours > 0),
|
||||
used_hours DECIMAL(8,2) DEFAULT 0 CHECK (used_hours >= 0),
|
||||
remaining_hours DECIMAL(8,2) GENERATED ALWAYS AS (purchased_hours - used_hours) STORED,
|
||||
|
||||
-- Priser
|
||||
price_per_hour DECIMAL(10,2) NOT NULL,
|
||||
total_amount DECIMAL(12,2) NOT NULL,
|
||||
|
||||
-- Lifecycle
|
||||
status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'depleted', 'expired', 'cancelled')),
|
||||
purchased_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP, -- NULL = ingen udløb
|
||||
|
||||
-- e-conomic integration
|
||||
economic_invoice_number VARCHAR(50),
|
||||
economic_product_number VARCHAR(50),
|
||||
|
||||
-- Metadata
|
||||
notes TEXT,
|
||||
created_by_user_id INTEGER, -- Reference til users.user_id (read-only)
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- CRITICAL: Kun 1 aktivt kort per customer
|
||||
CREATE UNIQUE INDEX idx_tticket_prepaid_unique_active ON tticket_prepaid_cards(customer_id)
|
||||
WHERE status = 'active';
|
||||
|
||||
CREATE INDEX idx_tticket_prepaid_customer ON tticket_prepaid_cards(customer_id);
|
||||
CREATE INDEX idx_tticket_prepaid_status ON tticket_prepaid_cards(status);
|
||||
CREATE INDEX idx_tticket_prepaid_expires ON tticket_prepaid_cards(expires_at);
|
||||
|
||||
-- ============================================================================
|
||||
-- PREPAID TRANSACTIONS (immutable log af forbrug)
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS tticket_prepaid_transactions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
card_id INTEGER NOT NULL REFERENCES tticket_prepaid_cards(id) ON DELETE CASCADE,
|
||||
worklog_id INTEGER, -- Reference til tticket_worklog (read-only, NULL for purchases/top-ups)
|
||||
|
||||
-- Transaction type
|
||||
transaction_type VARCHAR(20) NOT NULL CHECK (transaction_type IN ('purchase', 'top_up', 'usage', 'refund', 'expiration', 'cancellation')),
|
||||
|
||||
-- Beløb (positiv = tilføj, negativ = træk)
|
||||
hours DECIMAL(8,2) NOT NULL,
|
||||
balance_after DECIMAL(8,2) NOT NULL,
|
||||
|
||||
-- Beskrivelse
|
||||
description TEXT,
|
||||
|
||||
-- Metadata (immutable)
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by_user_id INTEGER -- Reference til users.user_id (read-only)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tticket_transactions_card ON tticket_prepaid_transactions(card_id);
|
||||
CREATE INDEX idx_tticket_transactions_worklog ON tticket_prepaid_transactions(worklog_id);
|
||||
CREATE INDEX idx_tticket_transactions_created ON tticket_prepaid_transactions(created_at DESC);
|
||||
|
||||
-- ============================================================================
|
||||
-- EMAIL INTEGRATION LOG (email → ticket mapping)
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS tticket_email_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
ticket_id INTEGER REFERENCES tticket_tickets(id) ON DELETE CASCADE,
|
||||
email_id INTEGER, -- Reference til email_messages.id (read-only, ingen FK)
|
||||
email_message_id VARCHAR(500), -- Email Message-ID header for threading
|
||||
action VARCHAR(50) NOT NULL, -- created|comment_added|updated|linked
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tticket_email_log_ticket ON tticket_email_log(ticket_id);
|
||||
CREATE INDEX idx_tticket_email_log_email ON tticket_email_log(email_id);
|
||||
CREATE INDEX idx_tticket_email_log_message_id ON tticket_email_log(email_message_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- AUDIT LOG (alle ændringer)
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS tticket_audit_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
ticket_id INTEGER, -- NULL for system-level events
|
||||
entity_type VARCHAR(50) NOT NULL, -- ticket, comment, worklog, prepaid_card, etc.
|
||||
entity_id INTEGER,
|
||||
user_id INTEGER, -- Reference til users.user_id (read-only)
|
||||
action VARCHAR(50) NOT NULL, -- created, updated, deleted, status_changed, assigned, etc.
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
details JSONB,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tticket_audit_ticket ON tticket_audit_log(ticket_id);
|
||||
CREATE INDEX idx_tticket_audit_entity ON tticket_audit_log(entity_type, entity_id);
|
||||
CREATE INDEX idx_tticket_audit_created ON tticket_audit_log(created_at DESC);
|
||||
|
||||
-- ============================================================================
|
||||
-- FUNCTIONS
|
||||
-- ============================================================================
|
||||
|
||||
-- Auto-update timestamp function
|
||||
CREATE OR REPLACE FUNCTION tticket_update_timestamp()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Generate ticket number function
|
||||
CREATE OR REPLACE FUNCTION tticket_generate_ticket_number()
|
||||
RETURNS VARCHAR AS $$
|
||||
DECLARE
|
||||
today_str VARCHAR(8);
|
||||
last_number INTEGER;
|
||||
next_number INTEGER;
|
||||
new_ticket_number VARCHAR(50);
|
||||
BEGIN
|
||||
-- Format: TKT-YYYYMMDD-XXX
|
||||
today_str := TO_CHAR(CURRENT_DATE, 'YYYYMMDD');
|
||||
|
||||
-- Find last ticket number for today
|
||||
SELECT COALESCE(
|
||||
MAX(CAST(SPLIT_PART(ticket_number, '-', 3) AS INTEGER)),
|
||||
0
|
||||
) INTO last_number
|
||||
FROM tticket_tickets
|
||||
WHERE ticket_number LIKE 'TKT-' || today_str || '-%';
|
||||
|
||||
next_number := last_number + 1;
|
||||
new_ticket_number := 'TKT-' || today_str || '-' || LPAD(next_number::TEXT, 3, '0');
|
||||
|
||||
RETURN new_ticket_number;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Generate prepaid card number function
|
||||
CREATE OR REPLACE FUNCTION tticket_generate_card_number()
|
||||
RETURNS VARCHAR AS $$
|
||||
DECLARE
|
||||
today_str VARCHAR(8);
|
||||
last_number INTEGER;
|
||||
next_number INTEGER;
|
||||
new_card_number VARCHAR(50);
|
||||
BEGIN
|
||||
-- Format: CARD-YYYYMMDD-XXX
|
||||
today_str := TO_CHAR(CURRENT_DATE, 'YYYYMMDD');
|
||||
|
||||
-- Find last card number for today
|
||||
SELECT COALESCE(
|
||||
MAX(CAST(SPLIT_PART(card_number, '-', 3) AS INTEGER)),
|
||||
0
|
||||
) INTO last_number
|
||||
FROM tticket_prepaid_cards
|
||||
WHERE card_number LIKE 'CARD-' || today_str || '-%';
|
||||
|
||||
next_number := last_number + 1;
|
||||
new_card_number := 'CARD-' || today_str || '-' || LPAD(next_number::TEXT, 3, '0');
|
||||
|
||||
RETURN new_card_number;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- ============================================================================
|
||||
-- TRIGGERS
|
||||
-- ============================================================================
|
||||
|
||||
-- Auto-update timestamps
|
||||
CREATE TRIGGER tticket_tickets_update
|
||||
BEFORE UPDATE ON tticket_tickets
|
||||
FOR EACH ROW EXECUTE FUNCTION tticket_update_timestamp();
|
||||
|
||||
CREATE TRIGGER tticket_comments_update
|
||||
BEFORE UPDATE ON tticket_comments
|
||||
FOR EACH ROW EXECUTE FUNCTION tticket_update_timestamp();
|
||||
|
||||
CREATE TRIGGER tticket_worklog_update
|
||||
BEFORE UPDATE ON tticket_worklog
|
||||
FOR EACH ROW EXECUTE FUNCTION tticket_update_timestamp();
|
||||
|
||||
CREATE TRIGGER tticket_prepaid_cards_update
|
||||
BEFORE UPDATE ON tticket_prepaid_cards
|
||||
FOR EACH ROW EXECUTE FUNCTION tticket_update_timestamp();
|
||||
|
||||
-- Auto-generate ticket number if not provided
|
||||
CREATE OR REPLACE FUNCTION tticket_auto_generate_ticket_number()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.ticket_number IS NULL OR NEW.ticket_number = '' THEN
|
||||
NEW.ticket_number := tticket_generate_ticket_number();
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER tticket_tickets_generate_number
|
||||
BEFORE INSERT ON tticket_tickets
|
||||
FOR EACH ROW EXECUTE FUNCTION tticket_auto_generate_ticket_number();
|
||||
|
||||
-- Auto-generate card number if not provided
|
||||
CREATE OR REPLACE FUNCTION tticket_auto_generate_card_number()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.card_number IS NULL OR NEW.card_number = '' THEN
|
||||
NEW.card_number := tticket_generate_card_number();
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER tticket_prepaid_cards_generate_number
|
||||
BEFORE INSERT ON tticket_prepaid_cards
|
||||
FOR EACH ROW EXECUTE FUNCTION tticket_auto_generate_card_number();
|
||||
|
||||
-- ============================================================================
|
||||
-- VIEWS (common queries)
|
||||
-- ============================================================================
|
||||
|
||||
-- View: Open tickets with statistics
|
||||
CREATE OR REPLACE VIEW tticket_open_tickets AS
|
||||
SELECT
|
||||
t.*,
|
||||
COUNT(DISTINCT c.id) AS comment_count,
|
||||
COUNT(DISTINCT a.id) AS attachment_count,
|
||||
SUM(w.hours) FILTER (WHERE w.status IN ('draft', 'billable')) AS pending_hours,
|
||||
SUM(w.hours) FILTER (WHERE w.status = 'billed') AS billed_hours,
|
||||
MAX(c.created_at) AS last_comment_at,
|
||||
EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - t.created_at))/3600 AS age_hours
|
||||
FROM tticket_tickets t
|
||||
LEFT JOIN tticket_comments c ON t.id = c.ticket_id
|
||||
LEFT JOIN tticket_attachments a ON t.id = a.ticket_id
|
||||
LEFT JOIN tticket_worklog w ON t.id = w.ticket_id
|
||||
WHERE t.status IN ('open', 'in_progress', 'waiting_customer', 'waiting_internal')
|
||||
GROUP BY t.id;
|
||||
|
||||
-- View: Worklog entries ready for billing
|
||||
CREATE OR REPLACE VIEW tticket_billable_worklog AS
|
||||
SELECT
|
||||
w.*,
|
||||
t.ticket_number,
|
||||
t.subject AS ticket_subject,
|
||||
t.customer_id,
|
||||
t.status AS ticket_status,
|
||||
CASE
|
||||
WHEN w.billing_method = 'prepaid_card' THEN pc.remaining_hours >= w.hours
|
||||
ELSE true
|
||||
END AS has_sufficient_balance
|
||||
FROM tticket_worklog w
|
||||
JOIN tticket_tickets t ON w.ticket_id = t.id
|
||||
LEFT JOIN tticket_prepaid_cards pc ON w.prepaid_card_id = pc.id
|
||||
WHERE w.status = 'billable';
|
||||
|
||||
-- View: Prepaid card balances
|
||||
CREATE OR REPLACE VIEW tticket_prepaid_balances AS
|
||||
SELECT
|
||||
pc.*,
|
||||
COUNT(w.id) AS usage_count,
|
||||
SUM(w.hours) AS total_hours_used,
|
||||
COUNT(w.id) FILTER (WHERE w.status = 'billed') AS billed_usage_count
|
||||
FROM tticket_prepaid_cards pc
|
||||
LEFT JOIN tticket_worklog w ON pc.id = w.prepaid_card_id
|
||||
GROUP BY pc.id;
|
||||
|
||||
-- View: Ticket statistics by status
|
||||
CREATE OR REPLACE VIEW tticket_stats_by_status AS
|
||||
SELECT
|
||||
status,
|
||||
priority,
|
||||
COUNT(*) AS ticket_count,
|
||||
AVG(EXTRACT(EPOCH FROM (COALESCE(resolved_at, CURRENT_TIMESTAMP) - created_at))/3600) AS avg_age_hours
|
||||
FROM tticket_tickets
|
||||
GROUP BY status, priority;
|
||||
|
||||
-- ============================================================================
|
||||
-- INITIAL DATA
|
||||
-- ============================================================================
|
||||
|
||||
-- Log installation in audit log
|
||||
INSERT INTO tticket_audit_log (entity_type, action, details)
|
||||
VALUES (
|
||||
'system',
|
||||
'module_installed',
|
||||
jsonb_build_object(
|
||||
'version', '1.0.0',
|
||||
'timestamp', CURRENT_TIMESTAMP,
|
||||
'tables_created', ARRAY[
|
||||
'tticket_metadata', 'tticket_tickets', 'tticket_comments',
|
||||
'tticket_attachments', 'tticket_worklog', 'tticket_prepaid_cards',
|
||||
'tticket_prepaid_transactions', 'tticket_email_log', 'tticket_audit_log'
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- UNINSTALL SCRIPT (bruges ved modul-sletning)
|
||||
-- ============================================================================
|
||||
-- ADVARSEL: Dette script sletter ALLE data i modulet!
|
||||
-- Kør kun hvis modulet skal fjernes fuldstændigt.
|
||||
--
|
||||
-- For at uninstalle, kør følgende kommandoer i rækkefølge:
|
||||
--
|
||||
-- -- Drop views
|
||||
-- DROP VIEW IF EXISTS tticket_stats_by_status CASCADE;
|
||||
-- DROP VIEW IF EXISTS tticket_prepaid_balances CASCADE;
|
||||
-- DROP VIEW IF EXISTS tticket_billable_worklog CASCADE;
|
||||
-- DROP VIEW IF EXISTS tticket_open_tickets CASCADE;
|
||||
--
|
||||
-- -- Drop triggers
|
||||
-- DROP TRIGGER IF EXISTS tticket_prepaid_cards_generate_number ON tticket_prepaid_cards;
|
||||
-- DROP TRIGGER IF EXISTS tticket_tickets_generate_number ON tticket_tickets;
|
||||
-- DROP TRIGGER IF EXISTS tticket_prepaid_cards_update ON tticket_prepaid_cards;
|
||||
-- DROP TRIGGER IF EXISTS tticket_worklog_update ON tticket_worklog;
|
||||
-- DROP TRIGGER IF EXISTS tticket_comments_update ON tticket_comments;
|
||||
-- DROP TRIGGER IF EXISTS tticket_tickets_update ON tticket_tickets;
|
||||
--
|
||||
-- -- Drop functions
|
||||
-- DROP FUNCTION IF EXISTS tticket_generate_card_number() CASCADE;
|
||||
-- DROP FUNCTION IF EXISTS tticket_generate_ticket_number() CASCADE;
|
||||
-- DROP FUNCTION IF EXISTS tticket_auto_generate_card_number() CASCADE;
|
||||
-- DROP FUNCTION IF EXISTS tticket_auto_generate_ticket_number() CASCADE;
|
||||
-- DROP FUNCTION IF EXISTS tticket_update_timestamp() CASCADE;
|
||||
--
|
||||
-- -- Drop tables (reverse dependency order)
|
||||
-- DROP TABLE IF EXISTS tticket_audit_log CASCADE;
|
||||
-- DROP TABLE IF EXISTS tticket_email_log CASCADE;
|
||||
-- DROP TABLE IF EXISTS tticket_prepaid_transactions CASCADE;
|
||||
-- DROP TABLE IF EXISTS tticket_prepaid_cards CASCADE;
|
||||
-- DROP TABLE IF EXISTS tticket_worklog CASCADE;
|
||||
-- DROP TABLE IF EXISTS tticket_attachments CASCADE;
|
||||
-- DROP TABLE IF EXISTS tticket_comments CASCADE;
|
||||
-- DROP TABLE IF EXISTS tticket_tickets CASCADE;
|
||||
-- DROP TABLE IF EXISTS tticket_metadata CASCADE;
|
||||
--
|
||||
-- -- Log uninstall
|
||||
-- -- (Dette vil fejle hvis tticket_audit_log er droppet, men det er OK)
|
||||
-- DO $$
|
||||
-- BEGIN
|
||||
-- IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'tticket_audit_log') THEN
|
||||
-- INSERT INTO tticket_audit_log (entity_type, action, details)
|
||||
-- VALUES ('system', 'module_uninstalled', jsonb_build_object('timestamp', CURRENT_TIMESTAMP));
|
||||
-- END IF;
|
||||
-- EXCEPTION WHEN OTHERS THEN
|
||||
-- NULL; -- Ignorer fejl
|
||||
-- END $$;
|
||||
--
|
||||
-- ============================================================================
|
||||
59
migrations/026_ticket_permissions.sql
Normal file
59
migrations/026_ticket_permissions.sql
Normal file
@ -0,0 +1,59 @@
|
||||
-- Migration 026: Add Ticket System Permissions
|
||||
-- Description: Adds ticket-related permissions to auth system
|
||||
-- Date: 2025-12-15
|
||||
|
||||
-- Add ticket permissions
|
||||
INSERT INTO permissions (code, description, category) VALUES
|
||||
('tickets.view', 'View tickets', 'tickets'),
|
||||
('tickets.create', 'Create tickets', 'tickets'),
|
||||
('tickets.edit', 'Edit tickets', 'tickets'),
|
||||
('tickets.delete', 'Delete tickets', 'tickets'),
|
||||
('tickets.assign', 'Assign tickets to users', 'tickets'),
|
||||
('tickets.worklog.create', 'Create worklog entries', 'tickets'),
|
||||
('tickets.worklog.approve', 'Approve worklog for billing', 'tickets'),
|
||||
('tickets.billing.export', 'Export billable worklog to e-conomic', 'tickets'),
|
||||
('tickets.prepaid.manage', 'Manage prepaid cards (klippekort)', 'tickets')
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
-- Assign ticket permissions to Administrators group (all permissions)
|
||||
INSERT INTO group_permissions (group_id, permission_id)
|
||||
SELECT g.id, p.id
|
||||
FROM groups g
|
||||
CROSS JOIN permissions p
|
||||
WHERE g.name = 'Administrators' AND p.category = 'tickets'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Assign ticket permissions to Managers group
|
||||
INSERT INTO group_permissions (group_id, permission_id)
|
||||
SELECT g.id, p.id
|
||||
FROM groups g
|
||||
CROSS JOIN permissions p
|
||||
WHERE g.name = 'Managers' AND p.code IN (
|
||||
'tickets.view', 'tickets.create', 'tickets.edit', 'tickets.assign',
|
||||
'tickets.worklog.approve', 'tickets.billing.export', 'tickets.prepaid.manage'
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Assign ticket permissions to Technicians group
|
||||
INSERT INTO group_permissions (group_id, permission_id)
|
||||
SELECT g.id, p.id
|
||||
FROM groups g
|
||||
CROSS JOIN permissions p
|
||||
WHERE g.name = 'Technicians' AND p.code IN (
|
||||
'tickets.view', 'tickets.create', 'tickets.edit', 'tickets.worklog.create'
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Assign ticket view permission to Viewers group
|
||||
INSERT INTO group_permissions (group_id, permission_id)
|
||||
SELECT g.id, p.id
|
||||
FROM groups g
|
||||
CROSS JOIN permissions p
|
||||
WHERE g.name = 'Viewers' AND p.code = 'tickets.view'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Verify migration
|
||||
SELECT 'Ticket permissions added successfully' AS status,
|
||||
COUNT(*) AS ticket_permissions_count
|
||||
FROM permissions
|
||||
WHERE category = 'tickets';
|
||||
490
test_ticket_module.py
Normal file
490
test_ticket_module.py
Normal file
@ -0,0 +1,490 @@
|
||||
"""
|
||||
Test script for Ticket Module
|
||||
==============================
|
||||
|
||||
Tester database schema, funktioner, constraints og services.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, '/app')
|
||||
|
||||
# Initialize database pool before importing services
|
||||
from app.core.database import init_db, execute_query, execute_insert, execute_update
|
||||
from app.ticket.backend.ticket_service import TicketService
|
||||
from app.ticket.backend.models import (
|
||||
TTicketCreate,
|
||||
TicketStatus,
|
||||
TicketPriority,
|
||||
TicketSource,
|
||||
WorkType,
|
||||
BillingMethod
|
||||
)
|
||||
|
||||
def print_test(name: str, passed: bool, details: str = ""):
|
||||
"""Print test result"""
|
||||
emoji = "✅" if passed else "❌"
|
||||
print(f"{emoji} {name}")
|
||||
if details:
|
||||
print(f" {details}")
|
||||
print()
|
||||
|
||||
|
||||
def test_tables_exist():
|
||||
"""Test 1: Check all tables are created"""
|
||||
print("=" * 60)
|
||||
print("TEST 1: Database Tables")
|
||||
print("=" * 60)
|
||||
|
||||
tables = [
|
||||
'tticket_metadata',
|
||||
'tticket_tickets',
|
||||
'tticket_comments',
|
||||
'tticket_attachments',
|
||||
'tticket_worklog',
|
||||
'tticket_prepaid_cards',
|
||||
'tticket_prepaid_transactions',
|
||||
'tticket_email_log',
|
||||
'tticket_audit_log'
|
||||
]
|
||||
|
||||
for table in tables:
|
||||
result = execute_query(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_name = %s
|
||||
)
|
||||
""",
|
||||
(table,),
|
||||
fetchone=True
|
||||
)
|
||||
print_test(f"Table '{table}' exists", result['exists'])
|
||||
|
||||
# Check views
|
||||
views = [
|
||||
'tticket_open_tickets',
|
||||
'tticket_billable_worklog',
|
||||
'tticket_prepaid_balances',
|
||||
'tticket_stats_by_status'
|
||||
]
|
||||
|
||||
for view in views:
|
||||
result = execute_query(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.views
|
||||
WHERE table_name = %s
|
||||
)
|
||||
""",
|
||||
(view,),
|
||||
fetchone=True
|
||||
)
|
||||
print_test(f"View '{view}' exists", result['exists'])
|
||||
|
||||
|
||||
def test_ticket_number_generation():
|
||||
"""Test 2: Auto-generate ticket number"""
|
||||
print("=" * 60)
|
||||
print("TEST 2: Ticket Number Generation")
|
||||
print("=" * 60)
|
||||
|
||||
# Insert ticket without ticket_number (should auto-generate)
|
||||
ticket_id = execute_insert(
|
||||
"""
|
||||
INSERT INTO tticket_tickets (subject, description, status)
|
||||
VALUES (%s, %s, %s)
|
||||
""",
|
||||
("Test Ticket", "Testing auto-generation", "open")
|
||||
)
|
||||
|
||||
ticket = execute_query(
|
||||
"SELECT ticket_number FROM tticket_tickets WHERE id = %s",
|
||||
(ticket_id,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
# Check format: TKT-YYYYMMDD-XXX
|
||||
import re
|
||||
pattern = r'TKT-\d{8}-\d{3}'
|
||||
matches = re.match(pattern, ticket['ticket_number'])
|
||||
|
||||
print_test(
|
||||
"Ticket number auto-generated",
|
||||
matches is not None,
|
||||
f"Generated: {ticket['ticket_number']}"
|
||||
)
|
||||
|
||||
# Test generating multiple tickets
|
||||
ticket_id2 = execute_insert(
|
||||
"""
|
||||
INSERT INTO tticket_tickets (subject, status)
|
||||
VALUES (%s, %s)
|
||||
""",
|
||||
("Test Ticket 2", "open")
|
||||
)
|
||||
|
||||
ticket2 = execute_query(
|
||||
"SELECT ticket_number FROM tticket_tickets WHERE id = %s",
|
||||
(ticket_id2,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
print_test(
|
||||
"Sequential ticket numbers",
|
||||
ticket2['ticket_number'] > ticket['ticket_number'],
|
||||
f"First: {ticket['ticket_number']}, Second: {ticket2['ticket_number']}"
|
||||
)
|
||||
|
||||
|
||||
def test_prepaid_card_constraints():
|
||||
"""Test 3: Prepaid card constraints (1 active per customer)"""
|
||||
print("=" * 60)
|
||||
print("TEST 3: Prepaid Card Constraints")
|
||||
print("=" * 60)
|
||||
|
||||
# Create first active card for customer 1
|
||||
card1_id = execute_insert(
|
||||
"""
|
||||
INSERT INTO tticket_prepaid_cards
|
||||
(customer_id, purchased_hours, price_per_hour, total_amount, status)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
""",
|
||||
(1, Decimal('10.0'), Decimal('850.0'), Decimal('8500.0'), 'active')
|
||||
)
|
||||
|
||||
card1 = execute_query(
|
||||
"SELECT card_number, remaining_hours FROM tticket_prepaid_cards WHERE id = %s",
|
||||
(card1_id,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
print_test(
|
||||
"First prepaid card created",
|
||||
card1 is not None,
|
||||
f"Card: {card1['card_number']}, Balance: {card1['remaining_hours']} hours"
|
||||
)
|
||||
|
||||
# Try to create second active card for same customer (should fail due to UNIQUE constraint)
|
||||
try:
|
||||
card2_id = execute_insert(
|
||||
"""
|
||||
INSERT INTO tticket_prepaid_cards
|
||||
(customer_id, purchased_hours, price_per_hour, total_amount, status)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
""",
|
||||
(1, Decimal('20.0'), Decimal('850.0'), Decimal('17000.0'), 'active')
|
||||
)
|
||||
print_test(
|
||||
"Cannot create 2nd active card (UNIQUE constraint)",
|
||||
False,
|
||||
"ERROR: Constraint not enforced!"
|
||||
)
|
||||
except Exception as e:
|
||||
print_test(
|
||||
"Cannot create 2nd active card (UNIQUE constraint)",
|
||||
"unique" in str(e).lower() or "duplicate" in str(e).lower(),
|
||||
f"Constraint enforced: {str(e)[:80]}"
|
||||
)
|
||||
|
||||
# Create inactive card for same customer (should work)
|
||||
card3_id = execute_insert(
|
||||
"""
|
||||
INSERT INTO tticket_prepaid_cards
|
||||
(customer_id, purchased_hours, price_per_hour, total_amount, status)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
""",
|
||||
(1, Decimal('5.0'), Decimal('850.0'), Decimal('4250.0'), 'depleted')
|
||||
)
|
||||
|
||||
print_test(
|
||||
"Can create inactive card for same customer",
|
||||
card3_id is not None,
|
||||
"Multiple inactive cards allowed"
|
||||
)
|
||||
|
||||
|
||||
def test_generated_column():
|
||||
"""Test 4: Generated column (remaining_hours)"""
|
||||
print("=" * 60)
|
||||
print("TEST 4: Generated Column (remaining_hours)")
|
||||
print("=" * 60)
|
||||
|
||||
# Create card
|
||||
card_id = execute_insert(
|
||||
"""
|
||||
INSERT INTO tticket_prepaid_cards
|
||||
(customer_id, purchased_hours, price_per_hour, total_amount, status)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
""",
|
||||
(2, Decimal('20.0'), Decimal('850.0'), Decimal('17000.0'), 'active')
|
||||
)
|
||||
|
||||
card = execute_query(
|
||||
"SELECT purchased_hours, used_hours, remaining_hours FROM tticket_prepaid_cards WHERE id = %s",
|
||||
(card_id,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
print_test(
|
||||
"Initial remaining_hours calculated",
|
||||
card['remaining_hours'] == Decimal('20.0'),
|
||||
f"Purchased: {card['purchased_hours']}, Used: {card['used_hours']}, Remaining: {card['remaining_hours']}"
|
||||
)
|
||||
|
||||
# Use some hours
|
||||
execute_update(
|
||||
"UPDATE tticket_prepaid_cards SET used_hours = %s WHERE id = %s",
|
||||
(Decimal('5.5'), card_id)
|
||||
)
|
||||
|
||||
card = execute_query(
|
||||
"SELECT purchased_hours, used_hours, remaining_hours FROM tticket_prepaid_cards WHERE id = %s",
|
||||
(card_id,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
expected = Decimal('14.5')
|
||||
print_test(
|
||||
"remaining_hours auto-updates",
|
||||
card['remaining_hours'] == expected,
|
||||
f"After using 5.5h: {card['remaining_hours']}h (expected: {expected}h)"
|
||||
)
|
||||
|
||||
|
||||
def test_ticket_service():
|
||||
"""Test 5: Ticket Service business logic"""
|
||||
print("=" * 60)
|
||||
print("TEST 5: Ticket Service")
|
||||
print("=" * 60)
|
||||
|
||||
# Test create ticket
|
||||
ticket_data = TTicketCreate(
|
||||
subject="Test Service Ticket",
|
||||
description="Testing TicketService",
|
||||
priority=TicketPriority.HIGH,
|
||||
customer_id=1,
|
||||
source=TicketSource.MANUAL
|
||||
)
|
||||
|
||||
ticket = TicketService.create_ticket(ticket_data, user_id=1)
|
||||
|
||||
print_test(
|
||||
"TicketService.create_ticket() works",
|
||||
ticket is not None and 'id' in ticket,
|
||||
f"Created ticket: {ticket['ticket_number']}"
|
||||
)
|
||||
|
||||
# Test status transition validation
|
||||
is_valid, error = TicketService.validate_status_transition('open', 'in_progress')
|
||||
print_test(
|
||||
"Valid status transition (open → in_progress)",
|
||||
is_valid and error is None,
|
||||
"Transition allowed"
|
||||
)
|
||||
|
||||
is_valid, error = TicketService.validate_status_transition('closed', 'open')
|
||||
print_test(
|
||||
"Invalid status transition (closed → open)",
|
||||
not is_valid and error is not None,
|
||||
f"Transition blocked: {error}"
|
||||
)
|
||||
|
||||
# Test update status
|
||||
try:
|
||||
updated = TicketService.update_ticket_status(
|
||||
ticket['id'],
|
||||
'in_progress',
|
||||
user_id=1,
|
||||
note="Starting work"
|
||||
)
|
||||
|
||||
print_test(
|
||||
"TicketService.update_ticket_status() works",
|
||||
updated['status'] == 'in_progress',
|
||||
f"Status changed: {ticket['status']} → {updated['status']}"
|
||||
)
|
||||
except Exception as e:
|
||||
print_test(
|
||||
"TicketService.update_ticket_status() works",
|
||||
False,
|
||||
f"ERROR: {e}"
|
||||
)
|
||||
|
||||
# Test add comment
|
||||
try:
|
||||
comment = TicketService.add_comment(
|
||||
ticket['id'],
|
||||
"Test comment from service",
|
||||
user_id=1,
|
||||
is_internal=False
|
||||
)
|
||||
|
||||
print_test(
|
||||
"TicketService.add_comment() works",
|
||||
comment is not None and 'id' in comment,
|
||||
f"Comment added (ID: {comment['id']})"
|
||||
)
|
||||
except Exception as e:
|
||||
print_test(
|
||||
"TicketService.add_comment() works",
|
||||
False,
|
||||
f"ERROR: {e}"
|
||||
)
|
||||
|
||||
|
||||
def test_worklog_creation():
|
||||
"""Test 6: Worklog creation"""
|
||||
print("=" * 60)
|
||||
print("TEST 6: Worklog")
|
||||
print("=" * 60)
|
||||
|
||||
# Get a ticket
|
||||
ticket = execute_query(
|
||||
"SELECT id FROM tticket_tickets LIMIT 1",
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
if not ticket:
|
||||
print_test("Worklog creation", False, "No tickets found to test worklog")
|
||||
return
|
||||
|
||||
# Create worklog entry
|
||||
worklog_id = execute_insert(
|
||||
"""
|
||||
INSERT INTO tticket_worklog
|
||||
(ticket_id, work_date, hours, work_type, billing_method, status, user_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(ticket['id'], date.today(), Decimal('2.5'), 'support', 'invoice', 'draft', 1)
|
||||
)
|
||||
|
||||
worklog = execute_query(
|
||||
"SELECT * FROM tticket_worklog WHERE id = %s",
|
||||
(worklog_id,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
print_test(
|
||||
"Worklog entry created",
|
||||
worklog is not None,
|
||||
f"ID: {worklog_id}, Hours: {worklog['hours']}, Status: {worklog['status']}"
|
||||
)
|
||||
|
||||
# Check view
|
||||
billable_count = execute_query(
|
||||
"SELECT COUNT(*) as count FROM tticket_billable_worklog WHERE status = 'billable'",
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
print_test(
|
||||
"Billable worklog view accessible",
|
||||
billable_count is not None,
|
||||
f"Found {billable_count['count']} billable entries"
|
||||
)
|
||||
|
||||
|
||||
def test_audit_logging():
|
||||
"""Test 7: Audit logging"""
|
||||
print("=" * 60)
|
||||
print("TEST 7: Audit Logging")
|
||||
print("=" * 60)
|
||||
|
||||
# Check if audit entries were created
|
||||
audit_count = execute_query(
|
||||
"SELECT COUNT(*) as count FROM tticket_audit_log",
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
print_test(
|
||||
"Audit log has entries",
|
||||
audit_count['count'] > 0,
|
||||
f"Found {audit_count['count']} audit entries"
|
||||
)
|
||||
|
||||
# Check recent audit entries
|
||||
recent = execute_query(
|
||||
"""
|
||||
SELECT action, entity_type, created_at
|
||||
FROM tticket_audit_log
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 5
|
||||
"""
|
||||
)
|
||||
|
||||
if recent:
|
||||
print("Recent audit entries:")
|
||||
for entry in recent:
|
||||
print(f" - {entry['action']} on {entry['entity_type']} at {entry['created_at']}")
|
||||
print()
|
||||
|
||||
|
||||
def test_views():
|
||||
"""Test 8: Database views"""
|
||||
print("=" * 60)
|
||||
print("TEST 8: Database Views")
|
||||
print("=" * 60)
|
||||
|
||||
# Test tticket_open_tickets view
|
||||
open_tickets = execute_query(
|
||||
"SELECT ticket_number, comment_count, age_hours FROM tticket_open_tickets LIMIT 5"
|
||||
)
|
||||
|
||||
print_test(
|
||||
"tticket_open_tickets view works",
|
||||
open_tickets is not None,
|
||||
f"Found {len(open_tickets)} open tickets"
|
||||
)
|
||||
|
||||
# Test tticket_stats_by_status view
|
||||
stats = execute_query(
|
||||
"SELECT status, ticket_count FROM tticket_stats_by_status"
|
||||
)
|
||||
|
||||
if stats:
|
||||
print("Ticket statistics by status:")
|
||||
for stat in stats:
|
||||
print(f" - {stat['status']}: {stat['ticket_count']} tickets")
|
||||
print()
|
||||
|
||||
|
||||
def main():
|
||||
"""Run all tests"""
|
||||
print("\n")
|
||||
print("🎫 TICKET MODULE TEST SUITE")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
# Initialize database pool
|
||||
print("🔌 Initializing database connection...")
|
||||
init_db()
|
||||
print("✅ Database connected\n")
|
||||
|
||||
try:
|
||||
test_tables_exist()
|
||||
test_ticket_number_generation()
|
||||
test_prepaid_card_constraints()
|
||||
test_generated_column()
|
||||
test_ticket_service()
|
||||
test_worklog_creation()
|
||||
test_audit_logging()
|
||||
test_views()
|
||||
|
||||
print("=" * 60)
|
||||
print("✅ ALL TESTS COMPLETED")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ TEST SUITE FAILED: {e}\n")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in New Issue
Block a user