- |
+ |
Loading...
@@ -169,8 +170,20 @@
@@ -270,7 +283,7 @@ function renderCards(cards) {
if (!cards || cards.length === 0) {
tbody.innerHTML = `
- |
+ | |
Ingen kort fundet
|
`;
@@ -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 `
|
@@ -307,6 +328,19 @@ function renderCards(cards) {
${remainingHours.toFixed(1)} t
|
+
+
+
+ ${usedPercent.toFixed(0)}%
+
+
+ Forbrug
+ |
${pricePerHour.toFixed(2)} kr |
${totalAmount.toFixed(2)} kr |
${statusBadge} |
@@ -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 {
diff --git a/app/ticket/backend/klippekort_service.py b/app/ticket/backend/klippekort_service.py
index 7e242f5..d6edd5a 100644
--- a/app/ticket/backend/klippekort_service.py
+++ b/app/ticket/backend/klippekort_service.py
@@ -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]:
diff --git a/app/ticket/backend/models.py b/app/ticket/backend/models.py
index 7255b1e..98f2b58 100644
--- a/app/ticket/backend/models.py
+++ b/app/ticket/backend/models.py
@@ -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):
diff --git a/app/ticket/backend/router.py b/app/ticket/backend/router.py
index c740d64..cf4ae9b 100644
--- a/app/ticket/backend/router.py
+++ b/app/ticket/backend/router.py
@@ -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
diff --git a/app/ticket/backend/ticket_service.py b/app/ticket/backend/ticket_service.py
index 2de6f35..4a060de 100644
--- a/app/ticket/backend/ticket_service.py
+++ b/app/ticket/backend/ticket_service.py
@@ -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
)
)
diff --git a/app/ticket/frontend/ticket_detail.html b/app/ticket/frontend/ticket_detail.html
index 2dab744..6c20df9 100644
--- a/app/ticket/frontend/ticket_detail.html
+++ b/app/ticket/frontend/ticket_detail.html
@@ -5,12 +5,16 @@
{% block extra_css %}
-
-
-
-
+{% endblock %}
-
+{% block content %}
-
+
Worklog Godkendelse
Godkend eller afvis enkelt-entries fra draft worklog
+
-
+
{{ total_entries }}
Entries til godkendelse
-
+
{{ "%.2f"|format(total_hours) }}t
Total timer
-
+
{{ "%.2f"|format(total_billable_hours) }}t
Fakturerbare timer
@@ -380,7 +236,7 @@
{% for worklog in worklogs %}
-
+
{{ worklog.ticket_number }}
@@ -420,6 +276,12 @@
{% if worklog.card_number %}
{{ worklog.card_number }}
{% endif %}
+ {% elif worklog.billing_method == 'unknown' %}
+
+ Ved ikke
+
+ {% else %}
+ {{ worklog.billing_method }}
{% endif %}
|
@@ -435,15 +297,31 @@
+
{% else %}
@@ -465,6 +343,76 @@
{% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ :
+
+
+ Total: 0.00 timer
+
+
+
+
+
+
+
+
+
+
+
+
+ Vælg hvilket klippekort der skal bruges
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -499,37 +447,10 @@
+{% endblock %}
-
+{% block extra_js %}
-
- | |