feat: Enhance time tracking with Hub Worklog integration and editing capabilities

- Added hub_customer_id to TModuleApprovalStats for better tracking.
- Introduced TModuleWizardEditRequest for editing time entries, allowing updates to description, hours, and billing method.
- Implemented approval and rejection logic for Hub Worklogs, including handling negative IDs.
- Created a new endpoint for updating entry details, supporting both Hub Worklogs and Module Times.
- Updated frontend to include an edit modal for time entries, with specific fields for Hub Worklogs and Module Times.
- Enhanced customer statistics retrieval to include pending counts from Hub Worklogs.
- Added migrations for ticket enhancements, including new fields and constraints for worklogs and prepaid cards.
This commit is contained in:
Christian 2026-01-10 21:09:29 +01:00
parent a1d4696005
commit f62cd8104a
20 changed files with 1771 additions and 447 deletions

View File

@ -3,15 +3,27 @@ Contact API Router - Simplified (Read-Only)
Only GET endpoints for now
"""
from fastapi import APIRouter, HTTPException, Query
from fastapi import APIRouter, HTTPException, Query, Body, status
from typing import Optional
from app.core.database import execute_query
from pydantic import BaseModel, Field
from app.core.database import execute_query, execute_insert
import logging
logger = logging.getLogger(__name__)
router = APIRouter()
class ContactCreate(BaseModel):
"""Schema for creating a contact"""
first_name: str
last_name: str = ""
email: Optional[str] = None
phone: Optional[str] = None
title: Optional[str] = None
company_id: Optional[int] = None
@router.get("/contacts-debug")
async def debug_contacts():
"""Debug endpoint: Check contact-company links"""
@ -119,6 +131,55 @@ async def get_contacts(
raise HTTPException(status_code=500, detail=str(e))
@router.post("/contacts", status_code=status.HTTP_201_CREATED)
async def create_contact(contact: ContactCreate):
"""
Create a new basic contact
"""
try:
# Check if email exists
if contact.email:
existing = execute_query(
"SELECT id FROM contacts WHERE email = %s",
(contact.email,)
)
if existing:
# Return existing contact if found? Or error?
# For now, let's error to be safe, or just return it?
# User prompted "Smart Create", implies if it exists, use it?
# But safer to say "Email already exists"
pass
insert_query = """
INSERT INTO contacts (first_name, last_name, email, phone, title, is_active)
VALUES (%s, %s, %s, %s, %s, true)
RETURNING id
"""
contact_id = execute_insert(
insert_query,
(contact.first_name, contact.last_name, contact.email, contact.phone, contact.title)
)
# Link to company if provided
if contact.company_id:
try:
link_query = """
INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
VALUES (%s, %s, true, 'primary')
"""
execute_insert(link_query, (contact_id, contact.company_id))
except Exception as e:
logger.error(f"Failed to link new contact {contact_id} to company {contact.company_id}: {e}")
# Don't fail the whole request, just log it
return await get_contact(contact_id)
except Exception as e:
logger.error(f"Failed to create contact: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/contacts/{contact_id}")
async def get_contact(contact_id: int):
"""Get a single contact by ID with linked companies"""

View File

@ -1,6 +1,7 @@
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse
from app.core.database import execute_query_single
router = APIRouter()
templates = Jinja2Templates(directory="app")
@ -10,4 +11,19 @@ async def dashboard(request: Request):
"""
Render the dashboard page
"""
return templates.TemplateResponse("dashboard/frontend/index.html", {"request": request})
# Fetch count of unknown billing worklogs
unknown_query = """
SELECT COUNT(*) as count
FROM tticket_worklog
WHERE billing_method = 'unknown'
AND status NOT IN ('billed', 'rejected')
"""
start_date = "2024-01-01" # Filter ancient history if needed, but for now take all
result = execute_query_single(unknown_query)
unknown_count = result['count'] if result else 0
return templates.TemplateResponse("dashboard/frontend/index.html", {
"request": request,
"unknown_worklog_count": unknown_count
})

View File

@ -14,6 +14,18 @@
</div>
</div>
<!-- Alerts -->
{% if unknown_worklog_count > 0 %}
<div class="alert alert-warning d-flex align-items-center mb-5" role="alert">
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 me-3 fs-4"></i>
<div>
<h5 class="alert-heading mb-1">Tidsregistreringer kræver handling</h5>
Der er <strong>{{ unknown_worklog_count }}</strong> tidsregistrering(er) med typen "Ved ikke".
<a href="/ticket/worklog/review" class="alert-link">Gå til godkendelse</a> for at afklare dem.
</div>
</div>
{% endif %}
<div class="row g-4 mb-5">
<div class="col-md-3">
<div class="card stat-card p-4 h-100">

View File

@ -122,23 +122,13 @@ async def get_prepaid_card(card_id: int):
async def create_prepaid_card(card: PrepaidCardCreate):
"""
Create a new prepaid card
Note: As of migration 065, customers can have multiple active cards simultaneously.
"""
try:
# Calculate total amount
total_amount = card.purchased_hours * card.price_per_hour
# Check if customer already has active card
existing = execute_query("""
SELECT id FROM tticket_prepaid_cards
WHERE customer_id = %s AND status = 'active'
""", (card.customer_id,))
if existing and len(existing) > 0:
raise HTTPException(
status_code=400,
detail="Customer already has an active prepaid card"
)
# Create card (need to use fetch=False for INSERT RETURNING)
conn = None
try:

View File

@ -48,6 +48,17 @@
<h5 class="mb-0">Oversigt</h5>
</div>
<div class="card-body">
<!-- Usage Meter -->
<div class="mb-4">
<div class="d-flex justify-content-between mb-1">
<small class="text-muted fw-bold">Forbrug</small>
<small class="fw-bold" id="statPercent">0%</small>
</div>
<div class="progress" style="height: 10px; background-color: #e9ecef; border-radius: 6px;">
<div class="progress-bar transition-all" id="statProgressBar" role="progressbar" style="width: 0%; transition: width 0.6s ease;"></div>
</div>
</div>
<div class="mb-3 pb-3 border-bottom">
<small class="text-muted d-block mb-1">Købte Timer</small>
<h4 class="mb-0" id="statPurchased">-</h4>
@ -148,6 +159,27 @@ async function loadCardDetails() {
currency: 'DKK'
}).format(parseFloat(card.total_amount));
// Update Progress Bar
const purchased = parseFloat(card.purchased_hours) || 0;
const used = parseFloat(card.used_hours) || 0;
const percent = purchased > 0 ? (used / purchased) * 100 : 0;
const progressBar = document.getElementById('statProgressBar');
progressBar.style.width = Math.min(percent, 100) + '%';
document.getElementById('statPercent').textContent = Math.round(percent) + '%';
// Color logic for progress bar
progressBar.className = 'progress-bar transition-all'; // Reset class but keep transition
if (percent >= 100) {
progressBar.classList.add('bg-secondary'); // Depleted
} else if (percent > 90) {
progressBar.classList.add('bg-danger'); // Critical
} else if (percent > 75) {
progressBar.classList.add('bg-warning'); // Warning
} else {
progressBar.classList.add('bg-success'); // Good
}
// Update card info
const statusBadge = getStatusBadge(card.status);
const expiresAt = card.expires_at ?

View File

@ -129,6 +129,7 @@
<th class="text-end">Købte Timer</th>
<th class="text-end">Brugte Timer</th>
<th class="text-end">Tilbage</th>
<th>Forbrug</th>
<th class="text-end">Pris/Time</th>
<th class="text-end">Total</th>
<th>Status</th>
@ -138,7 +139,7 @@
</thead>
<tbody id="cardsTableBody">
<tr>
<td colspan="10" class="text-center py-5">
<td colspan="11" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
@ -169,8 +170,20 @@
</div>
<div class="mb-3">
<label class="form-label">Antal Timer *</label>
<div class="input-group">
<input type="number" class="form-control" id="purchasedHours"
step="0.5" min="1" required>
<button type="button" class="btn btn-outline-secondary" onclick="setPurchasedHours(10)" title="10 timer">
10t
</button>
<button type="button" class="btn btn-outline-secondary" onclick="setPurchasedHours(25)" title="25 timer">
25t
</button>
<button type="button" class="btn btn-outline-secondary" onclick="setPurchasedHours(50)" title="50 timer">
50t
</button>
</div>
<div class="form-text">💡 Brug hurtigknapperne eller indtast tilpasset antal</div>
</div>
<div class="mb-3">
<label class="form-label">Pris pr. Time (DKK) *</label>
@ -270,7 +283,7 @@ function renderCards(cards) {
if (!cards || cards.length === 0) {
tbody.innerHTML = `
<tr><td colspan="10" class="text-center text-muted py-5">
<tr><td colspan="11" class="text-center text-muted py-5">
Ingen kort fundet
</td></tr>
`;
@ -289,6 +302,14 @@ function renderCards(cards) {
const pricePerHour = parseFloat(card.price_per_hour);
const totalAmount = parseFloat(card.total_amount);
// Calculate usage percentage
const usedPercent = purchasedHours > 0 ? Math.min(100, Math.max(0, (usedHours / purchasedHours) * 100)) : 0;
// Progress bar color based on usage
let progressClass = 'bg-success';
if (usedPercent >= 90) progressClass = 'bg-danger';
else if (usedPercent >= 75) progressClass = 'bg-warning';
return `
<tr>
<td>
@ -307,6 +328,19 @@ function renderCards(cards) {
${remainingHours.toFixed(1)} t
</strong>
</td>
<td>
<div class="progress" style="height: 20px; min-width: 100px;">
<div class="progress-bar ${progressClass}"
role="progressbar"
style="width: ${usedPercent.toFixed(0)}%"
aria-valuenow="${usedPercent.toFixed(0)}"
aria-valuemin="0"
aria-valuemax="100">
${usedPercent.toFixed(0)}%
</div>
</div>
<small class="text-muted">Forbrug</small>
</td>
<td class="text-end">${pricePerHour.toFixed(2)} kr</td>
<td class="text-end"><strong>${totalAmount.toFixed(2)} kr</strong></td>
<td>${statusBadge}</td>
@ -341,6 +375,13 @@ function getStatusBadge(status) {
return badges[status] || status;
}
// Set purchased hours from quick template buttons
function setPurchasedHours(hours) {
document.getElementById('purchasedHours').value = hours;
// Optionally focus next field (pricePerHour) for quick workflow
document.getElementById('pricePerHour').focus();
}
// Load Customers for Dropdown
async function loadCustomers() {
try {

View File

@ -4,7 +4,8 @@ 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).
NOTE: As of migration 065, customers can have multiple active cards simultaneously.
When multiple active cards exist, operations default to the card with earliest expiry.
"""
import logging
@ -38,8 +39,7 @@ class KlippekortService:
"""
Purchase a new prepaid card
CONSTRAINT: Only 1 active card allowed per customer.
This will fail if customer already has an active card.
Note: As of migration 065, customers can have multiple active cards simultaneously.
Args:
card_data: Card purchase data
@ -47,26 +47,9 @@ class KlippekortService:
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_single(
"""
SELECT id, card_number FROM tticket_prepaid_cards
WHERE customer_id = %s AND status = 'active'
""",
(card_data.customer_id,))
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)
@ -133,18 +116,30 @@ class KlippekortService:
(card_id,))
@staticmethod
def get_active_card_for_customer(customer_id: int) -> Optional[Dict[str, Any]]:
def get_active_cards_for_customer(customer_id: int) -> List[Dict[str, Any]]:
"""
Get active prepaid card for customer
Get all active prepaid cards for customer (sorted by expiry)
Returns None if no active card exists.
Returns empty list if no active cards exist.
"""
return execute_query_single(
cards = execute_query(
"""
SELECT * FROM tticket_prepaid_cards
WHERE customer_id = %s AND status = 'active'
ORDER BY expires_at ASC NULLS LAST, created_at ASC
""",
(customer_id,))
return cards or []
@staticmethod
def get_active_card_for_customer(customer_id: int) -> Optional[Dict[str, Any]]:
"""
Get active prepaid card for customer (defaults to earliest expiry if multiple)
Returns None if no active card exists.
"""
cards = KlippekortService.get_active_cards_for_customer(customer_id)
return cards[0] if cards else None
@staticmethod
def check_balance(customer_id: int) -> Dict[str, Any]:

View File

@ -60,6 +60,7 @@ class BillingMethod(str, Enum):
INVOICE = "invoice"
INTERNAL = "internal"
WARRANTY = "warranty"
UNKNOWN = "unknown"
class WorklogStatus(str, Enum):
@ -88,6 +89,14 @@ class TransactionType(str, Enum):
CANCELLATION = "cancellation"
class TicketType(str, Enum):
"""Ticket kategorisering"""
INCIDENT = "incident" # Fejl der skal fixes (Høj urgens)
REQUEST = "request" # Bestilling / Ønske (Planlægges)
PROBLEM = "problem" # Root cause (Fejlfinding)
PROJECT = "project" # Større projektarbejde
# ============================================================================
# TICKET MODELS
# ============================================================================
@ -98,6 +107,8 @@ class TTicketBase(BaseModel):
description: Optional[str] = None
status: TicketStatus = Field(default=TicketStatus.OPEN)
priority: TicketPriority = Field(default=TicketPriority.NORMAL)
ticket_type: TicketType = Field(default=TicketType.INCIDENT, description="Type af sag")
internal_note: Optional[str] = Field(default=None, description="Intern note der vises prominent til medarbejdere")
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")
@ -223,6 +234,7 @@ class TTicketWorklogBase(BaseModel):
work_type: WorkType = Field(default=WorkType.SUPPORT)
description: Optional[str] = None
billing_method: BillingMethod = Field(default=BillingMethod.INVOICE)
is_internal: bool = Field(default=False, description="Skjul for kunde (vises ikke på faktura/portal)")
@field_validator('hours')
@classmethod
@ -251,6 +263,7 @@ class TTicketWorklogUpdate(BaseModel):
billing_method: Optional[BillingMethod] = None
status: Optional[WorklogStatus] = None
prepaid_card_id: Optional[int] = None
is_internal: Optional[bool] = None
class TTicketWorklog(TTicketWorklogBase):

View File

@ -514,15 +514,61 @@ async def create_worklog(
Create worklog entry for ticket
Creates time entry in draft status.
If billing_method is 'prepaid_card', validates and auto-selects card when only 1 active.
"""
try:
from psycopg2.extras import Json
# Handle prepaid card selection/validation
prepaid_card_id = worklog_data.prepaid_card_id
if worklog_data.billing_method.value == 'prepaid_card':
# Get customer_id from ticket
ticket = execute_query_single(
"SELECT customer_id FROM tticket_tickets WHERE id = %s",
(ticket_id,))
if not ticket:
raise HTTPException(status_code=404, detail="Ticket not found")
customer_id = ticket['customer_id']
# Get active prepaid cards for customer
active_cards = execute_query(
"""SELECT id, remaining_hours, expires_at
FROM tticket_prepaid_cards
WHERE customer_id = %s AND status = 'active'
ORDER BY expires_at ASC NULLS LAST, created_at ASC""",
(customer_id,))
if not active_cards:
raise HTTPException(
status_code=400,
detail="Kunden har ingen aktive klippekort")
if len(active_cards) == 1:
# Auto-select if only 1 active
if not prepaid_card_id:
prepaid_card_id = active_cards[0]['id']
logger.info(f"🎫 Auto-selected prepaid card {prepaid_card_id} (only active card)")
else:
# Multiple active cards: require explicit selection
if not prepaid_card_id:
raise HTTPException(
status_code=400,
detail=f"Kunden har {len(active_cards)} aktive klippekort. Vælg et konkret kort.")
# Validate selected card is active and belongs to customer
selected = next((c for c in active_cards if c['id'] == prepaid_card_id), None)
if not selected:
raise HTTPException(
status_code=400,
detail="Valgt klippekort er ikke aktivt eller tilhører ikke kunden")
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, work_date, hours, work_type, description, billing_method, status, user_id, prepaid_card_id, is_internal)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
ticket_id,
@ -533,7 +579,8 @@ async def create_worklog(
worklog_data.billing_method.value,
'draft',
user_id or worklog_data.user_id,
worklog_data.prepaid_card_id
prepaid_card_id,
worklog_data.is_internal
)
)
@ -544,10 +591,14 @@ async def create_worklog(
entity_id=worklog_id,
user_id=user_id,
action="created",
details={"hours": float(worklog_data.hours), "work_type": worklog_data.work_type.value}
details={
"hours": float(worklog_data.hours),
"work_type": worklog_data.work_type.value,
"is_internal": worklog_data.is_internal
}
)
worklog = execute_query(
worklog = execute_query_single(
"SELECT * FROM tticket_worklog WHERE id = %s",
(worklog_id,))
@ -569,11 +620,12 @@ async def update_worklog(
Update worklog entry (partial update)
Only draft entries can be fully edited.
If billing_method changes to 'prepaid_card', validates and auto-selects card when only 1 active.
"""
try:
# Get current worklog
current = execute_query_single(
"SELECT * FROM tticket_worklog WHERE id = %s",
"SELECT w.*, t.customer_id FROM tticket_worklog w JOIN tticket_tickets t ON w.ticket_id = t.id WHERE w.id = %s",
(worklog_id,))
if not current:
@ -585,6 +637,43 @@ async def update_worklog(
update_dict = update_data.model_dump(exclude_unset=True)
# Handle prepaid card selection/validation if billing_method is being set to prepaid_card
if 'billing_method' in update_dict and update_dict['billing_method'] == 'prepaid_card':
customer_id = current['customer_id']
# Get active prepaid cards for customer
active_cards = execute_query(
"""SELECT id, remaining_hours, expires_at
FROM tticket_prepaid_cards
WHERE customer_id = %s AND status = 'active'
ORDER BY expires_at ASC NULLS LAST, created_at ASC""",
(customer_id,))
if not active_cards:
raise HTTPException(
status_code=400,
detail="Kunden har ingen aktive klippekort")
if len(active_cards) == 1:
# Auto-select if only 1 active and not explicitly provided
if 'prepaid_card_id' not in update_dict or not update_dict['prepaid_card_id']:
update_dict['prepaid_card_id'] = active_cards[0]['id']
logger.info(f"🎫 Auto-selected prepaid card {update_dict['prepaid_card_id']} (only active card)")
else:
# Multiple active cards: require explicit selection
if 'prepaid_card_id' not in update_dict or not update_dict['prepaid_card_id']:
raise HTTPException(
status_code=400,
detail=f"Kunden har {len(active_cards)} aktive klippekort. Vælg et konkret kort.")
# Validate selected card if provided
if 'prepaid_card_id' in update_dict and update_dict['prepaid_card_id']:
selected = next((c for c in active_cards if c['id'] == update_dict['prepaid_card_id']), None)
if not selected:
raise HTTPException(
status_code=400,
detail="Valgt klippekort er ikke aktivt eller tilhører ikke kunden")
for field, value in update_dict.items():
if hasattr(value, 'value'):
value = value.value

View File

@ -89,8 +89,8 @@ class TicketService:
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)
source, tags, custom_fields, ticket_type, internal_note
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
@ -106,7 +106,9 @@ class TicketService:
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
Json(ticket_data.custom_fields or {}), # PostgreSQL JSONB
ticket_data.ticket_type.value,
ticket_data.internal_note
)
)

View File

@ -5,13 +5,17 @@
{% block extra_css %}
<style>
.ticket-header {
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-light) 100%);
background: white;
padding: 2rem;
border-radius: var(--border-radius);
color: white;
margin-bottom: 2rem;
border-left: 6px solid var(--accent);
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
}
.ticket-header.priority-urgent { border-left-color: #dc3545; }
.ticket-header.priority-high { border-left-color: #fd7e14; }
.ticket-number {
font-family: 'Monaco', 'Courier New', monospace;
font-size: 1rem;
@ -288,41 +292,69 @@
{% 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() }}
<div class="ticket-header priority-{{ ticket.priority }}">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="d-flex align-items-center gap-2 mb-2 text-muted small">
<span class="ticket-number font-monospace">{{ ticket.ticket_number }}</span>
<span></span>
<span class="fw-bold text-uppercase" style="letter-spacing: 0.5px;">{{ ticket.ticket_type|default('Incident') }}</span>
<!-- SLA Timer Mockup -->
<span class="badge bg-light text-danger border border-danger ms-2">
<i class="bi bi-hourglass-split"></i> Deadline: 14:00
</span>
</div>
<div class="d-flex flex-wrap align-items-baseline gap-3">
<h1 class="ticket-title mt-0 text-dark mb-0">{{ ticket.subject }}</h1>
<h3 class="h4 text-muted fw-normal mb-0">
<a href="/customers/{{ ticket.customer_id }}" class="text-decoration-none text-muted hover-primary">
@ {{ ticket.customer_name }}
</a>
</h3>
</div>
</div>
<!-- Quick Status -->
<div class="d-flex align-items-center gap-2">
<select class="form-select" style="width: auto; font-weight: 500;"
onchange="updateStatus(this.value)" id="quickStatus">
<option value="open" {% if ticket.status == 'open' %}selected{% endif %}>Åben</option>
<option value="in_progress" {% if ticket.status == 'in_progress' %}selected{% endif %}>Igangværende</option>
<option value="waiting_customer" {% if ticket.status == 'waiting_customer' %}selected{% endif %}>Afventer Kunde</option>
<option value="waiting_internal" {% if ticket.status == 'waiting_internal' %}selected{% endif %}>Afventer Internt</option>
<option value="resolved" {% if ticket.status == 'resolved' %}selected{% endif %}>Løst</option>
<option value="closed" {% if ticket.status == 'closed' %}selected{% endif %}>Lukket</option>
</select>
</div>
</div>
<div class="mt-3 d-flex gap-2 align-items-center flex-wrap">
<!-- Priority Badge -->
<span class="badge badge-priority-{{ ticket.priority }}">
{{ ticket.priority.title() }} Priority
</span>
</div>
<div class="tags-container" id="ticketTags">
<!-- Tags loaded via JavaScript -->
</div>
<button class="add-tag-btn mt-2" onclick="showTagPicker('ticket', {{ ticket.id }}, reloadTags)">
<i class="bi bi-plus-circle"></i> Tilføj Tag (⌥⇧T)
<!-- Tags -->
<div class="tags-container d-inline-flex m-0" id="ticketTags"></div>
<button class="btn btn-sm btn-light text-muted" onclick="showTagPicker('ticket', {{ ticket.id }}, reloadTags)">
<i class="bi bi-plus-circle"></i>
</button>
</div>
<!-- Action Buttons -->
<div class="action-buttons mb-4">
<a href="/api/v1/ticket/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>
<!-- Internal Note Alert -->
{% if ticket.internal_note %}
<div class="alert alert-warning mt-3 mb-0 d-flex align-items-start border-warning" style="background-color: #fff3cd;">
<i class="bi bi-shield-lock-fill me-2 fs-5 text-warning"></i>
<div>
<strong><i class="bi bi-eye-slash"></i> Internt Notat:</strong>
<span style="white-space: pre-wrap;">{{ ticket.internal_note }}</span>
</div>
</div>
{% endif %}
</div>
<!-- Action Buttons Removed (Moved to specific sections) -->
<div class="row">
<!-- Main Content -->
<div class="col-lg-8">
@ -344,6 +376,23 @@
<div class="section-title">
<i class="bi bi-chat-dots"></i> Kommentarer ({{ comments|length }})
</div>
<!-- Quick Comment Input -->
<div class="mb-4 p-3 bg-light rounded-3 border">
<textarea id="quickCommentText" class="form-control border-0 bg-white mb-2 shadow-sm" rows="2" placeholder="Skriv en kommentar... (Ctrl+Enter for at sende)"></textarea>
<div class="d-flex justify-content-between align-items-center">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="quickCommentInternal">
<label class="form-check-label small text-muted fw-bold" for="quickCommentInternal">
<i class="bi bi-shield-lock-fill text-warning"></i> Internt Notat
</label>
</div>
<button class="btn btn-primary btn-sm px-4 rounded-pill" onclick="submitQuickComment()">
Send <i class="bi bi-send-fill ms-1"></i>
</button>
</div>
</div>
{% if comments %}
{% for comment in comments %}
<div class="comment {% if comment.internal_note %}internal{% endif %}">
@ -376,8 +425,14 @@
<!-- Worklog -->
<div class="card">
<div class="card-body">
<div class="section-title">
<i class="bi bi-clock-history"></i> Worklog ({{ worklog|length }})
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="section-title mb-0">
<i class="bi bi-clock-history"></i> Worklog
<span class="badge bg-light text-dark border ms-2" id="totalHoursBadge">...</span>
</div>
<button class="btn btn-primary btn-sm rounded-pill" onclick="showWorklogModal()">
<i class="bi bi-plus-lg"></i> Log Tid
</button>
</div>
{% if worklog %}
<div class="table-responsive">
@ -440,38 +495,32 @@
<!-- 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
<!-- Metadata Card (Consolidated) -->
<div class="card mb-3">
<div class="card-header bg-white py-3 border-bottom">
<h6 class="mb-0 fw-bold text-dark"><i class="bi bi-info-circle me-2"></i>Detaljer</h6>
</div>
<div class="list-group list-group-flush small">
<div class="list-group-item d-flex justify-content-between align-items-center px-3 py-3">
<span class="text-muted">Ansvarlig</span>
<span class="badge bg-light text-dark border">{{ ticket.assigned_to_name or 'Ubesat' }}</span>
</div>
<div class="list-group-item d-flex justify-content-between align-items-center px-3 py-3">
<span class="text-muted">Oprettet</span>
<span class="font-monospace">{{ ticket.created_at.strftime('%d-%m-%Y %H:%M') if ticket.created_at else '-' }}</span>
</div>
<div class="list-group-item d-flex justify-content-between align-items-center px-3 py-3">
<span class="text-muted">Opdateret</span>
<span class="font-monospace">{{ ticket.updated_at.strftime('%d-%m-%Y %H:%M') if ticket.updated_at else '-' }}</span>
</div>
{% if ticket.resolved_at %}
<div class="list-group-item d-flex justify-content-between align-items-center px-3 py-3 bg-light">
<span class="text-success fw-bold">Løst</span>
<span class="font-monospace">{{ ticket.resolved_at.strftime('%d-%m-%Y %H:%M') }}</span>
</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>
</div>
<!-- Contacts -->
<div class="card">
@ -490,47 +539,9 @@
</div>
</div>
<!-- Back to Ticket Info continuation -->
<div class="card">
<div class="card-body">
<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>
@ -538,15 +549,262 @@
{% block extra_js %}
<script>
// Add comment (placeholder - integrate with API)
function addComment() {
alert('Add comment functionality - integrate with POST /api/v1/ticket/tickets/{{ ticket.id }}/comments');
// ============================================
// QUICK COMMENT & STATUS
// ============================================
async function updateStatus(newStatus) {
try {
// Determine API endpoint and method
// Using generic update for now, ideally use specific status endpoint if workflow requires
const response = await fetch('/api/v1/ticket/tickets/{{ ticket.id }}', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus })
});
if (!response.ok) throw new Error('Failed to update status');
// Show success feedback
const select = document.getElementById('quickStatus');
// Optional: Flash success or reload.
// Reload is safer to update all timestamps and UI states
window.location.reload();
} catch (error) {
console.error('Error updating status:', error);
alert('Fejl ved opdatering af status');
// Revert select if possible
window.location.reload();
}
}
// Add worklog (placeholder - integrate with API)
function addWorklog() {
alert('Add worklog functionality - integrate with POST /api/v1/ticket/tickets/{{ ticket.id }}/worklog');
async function submitQuickComment() {
const textarea = document.getElementById('quickCommentText');
const internalCheck = document.getElementById('quickCommentInternal');
const text = textarea.value.trim();
const isInternal = internalCheck.checked;
if (!text) return;
try {
const response = await fetch('/api/v1/ticket/tickets/{{ ticket.id }}/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
comment_text: text,
is_internal: isInternal,
ticket_id: {{ ticket.id }}
})
});
if (!response.ok) throw new Error('Failed to post comment');
// Clear input
textarea.value = '';
// Reload page to show new comment (simpler than DOM manipulation for complex layouts)
window.location.reload();
} catch (error) {
console.error('Error posting comment:', error);
alert('Kunne ikke sende kommentar');
}
}
// Handle Ctrl+Enter in comment box
document.getElementById('quickCommentText')?.addEventListener('keydown', function(e) {
if (e.ctrlKey && e.key === 'Enter') {
submitQuickComment();
}
});
// ============================================
// WORKLOG MANAGEMENT
// ============================================
async function showWorklogModal() {
const today = new Date().toISOString().split('T')[0];
// Fetch Prepaid Cards for this customer
let prepaidOptions = '';
let activePrepaidCards = [];
try {
const response = await fetch('/api/v1/prepaid/prepaid-cards?status=active&customer_id={{ ticket.customer_id }}');
if (response.ok) {
const cards = await response.json();
activePrepaidCards = cards || [];
if (activePrepaidCards.length > 0) {
const cardOpts = activePrepaidCards.map(c => {
const remaining = parseFloat(c.remaining_hours).toFixed(2);
const expiryText = c.expires_at ? ` • Udløber ${new Date(c.expires_at).toLocaleDateString('da-DK')}` : '';
return `<option value="card_${c.id}">💳 Klippekort #${c.id} (${remaining}t tilbage${expiryText})</option>`;
}).join('');
prepaidOptions = `<optgroup label="Klippekort">${cardOpts}</optgroup>`;
}
}
} catch (e) {
console.error("Failed to load prepaid cards", e);
}
// Store for use in submitWorklog
window._activePrepaidCards = activePrepaidCards;
const modalHtml = `
<div class="modal fade" id="worklogModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-clock-history"></i> Registrer Tid</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="col-6">
<label class="form-label">Dato *</label>
<input type="date" class="form-control" id="worklogDate" value="${today}" required>
</div>
<div class="col-6">
<label class="form-label">Tid brugt *</label>
<div class="input-group">
<input type="number" class="form-control" id="worklogHours" min="0" placeholder="tt" step="1">
<span class="input-group-text">:</span>
<input type="number" class="form-control" id="worklogMinutes" min="0" placeholder="mm" step="1">
</div>
<div class="form-text text-end" id="worklogTotalCalc" style="font-size: 0.8rem;">Total: 0.00 timer</div>
</div>
<div class="col-6">
<label class="form-label">Type</label>
<select class="form-select" id="worklogType">
<option value="support" selected>Support</option>
<option value="troubleshooting">Fejlsøgning</option>
<option value="development">Udvikling</option>
<option value="on_site">Kørsel / On-site</option>
<option value="meeting">Møde</option>
<option value="other">Andet</option>
</select>
</div>
<div class="col-6">
<label class="form-label">Afregning</label>
<select class="form-select" id="worklogBilling">
<option value="invoice" selected>Faktura</option>
${prepaidOptions}
<option value="internal">Internt / Ingen faktura</option>
<option value="warranty">Garanti / Reklamation</option>
<option value="unknown">❓ Ved ikke (Send til godkendelse)</option>
</select>
</div>
<div class="col-12">
<label class="form-label">Beskrivelse</label>
<textarea class="form-control" id="worklogDesc" rows="3" placeholder="Hvad er der brugt tid på?"></textarea>
</div>
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="worklogInternal">
<label class="form-check-label text-muted" for="worklogInternal">
Skjul for kunde (Intern registrering)
</label>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="submitWorklog()">
<i class="bi bi-save"></i> Gem Tid
</button>
</div>
</div>
</div>
</div>
`;
// Clean up old
const oldModal = document.getElementById('worklogModal');
if(oldModal) oldModal.remove();
document.body.insertAdjacentHTML('beforeend', modalHtml);
const modal = new bootstrap.Modal(document.getElementById('worklogModal'));
modal.show();
// Setup listeners for live calculation
const calcTotal = () => {
const h = parseInt(document.getElementById('worklogHours').value) || 0;
const m = parseInt(document.getElementById('worklogMinutes').value) || 0;
const total = h + (m / 60);
document.getElementById('worklogTotalCalc').innerText = `Total: ${total.toFixed(2)} timer`;
};
document.getElementById('worklogHours').addEventListener('input', calcTotal);
document.getElementById('worklogMinutes').addEventListener('input', calcTotal);
// Focus hours (skipping date usually)
setTimeout(() => document.getElementById('worklogHours').focus(), 500);
}
async function submitWorklog() {
const date = document.getElementById('worklogDate').value;
// Calculate hours from split fields
const h = parseInt(document.getElementById('worklogHours').value) || 0;
const m = parseInt(document.getElementById('worklogMinutes').value) || 0;
const hours = h + (m / 60);
const type = document.getElementById('worklogType').value;
let billing = document.getElementById('worklogBilling').value;
const desc = document.getElementById('worklogDesc').value;
const isInternal = document.getElementById('worklogInternal').checked;
let prepaidCardId = null;
if(!date || hours <= 0) {
alert("Udfyld venligst dato og tid (timer/minutter)");
return;
}
// Handle prepaid card selection
if(billing.startsWith('card_')) {
prepaidCardId = parseInt(billing.replace('card_', ''));
billing = 'prepaid_card'; // Reset to enum value
} else if(billing === 'prepaid_card') {
// User selected generic "Klippekort" (shouldn't happen with new UI, but handle it)
// Backend will auto-select if only 1 active, or error if >1
prepaidCardId = null;
}
try {
const response = await fetch('/api/v1/ticket/tickets/{{ ticket.id }}/worklog', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
ticket_id: {{ ticket.id }},
work_date: date,
hours: hours,
work_type: type,
billing_method: billing,
description: desc,
is_internal: isInternal,
prepaid_card_id: prepaidCardId
})
});
if(!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Fejl ved oprettelse');
}
// Reload to show
window.location.reload();
} catch (e) {
console.error(e);
alert("Kunne ikke gemme tidsregistrering: " + e.message);
}
}
// ============================================
// TAGS MANAGEMENT
// ============================================
// Load and render ticket tags
async function loadTicketTags() {
@ -556,6 +814,7 @@
const tags = await response.json();
const container = document.getElementById('ticketTags');
if (!container) return; // Guard clause
if (tags.length === 0) {
container.innerHTML = '<small class="text-muted"><i class="bi bi-tags"></i> Ingen tags endnu</small>';
@ -597,9 +856,13 @@
}
// ============================================
// CONTACTS MANAGEMENT
// CONTACTS MANAGEMENT (SEARCHABLE)
// ============================================
let allContactsCache = [];
let customersCache = [];
let selectedContactId = null;
async function loadContacts() {
try {
const response = await fetch('/api/v1/ticket/tickets/{{ ticket.id }}/contacts');
@ -607,6 +870,7 @@
const data = await response.json();
const container = document.getElementById('contactsList');
if (!container) return;
if (!data.contacts || data.contacts.length === 0) {
container.innerHTML = `
@ -670,20 +934,25 @@
}
async function showAddContactModal() {
// Fetch all contacts for selection
// Load contacts if not cached
if (allContactsCache.length === 0) {
try {
const response = await fetch('/api/v1/contacts?limit=1000');
if (!response.ok) throw new Error('Failed to load contacts');
const data = await response.json();
const contacts = data.contacts || [];
allContactsCache = data.contacts || [];
} catch (e) {
console.error("Failed to load contacts for modal", e);
alert("Kunne ikke hente kontaktliste");
return;
}
}
// Check if this ticket has any contacts yet
// Check existing contacts
const ticketContactsResp = await fetch('/api/v1/ticket/tickets/{{ ticket.id }}/contacts');
const ticketContacts = await ticketContactsResp.json();
const isFirstContact = !ticketContacts.contacts || ticketContacts.contacts.length === 0;
// Create modal content
// Define Modal HTML
const modalHtml = `
<div class="modal fade" id="addContactModal" tabindex="-1">
<div class="modal-dialog">
@ -693,20 +962,82 @@
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Kontakt *</label>
<select class="form-select" id="contactSelect" required>
<option value="">Vælg kontakt...</option>
${contacts.map(c => `
<option value="${c.id}">${c.first_name} ${c.last_name} ${c.email ? '(' + c.email + ')' : ''}</option>
`).join('')}
</select>
<!-- Search Stage -->
<div id="contactSearchStage">
<label class="form-label">Find kontakt</label>
<input type="text" class="form-control mb-2" id="contactSearchInput"
placeholder="Søg navn eller email..." autocomplete="off">
<div class="list-group" id="contactSearchResults" style="max-height: 250px; overflow-y: auto;">
<!-- Results will appear here -->
<div class="text-center text-muted py-3 small">Begynd at skrive for at søge...</div>
</div>
<div class="mt-2 text-end">
<small class="text-muted">Finder du ikke kontakten? <a href="#" onclick="showCreateStage(); return false;">Smart Opret</a></small>
</div>
</div>
<!-- Create Stage (Hidden) -->
<div id="contactCreateStage" style="display:none;" class="animate__animated animate__fadeIn">
<h6 class="border-bottom pb-2 mb-3 text-primary"><i class="bi bi-person-plus"></i> Hurtig oprettelse</h6>
<div class="row g-2 mb-2">
<div class="col-6">
<label class="form-label small">Fornavn *</label>
<input type="text" class="form-control" id="newContactFirstName" required>
</div>
<div class="col-6">
<label class="form-label small">Efternavn</label>
<input type="text" class="form-control" id="newContactLastName">
</div>
</div>
<div class="mb-2">
<label class="form-label small">Email</label>
<input type="email" class="form-control" id="newContactEmail">
</div>
<div class="mb-2">
<label class="form-label small">Telefon</label>
<input type="tel" class="form-control" id="newContactPhone">
</div>
<div class="mb-2 position-relative">
<label class="form-label small">Firma</label>
<div class="input-group input-group-sm">
<input type="text" class="form-control" id="newContactCompanySearch" placeholder="Søg firma..." autocomplete="off">
<input type="hidden" id="newContactCompanyId" value="{{ ticket.customer_id }}">
<button class="btn btn-outline-secondary" type="button" onclick="clearCompanySelection()">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div id="companySearchResults" class="list-group position-absolute w-100 shadow-sm" style="display:none; z-index: 1050; max-height: 200px; overflow-y: auto;"></div>
<div class="form-text small" id="selectedCompanyName">Valgt: {{ ticket.customer_name }}</div>
</div>
<div class="mb-3">
<label class="form-label small">Titel</label>
<input type="text" class="form-control" id="newContactTitle">
</div>
<div class="d-flex justify-content-between pt-2 border-top">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="cancelCreate()">Annuller</button>
<button type="button" class="btn btn-sm btn-success text-white" onclick="createContactSmart()">
<i class="bi bi-check-lg"></i> Opret & Vælg
</button>
</div>
</div>
<!-- Selected Stage (Hidden initially) -->
<div id="contactSelectedStage" style="display:none;" class="animate__animated animate__fadeIn">
<input type="hidden" id="selectedContactId">
<div class="alert alert-primary d-flex justify-content-between align-items-center mb-3">
<div>
<i class="bi bi-person-check-fill me-2"></i>
<strong id="selectedContactName">Name</strong>
</div>
<button class="btn btn-sm btn-outline-primary bg-white" onclick="resetContactSelection()">Skift</button>
</div>
<div class="mb-3">
<label class="form-label">Rolle *</label>
<div id="firstContactNotice" class="alert alert-info mb-2" style="display: none;">
<i class="bi bi-star"></i> <strong>Første kontakt</strong> - Rollen sættes automatisk til "Primær kontakt"
</div>
<select class="form-select" id="roleSelect" onchange="toggleCustomRole()" required>
<optgroup label="Standard roller">
<option value="primary">⭐ Primær kontakt</option>
@ -727,20 +1058,22 @@
</optgroup>
</select>
</div>
<div class="mb-3" id="customRoleDiv" style="display: none;">
<label class="form-label">Custom Rolle</label>
<input type="text" class="form-control" id="customRoleInput"
placeholder="f.eks. 'bygningsingeniør' eller 'projektleder'">
<small class="text-muted">Brug lowercase og underscore i stedet for mellemrum (f.eks. bygnings_ingeniør)</small>
<input type="text" class="form-control" id="customRoleInput" placeholder="f.eks. projektleder">
</div>
<div class="mb-3">
<label class="form-label">Noter (valgfri)</label>
<textarea class="form-control" id="contactNotes" rows="2" placeholder="Evt. noter om kontaktens rolle..."></textarea>
<textarea class="form-control" id="contactNotes" rows="2" placeholder="Noter om rollen..."></textarea>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="addContact()">
<button type="button" class="btn btn-primary" id="btnAddContactConfirm" onclick="addContact()" disabled>
<i class="bi bi-plus-circle"></i> Tilføj
</button>
</div>
@ -749,26 +1082,210 @@
</div>
`;
// Remove old modal if exists
// Clean up old
const oldModal = document.getElementById('addContactModal');
if(oldModal) oldModal.remove();
// Append and show new modal
document.body.insertAdjacentHTML('beforeend', modalHtml);
const modal = new bootstrap.Modal(document.getElementById('addContactModal'));
const modalEl = document.getElementById('addContactModal');
const modal = new bootstrap.Modal(modalEl);
// Show notice and disable role selector if first contact
// Setup Search Listener
const input = document.getElementById('contactSearchInput');
input.addEventListener('input', (e) => filterContacts(e.target.value));
// Setup First Contact Logic
if (isFirstContact) {
document.getElementById('firstContactNotice').style.display = 'block';
document.getElementById('roleSelect').value = 'primary';
document.getElementById('roleSelect').disabled = true;
// Note: User still needs to select a contact first
}
modal.show();
} catch (error) {
console.error('Error:', error);
alert('Fejl ved indlæsning af kontakter');
// Focus input
setTimeout(() => input.focus(), 500);
}
async function loadCustomers() {
if(customersCache.length > 0) return;
try {
const response = await fetch('/api/v1/customers?limit=100');
const data = await response.json();
customersCache = data.customers || data;
} catch(e) { console.error("Failed to load customers", e); }
}
function showCreateStage() {
document.getElementById('contactSearchStage').style.display = 'none';
document.getElementById('contactCreateStage').style.display = 'block';
document.getElementById('newContactFirstName').focus();
loadCustomers();
// Setup company search
const input = document.getElementById('newContactCompanySearch');
input.addEventListener('input', (e) => filterCustomers(e.target.value));
input.addEventListener('focus', () => {
if(input.value.length === 0) filterCustomers('');
});
// Hide results on blur with delay to allow clicking
input.addEventListener('blur', () => {
setTimeout(() => document.getElementById('companySearchResults').style.display = 'none', 200);
});
}
function filterCustomers(query) {
const resultsDiv = document.getElementById('companySearchResults');
const term = query.toLowerCase();
const matches = customersCache.filter(c =>
c.name.toLowerCase().includes(term) ||
(c.cvr_number && c.cvr_number.includes(term))
).slice(0, 10);
if(matches.length === 0) {
resultsDiv.style.display = 'none';
return;
}
resultsDiv.innerHTML = matches.map(c => `
<a href="#" class="list-group-item list-group-item-action small py-1" onclick="selectCompany(${c.id}, '${c.name}'); return false;">
${c.name} <span class="text-muted ms-1">(${c.cvr_number || '-'})</span>
</a>
`).join('');
resultsDiv.style.display = 'block';
}
function selectCompany(id, name) {
document.getElementById('newContactCompanyId').value = id;
document.getElementById('selectedCompanyName').innerText = 'Valgt: ' + name;
document.getElementById('newContactCompanySearch').value = '';
document.getElementById('companySearchResults').style.display = 'none';
}
function clearCompanySelection() {
document.getElementById('newContactCompanyId').value = '';
document.getElementById('selectedCompanyName').innerText = 'Valgt: (Ingen / Privat)';
document.getElementById('newContactCompanySearch').value = '';
}
function cancelCreate() {
document.getElementById('contactCreateStage').style.display = 'none';
document.getElementById('contactSearchStage').style.display = 'block';
}
async function createContactSmart() {
const first = document.getElementById('newContactFirstName').value.trim();
const last = document.getElementById('newContactLastName').value.trim();
const email = document.getElementById('newContactEmail').value.trim();
const phone = document.getElementById('newContactPhone').value.trim();
const title = document.getElementById('newContactTitle').value.trim();
const companyId = document.getElementById('newContactCompanyId').value;
if(!first) {
alert("Fornavn er påkrævet");
return;
}
try {
const payload = {
first_name: first,
last_name: last,
email: email || null,
phone: phone || null,
title: title || null
};
if(companyId) {
payload.company_id = parseInt(companyId);
}
const response = await fetch('/api/v1/contacts', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
if(!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Fejl ved oprettelse');
}
const newContact = await response.json();
// Add to cache
allContactsCache.push(newContact);
// Hide create stage
document.getElementById('contactCreateStage').style.display = 'none';
// Select the new contact
selectContact(newContact.id, `${newContact.first_name} ${newContact.last_name}`, newContact.email);
} catch (e) {
console.error(e);
alert("Kunne ikke oprette kontakt: " + e.message);
}
}
function filterContacts(query) {
const resultsDiv = document.getElementById('contactSearchResults');
if(!query || query.length < 1) {
resultsDiv.innerHTML = '<div class="text-center text-muted py-3 small">Indtast navn, email eller firma...</div>';
return;
}
const term = query.toLowerCase();
const matches = allContactsCache.filter(c =>
(c.first_name + ' ' + c.last_name).toLowerCase().includes(term) ||
(c.email || '').toLowerCase().includes(term) ||
(c.company_names && c.company_names.some(comp => comp.toLowerCase().includes(term)))
).slice(0, 10); // Limit results
if(matches.length === 0) {
resultsDiv.innerHTML = '<div class="text-center text-muted py-3 small">Ingen kontakter fundet</div>';
return;
}
resultsDiv.innerHTML = matches.map(c => {
const companies = (c.company_names && c.company_names.length > 0)
? `<div class="small text-muted mt-1"><i class="bi bi-building"></i> ${c.company_names.join(', ')}</div>`
: '';
return `
<a href="#" class="list-group-item list-group-item-action contact-result-item"
onclick="selectContact(${c.id}, '${c.first_name} ${c.last_name}', '${c.email||''}')">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>${c.first_name} ${c.last_name}</strong>
<div class="small text-muted">${c.email || ''}</div>
${companies}
</div>
<i class="bi bi-chevron-right text-muted"></i>
</div>
</a>
`}).join('');
}
function selectContact(id, name, email) {
selectedContactId = id;
document.getElementById('selectedContactId').value = id;
document.getElementById('selectedContactName').innerText = name;
// Switch stages
document.getElementById('contactSearchStage').style.display = 'none';
document.getElementById('contactSelectedStage').style.display = 'block';
// Enable save
document.getElementById('btnAddContactConfirm').disabled = false;
}
function resetContactSelection() {
selectedContactId = null;
document.getElementById('contactSearchStage').style.display = 'block';
document.getElementById('contactSelectedStage').style.display = 'none';
document.getElementById('btnAddContactConfirm').disabled = true;
document.getElementById('contactSearchInput').focus();
}
function toggleCustomRole() {
@ -786,16 +1303,15 @@
}
async function addContact() {
const contactId = document.getElementById('contactSelect').value;
let role = document.getElementById('roleSelect').value;
const notes = document.getElementById('contactNotes').value;
if (!contactId) {
if (!selectedContactId) {
alert('Vælg venligst en kontakt');
return;
}
// If custom role selected, use the custom input
let role = document.getElementById('roleSelect').value;
const notes = document.getElementById('contactNotes').value;
// Custom role logic
if (role === '_custom') {
const customRole = document.getElementById('customRoleInput').value.trim();
if (!customRole) {
@ -806,7 +1322,7 @@
}
try {
const url = `/api/v1/ticket/tickets/{{ ticket.id }}/contacts?contact_id=${contactId}&role=${role}${notes ? '&notes=' + encodeURIComponent(notes) : ''}`;
const url = `/api/v1/ticket/tickets/{{ ticket.id }}/contacts?contact_id=${selectedContactId}&role=${role}${notes ? '&notes=' + encodeURIComponent(notes) : ''}`;
const response = await fetch(url, { method: 'POST' });
if (!response.ok) {
@ -861,9 +1377,15 @@
document.addEventListener('DOMContentLoaded', () => {
loadTicketTags();
loadContacts();
// Initialize tooltips/popovers if any
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl)
})
});
// Override global tag picker to auto-reload after adding
// Global Tag Picker Override
if (window.tagPicker) {
const originalShow = window.tagPicker.show.bind(window.tagPicker);
window.showTagPicker = function(entityType, entityId, onSelect) {

View File

@ -1,104 +1,9 @@
<!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">
{% extends "shared/frontend/base.html" %}
{% block title %}Worklog Godkendelse - BMC Hub{% endblock %}
{% block extra_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);
}
@ -184,22 +89,6 @@
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;
@ -253,74 +142,41 @@
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/ticket/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>
{% endblock %}
<div class="container-fluid px-4">
{% block content %}
<!-- Page Header -->
<div class="row mb-4">
<div class="row mb-4 align-items-center">
<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 class="col-auto">
<a href="/timetracking/wizard2{% if selected_customer_id %}?hub_id={{ selected_customer_id }}{% endif %}"
class="btn btn-primary btn-lg shadow-sm">
<i class="bi bi-magic me-2"></i> Åbn Billing Wizard
</a>
</div>
</div>
<!-- Statistics Row -->
<div class="row stats-row">
<div class="col-md-4">
<div class="card stat-card">
<div class="card stat-card p-4">
<h3>{{ total_entries }}</h3>
<p>Entries til godkendelse</p>
</div>
</div>
<div class="col-md-4">
<div class="card stat-card">
<div class="card stat-card p-4">
<h3>{{ "%.2f"|format(total_hours) }}t</h3>
<p>Total timer</p>
</div>
</div>
<div class="col-md-4">
<div class="card stat-card">
<div class="card stat-card p-4">
<h3>{{ "%.2f"|format(total_billable_hours) }}t</h3>
<p>Fakturerbare timer</p>
</div>
@ -380,7 +236,7 @@
</thead>
<tbody>
{% for worklog in worklogs %}
<tr class="worklog-row">
<tr class="worklog-row {% if worklog.billing_method == 'unknown' %}table-warning{% endif %}">
<td>
<span class="ticket-number">{{ worklog.ticket_number }}</span>
<br>
@ -420,6 +276,12 @@
{% if worklog.card_number %}
<br><small class="text-muted">{{ worklog.card_number }}</small>
{% endif %}
{% elif worklog.billing_method == 'unknown' %}
<span class="badge bg-warning text-dark">
<i class="bi bi-question-circle"></i> Ved ikke
</span>
{% else %}
<span class="badge bg-secondary">{{ worklog.billing_method }}</span>
{% endif %}
</td>
<td>
@ -435,15 +297,31 @@
<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 type="submit" class="btn btn-approve btn-sm" title="Godkend">
<i class="bi bi-check-circle"></i>
</button>
</form>
<button
type="button"
class="btn btn-outline-primary btn-sm ms-1"
title="Rediger"
data-id="{{ worklog.id }}"
data-customer-id="{{ worklog.customer_id }}"
data-hours="{{ worklog.hours }}"
data-type="{{ worklog.work_type }}"
data-billing="{{ worklog.billing_method }}"
data-prepaid-card-id="{{ worklog.prepaid_card_id or '' }}"
data-desc="{{ worklog.description }}"
data-internal="{{ 'true' if worklog.is_internal else 'false' }}"
onclick="openEditModal(this)">
<i class="bi bi-pencil"></i>
</button>
<button
type="button"
class="btn btn-reject btn-sm ms-1"
title="Afvis"
onclick="rejectWorklog({{ worklog.id }}, '{{ request.url }}')">
<i class="bi bi-x-circle"></i> Afvis
<i class="bi bi-x-circle"></i>
</button>
</div>
{% else %}
@ -465,6 +343,76 @@
</div>
</div>
{% endif %}
<!-- Edit Modal -->
<div class="modal fade" id="editModal" 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">Rediger Worklog Entry</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="editId">
<div class="row g-3">
<div class="col-12">
<label class="form-label">Tid brugt *</label>
<div class="input-group">
<input type="number" class="form-control" id="editHours" min="0" placeholder="tt" step="1"
style="background: var(--bg-body); color: var(--text-primary); border-color: var(--accent-light);">
<span class="input-group-text" style="background: var(--accent-light); border-color: var(--accent-light); color: var(--text-primary);">:</span>
<input type="number" class="form-control" id="editMinutes" min="0" max="59" placeholder="mm" step="1"
style="background: var(--bg-body); color: var(--text-primary); border-color: var(--accent-light);">
</div>
<div class="form-text text-end" id="editTotalCalc">Total: 0.00 timer</div>
</div>
<div class="col-12">
<label class="form-label">Type</label>
<select class="form-select" id="editType" style="background: var(--bg-body); color: var(--text-primary); border-color: var(--accent-light);">
<option value="support">Support</option>
<option value="troubleshooting">Fejlsøgning</option>
<option value="development">Udvikling</option>
<option value="on_site">Kørsel / On-site</option>
<option value="meeting">Møde</option>
<option value="other">Andet</option>
</select>
</div>
<div class="col-12">
<label class="form-label">Afregning</label>
<select class="form-select" id="editBilling" style="background: var(--bg-body); color: var(--text-primary); border-color: var(--accent-light);" onchange="handleBillingMethodChange()">
<option value="invoice">Faktura</option>
<option value="prepaid_card">Klippekort</option>
<option value="internal">Internt / Ingen faktura</option>
<option value="warranty">Garanti / Reklamation</option>
<option value="unknown">❓ Ved ikke</option>
</select>
</div>
<div class="col-12" id="prepaidCardSelectContainer" style="display: none;">
<label class="form-label">Vælg Klippekort *</label>
<select class="form-select" id="editPrepaidCard" style="background: var(--bg-body); color: var(--text-primary); border-color: var(--accent-light);">
<option value="">-- Henter klippekort --</option>
</select>
<div class="form-text">Vælg hvilket klippekort der skal bruges</div>
</div>
<div class="col-12">
<label class="form-label">Beskrivelse</label>
<textarea class="form-control" id="editDesc" rows="3"
style="background: var(--bg-body); color: var(--text-primary); border-color: var(--accent-light);"></textarea>
</div>
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="editInternal">
<label class="form-check-label" for="editInternal">Skjul for kunde (Intern registrering)</label>
</div>
</div>
</div>
</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="button" class="btn btn-primary" onclick="saveWorklog()">Gem Ændringer</button>
</div>
</div>
</div>
</div>
<!-- Reject Modal -->
@ -499,37 +447,10 @@
</div>
</div>
</div>
{% endblock %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
{% block extra_js %}
<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');
@ -555,6 +476,153 @@
document.body.appendChild(badge);
}
}, 60000);
// ==========================================
// EDIT FUNCTIONALITY
// ==========================================
async function openEditModal(btn) {
const id = btn.getAttribute('data-id');
const customerId = btn.getAttribute('data-customer-id');
const hoursDec = parseFloat(btn.getAttribute('data-hours'));
const type = btn.getAttribute('data-type');
const billing = btn.getAttribute('data-billing');
const prepaidCardId = btn.getAttribute('data-prepaid-card-id');
const desc = btn.getAttribute('data-desc');
const isInternal = btn.getAttribute('data-internal') === 'true';
document.getElementById('editId').value = id;
// Store customer_id for later use
window._editCustomerId = customerId;
window._editPrepaidCardId = prepaidCardId;
// Decimal to HH:MM logic
const h = Math.floor(hoursDec);
const m = Math.round((hoursDec - h) * 60);
document.getElementById('editHours').value = h;
document.getElementById('editMinutes').value = m;
document.getElementById('editTotalCalc').innerText = `Total: ${hoursDec.toFixed(2)} timer`;
document.getElementById('editType').value = type;
document.getElementById('editBilling').value = billing;
document.getElementById('editDesc').value = desc;
// Handle billing method if it was unknown/invalid before, default to invoice
if(!['invoice','prepaid_card','internal','warranty','unknown'].includes(billing)) {
document.getElementById('editBilling').value = 'invoice';
} else {
document.getElementById('editBilling').value = billing;
}
document.getElementById('editInternal').checked = isInternal;
// Load prepaid cards if billing method is prepaid_card
if(billing === 'prepaid_card') {
await loadPrepaidCardsForEdit(customerId, prepaidCardId);
} else {
document.getElementById('prepaidCardSelectContainer').style.display = 'none';
}
new bootstrap.Modal(document.getElementById('editModal')).show();
}
async function handleBillingMethodChange() {
const billing = document.getElementById('editBilling').value;
const customerId = window._editCustomerId;
if(billing === 'prepaid_card' && customerId) {
await loadPrepaidCardsForEdit(customerId, null);
} else {
document.getElementById('prepaidCardSelectContainer').style.display = 'none';
}
}
async function loadPrepaidCardsForEdit(customerId, selectedCardId) {
const container = document.getElementById('prepaidCardSelectContainer');
const select = document.getElementById('editPrepaidCard');
try {
const response = await fetch(`/api/v1/prepaid/prepaid-cards?status=active&customer_id=${customerId}`);
if(response.ok) {
const cards = await response.json();
if(cards && cards.length > 0) {
const options = cards.map(c => {
const remaining = parseFloat(c.remaining_hours).toFixed(2);
const expiryText = c.expires_at ? ` • Udløber ${new Date(c.expires_at).toLocaleDateString('da-DK')}` : '';
const selected = (selectedCardId && c.id == selectedCardId) ? 'selected' : '';
return `<option value="${c.id}" ${selected}>💳 Klippekort #${c.id} (${remaining}t tilbage${expiryText})</option>`;
}).join('');
select.innerHTML = options;
container.style.display = 'block';
} else {
select.innerHTML = '<option value="">Ingen aktive klippekort fundet</option>';
container.style.display = 'block';
}
} else {
select.innerHTML = '<option value="">Fejl ved hentning af klippekort</option>';
container.style.display = 'block';
}
} catch(e) {
console.error('Failed to load prepaid cards', e);
select.innerHTML = '<option value="">Fejl ved hentning af klippekort</option>';
container.style.display = 'block';
}
}
const calcEdit = () => {
const h = parseInt(document.getElementById('editHours').value) || 0;
const m = parseInt(document.getElementById('editMinutes').value) || 0;
const total = h + (m / 60);
document.getElementById('editTotalCalc').innerText = `Total: ${total.toFixed(2)} timer`;
};
document.getElementById('editHours').addEventListener('input', calcEdit);
document.getElementById('editMinutes').addEventListener('input', calcEdit);
async function saveWorklog() {
const id = document.getElementById('editId').value;
const h = parseInt(document.getElementById('editHours').value) || 0;
const m = parseInt(document.getElementById('editMinutes').value) || 0;
const totalHours = h + (m / 60);
const billingMethod = document.getElementById('editBilling').value;
const payload = {
hours: totalHours,
work_type: document.getElementById('editType').value,
billing_method: billingMethod,
description: document.getElementById('editDesc').value,
is_internal: document.getElementById('editInternal').checked
};
// Include prepaid_card_id if billing method is prepaid_card
if(billingMethod === 'prepaid_card') {
const prepaidCardId = document.getElementById('editPrepaidCard').value;
if(prepaidCardId) {
payload.prepaid_card_id = parseInt(prepaidCardId);
} else {
alert('⚠️ Vælg venligst et klippekort');
return;
}
}
try {
const response = await fetch(`/api/v1/ticket/worklog/${id}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
if(!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Failed to update');
}
location.reload();
} catch(e) {
alert("Fejl ved opdatering: " + e.message);
}
}
</script>
</body>
</html>
{% endblock %}

View File

@ -275,6 +275,7 @@ class TModuleOrderDetails(BaseModel):
class TModuleApprovalStats(BaseModel):
"""Approval statistics per customer (from view)"""
customer_id: int
hub_customer_id: Optional[int] = None
customer_name: str
customer_vtiger_id: str
uses_time_card: bool = False
@ -316,6 +317,13 @@ class TModuleWizardProgress(BaseModel):
return v
class TModuleWizardEditRequest(BaseModel):
"""Request model for editing a time entry via Wizard"""
description: Optional[str] = None
original_hours: Optional[Decimal] = None # Editing raw hours before approval
billing_method: Optional[str] = None # For Hub Worklogs (invoice, prepaid, etc)
billable: Optional[bool] = None # For Module Times
class TModuleWizardNextEntry(BaseModel):
"""Next entry for wizard approval"""
has_next: bool

View File

@ -8,6 +8,7 @@ Isoleret routing uden påvirkning af existing Hub endpoints.
import logging
from typing import Optional, List, Dict, Any
from datetime import datetime
from fastapi import APIRouter, HTTPException, Depends, Body
from fastapi.responses import JSONResponse
@ -397,6 +398,44 @@ async def approve_time_entry(
from app.core.config import settings
from decimal import Decimal
# SPECIAL HANDLER FOR HUB WORKLOGS (Negative IDs)
if time_id < 0:
worklog_id = abs(time_id)
logger.info(f"🔄 Approving Hub Worklog {worklog_id}")
w_entry = execute_query_single("SELECT * FROM tticket_worklog WHERE id = %s", (worklog_id,))
if not w_entry:
raise HTTPException(status_code=404, detail="Worklog not found")
billable_hours = request.get('billable_hours')
approved_hours = Decimal(str(billable_hours)) if billable_hours is not None else Decimal(str(w_entry['hours']))
is_billable = request.get('billable', True)
new_billing = 'invoice' if is_billable else 'internal'
execute_query("""
UPDATE tticket_worklog
SET hours = %s, billing_method = %s, status = 'billable'
WHERE id = %s
""", (approved_hours, new_billing, worklog_id))
return {
"id": time_id,
"worked_date": w_entry['work_date'],
"original_hours": w_entry['hours'],
"status": "approved",
# Mock fields for schema validation
"customer_id": 0,
"case_id": 0,
"description": w_entry['description'],
"case_title": "Ticket Worklog",
"customer_name": "Hub Customer",
"created_at": w_entry['created_at'],
"last_synced_at": datetime.now(),
"approved_hours": approved_hours
}
# Hent timelog
query = """
SELECT t.*, c.title as case_title, c.status as case_status,
@ -464,16 +503,144 @@ async def approve_time_entry(
@router.post("/wizard/reject/{time_id}", response_model=TModuleTimeWithContext, tags=["Wizard"])
async def reject_time_entry(
time_id: int,
request: Dict[str, Any] = Body(None), # Allow body
reason: Optional[str] = None,
user_id: Optional[int] = None
):
"""Afvis en tidsregistrering"""
try:
# Handle body extraction if reason is missing from query
if not reason and request and 'rejection_note' in request:
reason = request['rejection_note']
if time_id < 0:
worklog_id = abs(time_id)
# Retrieve to confirm existence
w = execute_query_single("SELECT * FROM tticket_worklog WHERE id = %s", (worklog_id,))
if not w:
raise HTTPException(status_code=404, detail="Entry not found")
execute_query("UPDATE tticket_worklog SET status = 'rejected' WHERE id = %s", (worklog_id,))
return {
"id": time_id,
"status": "rejected",
"original_hours": w['hours'],
"worked_date": w['work_date'],
# Mock fields for schema validation
"customer_id": 0,
"case_id": 0,
"description": w.get('description', ''),
"case_title": "Ticket Worklog",
"customer_name": "Hub Customer",
"created_at": w['created_at'],
"last_synced_at": datetime.now(),
"billable": False
}
return wizard.reject_time_entry(time_id, reason=reason, user_id=user_id)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
from app.timetracking.backend.models import TModuleWizardEditRequest
@router.patch("/wizard/entry/{time_id}", response_model=TModuleTimeWithContext, tags=["Wizard"])
async def update_entry_details(
time_id: int,
request: TModuleWizardEditRequest
):
"""
Opdater detaljer en tidsregistrering (før godkendelse).
Tillader ændring af beskrivelse, antal timer og faktureringsmetode.
"""
try:
from decimal import Decimal
# 1. Handling Hub Worklogs (Negative IDs)
if time_id < 0:
worklog_id = abs(time_id)
w = execute_query_single("SELECT * FROM tticket_worklog WHERE id = %s", (worklog_id,))
if not w:
raise HTTPException(status_code=404, detail="Worklog not found")
updates = []
params = []
if request.description is not None:
updates.append("description = %s")
params.append(request.description)
if request.original_hours is not None:
updates.append("hours = %s")
params.append(request.original_hours)
if request.billing_method is not None:
updates.append("billing_method = %s")
params.append(request.billing_method)
if updates:
params.append(worklog_id)
execute_query(f"UPDATE tticket_worklog SET {', '.join(updates)} WHERE id = %s", tuple(params))
w = execute_query_single("SELECT * FROM tticket_worklog WHERE id = %s", (worklog_id,))
return {
"id": time_id,
"worked_date": w['work_date'],
"original_hours": w['hours'],
"status": "pending", # Always return as pending/draft context here
"description": w['description'],
"customer_id": 0,
"case_id": 0,
"case_title": "Updated",
"customer_name": "Hub Customer",
"created_at": w['created_at'],
"last_synced_at": datetime.now(),
"billable": True
}
# 2. Handling Module Times (Positive IDs)
else:
t = execute_query_single("SELECT * FROM tmodule_times WHERE id = %s", (time_id,))
if not t:
raise HTTPException(status_code=404, detail="Time entry not found")
updates = []
params = []
if request.description is not None:
updates.append("description = %s")
params.append(request.description)
if request.original_hours is not None:
updates.append("original_hours = %s")
params.append(request.original_hours)
if request.billable is not None:
updates.append("billable = %s")
params.append(request.billable)
if updates:
params.append(time_id)
execute_query(f"UPDATE tmodule_times SET {', '.join(updates)} WHERE id = %s", tuple(params))
# Fetch fresh with context for response
query = """
SELECT t.*, c.title as case_title, c.status as case_status,
cust.name as customer_name
FROM tmodule_times t
LEFT JOIN tmodule_cases c ON t.case_id = c.id
LEFT JOIN tmodule_customers cust ON t.customer_id = cust.id
WHERE t.id = %s
"""
return execute_query_single(query, (time_id,))
except Exception as e:
logger.error(f"❌ Failed to update entry {time_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/wizard/reset/{time_id}", response_model=TModuleTimeWithContext, tags=["Wizard"])
async def reset_to_pending(
time_id: int,
@ -1258,6 +1425,78 @@ async def get_customer_time_entries(customer_id: int, status: Optional[str] = No
times = execute_query(query, tuple(params))
# 🔗 Combine with Hub Worklogs (tticket_worklog)
# Only if we can find the linked Hub Customer ID
try:
cust_res = execute_query_single(
"SELECT hub_customer_id, name FROM tmodule_customers WHERE id = %s",
(customer_id,)
)
if cust_res and cust_res.get('hub_customer_id'):
hub_id = cust_res['hub_customer_id']
hub_name = cust_res['name']
# Fetch worklogs
w_query = """
SELECT
(w.id * -1) as id,
w.work_date as worked_date,
w.hours as original_hours,
w.description,
CASE
WHEN w.status = 'draft' THEN 'pending'
ELSE w.status
END as status,
-- Ticket info as Case info
t.subject as case_title,
t.ticket_number as case_vtiger_id,
t.description as case_description,
CASE
WHEN t.priority = 'urgent' THEN 'Høj'
ELSE 'Normal'
END as case_priority,
w.work_type as case_type,
-- Customer info
%s as customer_name,
%s as customer_id,
-- Logic
CASE
WHEN w.billing_method IN ('internal', 'warranty') THEN false
ELSE true
END as billable,
false as is_travel,
-- Extra context for frontend flags if needed
w.billing_method as _billing_method
FROM tticket_worklog w
JOIN tticket_tickets t ON w.ticket_id = t.id
WHERE t.customer_id = %s
"""
w_params = [hub_name, customer_id, hub_id]
if status:
if status == 'pending':
w_query += " AND w.status = 'draft'"
else:
w_query += " AND w.status = %s"
w_params.append(status)
w_times = execute_query(w_query, tuple(w_params))
if w_times:
times.extend(w_times)
# Re-sort combined list
times.sort(key=lambda x: (x.get('worked_date') or '', x.get('id')), reverse=True)
except Exception as e:
logger.error(f"⚠️ Failed to fetch hub worklogs for wizard: {e}")
# Continue with just tmodule times
return {"times": times, "total": len(times)}
except Exception as e:

View File

@ -49,12 +49,70 @@ class WizardService:
@staticmethod
def get_all_customers_stats() -> list[TModuleApprovalStats]:
"""Hent approval statistics for alle kunder"""
"""Hent approval statistics for alle kunder (inkl. Hub Worklogs)"""
try:
query = "SELECT * FROM tmodule_approval_stats ORDER BY customer_name"
# 1. Get base stats from module view
query = """
SELECT s.*, c.hub_customer_id
FROM tmodule_approval_stats s
LEFT JOIN tmodule_customers c ON s.customer_id = c.id
ORDER BY s.customer_name
"""
results = execute_query(query)
stats_map = {row['customer_id']: dict(row) for row in results}
return [TModuleApprovalStats(**row) for row in results]
# 2. Get pending count from Hub Worklogs
# Filter logic: status='draft' in Hub = 'pending' in Wizard
hub_query = """
SELECT
mc.id as tmodule_customer_id,
mc.name as customer_name,
mc.vtiger_id as customer_vtiger_id,
mc.uses_time_card,
mc.hub_customer_id,
count(*) as pending_count,
sum(w.hours) as pending_hours
FROM tticket_worklog w
JOIN tticket_tickets t ON w.ticket_id = t.id
JOIN tmodule_customers mc ON mc.hub_customer_id = t.customer_id
WHERE w.status = 'draft'
GROUP BY mc.id, mc.name, mc.vtiger_id, mc.uses_time_card, mc.hub_customer_id
"""
hub_results = execute_query(hub_query)
# 3. Merge stats
for row in hub_results:
tm_id = row['tmodule_customer_id']
if tm_id in stats_map:
# Update existing
stats_map[tm_id]['pending_count'] += row['pending_count']
stats_map[tm_id]['total_entries'] += row['pending_count']
# Optional: Add to total_original_hours if desired
else:
# New entry for customer only present in Hub worklogs
stats_map[tm_id] = {
"customer_id": tm_id,
"hub_customer_id": row['hub_customer_id'],
"customer_name": row['customer_name'],
"customer_vtiger_id": row['customer_vtiger_id'] or '',
"uses_time_card": row['uses_time_card'],
"total_entries": row['pending_count'],
"pending_count": row['pending_count'],
"approved_count": 0,
"rejected_count": 0,
"billed_count": 0,
"total_original_hours": 0, # Could use row['pending_hours']
"total_approved_hours": 0,
"latest_work_date": None,
"last_sync": None
}
return [TModuleApprovalStats(**s) for s in sorted(stats_map.values(), key=lambda x: x['customer_name'])]
except Exception as e:
logger.error(f"❌ Error getting all customer stats: {e}")
return []
except Exception as e:
logger.error(f"❌ Error getting all customer stats: {e}")

View File

@ -252,7 +252,57 @@
<button class="btn btn-success" onclick="approveSelected()">Godkend</button>
</div>
</div>
<!-- Edit Modal -->
<div class="modal fade" id="editEntryModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Rediger Worklog</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="editEntryId">
<div class="mb-3">
<label class="form-label">Beskrivelse</label>
<textarea class="form-control" id="editDescription" rows="4"></textarea>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Timer</label>
<input type="number" class="form-control" id="editHours" step="0.25" min="0">
</div>
</div>
<!-- Hub Worklog Specific Fields -->
<div id="hubFields" class="d-none p-3 bg-light rounded mb-3">
<label class="form-label fw-bold">Hub Billing settings</label>
<select class="form-select" id="editBillingMethod">
<option value="invoice">Faktura</option>
<option value="internal">Intern / Ingen faktura</option>
<option value="warranty">Garanti</option>
<option value="unknown">❓ Ved ikke</option>
</select>
<div class="form-text mt-2">
<i class="bi bi-info-circle"></i> <strong>Note:</strong> Klippekort skal vælges direkte i ticket-detaljerne eller worklog review.
</div>
</div>
<!-- Module Time Specific Fields -->
<div id="moduleFields" class="d-none">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="editBillable">
<label class="form-check-label" for="editBillable">Fakturerbar</label>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
<button type="button" class="btn btn-primary" onclick="saveEntryChanges()">Gem ændringer</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
@ -267,9 +317,28 @@
// Config
const DEFAULT_RATE = 1200;
document.addEventListener('DOMContentLoaded', () => {
loadCustomerList();
if (currentCustomerId) {
document.addEventListener('DOMContentLoaded', async () => {
await loadCustomerList();
// Support linking via Hub Customer ID
if (!currentCustomerId) {
const params = new URLSearchParams(window.location.search);
const hubId = params.get('hub_id');
if (hubId) {
// Determine mapped customer from loaded list
const found = customerList.find(c => c.hub_customer_id == hubId);
if (found) {
currentCustomerId = found.customer_id;
window.history.replaceState({}, '', `?customer_id=${currentCustomerId}`);
// Update dropdown
const select = document.getElementById('customer-select');
if(select) select.value = currentCustomerId;
loadCustomerEntries(currentCustomerId);
}
}
} else {
loadCustomerEntries(currentCustomerId);
}
});
@ -280,7 +349,7 @@
const response = await fetch('/api/v1/timetracking/wizard/stats');
const stats = await response.json();
customerList = stats.filter(c => c.pending_entries > 0);
customerList = stats.filter(c => c.pending_count > 0);
const select = document.getElementById('customer-select');
select.innerHTML = '<option value="">Vælg kunde...</option>';
@ -288,7 +357,7 @@
customerList.forEach(c => {
const option = document.createElement('option');
option.value = c.customer_id;
option.textContent = `${c.customer_name} (${c.pending_entries})`;
option.textContent = `${c.customer_name} (${c.pending_count})`;
if (parseInt(currentCustomerId) === c.customer_id) {
option.selected = true;
}
@ -469,16 +538,22 @@
<td style="width: 120px;" class="text-end fw-bold">
<span id="total-${entry.id}">-</span>
</td>
<td style="width: 100px;" class="text-end">
<button class="btn btn-sm btn-outline-success" onclick="approveOne(${entry.id})" title="Godkend">
<td style="width: 140px;" class="text-end">
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-primary" onclick='openEditModal(${JSON.stringify(entry).replace(/'/g, "&#39;")})' title="Rediger">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-outline-success" onclick="approveOne(${entry.id})" title="Godkend">
<i class="bi bi-check-lg"></i>
</button>
</div>
</td>
</tr>
`;
});
groupDiv.innerHTML = `
${headerHtml}
<div class="table-responsive">
<table class="table mb-0">
@ -780,5 +855,70 @@
clearSelection();
}
// --- Edit Modal Logic ---
let editModal = null;
function openEditModal(entry) {
if (!editModal) {
editModal = new bootstrap.Modal(document.getElementById('editEntryModal'));
}
document.getElementById('editEntryId').value = entry.id;
document.getElementById('editDescription').value = entry.description || '';
document.getElementById('editHours').value = entry.original_hours;
const hubFields = document.getElementById('hubFields');
const moduleFields = document.getElementById('moduleFields');
if (entry.id < 0) {
// Hub Worklog
hubFields.classList.remove('d-none');
moduleFields.classList.add('d-none');
// Assuming _billing_method was passed via backend view logic
const billing = entry._billing_method || 'invoice';
document.getElementById('editBillingMethod').value = billing;
} else {
// Module Time
hubFields.classList.add('d-none');
moduleFields.classList.remove('d-none');
document.getElementById('editBillable').checked = entry.billable !== false;
}
editModal.show();
}
async function saveEntryChanges() {
const id = document.getElementById('editEntryId').value;
const desc = document.getElementById('editDescription').value;
const hours = document.getElementById('editHours').value;
const payload = {
description: desc,
original_hours: parseFloat(hours)
};
if (parseInt(id) < 0) {
payload.billing_method = document.getElementById('editBillingMethod').value;
} else {
payload.billable = document.getElementById('editBillable').checked;
}
try {
const res = await fetch(`/api/v1/timetracking/wizard/entry/${id}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error("Update failed");
editModal.hide();
// Reload EVERYTHING because changing hours/billing affects sums
loadCustomerEntries(currentCustomerId);
} catch (e) {
alert("Kunne ikke gemme: " + e.message);
}
}
</script>
{% endblock %}

View File

@ -158,8 +158,11 @@ if __name__ == "__main__":
import uvicorn
import os
# Only enable reload in local development (not in Docker)
enable_reload = os.getenv("ENABLE_RELOAD", "false").lower() == "true"
# Only enable reload in local development (not in Docker) - check both variables
enable_reload = (
os.getenv("ENABLE_RELOAD", "false").lower() == "true" or
os.getenv("API_RELOAD", "false").lower() == "true"
)
if enable_reload:
uvicorn.run(

View File

@ -0,0 +1,14 @@
-- Migration 063: Ticket Enhancements (Types, Internal Notes, Worklog Visibility)
-- 1. Add ticket_type and internal_note to tickets
-- Defaults: ticket_type='incident' (for existing rows)
ALTER TABLE tticket_tickets
ADD COLUMN IF NOT EXISTS ticket_type VARCHAR(50) DEFAULT 'incident',
ADD COLUMN IF NOT EXISTS internal_note TEXT;
-- 2. Add is_internal to worklog (singular)
ALTER TABLE tticket_worklog
ADD COLUMN IF NOT EXISTS is_internal BOOLEAN DEFAULT FALSE;
-- 3. Create index for performance on filtering by type
CREATE INDEX IF NOT EXISTS idx_tticket_tickets_type ON tticket_tickets(ticket_type);

View File

@ -0,0 +1,11 @@
-- Add 'unknown' to billing_method check constraint
ALTER TABLE tticket_worklog DROP CONSTRAINT IF EXISTS tticket_worklog_billing_method_check;
ALTER TABLE tticket_worklog ADD CONSTRAINT tticket_worklog_billing_method_check
CHECK (billing_method::text = ANY (ARRAY[
'prepaid_card',
'invoice',
'internal',
'warranty',
'unknown'
]));

View File

@ -0,0 +1,10 @@
-- Migration: Allow Multiple Active Prepaid Cards Per Customer
-- Date: 2025-01-10
-- Description: Drops the partial unique index that enforced "only one active prepaid card per customer"
-- to allow customers to have multiple active cards simultaneously.
-- Drop the constraint that prevents multiple active cards per customer
DROP INDEX IF EXISTS idx_tticket_prepaid_unique_active;
-- Add a comment to document the change
COMMENT ON TABLE tticket_prepaid_cards IS 'Prepaid cards (klippekort) for customers. Multiple active cards per customer are allowed as of migration 065.';