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:
parent
a1d4696005
commit
f62cd8104a
@ -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"""
|
||||
|
||||
@ -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
|
||||
})
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 ?
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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]:
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@ -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 ? '¬es=' + encodeURIComponent(notes) : ''}`;
|
||||
const url = `/api/v1/ticket/tickets/{{ ticket.id }}/contacts?contact_id=${selectedContactId}&role=${role}${notes ? '¬es=' + 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) {
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 på 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:
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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, "'")})' 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 %}
|
||||
|
||||
7
main.py
7
main.py
@ -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(
|
||||
|
||||
14
migrations/063_ticket_enhancements.sql
Normal file
14
migrations/063_ticket_enhancements.sql
Normal 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);
|
||||
11
migrations/064_add_unknown_billing.sql
Normal file
11
migrations/064_add_unknown_billing.sql
Normal 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'
|
||||
]));
|
||||
10
migrations/065_allow_multiple_active_prepaid_cards.sql
Normal file
10
migrations/065_allow_multiple_active_prepaid_cards.sql
Normal 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.';
|
||||
Loading…
Reference in New Issue
Block a user