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.
This commit is contained in:
Christian 2025-12-10 18:29:13 +01:00
parent 34555d1e36
commit a230071632
19 changed files with 3529 additions and 346 deletions

View File

@ -38,7 +38,7 @@ GITHUB_REPO=ct/bmc_hub
# OLLAMA AI INTEGRATION # OLLAMA AI INTEGRATION
# ===================================================== # =====================================================
OLLAMA_ENDPOINT=http://ai_direct.cs.blaahund.dk OLLAMA_ENDPOINT=http://ai_direct.cs.blaahund.dk
OLLAMA_MODEL=qwen2.5:3b OLLAMA_MODEL=qwen2.5-coder:7b
# ===================================================== # =====================================================
# e-conomic Integration (Optional) # e-conomic Integration (Optional)
@ -51,3 +51,21 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_agreement_grant_token_here
# 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede ændringer # 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede ændringer
ECONOMIC_READ_ONLY=true # Set to false ONLY after testing ECONOMIC_READ_ONLY=true # Set to false ONLY after testing
ECONOMIC_DRY_RUN=true # Set to false ONLY when ready for production writes 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

View File

@ -45,3 +45,48 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_agreement_grant_token_here
# 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede ændringer # 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede ændringer
ECONOMIC_READ_ONLY=true # Set to false ONLY after testing ECONOMIC_READ_ONLY=true # Set to false ONLY after testing
ECONOMIC_DRY_RUN=true # Set to false ONLY when ready for production writes 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

View File

@ -7,6 +7,12 @@ Et centralt management system til BMC Networks - håndterer kunder, services, ha
## 🌟 Features ## 🌟 Features
- **Customer Management**: Komplet kundedatabase med CRM integration - **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 - **Hardware Tracking**: Registrering og sporing af kundeudstyr
- **Service Management**: Håndtering af services og abonnementer - **Service Management**: Håndtering af services og abonnementer
- **Billing Integration**: Automatisk fakturering via e-conomic - **Billing Integration**: Automatisk fakturering via e-conomic
@ -123,12 +129,43 @@ bmc_hub/
## 🔌 API Endpoints ## 🔌 API Endpoints
### Main API
- `GET /api/v1/customers` - List customers - `GET /api/v1/customers` - List customers
- `GET /api/v1/hardware` - List hardware - `GET /api/v1/hardware` - List hardware
- `GET /api/v1/billing/invoices` - List invoices - `GET /api/v1/billing/invoices` - List invoices
- `GET /health` - Health check - `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 ## 🧪 Testing

View File

@ -50,8 +50,9 @@ class Settings(BaseSettings):
# Time Tracking Module - Business Logic # Time Tracking Module - Business Logic
TIMETRACKING_DEFAULT_HOURLY_RATE: float = 850.00 # DKK pr. time (fallback) 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_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) TIMETRACKING_REQUIRE_APPROVAL: bool = True # Kræv manuel godkendelse (ikke auto-approve)
# Ollama AI Integration # Ollama AI Integration

View File

@ -11,6 +11,7 @@ Safety Flags:
""" """
import logging import logging
import json
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
import aiohttp import aiohttp
@ -192,33 +193,80 @@ class EconomicExportService:
# REAL EXPORT (kun hvis safety flags er disabled) # REAL EXPORT (kun hvis safety flags er disabled)
logger.warning(f"⚠️ REAL EXPORT STARTING for order {request.order_id}") logger.warning(f"⚠️ REAL EXPORT STARTING for order {request.order_id}")
# TODO: Implementer rigtig e-conomic API call her # Hent e-conomic customer number fra vTiger customer
# Denne kode vil kun køre hvis READ_ONLY og DRY_RUN begge er False 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 # Build e-conomic draft order payload
economic_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", "currency": "DKK",
"exchangeRate": 100, "exchangeRate": 100,
"netAmount": float(order['subtotal']), "customer": {
"grossAmount": float(order['total_amount']), "customerNumber": customer_number
"vatAmount": float(order['vat_amount']),
"notes": {
"heading": f"Tidsregistrering - {order['order_number']}",
"textLine1": order.get('notes', '')
}, },
"lines": [ "recipient": {
{ "name": order['customer_name'],
"lineNumber": line['line_number'], "vatZone": {
"description": line['description'], "vatZoneNumber": 1 # Domestic Denmark
"quantity": float(line['quantity']),
"unitNetPrice": float(line['unit_price']),
"totalNetAmount": float(line['line_total'])
} }
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 # Call e-conomic API
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.post( async with session.post(
@ -227,23 +275,46 @@ class EconomicExportService:
json=economic_payload, json=economic_payload,
timeout=aiohttp.ClientTimeout(total=30) timeout=aiohttp.ClientTimeout(total=30)
) as response: ) as response:
response_text = await response.text()
if response.status not in [200, 201]: if response.status not in [200, 201]:
error_text = await response.text() logger.error(f"❌ e-conomic export failed: {response.status}")
logger.error(f"❌ e-conomic export failed: {response.status} - {error_text}") 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 # Log failed export
audit.log_export_failed( audit.log_export_failed(
order_id=request.order_id, order_id=request.order_id,
error=error_text, error=error_msg,
user_id=user_id user_id=user_id
) )
raise HTTPException( raise HTTPException(
status_code=response.status, 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() 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_draft_id = result_data.get('draftOrderNumber')
economic_order_number = result_data.get('orderNumber', str(economic_draft_id)) economic_order_number = result_data.get('orderNumber', str(economic_draft_id))

View File

@ -144,9 +144,14 @@ class TModuleTime(TModuleTimeBase):
class TModuleTimeWithContext(TModuleTime): class TModuleTimeWithContext(TModuleTime):
"""Time entry with case and customer context (for wizard)""" """Time entry with case and customer context (for wizard)"""
case_title: str case_title: str
case_description: Optional[str] = None
case_status: Optional[str] = None case_status: Optional[str] = None
case_vtiger_id: Optional[str] = None
case_vtiger_data: Optional[dict] = None
customer_name: str customer_name: str
customer_rate: Optional[Decimal] = None customer_rate: Optional[Decimal] = None
contact_name: Optional[str] = None
contact_company: Optional[str] = None
# ============================================================================ # ============================================================================
@ -176,6 +181,8 @@ class TModuleOrderLine(TModuleOrderLineBase):
id: int id: int
order_id: int order_id: int
created_at: datetime created_at: datetime
case_contact: Optional[str] = None # Contact name from case
time_date: Optional[date] = None # Date from time entries
class Config: class Config:
from_attributes = True from_attributes = True
@ -210,6 +217,7 @@ class TModuleOrder(TModuleOrderBase):
"""Full order model with DB fields""" """Full order model with DB fields"""
id: int id: int
order_number: Optional[str] = None order_number: Optional[str] = None
customer_name: Optional[str] = None # From JOIN med customers table
status: str = Field("draft", pattern="^(draft|exported|sent|cancelled)$") status: str = Field("draft", pattern="^(draft|exported|sent|cancelled)$")
economic_draft_id: Optional[int] = None economic_draft_id: Optional[int] = None
economic_order_number: Optional[str] = None economic_order_number: Optional[str] = None
@ -255,6 +263,7 @@ class TModuleApprovalStats(BaseModel):
customer_id: int customer_id: int
customer_name: str customer_name: str
customer_vtiger_id: str customer_vtiger_id: str
uses_time_card: bool = False
total_entries: int total_entries: int
pending_count: int pending_count: int
approved_count: int approved_count: int

View File

@ -95,11 +95,16 @@ class OrderService:
if not customer: if not customer:
raise HTTPException(status_code=404, detail="Customer not found") 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 = """ 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 FROM tmodule_times t
JOIN tmodule_cases c ON t.case_id = c.id 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 WHERE t.customer_id = %s
AND t.status = 'approved' AND t.status = 'approved'
AND t.billable = true AND t.billable = true
@ -121,16 +126,21 @@ class OrderService:
customer.get('hub_customer_id') customer.get('hub_customer_id')
) )
# Group by case # Group by case og gem ekstra metadata
case_groups = {} case_groups = {}
for time_entry in approved_times: for time_entry in approved_times:
case_id = time_entry['case_id'] case_id = time_entry['case_id']
if case_id not in case_groups: if case_id not in case_groups:
case_groups[case_id] = { case_groups[case_id] = {
'case_title': time_entry['case_title'], 'case_vtiger_id': time_entry.get('case_vtiger_id'),
'entries': [] 'contact_name': time_entry.get('contact_name'),
'entries': [],
'descriptions': [] # Samle alle beskrivelser
} }
case_groups[case_id]['entries'].append(time_entry) 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 # Build order lines
order_lines = [] order_lines = []
@ -144,9 +154,33 @@ class OrderService:
for entry in group['entries'] for entry in group['entries']
) )
# Build description # Extract case number from vtiger_id (format: 39x42930 -> CC2930)
entry_count = len(group['entries']) case_number = ""
description = f"{group['case_title']} ({entry_count} tidsregistreringer)" 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 # Calculate line total
line_total = case_hours * hourly_rate line_total = case_hours * hourly_rate
@ -260,18 +294,32 @@ class OrderService:
def get_order_with_lines(order_id: int) -> TModuleOrderWithLines: def get_order_with_lines(order_id: int) -> TModuleOrderWithLines:
"""Hent ordre med linjer""" """Hent ordre med linjer"""
try: try:
# Get order # Get order with customer name
order_query = "SELECT * FROM tmodule_orders WHERE id = %s" 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) order = execute_query(order_query, (order_id,), fetchone=True)
if not order: if not order:
raise HTTPException(status_code=404, detail="Order not found") raise HTTPException(status_code=404, detail="Order not found")
# Get lines # Get lines with additional context (contact, date)
lines_query = """ lines_query = """
SELECT * FROM tmodule_order_lines SELECT ol.*,
WHERE order_id = %s STRING_AGG(DISTINCT CONCAT(cont.first_name, ' ', cont.last_name), ', ') as case_contact,
ORDER BY line_number 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,)) lines = execute_query(lines_query, (order_id,))
@ -298,19 +346,21 @@ class OrderService:
params = [] params = []
if customer_id: if customer_id:
conditions.append("customer_id = %s") conditions.append("o.customer_id = %s")
params.append(customer_id) params.append(customer_id)
if status: if status:
conditions.append("status = %s") conditions.append("o.status = %s")
params.append(status) params.append(status)
where_clause = " WHERE " + " AND ".join(conditions) if conditions else "" where_clause = " WHERE " + " AND ".join(conditions) if conditions else ""
query = f""" 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} {where_clause}
ORDER BY order_date DESC, id DESC ORDER BY o.order_date DESC, o.id DESC
LIMIT %s LIMIT %s
""" """
params.append(limit) params.append(limit)

View File

@ -44,7 +44,10 @@ router = APIRouter()
# ============================================================================ # ============================================================================
@router.post("/sync", response_model=TModuleSyncStats, tags=["Sync"]) @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). 🔍 Synkroniser data fra vTiger (READ-ONLY).
@ -54,16 +57,53 @@ async def sync_from_vtiger(user_id: Optional[int] = None):
- ModComments (tidsregistreringer) - ModComments (tidsregistreringer)
Gemmes i tmodule_* tabeller (isoleret). Gemmes i tmodule_* tabeller (isoleret).
Args:
user_id: ID bruger der kører sync
fetch_comments: Hent også interne kommentarer (langsomt - ~0.4s pr case)
""" """
try: try:
logger.info("🚀 Starting vTiger sync...") 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 return result
except Exception as e: except Exception as e:
logger.error(f"❌ Sync failed: {e}") logger.error(f"❌ Sync failed: {e}")
raise HTTPException(status_code=500, detail=str(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 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"]) @router.get("/sync/test-connection", tags=["Sync"])
async def test_vtiger_connection(): async def test_vtiger_connection():
"""Test forbindelse til vTiger""" """Test forbindelse til vTiger"""
@ -93,37 +133,99 @@ async def get_all_customer_stats():
@router.get("/wizard/next", response_model=TModuleWizardNextEntry, tags=["Wizard"]) @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. Hent næste pending tidsregistrering til godkendelse.
Query params: 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: 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: except Exception as e:
raise HTTPException(status_code=500, detail=str(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( 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 user_id: Optional[int] = None
): ):
""" """
Godkend en tidsregistrering. Godkend en tidsregistrering.
Body: Path params:
- time_id: ID tidsregistreringen - time_id: ID tidsregistreringen
- approved_hours: Timer efter godkendelse (kan være afrundet)
- rounded_to: Afrundingsinterval (0.5, 1.0, etc.) Body (optional):
- approval_note: Valgfri note - billable_hours: Timer efter godkendelse (hvis ikke angivet, bruges original_hours med auto-rounding)
- billable: Skal faktureres? (default: true) - hourly_rate: Timepris i DKK (override customer rate)
- rounding_method: "up", "down", "nearest" (override default)
""" """
try: 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) return wizard.approve_time_entry(approval, user_id=user_id)
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error(f"❌ Error approving entry: {e}")
raise HTTPException(status_code=500, detail=str(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)) 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"]) @router.get("/wizard/progress/{customer_id}", response_model=TModuleWizardProgress, tags=["Wizard"])
async def get_customer_progress(customer_id: int): async def get_customer_progress(customer_id: int):
"""Hent wizard progress for en kunde""" """Hent wizard progress for en kunde"""
@ -336,14 +491,223 @@ async def module_health():
except Exception as e: except Exception as e:
logger.error(f"Health check error: {e}") logger.error(f"Health check error: {e}")
return JSONResponse( return JSONResponse(
status_code=503, status_code=500,
content={ content={"status": "error", "message": str(e)}
"status": "unhealthy",
"error": 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 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"]) @router.delete("/admin/uninstall", response_model=TModuleUninstallResult, tags=["Admin"])
async def uninstall_module( async def uninstall_module(
request: TModuleUninstallRequest, request: TModuleUninstallRequest,

View File

@ -19,6 +19,7 @@ Safety Flags:
import logging import logging
import hashlib import hashlib
import json import json
import asyncio
from datetime import datetime from datetime import datetime
from typing import List, Dict, Optional, Any from typing import List, Dict, Optional, Any
from decimal import Decimal from decimal import Decimal
@ -210,10 +211,49 @@ class TimeTrackingVTigerService:
logger.error(f"❌ vTiger connection error: {e}") logger.error(f"❌ vTiger connection error: {e}")
return False 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]: async def sync_customers(self, limit: int = 1000) -> Dict[str, int]:
""" """
Sync Accounts (customers) from vTiger to tmodule_customers. Sync Accounts (customers) from vTiger to tmodule_customers.
Uses ID-based pagination to fetch all accounts.
Returns: {imported: X, updated: Y, skipped: Z} Returns: {imported: X, updated: Y, skipped: Z}
""" """
logger.info("🔍 Syncing customers from vTiger...") logger.info("🔍 Syncing customers from vTiger...")
@ -221,14 +261,36 @@ class TimeTrackingVTigerService:
stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0} stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0}
try: try:
# Query vTiger for active accounts # Fetch ALL accounts using pagination (vTiger has 200 record limit)
# Start with simplest query to debug all_accounts = []
query = "SELECT * FROM Accounts;" last_id = None
accounts = await self._query(query) 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;"
for account in accounts: 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
logger.info(f"📥 Total fetched: {len(all_accounts)} accounts from vTiger")
for account in all_accounts:
try: try:
vtiger_id = account.get('id', '') vtiger_id = account.get('id', '')
if not vtiger_id: if not vtiger_id:
@ -252,16 +314,18 @@ class TimeTrackingVTigerService:
logger.debug(f"⏭️ No changes for customer {vtiger_id}") logger.debug(f"⏭️ No changes for customer {vtiger_id}")
stats["skipped"] += 1 stats["skipped"] += 1
continue continue
if existing:
# Update existing # Update existing
execute_update( execute_update(
"""UPDATE tmodule_customers """UPDATE tmodule_customers
SET name = %s, email = %s, vtiger_data = %s::jsonb, SET name = %s, email = %s, economic_customer_number = %s,
sync_hash = %s, last_synced_at = CURRENT_TIMESTAMP vtiger_data = %s::jsonb, sync_hash = %s,
last_synced_at = CURRENT_TIMESTAMP
WHERE vtiger_id = %s""", WHERE vtiger_id = %s""",
( (
account.get('accountname', 'Unknown'), account.get('accountname', 'Unknown'),
account.get('email1', None), account.get('email1', None),
int(account.get('cf_854')) if account.get('cf_854') else None,
json.dumps(account), json.dumps(account),
data_hash, data_hash,
vtiger_id vtiger_id
@ -273,12 +337,14 @@ class TimeTrackingVTigerService:
# Insert new # Insert new
execute_insert( execute_insert(
"""INSERT INTO tmodule_customers """INSERT INTO tmodule_customers
(vtiger_id, name, email, vtiger_data, sync_hash, last_synced_at) (vtiger_id, name, email, economic_customer_number,
VALUES (%s, %s, %s, %s::jsonb, %s, CURRENT_TIMESTAMP)""", vtiger_data, sync_hash, last_synced_at)
VALUES (%s, %s, %s, %s, %s::jsonb, %s, CURRENT_TIMESTAMP)""",
( (
vtiger_id, vtiger_id,
account.get('accountname', 'Unknown'), account.get('accountname', 'Unknown'),
account.get('email1', None), account.get('email1', None),
int(account.get('cf_854')) if account.get('cf_854') else None,
json.dumps(account), json.dumps(account),
data_hash data_hash
) )
@ -297,13 +363,20 @@ class TimeTrackingVTigerService:
logger.error(f"❌ Customer sync failed: {e}") logger.error(f"❌ Customer sync failed: {e}")
raise 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. 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} 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} stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0}
@ -363,7 +436,21 @@ class TimeTrackingVTigerService:
continue continue
customer_id = customer['id'] 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 # Check if exists
existing = execute_query( existing = execute_query(
@ -392,7 +479,7 @@ class TimeTrackingVTigerService:
ticket.get('ticketstatus', None), ticket.get('ticketstatus', None),
ticket.get('ticketpriorities', None), ticket.get('ticketpriorities', None),
'HelpDesk', 'HelpDesk',
json.dumps(ticket), json.dumps(ticket_with_comments),
data_hash, data_hash,
vtiger_id vtiger_id
) )
@ -413,7 +500,7 @@ class TimeTrackingVTigerService:
ticket.get('ticketstatus', None), ticket.get('ticketstatus', None),
ticket.get('ticketpriorities', None), ticket.get('ticketpriorities', None),
'HelpDesk', 'HelpDesk',
json.dumps(ticket), json.dumps(ticket_with_comments),
data_hash data_hash
) )
) )
@ -430,6 +517,86 @@ class TimeTrackingVTigerService:
logger.error(f"❌ Case sync failed: {e}") logger.error(f"❌ Case sync failed: {e}")
raise 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]: async def sync_time_entries(self, limit: int = 3000) -> Dict[str, int]:
""" """
Sync time entries from vTiger Timelog module to tmodule_times. Sync time entries from vTiger Timelog module to tmodule_times.
@ -438,13 +605,24 @@ class TimeTrackingVTigerService:
- timelognumber: Unique ID (TL1234) - timelognumber: Unique ID (TL1234)
- duration: Time in seconds - duration: Time in seconds
- relatedto: Reference to Case/Account - 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} stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0}
try: 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 # vTiger API doesn't support OFFSET - use id-based pagination instead
all_timelogs = [] all_timelogs = []
last_id = "0x0" # Start from beginning last_id = "0x0" # Start from beginning
@ -453,7 +631,8 @@ class TimeTrackingVTigerService:
for batch_num in range(max_batches): for batch_num in range(max_batches):
# Use id > last_id for pagination (vTiger format: 43x1234) # 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) batch = await self._query(query)
if not batch: # No more records if not batch: # No more records
@ -470,8 +649,15 @@ class TimeTrackingVTigerService:
if len(all_timelogs) >= limit: # Reached limit if len(all_timelogs) >= limit: # Reached limit
break 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 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) # 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 # 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) # Get related entity (Case or Account)
related_to = timelog.get('relatedto', '') related_to = timelog.get('relatedto', '')
if not related_to: case_id = None
logger.warning(f"⚠️ Timelog {vtiger_id} has no relatedto - RAW DATA: {timelog}") customer_id = None
stats["skipped"] += 1
continue
# Try to find case first, then account if related_to:
case = execute_query( # Try to find case first, then account
"SELECT id, customer_id FROM tmodule_cases WHERE vtiger_id = %s", case = execute_query(
(related_to,), "SELECT id, customer_id FROM tmodule_cases WHERE vtiger_id = %s",
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",
(related_to,), (related_to,),
fetchone=True fetchone=True
) )
if not customer: if case:
logger.debug(f"⏭️ Related entity {related_to} not found") case_id = case['id']
stats["skipped"] += 1 customer_id = case['customer_id']
continue else:
# Try to find customer directly
customer = execute_query(
"SELECT id FROM tmodule_customers WHERE vtiger_id = %s",
(related_to,),
fetchone=True
)
customer_id = customer['id'] if customer:
case_id = None # No specific case, just 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) data_hash = self._calculate_hash(timelog)
@ -550,7 +747,7 @@ class TimeTrackingVTigerService:
timelog.get('name', ''), timelog.get('name', ''),
hours, hours,
timelog.get('startedon', None), timelog.get('startedon', None),
timelog.get('assigned_user_id', None), user_name,
timelog.get('isbillable', '0') == '1', timelog.get('isbillable', '0') == '1',
json.dumps(timelog), json.dumps(timelog),
data_hash, data_hash,
@ -578,7 +775,7 @@ class TimeTrackingVTigerService:
timelog.get('name', ''), timelog.get('name', ''),
hours, hours,
timelog.get('startedon', None), timelog.get('startedon', None),
timelog.get('assigned_user_id', None), user_name,
timelog.get('isbillable', '0') == '1', timelog.get('isbillable', '0') == '1',
json.dumps(timelog), json.dumps(timelog),
data_hash data_hash
@ -599,14 +796,22 @@ class TimeTrackingVTigerService:
async def full_sync( async def full_sync(
self, self,
user_id: Optional[int] = None user_id: Optional[int] = None,
fetch_comments: bool = False
) -> TModuleSyncStats: ) -> TModuleSyncStats:
""" """
Perform full sync of all data from vTiger. Perform full sync of all data from vTiger.
Order: Customers -> Cases -> Time Entries (dependencies) 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() start_time = datetime.now()
@ -623,7 +828,7 @@ class TimeTrackingVTigerService:
# Sync in order of dependencies # Sync in order of dependencies
customer_stats = await self.sync_customers() 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() time_stats = await self.sync_time_entries()
end_time = datetime.now() end_time = datetime.now()

View File

@ -7,7 +7,7 @@ Brugeren godkender én tidsregistrering ad gangen.
""" """
import logging import logging
from typing import Optional from typing import Optional, List, Dict, Any
from decimal import Decimal from decimal import Decimal
from datetime import datetime from datetime import datetime
@ -62,13 +62,15 @@ class WizardService:
@staticmethod @staticmethod
def get_next_pending_entry( def get_next_pending_entry(
customer_id: Optional[int] = None customer_id: Optional[int] = None,
exclude_time_card: bool = True
) -> TModuleWizardNextEntry: ) -> TModuleWizardNextEntry:
""" """
Hent næste pending tidsregistrering til godkendelse. Hent næste pending tidsregistrering til godkendelse.
Args: Args:
customer_id: Valgfri - filtrer til specifik kunde customer_id: Valgfri - filtrer til specifik kunde
exclude_time_card: Ekskluder klippekort-kunder (default: true)
Returns: Returns:
TModuleWizardNextEntry med has_next=True hvis der er flere TModuleWizardNextEntry med has_next=True hvis der er flere
@ -84,7 +86,16 @@ class WizardService:
result = execute_query(query, (customer_id,), fetchone=True) result = execute_query(query, (customer_id,), fetchone=True)
else: else:
# Hent næste generelt # 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) result = execute_query(query, fetchone=True)
if not result: if not result:
@ -281,6 +292,98 @@ class WizardService:
logger.error(f"❌ Error rejecting time entry: {e}") logger.error(f"❌ Error rejecting time entry: {e}")
raise HTTPException(status_code=500, detail=str(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 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 @staticmethod
def get_customer_progress(customer_id: int) -> TModuleWizardProgress: def get_customer_progress(customer_id: int) -> TModuleWizardProgress:
"""Hent wizard progress for en kunde""" """Hent wizard progress for en kunde"""
@ -296,7 +399,7 @@ class WizardService:
if stats.pending_count > 0: if stats.pending_count > 0:
query = """ query = """
SELECT DISTINCT c.id, c.title SELECT c.id, c.title
FROM tmodule_times t FROM tmodule_times t
JOIN tmodule_cases c ON t.case_id = c.id JOIN tmodule_cases c ON t.case_id = c.id
WHERE t.customer_id = %s AND t.status = 'pending' WHERE t.customer_id = %s AND t.status = 'pending'
@ -325,6 +428,155 @@ class WizardService:
logger.error(f"❌ Error getting customer progress: {e}") logger.error(f"❌ Error getting customer progress: {e}")
raise HTTPException(status_code=500, detail=str(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 # Singleton instance
wizard = WizardService() wizard = WizardService()

View File

@ -0,0 +1,893 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kunde Timepriser - BMC Hub</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
:root {
--bg-body: #f8f9fa;
--bg-card: #ffffff;
--text-primary: #2c3e50;
--text-secondary: #6c757d;
--accent: #0f4c75;
--accent-light: #eef2f5;
--border-radius: 12px;
}
[data-theme="dark"] {
--bg-body: #1a1a1a;
--bg-card: #2d2d2d;
--text-primary: #e4e4e4;
--text-secondary: #a0a0a0;
--accent-light: #1e3a52;
}
body {
background-color: var(--bg-body);
color: var(--text-primary);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
padding-top: 80px;
}
.navbar {
background: var(--bg-card);
box-shadow: 0 2px 15px rgba(0,0,0,0.1);
}
.card {
border: none;
border-radius: var(--border-radius);
box-shadow: 0 2px 15px rgba(0,0,0,0.05);
background: var(--bg-card);
}
.table-hover tbody tr:hover {
background-color: var(--accent-light);
cursor: pointer;
}
.rate-input {
width: 150px;
text-align: right;
}
.editable-row {
transition: all 0.3s;
}
.editable-row.editing {
background-color: #fff3cd !important;
}
.badge-rate {
font-size: 0.9rem;
padding: 0.4rem 0.8rem;
}
</style>
</head>
<body>
<!-- Navigation -->
<nav class="navbar navbar-expand-lg fixed-top">
<div class="container-fluid">
<a class="navbar-brand" href="/dashboard">
<i class="bi bi-grid-3x3-gap-fill"></i> BMC Hub
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="/dashboard">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/timetracking">
<i class="bi bi-clock-history"></i> Tidsregistrering
</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="/timetracking/customers">
<i class="bi bi-building"></i> Kunder & Timepriser
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/timetracking/orders">
<i class="bi bi-receipt"></i> Ordrer
</a>
</li>
</ul>
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<button class="btn btn-link nav-link" onclick="toggleTheme()">
<i class="bi bi-moon-fill" id="theme-icon"></i>
</button>
</li>
</ul>
</div>
</div>
</nav>
<!-- Main Content -->
<div class="container py-4">
<!-- Header -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1 class="mb-1">
<i class="bi bi-building text-primary"></i> Kunde Timepriser
</h1>
<p class="text-muted mb-0">Administrer timepriser for kunder</p>
</div>
<div>
<span class="badge bg-info badge-rate">
<i class="bi bi-cash"></i> Standard: <span id="default-rate">850</span> DKK/time
</span>
</div>
</div>
</div>
</div>
<!-- Stats Cards -->
<div class="row mb-4" id="stats-cards">
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h6 class="text-muted mb-2">Total Kunder</h6>
<h3 class="mb-0" id="total-customers">-</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h6 class="text-muted mb-2">Custom Priser</h6>
<h3 class="mb-0" id="custom-rates">-</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h6 class="text-muted mb-2">Standard Priser</h6>
<h3 class="mb-0" id="standard-rates">-</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h6 class="text-muted mb-2">Gennemsnitspris</h6>
<h3 class="mb-0" id="avg-rate">-</h3>
</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="row mb-3">
<div class="col-md-6">
<input type="text" class="form-control" id="search-input" placeholder="🔍 Søg kunde..." onkeyup="filterTable()">
</div>
<div class="col-md-6">
<select class="form-select" id="filter-select" onchange="filterTable()">
<option value="all">Alle kunder</option>
<option value="custom">Kun custom priser</option>
<option value="standard">Kun standard priser</option>
</select>
</div>
</div>
<!-- Customers Table -->
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover" id="customers-table">
<thead>
<tr>
<th>Kunde</th>
<th>vTiger ID</th>
<th class="text-end">Timepris (DKK)</th>
<th class="text-center">Status</th>
<th class="text-end">Handlinger</th>
</tr>
</thead>
<tbody id="customers-tbody">
<tr>
<td colspan="5" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Indlæser...</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Time Entries Modal -->
<div class="modal fade" id="timeEntriesModal" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-clock-history"></i> Tidsregistreringer - <span id="modal-customer-name"></span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="time-entries-loading" class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
<p class="mt-2">Indlæser tidsregistreringer...</p>
</div>
<div id="time-entries-content" class="d-none">
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Case</th>
<th>Dato</th>
<th>Timer</th>
<th>Status</th>
<th>Udført af</th>
<th>Handlinger</th>
</tr>
</thead>
<tbody id="time-entries-tbody"></tbody>
</table>
</div>
</div>
<div id="time-entries-empty" class="alert alert-info d-none">
Ingen tidsregistreringer fundet for denne kunde
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
</div>
</div>
</div>
</div>
<!-- Create Order Modal -->
<div class="modal fade" id="createOrderModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-plus-circle"></i> Opret ordre - <span id="order-customer-name"></span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="order-loading" class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
<p class="mt-2">Henter godkendte tidsregistreringer...</p>
</div>
<div id="order-content" class="d-none">
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> Denne handling vil oprette en ordre for <strong>alle godkendte</strong> tidsregistreringer for denne kunde.
</div>
<div id="order-summary" class="mb-3"></div>
</div>
<div id="order-empty" class="alert alert-warning d-none">
<i class="bi bi-exclamation-triangle"></i> Ingen godkendte tidsregistreringer fundet for denne kunde
</div>
<div id="order-creating" class="text-center py-5 d-none">
<div class="spinner-border text-success" role="status"></div>
<p class="mt-2">Opretter ordre...</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-success" id="confirm-create-order" disabled>
<i class="bi bi-check-circle"></i> Opret ordre
</button>
</div>
</div>
</div>
</div>
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Indlæser...</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script>
let allCustomers = [];
let defaultRate = 850.00; // Fallback værdi
// Load customers on page load
document.addEventListener('DOMContentLoaded', () => {
loadConfig();
loadCustomers();
loadTheme();
});
// Load configuration
async function loadConfig() {
try {
const response = await fetch('/api/v1/timetracking/config');
if (response.ok) {
const config = await response.json();
defaultRate = config.default_hourly_rate;
document.getElementById('default-rate').textContent = defaultRate.toFixed(2);
}
} catch (error) {
console.warn('Failed to load config, using fallback rate:', error);
}
}
// Load all customers
async function loadCustomers() {
try {
const response = await fetch('/api/v1/timetracking/customers?include_time_card=true');
if (!response.ok) throw new Error('Failed to load customers');
const data = await response.json();
allCustomers = data.customers || [];
renderTable();
updateStats();
} catch (error) {
console.error('Error loading customers:', error);
document.getElementById('customers-tbody').innerHTML = `
<tr><td colspan="5" class="text-center text-danger">Fejl ved indlæsning: ${error.message}</td></tr>
`;
}
}
// Render table
function renderTable(filteredCustomers = null) {
const customers = filteredCustomers || allCustomers;
const tbody = document.getElementById('customers-tbody');
if (customers.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center py-4">Ingen kunder fundet</td></tr>';
return;
}
tbody.innerHTML = customers.map(customer => {
const rate = customer.hourly_rate || defaultRate;
const isCustom = customer.hourly_rate !== null;
const statusBadge = isCustom
? '<span class="badge bg-primary">Custom</span>'
: '<span class="badge bg-secondary">Standard</span>';
return `
<tr class="editable-row" id="row-${customer.id}">
<td style="cursor: pointer;" onclick="viewTimeEntries(${customer.id}, '${customer.name.replace(/'/g, "\\'")}')">
<strong>${customer.name}</strong>
${customer.uses_time_card ? '<span class="badge bg-warning text-dark ms-2">Klippekort</span>' : ''}
</td>
<td><small class="text-muted">${customer.vtiger_id || '-'}</small></td>
<td class="text-end">
<span class="rate-display" id="rate-display-${customer.id}">${rate.toFixed(2)}</span>
<input type="number" class="form-control rate-input d-none"
id="rate-input-${customer.id}"
value="${rate}"
step="50"
min="0">
</td>
<td class="text-center">${statusBadge}</td>
<td class="text-end">
<button class="btn btn-sm btn-success me-1"
onclick="createOrderForCustomer(${customer.id}, '${customer.name.replace(/'/g, "\\'")}')">
<i class="bi bi-plus-circle"></i> Ordre
</button>
<button class="btn btn-sm btn-info me-1"
onclick="viewTimeEntries(${customer.id}, '${customer.name.replace(/'/g, "\\'")}')">
<i class="bi bi-clock-history"></i>
</button>
<button class="btn btn-sm btn-primary edit-btn"
id="edit-btn-${customer.id}"
onclick="editRate(${customer.id})">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-success save-btn d-none"
id="save-btn-${customer.id}"
onclick="saveRate(${customer.id})">
<i class="bi bi-check"></i> Gem
</button>
<button class="btn btn-sm btn-secondary cancel-btn d-none"
id="cancel-btn-${customer.id}"
onclick="cancelEdit(${customer.id})">
<i class="bi bi-x"></i> Annuller
</button>
${isCustom ? `
<button class="btn btn-sm btn-outline-danger"
onclick="resetToDefault(${customer.id})">
<i class="bi bi-arrow-counterclockwise"></i>
</button>
` : ''}
</td>
</tr>
`;
}).join('');
}
// Edit rate
function editRate(customerId) {
const row = document.getElementById(`row-${customerId}`);
row.classList.add('editing');
document.getElementById(`rate-display-${customerId}`).classList.add('d-none');
document.getElementById(`rate-input-${customerId}`).classList.remove('d-none');
document.getElementById(`rate-input-${customerId}`).focus();
document.getElementById(`edit-btn-${customerId}`).classList.add('d-none');
document.getElementById(`save-btn-${customerId}`).classList.remove('d-none');
document.getElementById(`cancel-btn-${customerId}`).classList.remove('d-none');
}
// Cancel edit
function cancelEdit(customerId) {
const row = document.getElementById(`row-${customerId}`);
row.classList.remove('editing');
const customer = allCustomers.find(c => c.id === customerId);
const originalRate = customer.hourly_rate || defaultRate;
document.getElementById(`rate-input-${customerId}`).value = originalRate;
document.getElementById(`rate-display-${customerId}`).classList.remove('d-none');
document.getElementById(`rate-input-${customerId}`).classList.add('d-none');
document.getElementById(`edit-btn-${customerId}`).classList.remove('d-none');
document.getElementById(`save-btn-${customerId}`).classList.add('d-none');
document.getElementById(`cancel-btn-${customerId}`).classList.add('d-none');
}
// Save rate
async function saveRate(customerId) {
const newRate = parseFloat(document.getElementById(`rate-input-${customerId}`).value);
if (newRate < 0) {
alert('Timepris kan ikke være negativ');
return;
}
try {
const response = await fetch(`/api/v1/timetracking/customers/${customerId}/hourly-rate?hourly_rate=${newRate}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'}
});
if (!response.ok) throw new Error('Failed to update rate');
const result = await response.json();
// Update local data
const customer = allCustomers.find(c => c.id === customerId);
customer.hourly_rate = newRate;
// Update display
document.getElementById(`rate-display-${customerId}`).textContent = newRate.toFixed(2);
cancelEdit(customerId);
// Reload to update badges
await loadCustomers();
// Show success message
showToast('✅ Timepris opdateret', 'success');
} catch (error) {
console.error('Error saving rate:', error);
alert('Fejl ved opdatering: ' + error.message);
}
}
// Reset to default
async function resetToDefault(customerId) {
if (!confirm('Nulstil til standard timepris?')) return;
try {
const response = await fetch(`/api/v1/timetracking/customers/${customerId}/hourly-rate?hourly_rate=${defaultRate}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'}
});
if (!response.ok) throw new Error('Failed to reset rate');
// Update local data
const customer = allCustomers.find(c => c.id === customerId);
customer.hourly_rate = null; // NULL = uses default
await loadCustomers();
showToast('✅ Nulstillet til standard', 'success');
} catch (error) {
console.error('Error resetting rate:', error);
alert('Fejl ved nulstilling: ' + error.message);
}
}
// Filter table
function filterTable() {
const searchTerm = document.getElementById('search-input').value.toLowerCase();
const filterType = document.getElementById('filter-select').value;
let filtered = allCustomers.filter(customer => {
const matchesSearch = customer.name.toLowerCase().includes(searchTerm);
let matchesFilter = true;
if (filterType === 'custom') {
matchesFilter = customer.hourly_rate !== null;
} else if (filterType === 'standard') {
matchesFilter = customer.hourly_rate === null;
}
return matchesSearch && matchesFilter;
});
renderTable(filtered);
}
// Update stats
function updateStats() {
const total = allCustomers.length;
const customRates = allCustomers.filter(c => c.hourly_rate !== null).length;
const standardRates = total - customRates;
const rates = allCustomers.map(c => c.hourly_rate || defaultRate);
const avgRate = rates.reduce((sum, r) => sum + r, 0) / total;
document.getElementById('total-customers').textContent = total;
document.getElementById('custom-rates').textContent = customRates;
document.getElementById('standard-rates').textContent = standardRates;
document.getElementById('avg-rate').textContent = avgRate.toFixed(2) + ' DKK';
}
// Toast notification
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `alert alert-${type} position-fixed top-0 end-0 m-3`;
toast.style.zIndex = 9999;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
// View time entries for customer
async function viewTimeEntries(customerId, customerName) {
document.getElementById('modal-customer-name').textContent = customerName;
document.getElementById('time-entries-loading').classList.remove('d-none');
document.getElementById('time-entries-content').classList.add('d-none');
document.getElementById('time-entries-empty').classList.add('d-none');
const modal = new bootstrap.Modal(document.getElementById('timeEntriesModal'));
modal.show();
try {
const response = await fetch(`/api/v1/timetracking/customers/${customerId}/times`);
if (!response.ok) throw new Error('Failed to load time entries');
const data = await response.json();
const entries = data.times || [];
document.getElementById('time-entries-loading').classList.add('d-none');
if (entries.length === 0) {
document.getElementById('time-entries-empty').classList.remove('d-none');
return;
}
const tbody = document.getElementById('time-entries-tbody');
tbody.innerHTML = entries.map(entry => {
const date = new Date(entry.worked_date).toLocaleDateString('da-DK');
const statusBadge = {
'pending': '<span class="badge bg-warning">Afventer</span>',
'approved': '<span class="badge bg-success">Godkendt</span>',
'rejected': '<span class="badge bg-danger">Afvist</span>',
'billed': '<span class="badge bg-info">Faktureret</span>'
}[entry.status] || entry.status;
// Build case link
let caseLink = entry.case_title || 'Ingen case';
if (entry.case_vtiger_id) {
const recordId = entry.case_vtiger_id.split('x')[1];
const vtigerUrl = `https://bmcnetworks.od2.vtiger.com/view/detail?module=Cases&id=${recordId}&viewtype=summary`;
caseLink = `<a href="${vtigerUrl}" target="_blank" class="text-decoration-none">
${entry.case_title || 'Case'} <i class="bi bi-box-arrow-up-right"></i>
</a>`;
}
return `
<tr>
<td>${caseLink}</td>
<td>${date}</td>
<td>${entry.original_hours} timer</td>
<td>${statusBadge}</td>
<td>${entry.user_name || 'Ukendt'}</td>
<td>
${entry.status === 'pending' ? `
<button class="btn btn-sm btn-success" onclick="approveTimeEntry(${entry.id})">
<i class="bi bi-check"></i> Godkend
</button>
` : ''}
${entry.status === 'approved' && !entry.billed ? `
<button class="btn btn-sm btn-outline-danger" onclick="resetTimeEntry(${entry.id})">
<i class="bi bi-arrow-counterclockwise"></i> Nulstil
</button>
` : ''}
</td>
</tr>
`;
}).join('');
document.getElementById('time-entries-content').classList.remove('d-none');
} catch (error) {
console.error('Error loading time entries:', error);
document.getElementById('time-entries-loading').classList.add('d-none');
showToast('Fejl ved indlæsning af tidsregistreringer', 'danger');
modal.hide();
}
}
// Approve time entry
async function approveTimeEntry(timeId) {
if (!confirm('Godkend denne tidsregistrering?')) return;
try {
const response = await fetch(`/api/v1/timetracking/wizard/approve/${timeId}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'}
});
if (!response.ok) throw new Error('Failed to approve');
showToast('✅ Tidsregistrering godkendt', 'success');
// Reload modal content
const modalCustomerId = document.getElementById('modal-customer-name').textContent;
const customer = allCustomers.find(c => c.name === modalCustomerId);
if (customer) {
viewTimeEntries(customer.id, customer.name);
}
} catch (error) {
console.error('Error approving:', error);
showToast('Fejl ved godkendelse', 'danger');
}
}
// Reset time entry back to pending
async function resetTimeEntry(timeId) {
if (!confirm('Nulstil denne tidsregistrering tilbage til pending?')) return;
try {
const response = await fetch(`/api/v1/timetracking/wizard/reject/${timeId}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({reason: 'Reset til pending'})
});
if (!response.ok) throw new Error('Failed to reset');
showToast('✅ Tidsregistrering nulstillet', 'success');
// Reload modal content
const modalCustomerId = document.getElementById('modal-customer-name').textContent;
const customer = allCustomers.find(c => c.name === modalCustomerId);
if (customer) {
viewTimeEntries(customer.id, customer.name);
}
} catch (error) {
console.error('Error resetting:', error);
showToast('Fejl ved nulstilling', 'danger');
}
}
// Theme toggle
function toggleTheme() {
const html = document.documentElement;
const currentTheme = html.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
const icon = document.getElementById('theme-icon');
icon.className = newTheme === 'dark' ? 'bi bi-sun-fill' : 'bi bi-moon-fill';
}
function loadTheme() {
const theme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', theme);
const icon = document.getElementById('theme-icon');
icon.className = theme === 'dark' ? 'bi bi-sun-fill' : 'bi bi-moon-fill';
}
// Create order for customer
let currentOrderCustomerId = null;
async function createOrderForCustomer(customerId, customerName) {
currentOrderCustomerId = customerId;
document.getElementById('order-customer-name').textContent = customerName;
document.getElementById('order-loading').classList.remove('d-none');
document.getElementById('order-content').classList.add('d-none');
document.getElementById('order-empty').classList.add('d-none');
document.getElementById('order-creating').classList.add('d-none');
document.getElementById('confirm-create-order').disabled = true;
const modal = new bootstrap.Modal(document.getElementById('createOrderModal'));
modal.show();
try {
// Fetch customer's approved time entries
const response = await fetch(`/api/v1/timetracking/customers/${customerId}/times`);
if (!response.ok) throw new Error('Failed to load time entries');
const data = await response.json();
// Filter for approved and billable entries
const approvedEntries = (data.times || []).filter(entry =>
entry.status === 'approved' && entry.billable !== false
);
document.getElementById('order-loading').classList.add('d-none');
if (approvedEntries.length === 0) {
document.getElementById('order-empty').classList.remove('d-none');
return;
}
// Build summary
const totalHours = approvedEntries.reduce((sum, entry) =>
sum + parseFloat(entry.approved_hours || entry.original_hours || 0), 0
);
const customer = allCustomers.find(c => c.id === customerId);
const hourlyRate = customer?.hourly_rate || defaultRate;
const subtotal = totalHours * hourlyRate;
const vat = subtotal * 0.25;
const total = subtotal + vat;
// Group by case
const caseGroups = {};
approvedEntries.forEach(entry => {
const caseId = entry.case_id || 'no_case';
if (!caseGroups[caseId]) {
caseGroups[caseId] = {
title: entry.case_title || 'Ingen case',
entries: []
};
}
caseGroups[caseId].entries.push(entry);
});
const summaryHtml = `
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title mb-3">Ordre oversigt</h6>
<div class="row mb-3">
<div class="col-6">
<strong>Antal godkendte tider:</strong>
</div>
<div class="col-6 text-end">
${approvedEntries.length} stk
</div>
</div>
<div class="row mb-3">
<div class="col-6">
<strong>Total timer:</strong>
</div>
<div class="col-6 text-end">
${totalHours.toFixed(2)} timer
</div>
</div>
<div class="row mb-3">
<div class="col-6">
<strong>Timepris:</strong>
</div>
<div class="col-6 text-end">
${hourlyRate.toFixed(2)} DKK
</div>
</div>
<hr>
<div class="row mb-2">
<div class="col-6">
<strong>Subtotal (ekskl. moms):</strong>
</div>
<div class="col-6 text-end">
${subtotal.toFixed(2)} DKK
</div>
</div>
<div class="row mb-2">
<div class="col-6">
Moms (25%):
</div>
<div class="col-6 text-end">
${vat.toFixed(2)} DKK
</div>
</div>
<div class="row">
<div class="col-6">
<strong>Total (inkl. moms):</strong>
</div>
<div class="col-6 text-end">
<strong>${total.toFixed(2)} DKK</strong>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-body">
<h6 class="card-title mb-3">Cases inkluderet</h6>
${Object.entries(caseGroups).map(([caseId, group]) => `
<div class="mb-2">
<strong>${group.title}</strong>
<span class="badge bg-secondary">${group.entries.length} tidsregistreringer</span>
<span class="badge bg-info">${group.entries.reduce((sum, e) => sum + parseFloat(e.approved_hours || e.original_hours || 0), 0).toFixed(2)} timer</span>
</div>
`).join('')}
</div>
</div>
`;
document.getElementById('order-summary').innerHTML = summaryHtml;
document.getElementById('order-content').classList.remove('d-none');
document.getElementById('confirm-create-order').disabled = false;
} catch (error) {
console.error('Error loading order preview:', error);
document.getElementById('order-loading').classList.add('d-none');
showToast('Fejl ved indlæsning af ordre forhåndsvisning', 'danger');
modal.hide();
}
}
// Confirm order creation
document.getElementById('confirm-create-order')?.addEventListener('click', async function() {
if (!currentOrderCustomerId) return;
// Hide summary, show creating state
document.getElementById('order-content').classList.add('d-none');
document.getElementById('order-creating').classList.remove('d-none');
this.disabled = true;
try {
const response = await fetch(`/api/v1/timetracking/orders/generate/${currentOrderCustomerId}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'}
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Failed to create order');
}
const order = await response.json();
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('createOrderModal'));
modal.hide();
// Show success and redirect
showToast(`✅ Ordre ${order.order_number} oprettet!`, 'success');
// Reload customers to update stats
await loadCustomers();
// Redirect to order detail after 1 second
setTimeout(() => {
window.location.href = `/timetracking/orders?order_id=${order.id}`;
}, 1500);
} catch (error) {
console.error('Error creating order:', error);
document.getElementById('order-creating').classList.add('d-none');
document.getElementById('order-content').classList.remove('d-none');
this.disabled = false;
showToast(`Fejl ved oprettelse af ordre: ${error.message}`, 'danger');
}
});
</script>
</body>
</html>

View File

@ -59,11 +59,17 @@
transition: all 0.2s; transition: all 0.2s;
} }
.nav-link:hover, .nav-link.active { .nav-link:hover {
background-color: var(--accent-light); background-color: var(--accent-light);
color: var(--accent); color: var(--accent);
} }
.nav-link.active {
background-color: var(--accent);
color: white;
font-weight: 600;
}
.card { .card {
border: none; border: none;
border-radius: var(--border-radius); border-radius: var(--border-radius);
@ -153,11 +159,23 @@
<a class="nav-link" href="/dashboard">Dashboard</a> <a class="nav-link" href="/dashboard">Dashboard</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/customers">Kunder</a> <a class="nav-link active" href="/timetracking">
<i class="bi bi-clock-history"></i> Oversigt
</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link active" href="/timetracking"> <a class="nav-link" href="/timetracking/wizard">
<i class="bi bi-clock-history"></i> Tidsregistrering <i class="bi bi-check-circle"></i> Godkend Tider
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/timetracking/customers">
<i class="bi bi-building"></i> Kunder & Priser
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/timetracking/orders">
<i class="bi bi-receipt"></i> Ordrer
</a> </a>
</li> </li>
</ul> </ul>
@ -234,12 +252,19 @@
<div class="col-12"> <div class="col-12">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
<div> <div>
<h5 class="mb-1">Synkronisering</h5> <h5 class="mb-1">Synkronisering</h5>
<p class="text-muted mb-0 small">Hent nye tidsregistreringer fra vTiger</p> <p class="text-muted mb-0 small">Hent nye tidsregistreringer fra vTiger</p>
</div> </div>
<div> <div class="d-flex gap-2 align-items-center">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="hide-time-card"
onchange="loadCustomerStats()" checked>
<label class="form-check-label" for="hide-time-card">
Skjul klippekort-kunder
</label>
</div>
<button class="btn btn-primary" onclick="syncFromVTiger()" id="sync-btn"> <button class="btn btn-primary" onclick="syncFromVTiger()" id="sync-btn">
<i class="bi bi-arrow-repeat"></i> Synkroniser <i class="bi bi-arrow-repeat"></i> Synkroniser
</button> </button>
@ -319,6 +344,9 @@
// Load customer stats // Load customer stats
async function loadCustomerStats() { async function loadCustomerStats() {
try { 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'); const response = await fetch('/api/v1/timetracking/wizard/stats');
if (!response.ok) { if (!response.ok) {
@ -334,10 +362,15 @@
} }
// Filtrer kunder uden tidsregistreringer eller kun med godkendte/afviste // 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 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) { if (activeCustomers.length === 0) {
document.getElementById('loading').classList.add('d-none'); document.getElementById('loading').classList.add('d-none');
document.getElementById('no-data').classList.remove('d-none'); document.getElementById('no-data').classList.remove('d-none');
@ -361,8 +394,13 @@
tbody.innerHTML = activeCustomers.map(customer => ` tbody.innerHTML = activeCustomers.map(customer => `
<tr> <tr>
<td> <td>
<strong>${customer.customer_name || 'Ukendt kunde'}</strong> <div class="d-flex justify-content-between align-items-start">
<br><small class="text-muted">${customer.total_entries || 0} registreringer</small> <div>
<strong>${customer.customer_name || 'Ukendt kunde'}</strong>
<br><small class="text-muted">${customer.total_entries || 0} registreringer</small>
</div>
${customer.uses_time_card ? '<span class="badge bg-info">Klippekort</span>' : ''}
</div>
</td> </td>
<td>${parseFloat(customer.total_original_hours || 0).toFixed(1)}h</td> <td>${parseFloat(customer.total_original_hours || 0).toFixed(1)}h</td>
<td class="text-center"> <td class="text-center">
@ -375,9 +413,14 @@
<span class="badge bg-danger">${customer.rejected_count || 0}</span> <span class="badge bg-danger">${customer.rejected_count || 0}</span>
</td> </td>
<td class="text-end"> <td class="text-end">
<button class="btn btn-sm btn-outline-secondary me-1"
onclick="toggleTimeCard(${customer.customer_id}, ${customer.uses_time_card ? 'false' : 'true'})"
title="${customer.uses_time_card ? 'Fjern klippekort' : 'Markér som klippekort'}">
<i class="bi bi-card-checklist"></i>
</button>
${(customer.pending_count || 0) > 0 ? ` ${(customer.pending_count || 0) > 0 ? `
<a href="/timetracking/wizard?customer_id=${customer.customer_id}" <a href="/timetracking/wizard?customer_id=${customer.customer_id}"
class="btn btn-sm btn-primary"> class="btn btn-sm btn-primary me-1">
<i class="bi bi-check-circle"></i> Godkend <i class="bi bi-check-circle"></i> Godkend
</a> </a>
` : ''} ` : ''}
@ -462,13 +505,46 @@
const response = await fetch(`/api/v1/timetracking/orders/generate/${customerId}`, { const response = await fetch(`/api/v1/timetracking/orders/generate/${customerId}`, {
method: 'POST' 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(); const order = await response.json();
alert(`Ordre oprettet: ${order.order_number}\nTotal: ${order.total_amount} DKK`); // Show success message and redirect to orders page
location.reload(); 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) { } 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);
} }
} }

View File

@ -55,11 +55,17 @@
transition: all 0.2s; transition: all 0.2s;
} }
.nav-link:hover, .nav-link.active { .nav-link:hover {
background-color: var(--accent-light); background-color: var(--accent-light);
color: var(--accent); color: var(--accent);
} }
.nav-link.active {
background-color: var(--accent);
color: white;
font-weight: 600;
}
.card { .card {
border: none; border: none;
border-radius: var(--border-radius); border-radius: var(--border-radius);
@ -131,11 +137,23 @@
<a class="nav-link" href="/dashboard">Dashboard</a> <a class="nav-link" href="/dashboard">Dashboard</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/customers">Kunder</a> <a class="nav-link" href="/timetracking">
<i class="bi bi-clock-history"></i> Oversigt
</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link active" href="/timetracking"> <a class="nav-link" href="/timetracking/wizard">
<i class="bi bi-clock-history"></i> Tidsregistrering <i class="bi bi-check-circle"></i> Godkend Tider
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/timetracking/customers">
<i class="bi bi-building"></i> Kunder & Priser
</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="/timetracking/orders">
<i class="bi bi-receipt"></i> Ordrer
</a> </a>
</li> </li>
</ul> </ul>
@ -391,36 +409,71 @@
<hr class="my-3"> <hr class="my-3">
<h6 class="mb-3">Ordrelinjer:</h6> <h6 class="mb-3">Ordrelinjer:</h6>
${order.lines.map(line => ` ${order.lines.map(line => {
<div class="line-item"> // Parse data
<div class="d-flex justify-content-between mb-1"> const caseMatch = line.description.match(/CC(\d+)/);
<strong>${line.description}</strong> const caseTitle = line.description.split(' - ').slice(1).join(' - ') || line.description;
<strong>${parseFloat(line.line_total).toFixed(2)} DKK</strong> const hours = parseFloat(line.quantity);
</div> const unitPrice = parseFloat(line.unit_price);
<div class="d-flex justify-content-between text-muted small"> const total = parseFloat(line.line_total);
<span>${line.quantity} timer × ${parseFloat(line.unit_price).toFixed(2)} DKK</span> const date = new Date(line.time_date).toLocaleDateString('da-DK');
<span>${new Date(line.time_date).toLocaleDateString('da-DK')}</span>
// 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 `
<div class="line-item mb-3 p-3" style="border: 1px solid #dee2e6; border-radius: 8px;">
<div class="d-flex justify-content-between align-items-start mb-2">
<div class="flex-grow-1">
<div class="d-flex align-items-center gap-2 mb-1">
${caseMatch ? `<span class="badge bg-secondary">${caseMatch[0]}</span>` : ''}
<span class="fw-bold">${hours.toFixed(1)} timer</span>
<span class="text-muted">×</span>
<span>${unitPrice.toFixed(2)} DKK</span>
</div>
<div class="fw-bold text-uppercase mb-1" style="font-size: 0.95rem;">
${caseTitle}
</div>
<div class="text-muted small">
${date} - ${contactName}${isOnSite ? ' <span class="badge bg-info">Udkørsel</span>' : ''}
</div>
</div>
<div class="text-end">
<div class="fs-5 fw-bold text-primary">${total.toFixed(2)} DKK</div>
</div>
</div> </div>
</div> </div>
`).join('')} `;
}).join('')}
${order.exported_to_economic ? ` ${order.economic_draft_id ? `
<div class="alert alert-success mt-3 mb-0"> <div class="alert alert-success mt-3 mb-0">
<i class="bi bi-check-circle"></i> <i class="bi bi-check-circle"></i>
Eksporteret til e-conomic den ${new Date(order.exported_at).toLocaleDateString('da-DK')} Eksporteret til e-conomic den ${new Date(order.exported_at).toLocaleDateString('da-DK')}
${order.economic_draft_invoice_number ? `<br>Kladde nr.: ${order.economic_draft_invoice_number}` : ''} <br>Draft Order nr.: ${order.economic_draft_id}
${order.economic_order_number ? `<br>e-conomic ordre nr.: ${order.economic_order_number}` : ''}
</div> </div>
` : ''} ` : ''}
`; `;
// Update export button // Update export button
const exportBtn = document.getElementById('export-order-btn'); const exportBtn = document.getElementById('export-order-btn');
if (order.exported_to_economic) { if (order.economic_draft_id) {
exportBtn.disabled = true; exportBtn.disabled = false;
exportBtn.innerHTML = '<i class="bi bi-check-circle"></i> Allerede eksporteret'; exportBtn.innerHTML = '<i class="bi bi-arrow-repeat"></i> Re-eksporter (force)';
exportBtn.onclick = () => {
if (confirm('Re-eksporter ordre til e-conomic?\n\nDette vil overskrive den eksisterende draft order.')) {
exportOrderForce(currentOrderId);
}
};
} else { } else {
exportBtn.disabled = false; exportBtn.disabled = false;
exportBtn.innerHTML = '<i class="bi bi-cloud-upload"></i> Eksporter til e-conomic'; exportBtn.innerHTML = '<i class="bi bi-cloud-upload"></i> Eksporter til e-conomic';
exportBtn.onclick = exportCurrentOrder;
} }
orderModal.show(); orderModal.show();
@ -432,25 +485,39 @@
// Export order // Export order
async function exportOrder(orderId) { 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; return;
} }
try { try {
const response = await fetch(`/api/v1/timetracking/export/${orderId}`, { const response = await fetch(`/api/v1/timetracking/export`, {
method: 'POST' 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(); const result = await response.json();
if (result.dry_run) { 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 { } else {
alert(`Ordre eksporteret!\n\nKladde nr.: ${result.draft_invoice_number}\nTotal: ${result.total_amount} DKK`); throw new Error(result.message || 'Export failed');
}
loadOrders();
if (orderModal._isShown) {
orderModal.hide();
} }
} catch (error) { } catch (error) {
@ -464,6 +531,44 @@
exportOrder(currentOrderId); 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);
}
}
</script> </script>
</body> </body>
</html> </html>

View File

@ -48,6 +48,20 @@ async def timetracking_wizard(request: Request):
return response 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") @router.get("/timetracking/orders", response_class=HTMLResponse, name="timetracking_orders")
async def timetracking_orders(request: Request): async def timetracking_orders(request: Request):
"""Order oversigt""" """Order oversigt"""

File diff suppressed because it is too large Load Diff

296
docs/ECONOMIC_WRITE_MODE.md Normal file
View File

@ -0,0 +1,296 @@
# e-conomic Write Mode Guide
## Overview
Time Tracking modulet kan eksportere godkendte tidsregistreringer til e-conomic som **draft orders**.
**Safety-first approach**: Der er TWO lag af sikkerhedsflag for at beskytte mod utilsigtede ændringer.
## Safety Flags
### Layer 1: Read-Only Mode (BLOCKING)
```bash
TIMETRACKING_ECONOMIC_READ_ONLY=true # Bloker ALLE skrivninger
```
Når `READ_ONLY=true`:
- Alle write operationer blokeres 100%
- Logger: `🚫 BLOCKED: Export order X to e-conomic - READ_ONLY mode enabled`
- API returnerer fejl med instruktion om at disable flag
### Layer 2: Dry-Run Mode (SIMULATION)
```bash
TIMETRACKING_ECONOMIC_DRY_RUN=true # Log men send ikke
```
Når `DRY_RUN=true`:
- Write operationer simuleres
- Logger detaljeret payload der VILLE blive sendt
- API returnerer success men med `dry_run: true` flag
- INGEN data sendes til e-conomic
### Production Mode (LIVE WRITES)
```bash
TIMETRACKING_ECONOMIC_READ_ONLY=false
TIMETRACKING_ECONOMIC_DRY_RUN=false
```
Når **BEGGE** flags er `false`:
- ⚠️ REELLE skrivninger til e-conomic
- Logger: `⚠️ EXECUTING WRITE OPERATION: Export order X`
- Kræver også gyldig `economic_customer_number` på kunde
## Activation Steps
### 1. Verify e-conomic Credentials
```bash
# In .env file
ECONOMIC_API_URL=https://restapi.e-conomic.com
ECONOMIC_APP_SECRET_TOKEN=<your_token>
ECONOMIC_AGREEMENT_GRANT_TOKEN=<your_token>
```
Test connection:
```bash
curl http://localhost:8001/api/v1/timetracking/economic/test
```
Expected response:
```json
{
"status": "connected",
"read_only": true,
"dry_run": true
}
```
### 2. Ensure Customers Have e-conomic Numbers
Alle kunder SKAL have `economic_customer_number` for at kunne eksporteres:
```sql
-- Check customers without e-conomic number
SELECT id, name, vtiger_id
FROM tmodule_customers
WHERE economic_customer_number IS NULL;
```
e-conomic customer number synces automatisk fra vTiger `cf_854` felt.
**Fix missing numbers:**
1. Opdater vTiger med e-conomic customer number i `cf_854` felt
2. Kør sync: `POST /api/v1/timetracking/sync/customers`
3. Verify: `GET /api/v1/timetracking/customers`
### 3. Test with Dry-Run Mode
**Step 3.1**: Disable READ_ONLY (men behold DRY_RUN)
```bash
# In .env
TIMETRACKING_ECONOMIC_READ_ONLY=false # ⚠️ Allow operations
TIMETRACKING_ECONOMIC_DRY_RUN=true # ✅ Still safe - no real writes
```
Restart API:
```bash
docker-compose restart api
```
**Step 3.2**: Generate test order
```bash
# Get approved entries for a customer
curl http://localhost:8001/api/v1/timetracking/wizard/next
# Approve entry
curl -X POST http://localhost:8001/api/v1/timetracking/wizard/approve/123
# Generate order
curl -X POST http://localhost:8001/api/v1/timetracking/orders/generate \
-H "Content-Type: application/json" \
-d '{"customer_id": 1}'
```
**Step 3.3**: Test export (dry-run)
```bash
curl -X POST http://localhost:8001/api/v1/timetracking/orders/1/export \
-H "Content-Type: application/json" \
-d '{}'
```
Expected response:
```json
{
"success": true,
"dry_run": true,
"order_id": 1,
"economic_draft_id": null,
"message": "DRY-RUN: Would export order ORD-2024-001 to e-conomic",
"details": {
"order_number": "ORD-2024-001",
"customer_name": "Test Customer",
"total_amount": 425.0,
"line_count": 1,
"read_only": false,
"dry_run": true
}
}
```
**Check logs** for payload details:
```bash
docker-compose logs api | grep "📤 Sending to e-conomic"
```
### 4. Enable Production Mode (LIVE WRITES)
⚠️ **CRITICAL**: Only proceed if dry-run tests succeeded!
**Step 4.1**: Disable DRY_RUN
```bash
# In .env
TIMETRACKING_ECONOMIC_READ_ONLY=false # ⚠️ Writes enabled
TIMETRACKING_ECONOMIC_DRY_RUN=false # ⚠️⚠️ REAL WRITES TO PRODUCTION!
```
**Step 4.2**: Restart API
```bash
docker-compose restart api
```
**Step 4.3**: Export ONE test order
```bash
curl -X POST http://localhost:8001/api/v1/timetracking/orders/1/export \
-H "Content-Type: application/json" \
-d '{}'
```
Expected response (success):
```json
{
"success": true,
"dry_run": false,
"order_id": 1,
"economic_draft_id": 12345,
"economic_order_number": "12345",
"message": "Successfully exported to e-conomic draft 12345",
"details": {
"draftOrderNumber": 12345,
...
}
}
```
**Step 4.4**: Verify in e-conomic
- Login to e-conomic
- Go to **Sales → Draft Orders**
- Find order number `12345`
- Verify customer, lines, amounts
## Error Handling
### Missing Customer Number
```json
{
"status_code": 400,
"detail": "Customer ABC has no e-conomic customer number"
}
```
**Fix**: Sync customer data from vTiger (ensure `cf_854` is populated).
### e-conomic API Error
```json
{
"status_code": 400,
"detail": "e-conomic API error: customer.customerNumber: Customer not found"
}
```
**Fix**: Verify customer exists in e-conomic with correct number.
### Network/Timeout Error
```json
{
"status_code": 500,
"detail": "Internal error: Timeout connecting to e-conomic"
}
```
**Fix**: Check network, verify `ECONOMIC_API_URL` is correct.
## Audit Trail
All export operations are logged to `tmodule_sync_log`:
```sql
SELECT * FROM tmodule_sync_log
WHERE event_type = 'export_completed'
ORDER BY created_at DESC
LIMIT 10;
```
Fields tracked:
- `order_id` - Which order was exported
- `economic_draft_id` - Draft order number in e-conomic
- `economic_order_number` - Order number in e-conomic
- `dry_run` - Whether it was a simulation
- `created_by` - User ID who triggered export
- `created_at` - Timestamp
## Rollback Plan
If issues occur in production:
**Step 1**: Immediately re-enable safety flags
```bash
TIMETRACKING_ECONOMIC_READ_ONLY=true
TIMETRACKING_ECONOMIC_DRY_RUN=true
docker-compose restart api
```
**Step 2**: Review audit log
```sql
SELECT * FROM tmodule_sync_log
WHERE event_type LIKE 'export_%'
AND created_at > NOW() - INTERVAL '1 hour';
```
**Step 3**: Manual cleanup in e-conomic
- Go to **Sales → Draft Orders**
- Filter by date (today)
- Review and delete erroneous drafts
- Draft orders can be deleted without impact
## Best Practices
1. **Always test with dry-run first**
2. **Export ONE order to production** before bulk operations
3. **Verify in e-conomic** after first export
4. **Monitor audit logs** regularly
5. **Re-enable safety flags** when not actively exporting
6. **Keep `EXPORT_TYPE=draft`** - never book automatically
## Configuration Reference
```bash
# Safety Flags (default: SAFE)
TIMETRACKING_ECONOMIC_READ_ONLY=true # Block all writes
TIMETRACKING_ECONOMIC_DRY_RUN=true # Simulate writes
# Export Settings
TIMETRACKING_EXPORT_TYPE=draft # draft|booked (ALWAYS use draft)
# e-conomic Credentials
ECONOMIC_API_URL=https://restapi.e-conomic.com
ECONOMIC_APP_SECRET_TOKEN=<token>
ECONOMIC_AGREEMENT_GRANT_TOKEN=<token>
```
## Support
If export fails consistently:
1. Check `docker-compose logs api | grep ERROR`
2. Verify customer has `economic_customer_number`
3. Test e-conomic connection: `GET /timetracking/economic/test`
4. Review payload in dry-run logs
5. Contact e-conomic support if API errors persist

View File

@ -32,6 +32,7 @@ CREATE TABLE IF NOT EXISTS tmodule_customers (
email VARCHAR(255), email VARCHAR(255),
hub_customer_id INTEGER, -- Reference til customers.id (OPTIONAL, read-only) hub_customer_id INTEGER, -- Reference til customers.id (OPTIONAL, read-only)
hourly_rate DECIMAL(10,2), -- Kan override Hub-rate hourly_rate DECIMAL(10,2), -- Kan override Hub-rate
uses_time_card BOOLEAN DEFAULT false, -- Klippekort - faktureres eksternt
vtiger_data JSONB, -- Original vTiger data for reference vtiger_data JSONB, -- Original vTiger data for reference
sync_hash VARCHAR(64), -- SHA256 af data for change detection sync_hash VARCHAR(64), -- SHA256 af data for change detection
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
@ -292,31 +293,35 @@ SELECT
c.id AS customer_id, c.id AS customer_id,
c.name AS customer_name, c.name AS customer_name,
c.vtiger_id AS customer_vtiger_id, c.vtiger_id AS customer_vtiger_id,
COUNT(t.id) AS total_entries, c.uses_time_card AS uses_time_card,
COUNT(t.id) FILTER (WHERE t.status = 'pending') AS pending_count, COUNT(t.id) FILTER (WHERE t.billable = true AND t.vtiger_data->>'cf_timelog_invoiced' = '0') AS total_entries,
COUNT(t.id) FILTER (WHERE t.status = 'approved') AS approved_count, COUNT(t.id) FILTER (WHERE t.billable = true AND t.status = 'pending' AND t.vtiger_data->>'cf_timelog_invoiced' = '0') AS pending_count,
COUNT(t.id) FILTER (WHERE t.status = 'rejected') AS rejected_count, COUNT(t.id) FILTER (WHERE t.billable = true AND t.status = 'approved' AND t.vtiger_data->>'cf_timelog_invoiced' = '0') AS approved_count,
COUNT(t.id) FILTER (WHERE t.status = 'billed') AS billed_count, COUNT(t.id) FILTER (WHERE t.billable = true AND t.status = 'rejected' AND t.vtiger_data->>'cf_timelog_invoiced' = '0') AS rejected_count,
SUM(t.original_hours) AS total_original_hours, COUNT(t.id) FILTER (WHERE t.billable = true AND t.status = 'billed' AND t.vtiger_data->>'cf_timelog_invoiced' = '0') AS billed_count,
SUM(t.approved_hours) FILTER (WHERE t.status = 'approved') AS total_approved_hours, SUM(t.original_hours) FILTER (WHERE t.billable = true AND t.vtiger_data->>'cf_timelog_invoiced' = '0') AS total_original_hours,
MAX(t.worked_date) AS latest_work_date, SUM(t.approved_hours) FILTER (WHERE t.billable = true AND t.status = 'approved' AND t.vtiger_data->>'cf_timelog_invoiced' = '0') AS total_approved_hours,
MAX(t.last_synced_at) AS last_sync MAX(t.worked_date) FILTER (WHERE t.billable = true AND t.vtiger_data->>'cf_timelog_invoiced' = '0') AS latest_work_date,
MAX(t.last_synced_at) FILTER (WHERE t.billable = true AND t.vtiger_data->>'cf_timelog_invoiced' = '0') AS last_sync
FROM tmodule_customers c FROM tmodule_customers c
LEFT JOIN tmodule_times t ON c.id = t.customer_id LEFT JOIN tmodule_times t ON c.id = t.customer_id
GROUP BY c.id, c.name, c.vtiger_id; GROUP BY c.id, c.name, c.vtiger_id, c.uses_time_card;
-- Næste tid der skal godkendes (wizard helper) -- Næste tid der skal godkendes (wizard helper)
CREATE OR REPLACE VIEW tmodule_next_pending AS CREATE OR REPLACE VIEW tmodule_next_pending AS
SELECT SELECT
t.*, t.*,
c.title AS case_title, COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title,
c.status AS case_status, c.status AS case_status,
c.vtiger_id AS case_vtiger_id,
cust.name AS customer_name, cust.name AS customer_name,
cust.hourly_rate AS customer_rate cust.hourly_rate AS customer_rate
FROM tmodule_times t FROM tmodule_times t
JOIN tmodule_cases c ON t.case_id = c.id JOIN tmodule_cases c ON t.case_id = c.id
JOIN tmodule_customers cust ON t.customer_id = cust.id JOIN tmodule_customers cust ON t.customer_id = cust.id
WHERE t.status = 'pending' WHERE t.status = 'pending'
AND t.billable = true -- Only billable timelogs
AND t.vtiger_data->>'cf_timelog_invoiced' = '0' -- Only not-invoiced timelogs
ORDER BY cust.name, c.title, t.worked_date; ORDER BY cust.name, c.title, t.worked_date;
-- Order summary med linjer -- Order summary med linjer

View File

@ -0,0 +1,11 @@
-- Migration 014: Add user_company field to contacts
-- Adds company/organization field to contact records
ALTER TABLE contacts
ADD COLUMN IF NOT EXISTS user_company VARCHAR(255);
-- Add index for searching by company
CREATE INDEX IF NOT EXISTS idx_contacts_user_company ON contacts(user_company);
-- Add comment
COMMENT ON COLUMN contacts.user_company IS 'Company/organization name from vTiger contact';

View File

@ -0,0 +1,15 @@
-- ============================================================================
-- Migration 014: Add e-conomic customer number to tmodule_customers
-- ============================================================================
-- Add e-conomic customer number field
ALTER TABLE tmodule_customers
ADD COLUMN IF NOT EXISTS economic_customer_number INTEGER;
-- Add index for lookups
CREATE INDEX IF NOT EXISTS idx_tmodule_customers_economic
ON tmodule_customers(economic_customer_number);
-- Add comment
COMMENT ON COLUMN tmodule_customers.economic_customer_number IS
'e-conomic customer number for invoice export. Synced from vTiger cf_854 field.';