From f62cd8104a5061a0e9e610aeea5de759fa95fe65 Mon Sep 17 00:00:00 2001 From: Christian Date: Sat, 10 Jan 2026 21:09:29 +0100 Subject: [PATCH] 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. --- app/contacts/backend/router_simple.py | 65 +- app/dashboard/backend/views.py | 18 +- app/dashboard/frontend/index.html | 12 + app/prepaid/backend/router.py | 14 +- app/prepaid/frontend/detail.html | 32 + app/prepaid/frontend/index.html | 49 +- app/ticket/backend/klippekort_service.py | 43 +- app/ticket/backend/models.py | 13 + app/ticket/backend/router.py | 101 +- app/ticket/backend/ticket_service.py | 8 +- app/ticket/frontend/ticket_detail.html | 890 ++++++++++++++---- app/ticket/frontend/worklog_review.html | 460 +++++---- app/timetracking/backend/models.py | 8 + app/timetracking/backend/router.py | 239 +++++ app/timetracking/backend/wizard.py | 64 +- app/timetracking/frontend/wizard2.html | 160 +++- main.py | 7 +- migrations/063_ticket_enhancements.sql | 14 + migrations/064_add_unknown_billing.sql | 11 + ...65_allow_multiple_active_prepaid_cards.sql | 10 + 20 files changed, 1771 insertions(+), 447 deletions(-) create mode 100644 migrations/063_ticket_enhancements.sql create mode 100644 migrations/064_add_unknown_billing.sql create mode 100644 migrations/065_allow_multiple_active_prepaid_cards.sql diff --git a/app/contacts/backend/router_simple.py b/app/contacts/backend/router_simple.py index bc5d038..91c6928 100644 --- a/app/contacts/backend/router_simple.py +++ b/app/contacts/backend/router_simple.py @@ -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""" diff --git a/app/dashboard/backend/views.py b/app/dashboard/backend/views.py index 5d66642..c2d0d6d 100644 --- a/app/dashboard/backend/views.py +++ b/app/dashboard/backend/views.py @@ -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 + }) diff --git a/app/dashboard/frontend/index.html b/app/dashboard/frontend/index.html index 14525b9..b0227f4 100644 --- a/app/dashboard/frontend/index.html +++ b/app/dashboard/frontend/index.html @@ -14,6 +14,18 @@ + + {% if unknown_worklog_count > 0 %} + + {% endif %} +
diff --git a/app/prepaid/backend/router.py b/app/prepaid/backend/router.py index 5264f43..1123ece 100644 --- a/app/prepaid/backend/router.py +++ b/app/prepaid/backend/router.py @@ -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: diff --git a/app/prepaid/frontend/detail.html b/app/prepaid/frontend/detail.html index 0eeae94..6e6923b 100644 --- a/app/prepaid/frontend/detail.html +++ b/app/prepaid/frontend/detail.html @@ -48,6 +48,17 @@
Oversigt
+ +
+
+ Forbrug + 0% +
+
+
+
+
+
Købte Timer

-

@@ -147,6 +158,27 @@ async function loadCardDetails() { style: 'currency', currency: 'DKK' }).format(parseFloat(card.total_amount)); + + // Update Progress Bar + const purchased = parseFloat(card.purchased_hours) || 0; + const used = parseFloat(card.used_hours) || 0; + const percent = purchased > 0 ? (used / purchased) * 100 : 0; + + const progressBar = document.getElementById('statProgressBar'); + progressBar.style.width = Math.min(percent, 100) + '%'; + document.getElementById('statPercent').textContent = Math.round(percent) + '%'; + + // Color logic for progress bar + progressBar.className = 'progress-bar transition-all'; // Reset class but keep transition + if (percent >= 100) { + progressBar.classList.add('bg-secondary'); // Depleted + } else if (percent > 90) { + progressBar.classList.add('bg-danger'); // Critical + } else if (percent > 75) { + progressBar.classList.add('bg-warning'); // Warning + } else { + progressBar.classList.add('bg-success'); // Good + } // Update card info const statusBadge = getStatusBadge(card.status); diff --git a/app/prepaid/frontend/index.html b/app/prepaid/frontend/index.html index 7e68d10..3b8fdbe 100644 --- a/app/prepaid/frontend/index.html +++ b/app/prepaid/frontend/index.html @@ -129,6 +129,7 @@ Købte Timer Brugte Timer Tilbage + Forbrug Pris/Time Total Status @@ -138,7 +139,7 @@ - +
Loading...
@@ -169,8 +170,20 @@
- +
+ + + + +
+
💡 Brug hurtigknapperne eller indtast tilpasset antal
@@ -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 %} + + + @@ -499,37 +447,10 @@
+{% endblock %} - +{% block extra_js %} - - +{% endblock %} diff --git a/app/timetracking/backend/models.py b/app/timetracking/backend/models.py index ab6bb3e..0967044 100644 --- a/app/timetracking/backend/models.py +++ b/app/timetracking/backend/models.py @@ -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 diff --git a/app/timetracking/backend/router.py b/app/timetracking/backend/router.py index 1b1ee06..a4392b6 100644 --- a/app/timetracking/backend/router.py +++ b/app/timetracking/backend/router.py @@ -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: diff --git a/app/timetracking/backend/wizard.py b/app/timetracking/backend/wizard.py index 82ac49e..5e3305c 100644 --- a/app/timetracking/backend/wizard.py +++ b/app/timetracking/backend/wizard.py @@ -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}") diff --git a/app/timetracking/frontend/wizard2.html b/app/timetracking/frontend/wizard2.html index df6c44a..1b2470e 100644 --- a/app/timetracking/frontend/wizard2.html +++ b/app/timetracking/frontend/wizard2.html @@ -252,7 +252,57 @@
+ + {% 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 = ''; @@ -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 @@ - - - + +
+ + +
`; }); groupDiv.innerHTML = ` + ${headerHtml}
@@ -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); + } + } {% endblock %} diff --git a/main.py b/main.py index 02e5876..93ccb29 100644 --- a/main.py +++ b/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( diff --git a/migrations/063_ticket_enhancements.sql b/migrations/063_ticket_enhancements.sql new file mode 100644 index 0000000..713b950 --- /dev/null +++ b/migrations/063_ticket_enhancements.sql @@ -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); diff --git a/migrations/064_add_unknown_billing.sql b/migrations/064_add_unknown_billing.sql new file mode 100644 index 0000000..32f8bc2 --- /dev/null +++ b/migrations/064_add_unknown_billing.sql @@ -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' +])); diff --git a/migrations/065_allow_multiple_active_prepaid_cards.sql b/migrations/065_allow_multiple_active_prepaid_cards.sql new file mode 100644 index 0000000..eae5282 --- /dev/null +++ b/migrations/065_allow_multiple_active_prepaid_cards.sql @@ -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.';