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>
@ -147,6 +158,27 @@ async function loadCardDetails() {
style: 'currency',
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);

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>
<input type="number" class="form-control" id="purchasedHours"
step="0.5" min="1" required>
<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
)
)

File diff suppressed because it is too large Load Diff

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,10 +317,29 @@
// Config
const DEFAULT_RATE = 1200;
document.addEventListener('DOMContentLoaded', () => {
loadCustomerList();
if (currentCustomerId) {
loadCustomerEntries(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">
<i class="bi bi-check-lg"></i>
</button>
<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.';