From a230071632a2b6a33884ad097230d2959b573041 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 10 Dec 2025 18:29:13 +0100 Subject: [PATCH] feat: Add customer time pricing management page with dynamic features - Implemented a new HTML page for managing customer time pricing with Bootstrap styling. - Added navigation and responsive design elements. - Integrated JavaScript for loading customer data, editing rates, and handling modals for time entries and order creation. - Included theme toggle functionality and statistics display for customer rates. - Enhanced user experience with toast notifications for actions performed. docs: Create e-conomic Write Mode guide - Added comprehensive documentation for exporting approved time entries to e-conomic as draft orders. - Detailed safety flags for write operations, including read-only and dry-run modes. - Provided activation steps, error handling, and best practices for using the e-conomic integration. migrations: Add user_company field to contacts and e-conomic customer number to customers - Created migration to add user_company field to contacts for better organization tracking. - Added e-conomic customer number field to tmodule_customers for invoice export synchronization. --- .env.bak | 20 +- .env.example | 45 + README.md | 39 +- app/core/config.py | 3 +- app/timetracking/backend/economic_export.py | 115 +- app/timetracking/backend/models.py | 9 + app/timetracking/backend/order_service.py | 86 +- app/timetracking/backend/router.py | 398 ++++++- app/timetracking/backend/vtiger_sync.py | 305 +++++- app/timetracking/backend/wizard.py | 260 ++++- app/timetracking/frontend/customers.html | 893 +++++++++++++++ app/timetracking/frontend/dashboard.html | 102 +- app/timetracking/frontend/orders.html | 163 ++- app/timetracking/frontend/views.py | 14 + app/timetracking/frontend/wizard.html | 1072 ++++++++++++++++--- docs/ECONOMIC_WRITE_MODE.md | 296 +++++ migrations/013_timetracking_module.sql | 29 +- migrations/014_add_contact_user_company.sql | 11 + migrations/014_economic_customer_number.sql | 15 + 19 files changed, 3529 insertions(+), 346 deletions(-) create mode 100644 app/timetracking/frontend/customers.html create mode 100644 docs/ECONOMIC_WRITE_MODE.md create mode 100644 migrations/014_add_contact_user_company.sql create mode 100644 migrations/014_economic_customer_number.sql diff --git a/.env.bak b/.env.bak index 0d915ad..5fee4e8 100644 --- a/.env.bak +++ b/.env.bak @@ -38,7 +38,7 @@ GITHUB_REPO=ct/bmc_hub # OLLAMA AI INTEGRATION # ===================================================== OLLAMA_ENDPOINT=http://ai_direct.cs.blaahund.dk -OLLAMA_MODEL=qwen2.5:3b +OLLAMA_MODEL=qwen2.5-coder:7b # ===================================================== # e-conomic Integration (Optional) @@ -51,3 +51,21 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_agreement_grant_token_here # 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede ændringer ECONOMIC_READ_ONLY=true # Set to false ONLY after testing ECONOMIC_DRY_RUN=true # Set to false ONLY when ready for production writes + +# vTiger CRM Integration (for Time Tracking Module) +VTIGER_URL=https://bmcnetworks.od2.vtiger.com +VTIGER_USERNAME=ct@bmcnetworks.dk +VTIGER_API_KEY=bD8cW8zRFuKpPZ2S + +# Time Tracking Module Settings +TIMETRACKING_DEFAULT_HOURLY_RATE=1200.00 # Standard timepris i DKK +TIMETRACKING_AUTO_ROUND=true +TIMETRACKING_ROUND_INCREMENT=0.5 +TIMETRACKING_ROUND_METHOD=up + +# Time Tracking Safety Switches +TIMETRACKING_VTIGER_READ_ONLY=true +TIMETRACKING_VTIGER_DRY_RUN=true +TIMETRACKING_ECONOMIC_READ_ONLY=true +TIMETRACKING_ECONOMIC_DRY_RUN=true + diff --git a/.env.example b/.env.example index bc83e8d..5c62689 100644 --- a/.env.example +++ b/.env.example @@ -45,3 +45,48 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_agreement_grant_token_here # 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede ændringer ECONOMIC_READ_ONLY=true # Set to false ONLY after testing ECONOMIC_DRY_RUN=true # Set to false ONLY when ready for production writes + +# ===================================================== +# vTiger CRM Integration (Optional) +# ===================================================== +VTIGER_URL=https://your-instance.od2.vtiger.com +VTIGER_USERNAME=your_username@yourdomain.com +VTIGER_API_KEY=your_api_key_or_access_key +VTIGER_PASSWORD=your_password_if_using_basic_auth + +# ===================================================== +# TIME TRACKING MODULE - Isolated Settings +# ===================================================== + +# vTiger Integration Safety Flags +TIMETRACKING_VTIGER_READ_ONLY=true # 🚨 Bloker ALLE skrivninger til vTiger +TIMETRACKING_VTIGER_DRY_RUN=true # 🚨 Log uden at synkronisere + +# e-conomic Integration Safety Flags +TIMETRACKING_ECONOMIC_READ_ONLY=true # 🚨 Bloker ALLE skrivninger til e-conomic +TIMETRACKING_ECONOMIC_DRY_RUN=true # 🚨 Log uden at eksportere +TIMETRACKING_EXPORT_TYPE=draft # draft|booked (draft er sikrest) + +# Business Logic Settings +TIMETRACKING_DEFAULT_HOURLY_RATE=850.00 # DKK pr. time (fallback hvis kunde ikke har rate) +TIMETRACKING_AUTO_ROUND=true # Auto-afrund til nærmeste interval +TIMETRACKING_ROUND_INCREMENT=0.5 # Afrundingsinterval (0.25, 0.5, 1.0) +TIMETRACKING_ROUND_METHOD=up # up (op til), nearest (nærmeste), down (ned til) +TIMETRACKING_REQUIRE_APPROVAL=true # Kræv manuel godkendelse + +# ===================================================== +# OLLAMA AI Integration (Optional - for document extraction) +# ===================================================== +OLLAMA_ENDPOINT=http://ai_direct.cs.blaahund.dk +OLLAMA_MODEL=qwen2.5-coder:7b + +# ===================================================== +# COMPANY INFO +# ===================================================== +OWN_CVR=29522790 # BMC Denmark ApS - ignore when detecting vendors + +# ===================================================== +# FILE UPLOAD +# ===================================================== +UPLOAD_DIR=uploads +MAX_FILE_SIZE_MB=50 diff --git a/README.md b/README.md index b026b6a..a05a00a 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,12 @@ Et centralt management system til BMC Networks - håndterer kunder, services, ha ## 🌟 Features - **Customer Management**: Komplet kundedatabase med CRM integration +- **Time Tracking Module**: vTiger integration med tidsregistrering og fakturering + - Automatisk sync fra vTiger (billable timelogs) + - Step-by-step godkendelses-wizard + - Auto-afrunding til 0.5 timer + - Klippekort-funktionalitet + - e-conomic export (draft orders) - **Hardware Tracking**: Registrering og sporing af kundeudstyr - **Service Management**: Håndtering af services og abonnementer - **Billing Integration**: Automatisk fakturering via e-conomic @@ -123,12 +129,43 @@ bmc_hub/ ## 🔌 API Endpoints +### Main API - `GET /api/v1/customers` - List customers - `GET /api/v1/hardware` - List hardware - `GET /api/v1/billing/invoices` - List invoices - `GET /health` - Health check -Se fuld dokumentation: http://localhost:8000/api/docs +### Time Tracking Module +- `POST /api/v1/timetracking/sync` - Sync from vTiger (read-only) +- `GET /api/v1/timetracking/wizard/next` - Get next pending timelog +- `POST /api/v1/timetracking/wizard/approve/{id}` - Approve timelog +- `POST /api/v1/timetracking/orders/generate` - Generate invoice order +- `POST /api/v1/timetracking/export` - Export to e-conomic (with safety flags) +- `GET /api/v1/timetracking/export/test-connection` - Test e-conomic connection + +Se fuld dokumentation: http://localhost:8001/api/docs + +## 🚨 e-conomic Write Mode + +Time Tracking modulet kan eksportere ordrer til e-conomic med **safety-first approach**: + +### Safety Flags (default: SAFE) +```bash +TIMETRACKING_ECONOMIC_READ_ONLY=true # Block all writes +TIMETRACKING_ECONOMIC_DRY_RUN=true # Simulate writes (log only) +``` + +### Enable Write Mode +Se detaljeret guide: [docs/ECONOMIC_WRITE_MODE.md](docs/ECONOMIC_WRITE_MODE.md) + +**Quick steps:** +1. Test connection: `GET /api/v1/timetracking/export/test-connection` +2. Test dry-run: Set `READ_ONLY=false`, keep `DRY_RUN=true` +3. Export test order: `POST /api/v1/timetracking/export` +4. Enable production: Set **both** flags to `false` +5. Verify first order in e-conomic before bulk operations + +**CRITICAL**: All customers must have `economic_customer_number` (synced from vTiger `cf_854` field). ## 🧪 Testing diff --git a/app/core/config.py b/app/core/config.py index 17a59b7..1a7ca8c 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -50,8 +50,9 @@ class Settings(BaseSettings): # Time Tracking Module - Business Logic TIMETRACKING_DEFAULT_HOURLY_RATE: float = 850.00 # DKK pr. time (fallback) - TIMETRACKING_AUTO_ROUND: bool = False # Auto-afrund til nærmeste 0.5 time + TIMETRACKING_AUTO_ROUND: bool = True # Auto-afrund til nærmeste 0.5 time TIMETRACKING_ROUND_INCREMENT: float = 0.5 # Afrundingsinterval (0.25, 0.5, 1.0) + TIMETRACKING_ROUND_METHOD: str = "up" # up (op til), nearest (nærmeste), down (ned til) TIMETRACKING_REQUIRE_APPROVAL: bool = True # Kræv manuel godkendelse (ikke auto-approve) # Ollama AI Integration diff --git a/app/timetracking/backend/economic_export.py b/app/timetracking/backend/economic_export.py index 1289f35..dd0f39c 100644 --- a/app/timetracking/backend/economic_export.py +++ b/app/timetracking/backend/economic_export.py @@ -11,6 +11,7 @@ Safety Flags: """ import logging +import json from typing import Optional, Dict, Any import aiohttp @@ -192,33 +193,80 @@ class EconomicExportService: # REAL EXPORT (kun hvis safety flags er disabled) logger.warning(f"⚠️ REAL EXPORT STARTING for order {request.order_id}") - # TODO: Implementer rigtig e-conomic API call her - # Denne kode vil kun køre hvis READ_ONLY og DRY_RUN begge er False + # Hent e-conomic customer number fra vTiger customer + customer_number_query = """ + SELECT economic_customer_number + FROM tmodule_customers + WHERE id = %s + """ + customer_data = execute_query(customer_number_query, (order['customer_id'],), fetchone=True) + + if not customer_data or not customer_data.get('economic_customer_number'): + raise HTTPException( + status_code=400, + detail=f"Customer {order['customer_name']} has no e-conomic customer number" + ) + + customer_number = customer_data['economic_customer_number'] # Build e-conomic draft order payload economic_payload = { - "date": order['order_date'].isoformat(), + "date": order['order_date'].isoformat() if hasattr(order['order_date'], 'isoformat') else str(order['order_date']), "currency": "DKK", "exchangeRate": 100, - "netAmount": float(order['subtotal']), - "grossAmount": float(order['total_amount']), - "vatAmount": float(order['vat_amount']), - "notes": { - "heading": f"Tidsregistrering - {order['order_number']}", - "textLine1": order.get('notes', '') + "customer": { + "customerNumber": customer_number }, - "lines": [ - { - "lineNumber": line['line_number'], - "description": line['description'], - "quantity": float(line['quantity']), - "unitNetPrice": float(line['unit_price']), - "totalNetAmount": float(line['line_total']) + "recipient": { + "name": order['customer_name'], + "vatZone": { + "vatZoneNumber": 1 # Domestic Denmark } - for line in lines - ] + }, + "paymentTerms": { + "paymentTermsNumber": 1 # Default payment terms + }, + "layout": { + "layoutNumber": 19 # Default layout + }, + "notes": { + "heading": f"Tidsregistrering - {order['order_number']}" + }, + "lines": [] } + # Add notes if present + if order.get('notes'): + economic_payload['notes']['textLine1'] = order['notes'] + + # Build order lines + for idx, line in enumerate(lines, start=1): + economic_line = { + "lineNumber": idx, + "sortKey": idx, + "description": line['description'], + "quantity": float(line['quantity']), + "unitNetPrice": float(line['unit_price']), + "unit": { + "unitNumber": 1 # Default unit (stk/pcs) + } + } + + # Add product if specified + if line.get('product_number'): + product_number = str(line['product_number'])[:25] # Max 25 chars + economic_line['product'] = { + "productNumber": product_number + } + + # Add discount if present + if line.get('discount_percentage'): + economic_line['discountPercentage'] = float(line['discount_percentage']) + + economic_payload['lines'].append(economic_line) + + logger.info(f"📤 Sending to e-conomic: {json.dumps(economic_payload, indent=2, default=str)}") + # Call e-conomic API async with aiohttp.ClientSession() as session: async with session.post( @@ -227,23 +275,46 @@ class EconomicExportService: json=economic_payload, timeout=aiohttp.ClientTimeout(total=30) ) as response: + response_text = await response.text() + if response.status not in [200, 201]: - error_text = await response.text() - logger.error(f"❌ e-conomic export failed: {response.status} - {error_text}") + logger.error(f"❌ e-conomic export failed: {response.status}") + logger.error(f"Response: {response_text}") + logger.error(f"Payload: {json.dumps(economic_payload, indent=2, default=str)}") + + # Try to parse error message + try: + error_data = json.loads(response_text) + error_msg = error_data.get('message', response_text) + + # Parse detailed validation errors if present + if 'errors' in error_data: + error_details = [] + for entity, entity_errors in error_data['errors'].items(): + if isinstance(entity_errors, dict) and 'errors' in entity_errors: + for err in entity_errors['errors']: + field = err.get('propertyName', entity) + msg = err.get('errorMessage', err.get('message', 'Unknown')) + error_details.append(f"{field}: {msg}") + if error_details: + error_msg = '; '.join(error_details) + except: + error_msg = response_text # Log failed export audit.log_export_failed( order_id=request.order_id, - error=error_text, + error=error_msg, user_id=user_id ) raise HTTPException( status_code=response.status, - detail=f"e-conomic API error: {error_text}" + detail=f"e-conomic API error: {error_msg}" ) result_data = await response.json() + logger.info(f"✅ e-conomic response: {json.dumps(result_data, indent=2, default=str)}") economic_draft_id = result_data.get('draftOrderNumber') economic_order_number = result_data.get('orderNumber', str(economic_draft_id)) diff --git a/app/timetracking/backend/models.py b/app/timetracking/backend/models.py index 07dbb83..b809023 100644 --- a/app/timetracking/backend/models.py +++ b/app/timetracking/backend/models.py @@ -144,9 +144,14 @@ class TModuleTime(TModuleTimeBase): class TModuleTimeWithContext(TModuleTime): """Time entry with case and customer context (for wizard)""" case_title: str + case_description: Optional[str] = None case_status: Optional[str] = None + case_vtiger_id: Optional[str] = None + case_vtiger_data: Optional[dict] = None customer_name: str customer_rate: Optional[Decimal] = None + contact_name: Optional[str] = None + contact_company: Optional[str] = None # ============================================================================ @@ -176,6 +181,8 @@ class TModuleOrderLine(TModuleOrderLineBase): id: int order_id: int created_at: datetime + case_contact: Optional[str] = None # Contact name from case + time_date: Optional[date] = None # Date from time entries class Config: from_attributes = True @@ -210,6 +217,7 @@ class TModuleOrder(TModuleOrderBase): """Full order model with DB fields""" id: int order_number: Optional[str] = None + customer_name: Optional[str] = None # From JOIN med customers table status: str = Field("draft", pattern="^(draft|exported|sent|cancelled)$") economic_draft_id: Optional[int] = None economic_order_number: Optional[str] = None @@ -255,6 +263,7 @@ class TModuleApprovalStats(BaseModel): customer_id: int customer_name: str customer_vtiger_id: str + uses_time_card: bool = False total_entries: int pending_count: int approved_count: int diff --git a/app/timetracking/backend/order_service.py b/app/timetracking/backend/order_service.py index a5b67d1..cd9929b 100644 --- a/app/timetracking/backend/order_service.py +++ b/app/timetracking/backend/order_service.py @@ -95,11 +95,16 @@ class OrderService: if not customer: raise HTTPException(status_code=404, detail="Customer not found") - # Hent godkendte tider for kunden + # Hent godkendte tider for kunden med case og contact detaljer query = """ - SELECT t.*, c.title as case_title + SELECT t.*, + c.title as case_title, + c.vtiger_id as case_vtiger_id, + c.vtiger_data->>'ticket_title' as vtiger_title, + CONCAT(cont.first_name, ' ', cont.last_name) as contact_name FROM tmodule_times t JOIN tmodule_cases c ON t.case_id = c.id + LEFT JOIN contacts cont ON cont.vtiger_id = c.vtiger_data->>'contact_id' WHERE t.customer_id = %s AND t.status = 'approved' AND t.billable = true @@ -121,16 +126,21 @@ class OrderService: customer.get('hub_customer_id') ) - # Group by case + # Group by case og gem ekstra metadata case_groups = {} for time_entry in approved_times: case_id = time_entry['case_id'] if case_id not in case_groups: case_groups[case_id] = { - 'case_title': time_entry['case_title'], - 'entries': [] + 'case_vtiger_id': time_entry.get('case_vtiger_id'), + 'contact_name': time_entry.get('contact_name'), + 'entries': [], + 'descriptions': [] # Samle alle beskrivelser } case_groups[case_id]['entries'].append(time_entry) + # Tilføj beskrivelse hvis den ikke er tom + if time_entry.get('description') and time_entry['description'].strip(): + case_groups[case_id]['descriptions'].append(time_entry['description'].strip()) # Build order lines order_lines = [] @@ -144,9 +154,33 @@ class OrderService: for entry in group['entries'] ) - # Build description - entry_count = len(group['entries']) - description = f"{group['case_title']} ({entry_count} tidsregistreringer)" + # Extract case number from vtiger_id (format: 39x42930 -> CC2930) + case_number = "" + if group['case_vtiger_id']: + vtiger_parts = group['case_vtiger_id'].split('x') + if len(vtiger_parts) > 1: + # Take last 4 digits + case_number = f"CC{vtiger_parts[1][-4:]}" + + # Brug tidsregistreringers beskrivelser som titel + # Tag første beskrivelse, eller alle hvis de er forskellige + case_title = "Ingen beskrivelse" + if group['descriptions']: + # Hvis alle beskrivelser er ens, brug kun én + unique_descriptions = list(set(group['descriptions'])) + if len(unique_descriptions) == 1: + case_title = unique_descriptions[0] + else: + # Hvis forskellige, join dem + case_title = ", ".join(unique_descriptions[:3]) # Max 3 for ikke at blive for lang + if len(unique_descriptions) > 3: + case_title += "..." + + # Build description med case nummer prefix + if case_number: + description = f"{case_number} - {case_title}" + else: + description = case_title # Calculate line total line_total = case_hours * hourly_rate @@ -260,18 +294,32 @@ class OrderService: def get_order_with_lines(order_id: int) -> TModuleOrderWithLines: """Hent ordre med linjer""" try: - # Get order - order_query = "SELECT * FROM tmodule_orders WHERE id = %s" + # Get order with customer name + order_query = """ + SELECT o.*, c.name as customer_name + FROM tmodule_orders o + LEFT JOIN customers c ON o.customer_id = c.id + WHERE o.id = %s + """ order = execute_query(order_query, (order_id,), fetchone=True) if not order: raise HTTPException(status_code=404, detail="Order not found") - # Get lines + # Get lines with additional context (contact, date) lines_query = """ - SELECT * FROM tmodule_order_lines - WHERE order_id = %s - ORDER BY line_number + SELECT ol.*, + STRING_AGG(DISTINCT CONCAT(cont.first_name, ' ', cont.last_name), ', ') as case_contact, + MIN(t.worked_date) as time_date + FROM tmodule_order_lines ol + LEFT JOIN tmodule_times t ON t.id = ANY(ol.time_entry_ids) + LEFT JOIN tmodule_cases c ON c.id = ol.case_id + LEFT JOIN contacts cont ON cont.vtiger_id = c.vtiger_data->>'contact_id' + WHERE ol.order_id = %s + GROUP BY ol.id, ol.order_id, ol.case_id, ol.line_number, ol.description, + ol.quantity, ol.unit_price, ol.line_total, ol.time_entry_ids, + ol.product_number, ol.account_number, ol.created_at + ORDER BY ol.line_number """ lines = execute_query(lines_query, (order_id,)) @@ -298,19 +346,21 @@ class OrderService: params = [] if customer_id: - conditions.append("customer_id = %s") + conditions.append("o.customer_id = %s") params.append(customer_id) if status: - conditions.append("status = %s") + conditions.append("o.status = %s") params.append(status) where_clause = " WHERE " + " AND ".join(conditions) if conditions else "" query = f""" - SELECT * FROM tmodule_orders + SELECT o.*, c.name as customer_name + FROM tmodule_orders o + LEFT JOIN customers c ON o.customer_id = c.id {where_clause} - ORDER BY order_date DESC, id DESC + ORDER BY o.order_date DESC, o.id DESC LIMIT %s """ params.append(limit) diff --git a/app/timetracking/backend/router.py b/app/timetracking/backend/router.py index d717dc0..d69d3ab 100644 --- a/app/timetracking/backend/router.py +++ b/app/timetracking/backend/router.py @@ -44,7 +44,10 @@ router = APIRouter() # ============================================================================ @router.post("/sync", response_model=TModuleSyncStats, tags=["Sync"]) -async def sync_from_vtiger(user_id: Optional[int] = None): +async def sync_from_vtiger( + user_id: Optional[int] = None, + fetch_comments: bool = False +): """ 🔍 Synkroniser data fra vTiger (READ-ONLY). @@ -54,16 +57,53 @@ async def sync_from_vtiger(user_id: Optional[int] = None): - ModComments (tidsregistreringer) Gemmes i tmodule_* tabeller (isoleret). + + Args: + user_id: ID på bruger der kører sync + fetch_comments: Hent også interne kommentarer (langsomt - ~0.4s pr case) """ try: logger.info("🚀 Starting vTiger sync...") - result = await vtiger_service.full_sync(user_id=user_id) + result = await vtiger_service.full_sync(user_id=user_id, fetch_comments=fetch_comments) return result except Exception as e: logger.error(f"❌ Sync failed: {e}") raise HTTPException(status_code=500, detail=str(e)) +@router.post("/sync/case/{case_id}/comments", tags=["Sync"]) +async def sync_case_comments(case_id: int): + """ + 🔍 Synkroniser kommentarer for en specifik case fra vTiger. + + Bruges til on-demand opdatering når man ser på en case i wizard. + """ + try: + # Hent case fra database + case = execute_query( + "SELECT vtiger_id FROM tmodule_cases WHERE id = %s", + (case_id,), + fetchone=True + ) + + if not case: + raise HTTPException(status_code=404, detail="Case not found") + + # Sync comments + result = await vtiger_service.sync_case_comments(case['vtiger_id']) + + if not result['success']: + raise HTTPException(status_code=500, detail=result.get('error', 'Failed to sync comments')) + + return result + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Failed to sync comments for case {case_id}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @router.get("/sync/test-connection", tags=["Sync"]) async def test_vtiger_connection(): """Test forbindelse til vTiger""" @@ -93,37 +133,99 @@ async def get_all_customer_stats(): @router.get("/wizard/next", response_model=TModuleWizardNextEntry, tags=["Wizard"]) -async def get_next_pending_entry(customer_id: Optional[int] = None): +async def get_next_pending_entry( + customer_id: Optional[int] = None, + exclude_time_card: bool = True +): """ Hent næste pending tidsregistrering til godkendelse. Query params: - - customer_id: Valgfri - filtrer til specifik kunde + - customer_id: Filtrer til specifik kunde (optional) + - exclude_time_card: Ekskluder klippekort-kunder (default: true) """ try: - return wizard.get_next_pending_entry(customer_id=customer_id) + return wizard.get_next_pending_entry( + customer_id=customer_id, + exclude_time_card=exclude_time_card + ) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) -@router.post("/wizard/approve", response_model=TModuleTimeWithContext, tags=["Wizard"]) +@router.post("/wizard/approve/{time_id}", response_model=TModuleTimeWithContext, tags=["Wizard"]) async def approve_time_entry( - approval: TModuleTimeApproval, + time_id: int, + billable_hours: Optional[float] = None, + hourly_rate: Optional[float] = None, + rounding_method: Optional[str] = None, user_id: Optional[int] = None ): """ Godkend en tidsregistrering. - Body: + Path params: - time_id: ID på tidsregistreringen - - approved_hours: Timer efter godkendelse (kan være afrundet) - - rounded_to: Afrundingsinterval (0.5, 1.0, etc.) - - approval_note: Valgfri note - - billable: Skal faktureres? (default: true) + + Body (optional): + - billable_hours: Timer efter godkendelse (hvis ikke angivet, bruges original_hours med auto-rounding) + - hourly_rate: Timepris i DKK (override customer rate) + - rounding_method: "up", "down", "nearest" (override default) """ try: + from app.core.config import settings + from decimal import Decimal + + # Hent timelog + query = """ + SELECT t.*, c.title as case_title, c.status as case_status, + cust.name as customer_name, cust.hourly_rate as customer_rate + FROM tmodule_times t + JOIN tmodule_cases c ON t.case_id = c.id + JOIN tmodule_customers cust ON t.customer_id = cust.id + WHERE t.id = %s + """ + entry = execute_query(query, (time_id,), fetchone=True) + + if not entry: + raise HTTPException(status_code=404, detail="Time entry not found") + + # Beregn approved_hours + if billable_hours is None: + approved_hours = Decimal(str(entry['original_hours'])) + + # Auto-afrund hvis enabled + if settings.TIMETRACKING_AUTO_ROUND: + increment = Decimal(str(settings.TIMETRACKING_ROUND_INCREMENT)) + method = rounding_method or settings.TIMETRACKING_ROUND_METHOD + + if method == "up": + approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_UP') * increment + elif method == "down": + approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_DOWN') * increment + else: + approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_HALF_UP') * increment + else: + approved_hours = Decimal(str(billable_hours)) + + # Opdater med hourly_rate hvis angivet + if hourly_rate is not None: + execute_update( + "UPDATE tmodule_times SET hourly_rate = %s WHERE id = %s", + (Decimal(str(hourly_rate)), time_id) + ) + + # Godkend + approval = TModuleTimeApproval( + time_id=time_id, + approved_hours=float(approved_hours) + ) + return wizard.approve_time_entry(approval, user_id=user_id) + except HTTPException: + raise except Exception as e: + logger.error(f"❌ Error approving entry: {e}") raise HTTPException(status_code=500, detail=str(e)) @@ -140,6 +242,59 @@ async def reject_time_entry( raise HTTPException(status_code=500, detail=str(e)) +@router.get("/wizard/case/{case_id}/entries", response_model=List[TModuleTimeWithContext], tags=["Wizard"]) +async def get_case_entries( + case_id: int, + exclude_time_card: bool = True +): + """ + Hent alle pending timelogs for en case. + + Bruges til at vise alle tidsregistreringer i samme case grupperet. + """ + try: + return wizard.get_case_entries(case_id, exclude_time_card=exclude_time_card) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/wizard/case/{case_id}/details", tags=["Wizard"]) +async def get_case_details(case_id: int): + """ + Hent komplet case information inkl. alle timelogs og kommentarer. + + Returnerer: + - case_id, case_title, case_description, case_status + - timelogs: ALLE tidsregistreringer (pending, approved, rejected) + - case_comments: Kommentarer fra vTiger + """ + try: + return wizard.get_case_details(case_id) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/wizard/case/{case_id}/approve-all", tags=["Wizard"]) +async def approve_all_case_entries( + case_id: int, + user_id: Optional[int] = None, + exclude_time_card: bool = True +): + """ + Bulk-godkend alle pending timelogs for en case. + + Afr under automatisk efter configured settings. + """ + try: + return wizard.approve_case_entries( + case_id=case_id, + user_id=user_id, + exclude_time_card=exclude_time_card + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + @router.get("/wizard/progress/{customer_id}", response_model=TModuleWizardProgress, tags=["Wizard"]) async def get_customer_progress(customer_id: int): """Hent wizard progress for en kunde""" @@ -336,14 +491,223 @@ async def module_health(): except Exception as e: logger.error(f"Health check error: {e}") return JSONResponse( - status_code=503, - content={ - "status": "unhealthy", - "error": str(e) - } + status_code=500, + content={"status": "error", "message": str(e)} ) +@router.get("/config", tags=["Admin"]) +async def get_config(): + """Hent modul konfiguration""" + from app.core.config import settings + + return { + "default_hourly_rate": float(settings.TIMETRACKING_DEFAULT_HOURLY_RATE), + "auto_round": settings.TIMETRACKING_AUTO_ROUND, + "round_increment": float(settings.TIMETRACKING_ROUND_INCREMENT), + "round_method": settings.TIMETRACKING_ROUND_METHOD, + "vtiger_read_only": settings.TIMETRACKING_VTIGER_READ_ONLY, + "vtiger_dry_run": settings.TIMETRACKING_VTIGER_DRY_RUN, + "economic_read_only": settings.TIMETRACKING_ECONOMIC_READ_ONLY, + "economic_dry_run": settings.TIMETRACKING_ECONOMIC_DRY_RUN + } + + +# ============================================================================ +# CUSTOMER MANAGEMENT ENDPOINTS +# ============================================================================ + +@router.patch("/customers/{customer_id}/hourly-rate", tags=["Customers"]) +async def update_customer_hourly_rate(customer_id: int, hourly_rate: float, user_id: Optional[int] = None): + """ + Opdater timepris for en kunde. + + Args: + customer_id: Kunde ID + hourly_rate: Ny timepris i DKK (f.eks. 850.00) + """ + try: + from decimal import Decimal + + # Validate rate + if hourly_rate < 0: + raise HTTPException(status_code=400, detail="Hourly rate must be positive") + + rate_decimal = Decimal(str(hourly_rate)) + + # Update customer hourly rate + execute_update( + "UPDATE tmodule_customers SET hourly_rate = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s", + (rate_decimal, customer_id) + ) + + # Audit log + audit.log_event( + entity_type="customer", + entity_id=str(customer_id), + event_type="hourly_rate_updated", + details={"hourly_rate": float(hourly_rate)}, + user_id=user_id + ) + + # Return updated customer + customer = execute_query( + "SELECT id, name, hourly_rate FROM tmodule_customers WHERE id = %s", + (customer_id,), + fetchone=True + ) + + if not customer: + raise HTTPException(status_code=404, detail="Customer not found") + + return { + "customer_id": customer_id, + "name": customer['name'], + "hourly_rate": float(customer['hourly_rate']) if customer['hourly_rate'] else None, + "updated": True + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Error updating hourly rate: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.patch("/customers/{customer_id}/time-card", tags=["Customers"]) +async def toggle_customer_time_card(customer_id: int, enabled: bool, user_id: Optional[int] = None): + """ + Skift klippekort-status for kunde. + + Klippekort-kunder faktureres eksternt og skal kunne skjules i godkendelsesflow. + """ + try: + # Update customer time card flag + execute_update( + "UPDATE tmodule_customers SET uses_time_card = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s", + (enabled, customer_id) + ) + + # Audit log + audit.log_event( + entity_type="customer", + entity_id=str(customer_id), + event_type="time_card_toggled", + details={"enabled": enabled}, + user_id=user_id + ) + + # Return updated customer + customer = execute_query( + "SELECT * FROM tmodule_customers WHERE id = %s", + (customer_id,), + fetchone=True + ) + + if not customer: + raise HTTPException(status_code=404, detail="Customer not found") + + return { + "customer_id": customer_id, + "name": customer['name'], + "uses_time_card": customer['uses_time_card'], + "updated": True + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Error toggling time card: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/customers", tags=["Customers"]) +async def list_customers( + include_time_card: bool = True, + only_with_entries: bool = False +): + """ + List kunder med filtrering. + + Query params: + - include_time_card: Inkluder klippekort-kunder (default: true) + - only_with_entries: Kun kunder med pending tidsregistreringer (default: false) + """ + try: + if only_with_entries: + # Use view that includes entry counts + query = """ + SELECT customer_id, customer_name, customer_vtiger_id, uses_time_card, + total_entries, pending_count + FROM tmodule_approval_stats + WHERE total_entries > 0 + """ + + if not include_time_card: + query += " AND uses_time_card = false" + + query += " ORDER BY customer_name" + + customers = execute_query(query) + else: + # Simple customer list + query = "SELECT * FROM tmodule_customers" + + if not include_time_card: + query += " WHERE uses_time_card = false" + + query += " ORDER BY name" + + customers = execute_query(query) + + return {"customers": customers, "total": len(customers)} + + except Exception as e: + logger.error(f"❌ Error listing customers: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/customers/{customer_id}/times", tags=["Customers"]) +async def get_customer_time_entries(customer_id: int, status: Optional[str] = None): + """ + Hent alle tidsregistreringer for en kunde. + + Path params: + - customer_id: Kunde ID + + Query params: + - status: Filtrer på status (pending, approved, rejected, billed) + """ + try: + query = """ + SELECT t.*, + COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title, + c.vtiger_id AS case_vtiger_id, + c.description AS case_description, + 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.customer_id = %s + """ + + params = [customer_id] + + if status: + query += " AND t.status = %s" + params.append(status) + + query += " ORDER BY t.worked_date DESC, t.id DESC" + + times = execute_query(query, tuple(params)) + + return {"times": times, "total": len(times)} + + except Exception as e: + logger.error(f"❌ Error getting customer time entries: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @router.delete("/admin/uninstall", response_model=TModuleUninstallResult, tags=["Admin"]) async def uninstall_module( request: TModuleUninstallRequest, diff --git a/app/timetracking/backend/vtiger_sync.py b/app/timetracking/backend/vtiger_sync.py index 65a7db0..2a10012 100644 --- a/app/timetracking/backend/vtiger_sync.py +++ b/app/timetracking/backend/vtiger_sync.py @@ -19,6 +19,7 @@ Safety Flags: import logging import hashlib import json +import asyncio from datetime import datetime from typing import List, Dict, Optional, Any from decimal import Decimal @@ -210,10 +211,49 @@ class TimeTrackingVTigerService: logger.error(f"❌ vTiger connection error: {e}") return False + async def _fetch_user_name(self, user_id: str) -> str: + """ + Fetch user name from vTiger using retrieve API. + + Args: + user_id: vTiger user ID (e.g., "19x1") + + Returns: + User's full name or user_id if not found + """ + try: + user_data = await self._retrieve(user_id) + + if not user_data: + return user_id + + # Build full name from first + last, fallback to username + first_name = user_data.get('first_name', '').strip() + last_name = user_data.get('last_name', '').strip() + user_name = user_data.get('user_name', '').strip() + + if first_name and last_name: + return f"{first_name} {last_name}" + elif first_name: + return first_name + elif last_name: + return last_name + elif user_name: + return user_name + else: + return user_id + + except Exception as e: + logger.debug(f"Could not fetch user {user_id}: {e}") + return user_id + return False + async def sync_customers(self, limit: int = 1000) -> Dict[str, int]: """ Sync Accounts (customers) from vTiger to tmodule_customers. + Uses ID-based pagination to fetch all accounts. + Returns: {imported: X, updated: Y, skipped: Z} """ logger.info("🔍 Syncing customers from vTiger...") @@ -221,14 +261,36 @@ class TimeTrackingVTigerService: stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0} try: - # Query vTiger for active accounts - # Start with simplest query to debug - query = "SELECT * FROM Accounts;" - accounts = await self._query(query) + # Fetch ALL accounts using pagination (vTiger has 200 record limit) + all_accounts = [] + last_id = None + page = 1 - logger.info(f"📥 Fetched {len(accounts)} accounts from vTiger") + while True: + if last_id: + query = f"SELECT * FROM Accounts WHERE id > '{last_id}' ORDER BY id LIMIT 200;" + else: + query = "SELECT * FROM Accounts ORDER BY id LIMIT 200;" + + accounts = await self._query(query) + + if not accounts: + break + + all_accounts.extend(accounts) + last_id = accounts[-1]['id'] + + logger.info(f"📥 Fetched page {page}: {len(accounts)} accounts (last_id: {last_id})") + + # Safety: if we got less than 200, we're done + if len(accounts) < 200: + break + + page += 1 - for account in accounts: + logger.info(f"📥 Total fetched: {len(all_accounts)} accounts from vTiger") + + for account in all_accounts: try: vtiger_id = account.get('id', '') if not vtiger_id: @@ -252,16 +314,18 @@ class TimeTrackingVTigerService: logger.debug(f"⏭️ No changes for customer {vtiger_id}") stats["skipped"] += 1 continue - + if existing: # Update existing execute_update( """UPDATE tmodule_customers - SET name = %s, email = %s, vtiger_data = %s::jsonb, - sync_hash = %s, last_synced_at = CURRENT_TIMESTAMP + SET name = %s, email = %s, economic_customer_number = %s, + vtiger_data = %s::jsonb, sync_hash = %s, + last_synced_at = CURRENT_TIMESTAMP WHERE vtiger_id = %s""", ( account.get('accountname', 'Unknown'), account.get('email1', None), + int(account.get('cf_854')) if account.get('cf_854') else None, json.dumps(account), data_hash, vtiger_id @@ -273,12 +337,14 @@ class TimeTrackingVTigerService: # Insert new execute_insert( """INSERT INTO tmodule_customers - (vtiger_id, name, email, vtiger_data, sync_hash, last_synced_at) - VALUES (%s, %s, %s, %s::jsonb, %s, CURRENT_TIMESTAMP)""", + (vtiger_id, name, email, economic_customer_number, + vtiger_data, sync_hash, last_synced_at) + VALUES (%s, %s, %s, %s, %s::jsonb, %s, CURRENT_TIMESTAMP)""", ( vtiger_id, account.get('accountname', 'Unknown'), account.get('email1', None), + int(account.get('cf_854')) if account.get('cf_854') else None, json.dumps(account), data_hash ) @@ -297,13 +363,20 @@ class TimeTrackingVTigerService: logger.error(f"❌ Customer sync failed: {e}") raise - async def sync_cases(self, limit: int = 5000) -> Dict[str, int]: + async def sync_cases(self, limit: int = 5000, fetch_comments: bool = False) -> Dict[str, int]: """ Sync HelpDesk tickets (cases) from vTiger to tmodule_cases. + Args: + limit: Maximum number of cases to sync + fetch_comments: Whether to fetch ModComments for each case (slow - rate limited) + Returns: {imported: X, updated: Y, skipped: Z} """ - logger.info(f"🔍 Syncing up to {limit} cases from vTiger...") + if fetch_comments: + logger.info(f"🔍 Syncing up to {limit} cases from vTiger WITH comments (slow)...") + else: + logger.info(f"🔍 Syncing up to {limit} cases from vTiger WITHOUT comments (fast)...") stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0} @@ -363,7 +436,21 @@ class TimeTrackingVTigerService: continue customer_id = customer['id'] - data_hash = self._calculate_hash(ticket) + + # Fetch internal comments for this case (with rate limiting) - ONLY if enabled + internal_comments = [] + if fetch_comments: + internal_comments = await self._get_case_comments(vtiger_id) + # Small delay to avoid rate limiting (vTiger allows ~2-3 requests/sec) + await asyncio.sleep(0.4) # 400ms between comment fetches + + # Merge comments into ticket data before storing + ticket_with_comments = ticket.copy() + if internal_comments: + ticket_with_comments['internal_comments'] = internal_comments + + # Calculate hash AFTER adding comments (so changes to comments trigger update) + data_hash = self._calculate_hash(ticket_with_comments) # Check if exists existing = execute_query( @@ -392,7 +479,7 @@ class TimeTrackingVTigerService: ticket.get('ticketstatus', None), ticket.get('ticketpriorities', None), 'HelpDesk', - json.dumps(ticket), + json.dumps(ticket_with_comments), data_hash, vtiger_id ) @@ -413,7 +500,7 @@ class TimeTrackingVTigerService: ticket.get('ticketstatus', None), ticket.get('ticketpriorities', None), 'HelpDesk', - json.dumps(ticket), + json.dumps(ticket_with_comments), data_hash ) ) @@ -430,6 +517,86 @@ class TimeTrackingVTigerService: logger.error(f"❌ Case sync failed: {e}") raise + async def _get_case_comments(self, case_id: str) -> List[Dict]: + """ + Fetch all ModComments (internal comments) for a specific case from vTiger. + + Args: + case_id: vTiger case ID (format: "32x1234") + + Returns: + List of comment dicts with structure: {text, author, date, created_at} + Sorted by creation date (newest first) + """ + try: + # Query ModComments where related_to = case_id + query = f"SELECT * FROM ModComments WHERE related_to = '{case_id}' ORDER BY createdtime DESC;" + comments = await self._query(query) + + if not comments: + return [] + + # Transform vTiger format to internal format + formatted_comments = [] + for comment in comments: + formatted_comments.append({ + "text": comment.get("commentcontent", ""), + "author": comment.get("assigned_user_id", "Unknown"), # Will be user ID - could enhance with name lookup + "date": comment.get("createdtime", "")[:10], # Format: YYYY-MM-DD from YYYY-MM-DD HH:MM:SS + "created_at": comment.get("createdtime", "") + }) + + logger.info(f"📝 Fetched {len(formatted_comments)} comments for case {case_id}") + return formatted_comments + + except HTTPException as e: + # Rate limit or API error - log but don't fail sync + if "429" in str(e.detail) or "TOO_MANY_REQUESTS" in str(e.detail): + logger.warning(f"⚠️ Rate limited fetching comments for case {case_id} - skipping") + else: + logger.error(f"❌ API error fetching comments for case {case_id}: {e.detail}") + return [] + except Exception as e: + logger.error(f"❌ Failed to fetch comments for case {case_id}: {e}") + return [] # Return empty list on error - don't fail entire sync + + async def sync_case_comments(self, case_vtiger_id: str) -> Dict[str, Any]: + """ + Sync comments for a specific case (for on-demand updates). + + Args: + case_vtiger_id: vTiger case ID (format: "39x1234") + + Returns: + Dict with success status and comment count + """ + try: + # Fetch comments + comments = await self._get_case_comments(case_vtiger_id) + + if not comments: + return {"success": True, "comments": 0, "message": "No comments found"} + + # Update case in database + execute_update( + """UPDATE tmodule_cases + SET vtiger_data = jsonb_set( + COALESCE(vtiger_data, '{}'::jsonb), + '{internal_comments}', + %s::jsonb + ), + last_synced_at = CURRENT_TIMESTAMP + WHERE vtiger_id = %s""", + (json.dumps(comments), case_vtiger_id) + ) + + logger.info(f"✅ Synced {len(comments)} comments for case {case_vtiger_id}") + return {"success": True, "comments": len(comments), "message": f"Synced {len(comments)} comments"} + + except Exception as e: + logger.error(f"❌ Failed to sync comments for case {case_vtiger_id}: {e}") + return {"success": False, "comments": 0, "error": str(e)} + async def sync_time_entries(self, limit: int = 3000) -> Dict[str, int]: """ Sync time entries from vTiger Timelog module to tmodule_times. @@ -438,13 +605,24 @@ class TimeTrackingVTigerService: - timelognumber: Unique ID (TL1234) - duration: Time in seconds - relatedto: Reference to Case/Account - - isbillable: Billable flag + - is_billable: '1' = yes, '0' = no + - cf_timelog_invoiced: '1' = has been invoiced + + We only sync entries where: + - relatedto is not empty (linked to a Case or Account) + - Has valid duration > 0 + + NOTE: is_billable and cf_timelog_invoiced fields are not reliably populated in vTiger, + so we sync all timelogs and let the approval workflow decide what to bill. """ - logger.info(f"🔍 Syncing up to {limit} time entries from vTiger Timelog...") + logger.info(f"🔍 Syncing all timelogs from vTiger with valid relatedto...") stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0} try: + # Cache for user names (avoid fetching same user multiple times) + user_name_cache = {} + # vTiger API doesn't support OFFSET - use id-based pagination instead all_timelogs = [] last_id = "0x0" # Start from beginning @@ -453,7 +631,8 @@ class TimeTrackingVTigerService: for batch_num in range(max_batches): # Use id > last_id for pagination (vTiger format: 43x1234) - query = f"SELECT * FROM Timelog WHERE timelog_status = 'Completed' AND id > '{last_id}' ORDER BY id LIMIT {batch_size};" + # NOTE: vTiger query API ignores WHERE on custom fields, so we fetch all and filter later + query = f"SELECT * FROM Timelog WHERE id > '{last_id}' ORDER BY id LIMIT {batch_size};" batch = await self._query(query) if not batch: # No more records @@ -470,8 +649,15 @@ class TimeTrackingVTigerService: if len(all_timelogs) >= limit: # Reached limit break + logger.info(f"✅ Total fetched: {len(all_timelogs)} Timelog entries from vTiger") + + # We don't filter here - the existing code already filters by: + # 1. duration > 0 + # 2. relatedto not empty + # These filters happen in the processing loop below + timelogs = all_timelogs[:limit] # Trim to requested limit - logger.info(f"✅ Total fetched: {len(timelogs)} Timelog entries from vTiger") + logger.info(f"📊 Processing {len(timelogs)} timelogs...") # NOTE: retrieve API is too slow for batch operations (1500+ individual calls) # We'll work with query data and accept that relatedto might be empty for some @@ -494,36 +680,47 @@ class TimeTrackingVTigerService: # Get related entity (Case or Account) related_to = timelog.get('relatedto', '') - if not related_to: - logger.warning(f"⚠️ Timelog {vtiger_id} has no relatedto - RAW DATA: {timelog}") - stats["skipped"] += 1 - continue + case_id = None + customer_id = None - # Try to find case first, then account - case = execute_query( - "SELECT id, customer_id FROM tmodule_cases WHERE vtiger_id = %s", - (related_to,), - fetchone=True - ) - - if case: - case_id = case['id'] - customer_id = case['customer_id'] - else: - # Try to find customer directly - customer = execute_query( - "SELECT id FROM tmodule_customers WHERE vtiger_id = %s", + if related_to: + # Try to find case first, then account + case = execute_query( + "SELECT id, customer_id FROM tmodule_cases WHERE vtiger_id = %s", (related_to,), fetchone=True ) - if not customer: - logger.debug(f"⏭️ Related entity {related_to} not found") - stats["skipped"] += 1 - continue - - customer_id = customer['id'] - case_id = None # No specific case, just customer + if case: + case_id = case['id'] + customer_id = case['customer_id'] + else: + # Try to find customer directly + customer = execute_query( + "SELECT id FROM tmodule_customers WHERE vtiger_id = %s", + (related_to,), + fetchone=True + ) + + if customer: + customer_id = customer['id'] + case_id = None # No specific case, just customer + else: + logger.debug(f"⏭️ Related entity {related_to} not found in our database - will skip") + stats["skipped"] += 1 + continue + + # If no customer found at all, skip this timelog + if not customer_id: + logger.warning(f"⚠️ Timelog {vtiger_id} has no valid customer reference - skipping") + stats["skipped"] += 1 + continue + + # Get user name with caching + assigned_user_id = timelog.get('assigned_user_id', '') + if assigned_user_id and assigned_user_id not in user_name_cache: + user_name_cache[assigned_user_id] = await self._fetch_user_name(assigned_user_id) + user_name = user_name_cache.get(assigned_user_id, assigned_user_id) data_hash = self._calculate_hash(timelog) @@ -550,7 +747,7 @@ class TimeTrackingVTigerService: timelog.get('name', ''), hours, timelog.get('startedon', None), - timelog.get('assigned_user_id', None), + user_name, timelog.get('isbillable', '0') == '1', json.dumps(timelog), data_hash, @@ -578,7 +775,7 @@ class TimeTrackingVTigerService: timelog.get('name', ''), hours, timelog.get('startedon', None), - timelog.get('assigned_user_id', None), + user_name, timelog.get('isbillable', '0') == '1', json.dumps(timelog), data_hash @@ -599,14 +796,22 @@ class TimeTrackingVTigerService: async def full_sync( self, - user_id: Optional[int] = None + user_id: Optional[int] = None, + fetch_comments: bool = False ) -> TModuleSyncStats: """ Perform full sync of all data from vTiger. Order: Customers -> Cases -> Time Entries (dependencies) + + Args: + user_id: User performing the sync + fetch_comments: Whether to fetch ModComments (slow - adds ~0.4s per case) """ - logger.info("🚀 Starting FULL vTiger sync...") + if fetch_comments: + logger.info("🚀 Starting FULL vTiger sync WITH comments (this will be slow)...") + else: + logger.info("🚀 Starting FULL vTiger sync WITHOUT comments (fast mode)...") start_time = datetime.now() @@ -623,7 +828,7 @@ class TimeTrackingVTigerService: # Sync in order of dependencies customer_stats = await self.sync_customers() - case_stats = await self.sync_cases() + case_stats = await self.sync_cases(fetch_comments=fetch_comments) time_stats = await self.sync_time_entries() end_time = datetime.now() diff --git a/app/timetracking/backend/wizard.py b/app/timetracking/backend/wizard.py index ab4d30c..bb3d05b 100644 --- a/app/timetracking/backend/wizard.py +++ b/app/timetracking/backend/wizard.py @@ -7,7 +7,7 @@ Brugeren godkender én tidsregistrering ad gangen. """ import logging -from typing import Optional +from typing import Optional, List, Dict, Any from decimal import Decimal from datetime import datetime @@ -62,13 +62,15 @@ class WizardService: @staticmethod def get_next_pending_entry( - customer_id: Optional[int] = None + customer_id: Optional[int] = None, + exclude_time_card: bool = True ) -> TModuleWizardNextEntry: """ Hent næste pending tidsregistrering til godkendelse. Args: customer_id: Valgfri - filtrer til specifik kunde + exclude_time_card: Ekskluder klippekort-kunder (default: true) Returns: TModuleWizardNextEntry med has_next=True hvis der er flere @@ -84,7 +86,16 @@ class WizardService: result = execute_query(query, (customer_id,), fetchone=True) else: # Hent næste generelt - query = "SELECT * FROM tmodule_next_pending LIMIT 1" + if exclude_time_card: + query = """ + SELECT np.* FROM tmodule_next_pending np + JOIN tmodule_customers c ON np.customer_id = c.id + WHERE c.uses_time_card = false + LIMIT 1 + """ + else: + query = "SELECT * FROM tmodule_next_pending LIMIT 1" + result = execute_query(query, fetchone=True) if not result: @@ -281,6 +292,98 @@ class WizardService: logger.error(f"❌ Error rejecting time entry: {e}") raise HTTPException(status_code=500, detail=str(e)) + @staticmethod + def approve_case_entries( + case_id: int, + user_id: Optional[int] = None, + exclude_time_card: bool = True + ) -> Dict[str, Any]: + """ + Bulk-godkend alle pending tidsregistreringer for en case. + + Args: + case_id: Case ID + user_id: ID på brugeren der godkender + exclude_time_card: Ekskluder klippekort-kunder + + Returns: + Dict med statistik: approved_count, total_hours, etc. + """ + try: + # Hent alle pending entries for case + entries = WizardService.get_case_entries(case_id, exclude_time_card) + + if not entries: + return { + "approved_count": 0, + "total_hours": 0.0, + "case_id": case_id, + "entries": [] + } + + approved_entries = [] + total_hours = 0.0 + + for entry in entries: + # Auto-approve med samme timer som original (eller afrundet hvis enabled) + from app.core.config import settings + from decimal import Decimal + + approved_hours = Decimal(str(entry.original_hours)) + + # Afrund hvis enabled + if settings.TIMETRACKING_AUTO_ROUND: + increment = Decimal(str(settings.TIMETRACKING_ROUND_INCREMENT)) + + if settings.TIMETRACKING_ROUND_METHOD == "up": + # Afrund op + approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_UP') * increment + elif settings.TIMETRACKING_ROUND_METHOD == "down": + # Afrund ned + approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_DOWN') * increment + else: + # Nærmeste + approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_HALF_UP') * increment + + # Godkend entry + approval = TModuleTimeApproval( + time_id=entry.id, + approved_hours=float(approved_hours) + ) + + approved = WizardService.approve_time_entry(approval, user_id) + approved_entries.append({ + "id": approved.id, + "original_hours": float(approved.original_hours), + "approved_hours": float(approved.approved_hours) + }) + total_hours += float(approved.approved_hours) + + # Log bulk approval + audit.log_event( + entity_type="case", + entity_id=str(case_id), + event_type="bulk_approval", + details={ + "approved_count": len(approved_entries), + "total_hours": total_hours + }, + user_id=user_id + ) + + return { + "approved_count": len(approved_entries), + "total_hours": total_hours, + "case_id": case_id, + "entries": approved_entries + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Error bulk approving case: {e}") + raise HTTPException(status_code=500, detail=str(e)) + @staticmethod def get_customer_progress(customer_id: int) -> TModuleWizardProgress: """Hent wizard progress for en kunde""" @@ -296,7 +399,7 @@ class WizardService: if stats.pending_count > 0: query = """ - SELECT DISTINCT c.id, c.title + SELECT c.id, c.title FROM tmodule_times t JOIN tmodule_cases c ON t.case_id = c.id WHERE t.customer_id = %s AND t.status = 'pending' @@ -324,6 +427,155 @@ class WizardService: except Exception as e: logger.error(f"❌ Error getting customer progress: {e}") raise HTTPException(status_code=500, detail=str(e)) + + @staticmethod + def get_case_entries( + case_id: int, + exclude_time_card: bool = True + ) -> List[TModuleTimeWithContext]: + """ + Hent alle pending tidsregistreringer for en specifik case. + + Bruges til at vise alle timelogs i samme case samtidig. + + Args: + case_id: Case ID + exclude_time_card: Ekskluder klippekort-kunder + + Returns: + Liste af tidsregistreringer for casen + """ + try: + if exclude_time_card: + query = """ + SELECT t.id, t.vtiger_id, t.case_id, t.customer_id, t.description, + t.original_hours, t.worked_date, t.user_name, t.status, + t.approved_hours, t.rounded_to, t.approval_note, t.billable, + t.approved_at, t.approved_by, t.vtiger_data, t.sync_hash, + t.created_at, t.updated_at, t.last_synced_at, + COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title, + c.description AS case_description, + c.status AS case_status, + c.vtiger_id AS case_vtiger_id, + cust.name AS customer_name, + cust.hourly_rate AS customer_rate, + CONCAT(cont.first_name, ' ', cont.last_name) AS contact_name, + cont.user_company AS contact_company, + c.vtiger_data AS case_vtiger_data + FROM tmodule_times t + JOIN tmodule_cases c ON t.case_id = c.id + JOIN tmodule_customers cust ON t.customer_id = cust.id + LEFT JOIN contacts cont ON cont.vtiger_id = c.vtiger_data->>'contact_id' + WHERE t.case_id = %s + AND t.status = 'pending' + AND t.billable = true + AND t.vtiger_data->>'cf_timelog_invoiced' = '0' + AND cust.uses_time_card = false + ORDER BY t.worked_date, t.id + """ + else: + query = """ + SELECT t.id, t.vtiger_id, t.case_id, t.customer_id, t.description, + t.original_hours, t.worked_date, t.user_name, t.status, + t.approved_hours, t.rounded_to, t.approval_note, t.billable, + t.approved_at, t.approved_by, t.vtiger_data, t.sync_hash, + t.created_at, t.updated_at, t.last_synced_at, + COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title, + c.description AS case_description, + c.status AS case_status, + c.vtiger_id AS case_vtiger_id, + cust.name AS customer_name, + cust.hourly_rate AS customer_rate, + CONCAT(cont.first_name, ' ', cont.last_name) AS contact_name, + cont.user_company AS contact_company, + c.vtiger_data AS case_vtiger_data + FROM tmodule_times t + JOIN tmodule_cases c ON t.case_id = c.id + JOIN tmodule_customers cust ON t.customer_id = cust.id + LEFT JOIN contacts cont ON cont.vtiger_id = c.vtiger_data->>'contact_id' + WHERE t.case_id = %s + AND t.status = 'pending' + AND t.billable = true + AND t.vtiger_data->>'cf_timelog_invoiced' = '0' + ORDER BY t.worked_date, t.id + """ + + results = execute_query(query, (case_id,)) + return [TModuleTimeWithContext(**row) for row in results] + + except Exception as e: + logger.error(f"❌ Error getting case entries: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @staticmethod + def get_case_details(case_id: int) -> Dict[str, Any]: + """ + Hent komplet case information inkl. alle timelogs og kommentarer. + + Returns: + Dict med case info, timelogs (alle statuses), og kommentarer fra vtiger_data + """ + try: + # Hent case info + case_query = """ + SELECT id, vtiger_id, title, description, status, + vtiger_data, customer_id + FROM tmodule_cases + WHERE id = %s + """ + case = execute_query(case_query, (case_id,), fetchone=True) + + if not case: + raise HTTPException(status_code=404, detail="Case not found") + + # Hent ALLE timelogs for casen (ikke kun pending) + timelogs_query = """ + SELECT t.*, + COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title, + c.status AS case_status, + c.vtiger_id AS case_vtiger_id, + cust.name AS customer_name, + cust.hourly_rate AS customer_rate + FROM tmodule_times t + JOIN tmodule_cases c ON t.case_id = c.id + JOIN tmodule_customers cust ON t.customer_id = cust.id + WHERE t.case_id = %s + ORDER BY t.worked_date DESC, t.created_at DESC + """ + timelogs = execute_query(timelogs_query, (case_id,)) + + # Parse case comments from vtiger_data JSON + case_comments = [] + if case.get('vtiger_data'): + vtiger_data = case['vtiger_data'] + # vTiger gemmer comments som en array i JSON + if isinstance(vtiger_data, dict): + raw_comments = vtiger_data.get('comments', []) or vtiger_data.get('modcomments', []) + + for comment in raw_comments: + if isinstance(comment, dict): + case_comments.append({ + 'id': comment.get('modcommentsid', comment.get('id')), + 'comment_text': comment.get('commentcontent', comment.get('comment', '')), + 'creator_name': comment.get('assigned_user_id', comment.get('creator', 'Unknown')), + 'created_at': comment.get('createdtime', comment.get('created_at', '')) + }) + + return { + 'case_id': case['id'], + 'case_vtiger_id': case['vtiger_id'], + 'case_title': case['title'], + 'case_description': case['description'], + 'case_status': case['status'], + 'timelogs': [TModuleTimeWithContext(**t) for t in timelogs], + 'case_comments': case_comments + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Error getting case details: {e}") + raise HTTPException(status_code=500, detail=str(e)) # Singleton instance diff --git a/app/timetracking/frontend/customers.html b/app/timetracking/frontend/customers.html new file mode 100644 index 0000000..d724585 --- /dev/null +++ b/app/timetracking/frontend/customers.html @@ -0,0 +1,893 @@ + + + + + + Kunde Timepriser - BMC Hub + + + + + + + + + +
+ +
+
+
+
+

+ Kunde Timepriser +

+

Administrer timepriser for kunder

+
+
+ + Standard: 850 DKK/time + +
+
+
+
+ + +
+
+
+
+
Total Kunder
+

-

+
+
+
+
+
+
+
Custom Priser
+

-

+
+
+
+
+
+
+
Standard Priser
+

-

+
+
+
+
+
+
+
Gennemsnitspris
+

-

+
+
+
+
+ + +
+
+ +
+
+ +
+
+ + +
+
+
+ + + + + + + + + + + + + + + +
KundevTiger IDTimepris (DKK)StatusHandlinger
+
+ Indlæser... +
+
+
+
+
+
+ + + + + + +
+ Indlæser... +
+ + + + + + + + + + + + + diff --git a/app/timetracking/frontend/dashboard.html b/app/timetracking/frontend/dashboard.html index 98c9e0b..bd0bef9 100644 --- a/app/timetracking/frontend/dashboard.html +++ b/app/timetracking/frontend/dashboard.html @@ -59,11 +59,17 @@ transition: all 0.2s; } - .nav-link:hover, .nav-link.active { + .nav-link:hover { background-color: var(--accent-light); color: var(--accent); } + .nav-link.active { + background-color: var(--accent); + color: white; + font-weight: 600; + } + .card { border: none; border-radius: var(--border-radius); @@ -153,11 +159,23 @@ Dashboard + + @@ -234,12 +252,19 @@
-
+
Synkronisering

Hent nye tidsregistreringer fra vTiger

-
+
+
+ + +
@@ -319,6 +344,9 @@ // Load customer stats async function loadCustomerStats() { try { + // Check if we should hide time card customers + const hideTimeCard = document.getElementById('hide-time-card')?.checked ?? true; + const response = await fetch('/api/v1/timetracking/wizard/stats'); if (!response.ok) { @@ -334,10 +362,15 @@ } // Filtrer kunder uden tidsregistreringer eller kun med godkendte/afviste - const activeCustomers = customers.filter(c => + let activeCustomers = customers.filter(c => c.pending_count > 0 || c.approved_count > 0 ); + // Filtrer klippekort-kunder hvis toggled + if (hideTimeCard) { + activeCustomers = activeCustomers.filter(c => !c.uses_time_card); + } + if (activeCustomers.length === 0) { document.getElementById('loading').classList.add('d-none'); document.getElementById('no-data').classList.remove('d-none'); @@ -361,8 +394,13 @@ tbody.innerHTML = activeCustomers.map(customer => ` - ${customer.customer_name || 'Ukendt kunde'} -
${customer.total_entries || 0} registreringer +
+
+ ${customer.customer_name || 'Ukendt kunde'} +
${customer.total_entries || 0} registreringer +
+ ${customer.uses_time_card ? 'Klippekort' : ''} +
${parseFloat(customer.total_original_hours || 0).toFixed(1)}h @@ -375,9 +413,14 @@ ${customer.rejected_count || 0} + ${(customer.pending_count || 0) > 0 ? ` + class="btn btn-sm btn-primary me-1"> Godkend ` : ''} @@ -462,13 +505,46 @@ const response = await fetch(`/api/v1/timetracking/orders/generate/${customerId}`, { method: 'POST' }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Fejl ved oprettelse af ordre'); + } + const order = await response.json(); - alert(`Ordre oprettet: ${order.order_number}\nTotal: ${order.total_amount} DKK`); - location.reload(); + // Show success message and redirect to orders page + alert(`✅ Ordre oprettet!\n\nOrdrenummer: ${order.order_number}\nTotal: ${parseFloat(order.total_amount).toFixed(2)} DKK\n\nDu redirectes nu til ordre-siden...`); + + // Redirect to orders page instead of reloading + window.location.href = '/timetracking/orders'; } catch (error) { - alert('Fejl ved oprettelse af ordre: ' + error.message); + alert('❌ Fejl ved oprettelse af ordre:\n\n' + error.message); + } + } + + // Toggle time card for customer + async function toggleTimeCard(customerId, enabled) { + const action = enabled ? 'markere som klippekort' : 'fjerne klippekort-markering'; + if (!confirm(`Er du sikker på at du vil ${action} for denne kunde?`)) { + return; + } + + try { + const response = await fetch(`/api/v1/timetracking/customers/${customerId}/time-card?enabled=${enabled}`, { + method: 'PATCH' + }); + + if (!response.ok) { + throw new Error('Fejl ved opdatering'); + } + + // Reload customer list + loadCustomerStats(); + + } catch (error) { + alert('❌ Fejl: ' + error.message); } } diff --git a/app/timetracking/frontend/orders.html b/app/timetracking/frontend/orders.html index ad676f3..871ff8e 100644 --- a/app/timetracking/frontend/orders.html +++ b/app/timetracking/frontend/orders.html @@ -55,11 +55,17 @@ transition: all 0.2s; } - .nav-link:hover, .nav-link.active { + .nav-link:hover { background-color: var(--accent-light); color: var(--accent); } + .nav-link.active { + background-color: var(--accent); + color: white; + font-weight: 600; + } + .card { border: none; border-radius: var(--border-radius); @@ -131,11 +137,23 @@ Dashboard + + @@ -391,36 +409,71 @@
Ordrelinjer:
- ${order.lines.map(line => ` -
-
- ${line.description} - ${parseFloat(line.line_total).toFixed(2)} DKK -
-
- ${line.quantity} timer × ${parseFloat(line.unit_price).toFixed(2)} DKK - ${new Date(line.time_date).toLocaleDateString('da-DK')} + ${order.lines.map(line => { + // Parse data + const caseMatch = line.description.match(/CC(\d+)/); + const caseTitle = line.description.split(' - ').slice(1).join(' - ') || line.description; + const hours = parseFloat(line.quantity); + const unitPrice = parseFloat(line.unit_price); + const total = parseFloat(line.line_total); + const date = new Date(line.time_date).toLocaleDateString('da-DK'); + + // Extract contact name from case_contact if available + const contactName = line.case_contact || 'Ingen kontakt'; + + // Check if it's an on-site visit (udkørsel) + const isOnSite = line.description.toLowerCase().includes('udkørsel') || + line.description.toLowerCase().includes('on-site'); + + return ` +
+
+
+
+ ${caseMatch ? `${caseMatch[0]}` : ''} + ${hours.toFixed(1)} timer + × + ${unitPrice.toFixed(2)} DKK +
+
+ ${caseTitle} +
+
+ ${date} - ${contactName}${isOnSite ? ' Udkørsel' : ''} +
+
+
+
${total.toFixed(2)} DKK
+
- `).join('')} + `; + }).join('')} - ${order.exported_to_economic ? ` + ${order.economic_draft_id ? `
Eksporteret til e-conomic den ${new Date(order.exported_at).toLocaleDateString('da-DK')} - ${order.economic_draft_invoice_number ? `
Kladde nr.: ${order.economic_draft_invoice_number}` : ''} +
Draft Order nr.: ${order.economic_draft_id} + ${order.economic_order_number ? `
e-conomic ordre nr.: ${order.economic_order_number}` : ''}
` : ''} `; // Update export button const exportBtn = document.getElementById('export-order-btn'); - if (order.exported_to_economic) { - exportBtn.disabled = true; - exportBtn.innerHTML = ' Allerede eksporteret'; + if (order.economic_draft_id) { + exportBtn.disabled = false; + exportBtn.innerHTML = ' Re-eksporter (force)'; + exportBtn.onclick = () => { + if (confirm('Re-eksporter ordre til e-conomic?\n\nDette vil overskrive den eksisterende draft order.')) { + exportOrderForce(currentOrderId); + } + }; } else { exportBtn.disabled = false; exportBtn.innerHTML = ' Eksporter til e-conomic'; + exportBtn.onclick = exportCurrentOrder; } orderModal.show(); @@ -432,25 +485,39 @@ // Export order async function exportOrder(orderId) { - if (!confirm('Eksporter ordre til e-conomic?\n\nDette opretter en kladde-faktura i e-conomic.')) { + if (!confirm('Eksporter ordre til e-conomic?\n\nDette opretter en kladde-ordre i e-conomic.')) { return; } try { - const response = await fetch(`/api/v1/timetracking/export/${orderId}`, { - method: 'POST' + const response = await fetch(`/api/v1/timetracking/export`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + order_id: orderId, + force: false + }) }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || 'Export failed'); + } + const result = await response.json(); if (result.dry_run) { - alert(`DRY-RUN MODE:\n\nFakturaen ville blive oprettet med:\n- Kladde nr.: ${result.draft_invoice_number}\n- Total: ${result.total_amount} DKK\n\nIngen ændringer er foretaget i e-conomic.`); + alert(`DRY-RUN MODE:\n\n${result.message}\n\nDetails:\n- Ordre: ${result.details.order_number}\n- Kunde: ${result.details.customer_name}\n- Total: ${result.details.total_amount} DKK\n- Linjer: ${result.details.line_count}\n\n⚠️ Ingen ændringer er foretaget i e-conomic (DRY-RUN mode aktiveret).`); + } else if (result.success) { + alert(`✅ Ordre eksporteret til e-conomic!\n\n- Draft Order nr.: ${result.economic_draft_id}\n- e-conomic ordre nr.: ${result.economic_order_number}\n\n${result.message}`); + loadOrders(); + if (orderModal._isShown) { + orderModal.hide(); + } } else { - alert(`Ordre eksporteret!\n\nKladde nr.: ${result.draft_invoice_number}\nTotal: ${result.total_amount} DKK`); - } - - loadOrders(); - if (orderModal._isShown) { - orderModal.hide(); + throw new Error(result.message || 'Export failed'); } } catch (error) { @@ -464,6 +531,44 @@ exportOrder(currentOrderId); } } + + // Force re-export order + async function exportOrderForce(orderId) { + try { + const response = await fetch(`/api/v1/timetracking/export`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + order_id: orderId, + force: true + }) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || 'Export failed'); + } + + const result = await response.json(); + + if (result.dry_run) { + alert(`DRY-RUN MODE:\n\n${result.message}\n\n⚠️ Ingen ændringer er foretaget i e-conomic (DRY-RUN mode aktiveret).`); + } else if (result.success) { + alert(`✅ Ordre re-eksporteret til e-conomic!\n\n- Draft Order nr.: ${result.economic_draft_id}\n- e-conomic ordre nr.: ${result.economic_order_number}`); + loadOrders(); + if (orderModal._isShown) { + orderModal.hide(); + } + } else { + throw new Error(result.message || 'Export failed'); + } + + } catch (error) { + alert('Fejl ved eksport: ' + error.message); + } + } diff --git a/app/timetracking/frontend/views.py b/app/timetracking/frontend/views.py index 60982f2..5943658 100644 --- a/app/timetracking/frontend/views.py +++ b/app/timetracking/frontend/views.py @@ -48,6 +48,20 @@ async def timetracking_wizard(request: Request): return response +@router.get("/timetracking/customers", response_class=HTMLResponse, name="timetracking_customers") +async def timetracking_customers(request: Request): + """Time Tracking Customers - manage hourly rates""" + template_path = TEMPLATE_DIR / "customers.html" + logger.info(f"Serving customers page from: {template_path}") + + # Force no-cache headers + response = FileResponse(template_path) + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + return response + + @router.get("/timetracking/orders", response_class=HTMLResponse, name="timetracking_orders") async def timetracking_orders(request: Request): """Order oversigt""" diff --git a/app/timetracking/frontend/wizard.html b/app/timetracking/frontend/wizard.html index 918d7ce..d31f728 100644 --- a/app/timetracking/frontend/wizard.html +++ b/app/timetracking/frontend/wizard.html @@ -3,6 +3,7 @@ + Godkend Tider - BMC Hub @@ -55,11 +56,17 @@ transition: all 0.2s; } - .nav-link:hover, .nav-link.active { + .nav-link:hover { background-color: var(--accent-light); color: var(--accent); } + .nav-link.active { + background-color: var(--accent); + color: white; + font-weight: 600; + } + .card { border: none; border-radius: var(--border-radius); @@ -90,6 +97,12 @@ .time-entry-card .card-body { padding: 2rem; } + + #case-header-title { + text-transform: uppercase !important; + font-weight: 700 !important; + letter-spacing: 0.5px !important; + } .info-row { display: flex; @@ -152,6 +165,66 @@ font-family: monospace; white-space: pre-wrap; } + + /* Internal Comments Styling */ + .comment-item { + background: var(--bg-card); + border: 1px solid rgba(0,0,0,0.1); + padding: 0.75rem; + border-radius: 8px; + margin-bottom: 0.75rem; + cursor: pointer; + transition: all 0.2s; + box-shadow: 0 1px 3px rgba(0,0,0,0.05); + } + + .comment-item:hover { + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + border-color: var(--accent); + } + + .comment-preview { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.5; + white-space: pre-wrap; + } + + .comment-item.expanded .comment-preview { + -webkit-line-clamp: unset; + display: block; + } + + .comment-expand-btn { + font-size: 0.7rem; + color: var(--accent); + margin-top: 0.5rem; + font-weight: 600; + text-align: center; + padding: 0.25rem; + background: var(--accent-light); + border-radius: 4px; + } + + .comment-meta { + font-size: 0.75rem; + color: var(--text-secondary); + margin-top: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid rgba(0,0,0,0.05); + } + + @keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + + .spin { + animation: spin 1s linear infinite; + display: inline-block; + } @@ -170,13 +243,26 @@ Dashboard + + +
- +
-
+ +
-

Tidsregistrering

- Afventer godkendelse -
- -
- - Kunde - - - -
- -
- - Case - - - -
- -
- - Dato - - - -
- -
- - Original Timer - - - -
- -
- - Udført af - - - -
- -
- -
-
-
- - -
-
- Afrunding -
-
-
- - +
+

+ - +

+

+ - +

+
+ + + + +
-
- - +
-
-
- Fakturerbare timer: - - +
+ 0 tidsregistreringer +
+ +
+
+
+ + +
+ +
- +
-
-
-
Handlinger
- - - - - -
- -
-

- - Godkend for at inkludere i fakturering -

-

- - Afvisning kan ikke fortrydes -

-
-
-
-
@@ -371,13 +402,40 @@ Afventer godkendelse:
-
-
+
Godkendte timer:
-
+ + +
+
+
+ Case Historik +
+ + + + + +
+ Alle tidsregistreringer i case: +
+ +
+
+
+
@@ -390,13 +448,15 @@

Alle tider gennemgået!

Der er ingen flere tidsregistreringer der afventer godkendelse. +
+ Gå til Dashboard for at oprette fakturaordrer.

@@ -409,6 +469,7 @@