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>
|
||||
<input type="number" class="form-control" id="purchasedHours"
|
||||
step="0.5" min="1" required>
|
||||
<div class="input-group">
|
||||
<input type="number" class="form-control" id="purchasedHours"
|
||||
step="0.5" min="1" required>
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="setPurchasedHours(10)" title="10 timer">
|
||||
10t
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="setPurchasedHours(25)" title="25 timer">
|
||||
25t
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="setPurchasedHours(50)" title="50 timer">
|
||||
50t
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">💡 Brug hurtigknapperne eller indtast tilpasset antal</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Pris pr. Time (DKK) *</label>
|
||||
@ -270,7 +283,7 @@ function renderCards(cards) {
|
||||
|
||||
if (!cards || cards.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr><td colspan="10" class="text-center text-muted py-5">
|
||||
<tr><td colspan="11" class="text-center text-muted py-5">
|
||||
Ingen kort fundet
|
||||
</td></tr>
|
||||
`;
|
||||
@ -289,6 +302,14 @@ function renderCards(cards) {
|
||||
const pricePerHour = parseFloat(card.price_per_hour);
|
||||
const totalAmount = parseFloat(card.total_amount);
|
||||
|
||||
// Calculate usage percentage
|
||||
const usedPercent = purchasedHours > 0 ? Math.min(100, Math.max(0, (usedHours / purchasedHours) * 100)) : 0;
|
||||
|
||||
// Progress bar color based on usage
|
||||
let progressClass = 'bg-success';
|
||||
if (usedPercent >= 90) progressClass = 'bg-danger';
|
||||
else if (usedPercent >= 75) progressClass = 'bg-warning';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>
|
||||
@ -307,6 +328,19 @@ function renderCards(cards) {
|
||||
${remainingHours.toFixed(1)} t
|
||||
</strong>
|
||||
</td>
|
||||
<td>
|
||||
<div class="progress" style="height: 20px; min-width: 100px;">
|
||||
<div class="progress-bar ${progressClass}"
|
||||
role="progressbar"
|
||||
style="width: ${usedPercent.toFixed(0)}%"
|
||||
aria-valuenow="${usedPercent.toFixed(0)}"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100">
|
||||
${usedPercent.toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted">Forbrug</small>
|
||||
</td>
|
||||
<td class="text-end">${pricePerHour.toFixed(2)} kr</td>
|
||||
<td class="text-end"><strong>${totalAmount.toFixed(2)} kr</strong></td>
|
||||
<td>${statusBadge}</td>
|
||||
@ -341,6 +375,13 @@ function getStatusBadge(status) {
|
||||
return badges[status] || status;
|
||||
}
|
||||
|
||||
// Set purchased hours from quick template buttons
|
||||
function setPurchasedHours(hours) {
|
||||
document.getElementById('purchasedHours').value = hours;
|
||||
// Optionally focus next field (pricePerHour) for quick workflow
|
||||
document.getElementById('pricePerHour').focus();
|
||||
}
|
||||
|
||||
// Load Customers for Dropdown
|
||||
async function loadCustomers() {
|
||||
try {
|
||||
|
||||
@ -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
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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,10 +317,29 @@
|
||||
// Config
|
||||
const DEFAULT_RATE = 1200;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadCustomerList();
|
||||
if (currentCustomerId) {
|
||||
loadCustomerEntries(currentCustomerId);
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await loadCustomerList();
|
||||
|
||||
// Support linking via Hub Customer ID
|
||||
if (!currentCustomerId) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const hubId = params.get('hub_id');
|
||||
if (hubId) {
|
||||
// Determine mapped customer from loaded list
|
||||
const found = customerList.find(c => c.hub_customer_id == hubId);
|
||||
if (found) {
|
||||
currentCustomerId = found.customer_id;
|
||||
window.history.replaceState({}, '', `?customer_id=${currentCustomerId}`);
|
||||
|
||||
// Update dropdown
|
||||
const select = document.getElementById('customer-select');
|
||||
if(select) select.value = currentCustomerId;
|
||||
|
||||
loadCustomerEntries(currentCustomerId);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
loadCustomerEntries(currentCustomerId);
|
||||
}
|
||||
});
|
||||
|
||||
@ -280,7 +349,7 @@
|
||||
const response = await fetch('/api/v1/timetracking/wizard/stats');
|
||||
const stats = await response.json();
|
||||
|
||||
customerList = stats.filter(c => c.pending_entries > 0);
|
||||
customerList = stats.filter(c => c.pending_count > 0);
|
||||
|
||||
const select = document.getElementById('customer-select');
|
||||
select.innerHTML = '<option value="">Vælg kunde...</option>';
|
||||
@ -288,7 +357,7 @@
|
||||
customerList.forEach(c => {
|
||||
const option = document.createElement('option');
|
||||
option.value = c.customer_id;
|
||||
option.textContent = `${c.customer_name} (${c.pending_entries})`;
|
||||
option.textContent = `${c.customer_name} (${c.pending_count})`;
|
||||
if (parseInt(currentCustomerId) === c.customer_id) {
|
||||
option.selected = true;
|
||||
}
|
||||
@ -469,16 +538,22 @@
|
||||
<td style="width: 120px;" class="text-end fw-bold">
|
||||
<span id="total-${entry.id}">-</span>
|
||||
</td>
|
||||
<td style="width: 100px;" class="text-end">
|
||||
<button class="btn btn-sm btn-outline-success" onclick="approveOne(${entry.id})" title="Godkend">
|
||||
<i class="bi bi-check-lg"></i>
|
||||
</button>
|
||||
<td style="width: 140px;" class="text-end">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-outline-primary" onclick='openEditModal(${JSON.stringify(entry).replace(/'/g, "'")})' 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