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_ENDPOINT=http://ai_direct.cs.blaahund.dk
OLLAMA_MODEL=qwen2.5:3b
OLLAMA_MODEL=qwen2.5-coder:7b
# =====================================================
# e-conomic Integration (Optional)
@ -51,3 +51,21 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_agreement_grant_token_here
# 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede ændringer
ECONOMIC_READ_ONLY=true # Set to false ONLY after testing
ECONOMIC_DRY_RUN=true # Set to false ONLY when ready for production writes
# vTiger CRM Integration (for Time Tracking Module)
VTIGER_URL=https://bmcnetworks.od2.vtiger.com
VTIGER_USERNAME=ct@bmcnetworks.dk
VTIGER_API_KEY=bD8cW8zRFuKpPZ2S
# Time Tracking Module Settings
TIMETRACKING_DEFAULT_HOURLY_RATE=1200.00 # Standard timepris i DKK
TIMETRACKING_AUTO_ROUND=true
TIMETRACKING_ROUND_INCREMENT=0.5
TIMETRACKING_ROUND_METHOD=up
# Time Tracking Safety Switches
TIMETRACKING_VTIGER_READ_ONLY=true
TIMETRACKING_VTIGER_DRY_RUN=true
TIMETRACKING_ECONOMIC_READ_ONLY=true
TIMETRACKING_ECONOMIC_DRY_RUN=true

View File

@ -45,3 +45,48 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_agreement_grant_token_here
# 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede ændringer
ECONOMIC_READ_ONLY=true # Set to false ONLY after testing
ECONOMIC_DRY_RUN=true # Set to false ONLY when ready for production writes
# =====================================================
# vTiger CRM Integration (Optional)
# =====================================================
VTIGER_URL=https://your-instance.od2.vtiger.com
VTIGER_USERNAME=your_username@yourdomain.com
VTIGER_API_KEY=your_api_key_or_access_key
VTIGER_PASSWORD=your_password_if_using_basic_auth
# =====================================================
# TIME TRACKING MODULE - Isolated Settings
# =====================================================
# vTiger Integration Safety Flags
TIMETRACKING_VTIGER_READ_ONLY=true # 🚨 Bloker ALLE skrivninger til vTiger
TIMETRACKING_VTIGER_DRY_RUN=true # 🚨 Log uden at synkronisere
# e-conomic Integration Safety Flags
TIMETRACKING_ECONOMIC_READ_ONLY=true # 🚨 Bloker ALLE skrivninger til e-conomic
TIMETRACKING_ECONOMIC_DRY_RUN=true # 🚨 Log uden at eksportere
TIMETRACKING_EXPORT_TYPE=draft # draft|booked (draft er sikrest)
# Business Logic Settings
TIMETRACKING_DEFAULT_HOURLY_RATE=850.00 # DKK pr. time (fallback hvis kunde ikke har rate)
TIMETRACKING_AUTO_ROUND=true # Auto-afrund til nærmeste interval
TIMETRACKING_ROUND_INCREMENT=0.5 # Afrundingsinterval (0.25, 0.5, 1.0)
TIMETRACKING_ROUND_METHOD=up # up (op til), nearest (nærmeste), down (ned til)
TIMETRACKING_REQUIRE_APPROVAL=true # Kræv manuel godkendelse
# =====================================================
# OLLAMA AI Integration (Optional - for document extraction)
# =====================================================
OLLAMA_ENDPOINT=http://ai_direct.cs.blaahund.dk
OLLAMA_MODEL=qwen2.5-coder:7b
# =====================================================
# COMPANY INFO
# =====================================================
OWN_CVR=29522790 # BMC Denmark ApS - ignore when detecting vendors
# =====================================================
# FILE UPLOAD
# =====================================================
UPLOAD_DIR=uploads
MAX_FILE_SIZE_MB=50

View File

@ -7,6 +7,12 @@ Et centralt management system til BMC Networks - håndterer kunder, services, ha
## 🌟 Features
- **Customer Management**: Komplet kundedatabase med CRM integration
- **Time Tracking Module**: vTiger integration med tidsregistrering og fakturering
- Automatisk sync fra vTiger (billable timelogs)
- Step-by-step godkendelses-wizard
- Auto-afrunding til 0.5 timer
- Klippekort-funktionalitet
- e-conomic export (draft orders)
- **Hardware Tracking**: Registrering og sporing af kundeudstyr
- **Service Management**: Håndtering af services og abonnementer
- **Billing Integration**: Automatisk fakturering via e-conomic
@ -123,12 +129,43 @@ bmc_hub/
## 🔌 API Endpoints
### Main API
- `GET /api/v1/customers` - List customers
- `GET /api/v1/hardware` - List hardware
- `GET /api/v1/billing/invoices` - List invoices
- `GET /health` - Health check
Se fuld dokumentation: http://localhost:8000/api/docs
### Time Tracking Module
- `POST /api/v1/timetracking/sync` - Sync from vTiger (read-only)
- `GET /api/v1/timetracking/wizard/next` - Get next pending timelog
- `POST /api/v1/timetracking/wizard/approve/{id}` - Approve timelog
- `POST /api/v1/timetracking/orders/generate` - Generate invoice order
- `POST /api/v1/timetracking/export` - Export to e-conomic (with safety flags)
- `GET /api/v1/timetracking/export/test-connection` - Test e-conomic connection
Se fuld dokumentation: http://localhost:8001/api/docs
## 🚨 e-conomic Write Mode
Time Tracking modulet kan eksportere ordrer til e-conomic med **safety-first approach**:
### Safety Flags (default: SAFE)
```bash
TIMETRACKING_ECONOMIC_READ_ONLY=true # Block all writes
TIMETRACKING_ECONOMIC_DRY_RUN=true # Simulate writes (log only)
```
### Enable Write Mode
Se detaljeret guide: [docs/ECONOMIC_WRITE_MODE.md](docs/ECONOMIC_WRITE_MODE.md)
**Quick steps:**
1. Test connection: `GET /api/v1/timetracking/export/test-connection`
2. Test dry-run: Set `READ_ONLY=false`, keep `DRY_RUN=true`
3. Export test order: `POST /api/v1/timetracking/export`
4. Enable production: Set **both** flags to `false`
5. Verify first order in e-conomic before bulk operations
**CRITICAL**: All customers must have `economic_customer_number` (synced from vTiger `cf_854` field).
## 🧪 Testing

View File

@ -50,8 +50,9 @@ class Settings(BaseSettings):
# Time Tracking Module - Business Logic
TIMETRACKING_DEFAULT_HOURLY_RATE: float = 850.00 # DKK pr. time (fallback)
TIMETRACKING_AUTO_ROUND: bool = False # Auto-afrund til nærmeste 0.5 time
TIMETRACKING_AUTO_ROUND: bool = True # Auto-afrund til nærmeste 0.5 time
TIMETRACKING_ROUND_INCREMENT: float = 0.5 # Afrundingsinterval (0.25, 0.5, 1.0)
TIMETRACKING_ROUND_METHOD: str = "up" # up (op til), nearest (nærmeste), down (ned til)
TIMETRACKING_REQUIRE_APPROVAL: bool = True # Kræv manuel godkendelse (ikke auto-approve)
# Ollama AI Integration

View File

@ -11,6 +11,7 @@ Safety Flags:
"""
import logging
import json
from typing import Optional, Dict, Any
import aiohttp
@ -192,33 +193,80 @@ class EconomicExportService:
# REAL EXPORT (kun hvis safety flags er disabled)
logger.warning(f"⚠️ REAL EXPORT STARTING for order {request.order_id}")
# TODO: Implementer rigtig e-conomic API call her
# Denne kode vil kun køre hvis READ_ONLY og DRY_RUN begge er False
# Hent e-conomic customer number fra vTiger customer
customer_number_query = """
SELECT economic_customer_number
FROM tmodule_customers
WHERE id = %s
"""
customer_data = execute_query(customer_number_query, (order['customer_id'],), fetchone=True)
if not customer_data or not customer_data.get('economic_customer_number'):
raise HTTPException(
status_code=400,
detail=f"Customer {order['customer_name']} has no e-conomic customer number"
)
customer_number = customer_data['economic_customer_number']
# Build e-conomic draft order payload
economic_payload = {
"date": order['order_date'].isoformat(),
"date": order['order_date'].isoformat() if hasattr(order['order_date'], 'isoformat') else str(order['order_date']),
"currency": "DKK",
"exchangeRate": 100,
"netAmount": float(order['subtotal']),
"grossAmount": float(order['total_amount']),
"vatAmount": float(order['vat_amount']),
"notes": {
"heading": f"Tidsregistrering - {order['order_number']}",
"textLine1": order.get('notes', '')
"customer": {
"customerNumber": customer_number
},
"lines": [
{
"lineNumber": line['line_number'],
"description": line['description'],
"quantity": float(line['quantity']),
"unitNetPrice": float(line['unit_price']),
"totalNetAmount": float(line['line_total'])
"recipient": {
"name": order['customer_name'],
"vatZone": {
"vatZoneNumber": 1 # Domestic Denmark
}
for line in lines
]
},
"paymentTerms": {
"paymentTermsNumber": 1 # Default payment terms
},
"layout": {
"layoutNumber": 19 # Default layout
},
"notes": {
"heading": f"Tidsregistrering - {order['order_number']}"
},
"lines": []
}
# Add notes if present
if order.get('notes'):
economic_payload['notes']['textLine1'] = order['notes']
# Build order lines
for idx, line in enumerate(lines, start=1):
economic_line = {
"lineNumber": idx,
"sortKey": idx,
"description": line['description'],
"quantity": float(line['quantity']),
"unitNetPrice": float(line['unit_price']),
"unit": {
"unitNumber": 1 # Default unit (stk/pcs)
}
}
# Add product if specified
if line.get('product_number'):
product_number = str(line['product_number'])[:25] # Max 25 chars
economic_line['product'] = {
"productNumber": product_number
}
# Add discount if present
if line.get('discount_percentage'):
economic_line['discountPercentage'] = float(line['discount_percentage'])
economic_payload['lines'].append(economic_line)
logger.info(f"📤 Sending to e-conomic: {json.dumps(economic_payload, indent=2, default=str)}")
# Call e-conomic API
async with aiohttp.ClientSession() as session:
async with session.post(
@ -227,23 +275,46 @@ class EconomicExportService:
json=economic_payload,
timeout=aiohttp.ClientTimeout(total=30)
) as response:
response_text = await response.text()
if response.status not in [200, 201]:
error_text = await response.text()
logger.error(f"❌ e-conomic export failed: {response.status} - {error_text}")
logger.error(f"❌ e-conomic export failed: {response.status}")
logger.error(f"Response: {response_text}")
logger.error(f"Payload: {json.dumps(economic_payload, indent=2, default=str)}")
# Try to parse error message
try:
error_data = json.loads(response_text)
error_msg = error_data.get('message', response_text)
# Parse detailed validation errors if present
if 'errors' in error_data:
error_details = []
for entity, entity_errors in error_data['errors'].items():
if isinstance(entity_errors, dict) and 'errors' in entity_errors:
for err in entity_errors['errors']:
field = err.get('propertyName', entity)
msg = err.get('errorMessage', err.get('message', 'Unknown'))
error_details.append(f"{field}: {msg}")
if error_details:
error_msg = '; '.join(error_details)
except:
error_msg = response_text
# Log failed export
audit.log_export_failed(
order_id=request.order_id,
error=error_text,
error=error_msg,
user_id=user_id
)
raise HTTPException(
status_code=response.status,
detail=f"e-conomic API error: {error_text}"
detail=f"e-conomic API error: {error_msg}"
)
result_data = await response.json()
logger.info(f"✅ e-conomic response: {json.dumps(result_data, indent=2, default=str)}")
economic_draft_id = result_data.get('draftOrderNumber')
economic_order_number = result_data.get('orderNumber', str(economic_draft_id))

View File

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

View File

@ -95,11 +95,16 @@ class OrderService:
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
# Hent godkendte tider for kunden
# Hent godkendte tider for kunden med case og contact detaljer
query = """
SELECT t.*, c.title as case_title
SELECT t.*,
c.title as case_title,
c.vtiger_id as case_vtiger_id,
c.vtiger_data->>'ticket_title' as vtiger_title,
CONCAT(cont.first_name, ' ', cont.last_name) as contact_name
FROM tmodule_times t
JOIN tmodule_cases c ON t.case_id = c.id
LEFT JOIN contacts cont ON cont.vtiger_id = c.vtiger_data->>'contact_id'
WHERE t.customer_id = %s
AND t.status = 'approved'
AND t.billable = true
@ -121,16 +126,21 @@ class OrderService:
customer.get('hub_customer_id')
)
# Group by case
# Group by case og gem ekstra metadata
case_groups = {}
for time_entry in approved_times:
case_id = time_entry['case_id']
if case_id not in case_groups:
case_groups[case_id] = {
'case_title': time_entry['case_title'],
'entries': []
'case_vtiger_id': time_entry.get('case_vtiger_id'),
'contact_name': time_entry.get('contact_name'),
'entries': [],
'descriptions': [] # Samle alle beskrivelser
}
case_groups[case_id]['entries'].append(time_entry)
# Tilføj beskrivelse hvis den ikke er tom
if time_entry.get('description') and time_entry['description'].strip():
case_groups[case_id]['descriptions'].append(time_entry['description'].strip())
# Build order lines
order_lines = []
@ -144,9 +154,33 @@ class OrderService:
for entry in group['entries']
)
# Build description
entry_count = len(group['entries'])
description = f"{group['case_title']} ({entry_count} tidsregistreringer)"
# Extract case number from vtiger_id (format: 39x42930 -> CC2930)
case_number = ""
if group['case_vtiger_id']:
vtiger_parts = group['case_vtiger_id'].split('x')
if len(vtiger_parts) > 1:
# Take last 4 digits
case_number = f"CC{vtiger_parts[1][-4:]}"
# Brug tidsregistreringers beskrivelser som titel
# Tag første beskrivelse, eller alle hvis de er forskellige
case_title = "Ingen beskrivelse"
if group['descriptions']:
# Hvis alle beskrivelser er ens, brug kun én
unique_descriptions = list(set(group['descriptions']))
if len(unique_descriptions) == 1:
case_title = unique_descriptions[0]
else:
# Hvis forskellige, join dem
case_title = ", ".join(unique_descriptions[:3]) # Max 3 for ikke at blive for lang
if len(unique_descriptions) > 3:
case_title += "..."
# Build description med case nummer prefix
if case_number:
description = f"{case_number} - {case_title}"
else:
description = case_title
# Calculate line total
line_total = case_hours * hourly_rate
@ -260,18 +294,32 @@ class OrderService:
def get_order_with_lines(order_id: int) -> TModuleOrderWithLines:
"""Hent ordre med linjer"""
try:
# Get order
order_query = "SELECT * FROM tmodule_orders WHERE id = %s"
# Get order with customer name
order_query = """
SELECT o.*, c.name as customer_name
FROM tmodule_orders o
LEFT JOIN customers c ON o.customer_id = c.id
WHERE o.id = %s
"""
order = execute_query(order_query, (order_id,), fetchone=True)
if not order:
raise HTTPException(status_code=404, detail="Order not found")
# Get lines
# Get lines with additional context (contact, date)
lines_query = """
SELECT * FROM tmodule_order_lines
WHERE order_id = %s
ORDER BY line_number
SELECT ol.*,
STRING_AGG(DISTINCT CONCAT(cont.first_name, ' ', cont.last_name), ', ') as case_contact,
MIN(t.worked_date) as time_date
FROM tmodule_order_lines ol
LEFT JOIN tmodule_times t ON t.id = ANY(ol.time_entry_ids)
LEFT JOIN tmodule_cases c ON c.id = ol.case_id
LEFT JOIN contacts cont ON cont.vtiger_id = c.vtiger_data->>'contact_id'
WHERE ol.order_id = %s
GROUP BY ol.id, ol.order_id, ol.case_id, ol.line_number, ol.description,
ol.quantity, ol.unit_price, ol.line_total, ol.time_entry_ids,
ol.product_number, ol.account_number, ol.created_at
ORDER BY ol.line_number
"""
lines = execute_query(lines_query, (order_id,))
@ -298,19 +346,21 @@ class OrderService:
params = []
if customer_id:
conditions.append("customer_id = %s")
conditions.append("o.customer_id = %s")
params.append(customer_id)
if status:
conditions.append("status = %s")
conditions.append("o.status = %s")
params.append(status)
where_clause = " WHERE " + " AND ".join(conditions) if conditions else ""
query = f"""
SELECT * FROM tmodule_orders
SELECT o.*, c.name as customer_name
FROM tmodule_orders o
LEFT JOIN customers c ON o.customer_id = c.id
{where_clause}
ORDER BY order_date DESC, id DESC
ORDER BY o.order_date DESC, o.id DESC
LIMIT %s
"""
params.append(limit)

View File

@ -44,7 +44,10 @@ router = APIRouter()
# ============================================================================
@router.post("/sync", response_model=TModuleSyncStats, tags=["Sync"])
async def sync_from_vtiger(user_id: Optional[int] = None):
async def sync_from_vtiger(
user_id: Optional[int] = None,
fetch_comments: bool = False
):
"""
🔍 Synkroniser data fra vTiger (READ-ONLY).
@ -54,16 +57,53 @@ async def sync_from_vtiger(user_id: Optional[int] = None):
- ModComments (tidsregistreringer)
Gemmes i tmodule_* tabeller (isoleret).
Args:
user_id: ID bruger der kører sync
fetch_comments: Hent også interne kommentarer (langsomt - ~0.4s pr case)
"""
try:
logger.info("🚀 Starting vTiger sync...")
result = await vtiger_service.full_sync(user_id=user_id)
result = await vtiger_service.full_sync(user_id=user_id, fetch_comments=fetch_comments)
return result
except Exception as e:
logger.error(f"❌ Sync failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/sync/case/{case_id}/comments", tags=["Sync"])
async def sync_case_comments(case_id: int):
"""
🔍 Synkroniser kommentarer for en specifik case fra vTiger.
Bruges til on-demand opdatering når man ser en case i wizard.
"""
try:
# Hent case fra database
case = execute_query(
"SELECT vtiger_id FROM tmodule_cases WHERE id = %s",
(case_id,),
fetchone=True
)
if not case:
raise HTTPException(status_code=404, detail="Case not found")
# Sync comments
result = await vtiger_service.sync_case_comments(case['vtiger_id'])
if not result['success']:
raise HTTPException(status_code=500, detail=result.get('error', 'Failed to sync comments'))
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Failed to sync comments for case {case_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/sync/test-connection", tags=["Sync"])
async def test_vtiger_connection():
"""Test forbindelse til vTiger"""
@ -93,37 +133,99 @@ async def get_all_customer_stats():
@router.get("/wizard/next", response_model=TModuleWizardNextEntry, tags=["Wizard"])
async def get_next_pending_entry(customer_id: Optional[int] = None):
async def get_next_pending_entry(
customer_id: Optional[int] = None,
exclude_time_card: bool = True
):
"""
Hent næste pending tidsregistrering til godkendelse.
Query params:
- customer_id: Valgfri - filtrer til specifik kunde
- customer_id: Filtrer til specifik kunde (optional)
- exclude_time_card: Ekskluder klippekort-kunder (default: true)
"""
try:
return wizard.get_next_pending_entry(customer_id=customer_id)
return wizard.get_next_pending_entry(
customer_id=customer_id,
exclude_time_card=exclude_time_card
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/wizard/approve", response_model=TModuleTimeWithContext, tags=["Wizard"])
@router.post("/wizard/approve/{time_id}", response_model=TModuleTimeWithContext, tags=["Wizard"])
async def approve_time_entry(
approval: TModuleTimeApproval,
time_id: int,
billable_hours: Optional[float] = None,
hourly_rate: Optional[float] = None,
rounding_method: Optional[str] = None,
user_id: Optional[int] = None
):
"""
Godkend en tidsregistrering.
Body:
Path params:
- time_id: ID tidsregistreringen
- approved_hours: Timer efter godkendelse (kan være afrundet)
- rounded_to: Afrundingsinterval (0.5, 1.0, etc.)
- approval_note: Valgfri note
- billable: Skal faktureres? (default: true)
Body (optional):
- billable_hours: Timer efter godkendelse (hvis ikke angivet, bruges original_hours med auto-rounding)
- hourly_rate: Timepris i DKK (override customer rate)
- rounding_method: "up", "down", "nearest" (override default)
"""
try:
from app.core.config import settings
from decimal import Decimal
# Hent timelog
query = """
SELECT t.*, c.title as case_title, c.status as case_status,
cust.name as customer_name, cust.hourly_rate as customer_rate
FROM tmodule_times t
JOIN tmodule_cases c ON t.case_id = c.id
JOIN tmodule_customers cust ON t.customer_id = cust.id
WHERE t.id = %s
"""
entry = execute_query(query, (time_id,), fetchone=True)
if not entry:
raise HTTPException(status_code=404, detail="Time entry not found")
# Beregn approved_hours
if billable_hours is None:
approved_hours = Decimal(str(entry['original_hours']))
# Auto-afrund hvis enabled
if settings.TIMETRACKING_AUTO_ROUND:
increment = Decimal(str(settings.TIMETRACKING_ROUND_INCREMENT))
method = rounding_method or settings.TIMETRACKING_ROUND_METHOD
if method == "up":
approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_UP') * increment
elif method == "down":
approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_DOWN') * increment
else:
approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_HALF_UP') * increment
else:
approved_hours = Decimal(str(billable_hours))
# Opdater med hourly_rate hvis angivet
if hourly_rate is not None:
execute_update(
"UPDATE tmodule_times SET hourly_rate = %s WHERE id = %s",
(Decimal(str(hourly_rate)), time_id)
)
# Godkend
approval = TModuleTimeApproval(
time_id=time_id,
approved_hours=float(approved_hours)
)
return wizard.approve_time_entry(approval, user_id=user_id)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error approving entry: {e}")
raise HTTPException(status_code=500, detail=str(e))
@ -140,6 +242,59 @@ async def reject_time_entry(
raise HTTPException(status_code=500, detail=str(e))
@router.get("/wizard/case/{case_id}/entries", response_model=List[TModuleTimeWithContext], tags=["Wizard"])
async def get_case_entries(
case_id: int,
exclude_time_card: bool = True
):
"""
Hent alle pending timelogs for en case.
Bruges til at vise alle tidsregistreringer i samme case grupperet.
"""
try:
return wizard.get_case_entries(case_id, exclude_time_card=exclude_time_card)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/wizard/case/{case_id}/details", tags=["Wizard"])
async def get_case_details(case_id: int):
"""
Hent komplet case information inkl. alle timelogs og kommentarer.
Returnerer:
- case_id, case_title, case_description, case_status
- timelogs: ALLE tidsregistreringer (pending, approved, rejected)
- case_comments: Kommentarer fra vTiger
"""
try:
return wizard.get_case_details(case_id)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/wizard/case/{case_id}/approve-all", tags=["Wizard"])
async def approve_all_case_entries(
case_id: int,
user_id: Optional[int] = None,
exclude_time_card: bool = True
):
"""
Bulk-godkend alle pending timelogs for en case.
Afr under automatisk efter configured settings.
"""
try:
return wizard.approve_case_entries(
case_id=case_id,
user_id=user_id,
exclude_time_card=exclude_time_card
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/wizard/progress/{customer_id}", response_model=TModuleWizardProgress, tags=["Wizard"])
async def get_customer_progress(customer_id: int):
"""Hent wizard progress for en kunde"""
@ -336,14 +491,223 @@ async def module_health():
except Exception as e:
logger.error(f"Health check error: {e}")
return JSONResponse(
status_code=503,
content={
"status": "unhealthy",
"error": str(e)
}
status_code=500,
content={"status": "error", "message": str(e)}
)
@router.get("/config", tags=["Admin"])
async def get_config():
"""Hent modul konfiguration"""
from app.core.config import settings
return {
"default_hourly_rate": float(settings.TIMETRACKING_DEFAULT_HOURLY_RATE),
"auto_round": settings.TIMETRACKING_AUTO_ROUND,
"round_increment": float(settings.TIMETRACKING_ROUND_INCREMENT),
"round_method": settings.TIMETRACKING_ROUND_METHOD,
"vtiger_read_only": settings.TIMETRACKING_VTIGER_READ_ONLY,
"vtiger_dry_run": settings.TIMETRACKING_VTIGER_DRY_RUN,
"economic_read_only": settings.TIMETRACKING_ECONOMIC_READ_ONLY,
"economic_dry_run": settings.TIMETRACKING_ECONOMIC_DRY_RUN
}
# ============================================================================
# CUSTOMER MANAGEMENT ENDPOINTS
# ============================================================================
@router.patch("/customers/{customer_id}/hourly-rate", tags=["Customers"])
async def update_customer_hourly_rate(customer_id: int, hourly_rate: float, user_id: Optional[int] = None):
"""
Opdater timepris for en kunde.
Args:
customer_id: Kunde ID
hourly_rate: Ny timepris i DKK (f.eks. 850.00)
"""
try:
from decimal import Decimal
# Validate rate
if hourly_rate < 0:
raise HTTPException(status_code=400, detail="Hourly rate must be positive")
rate_decimal = Decimal(str(hourly_rate))
# Update customer hourly rate
execute_update(
"UPDATE tmodule_customers SET hourly_rate = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
(rate_decimal, customer_id)
)
# Audit log
audit.log_event(
entity_type="customer",
entity_id=str(customer_id),
event_type="hourly_rate_updated",
details={"hourly_rate": float(hourly_rate)},
user_id=user_id
)
# Return updated customer
customer = execute_query(
"SELECT id, name, hourly_rate FROM tmodule_customers WHERE id = %s",
(customer_id,),
fetchone=True
)
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
return {
"customer_id": customer_id,
"name": customer['name'],
"hourly_rate": float(customer['hourly_rate']) if customer['hourly_rate'] else None,
"updated": True
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error updating hourly rate: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/customers/{customer_id}/time-card", tags=["Customers"])
async def toggle_customer_time_card(customer_id: int, enabled: bool, user_id: Optional[int] = None):
"""
Skift klippekort-status for kunde.
Klippekort-kunder faktureres eksternt og skal kunne skjules i godkendelsesflow.
"""
try:
# Update customer time card flag
execute_update(
"UPDATE tmodule_customers SET uses_time_card = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
(enabled, customer_id)
)
# Audit log
audit.log_event(
entity_type="customer",
entity_id=str(customer_id),
event_type="time_card_toggled",
details={"enabled": enabled},
user_id=user_id
)
# Return updated customer
customer = execute_query(
"SELECT * FROM tmodule_customers WHERE id = %s",
(customer_id,),
fetchone=True
)
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
return {
"customer_id": customer_id,
"name": customer['name'],
"uses_time_card": customer['uses_time_card'],
"updated": True
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error toggling time card: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/customers", tags=["Customers"])
async def list_customers(
include_time_card: bool = True,
only_with_entries: bool = False
):
"""
List kunder med filtrering.
Query params:
- include_time_card: Inkluder klippekort-kunder (default: true)
- only_with_entries: Kun kunder med pending tidsregistreringer (default: false)
"""
try:
if only_with_entries:
# Use view that includes entry counts
query = """
SELECT customer_id, customer_name, customer_vtiger_id, uses_time_card,
total_entries, pending_count
FROM tmodule_approval_stats
WHERE total_entries > 0
"""
if not include_time_card:
query += " AND uses_time_card = false"
query += " ORDER BY customer_name"
customers = execute_query(query)
else:
# Simple customer list
query = "SELECT * FROM tmodule_customers"
if not include_time_card:
query += " WHERE uses_time_card = false"
query += " ORDER BY name"
customers = execute_query(query)
return {"customers": customers, "total": len(customers)}
except Exception as e:
logger.error(f"❌ Error listing customers: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/customers/{customer_id}/times", tags=["Customers"])
async def get_customer_time_entries(customer_id: int, status: Optional[str] = None):
"""
Hent alle tidsregistreringer for en kunde.
Path params:
- customer_id: Kunde ID
Query params:
- status: Filtrer status (pending, approved, rejected, billed)
"""
try:
query = """
SELECT t.*,
COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title,
c.vtiger_id AS case_vtiger_id,
c.description AS case_description,
cust.name AS customer_name
FROM tmodule_times t
LEFT JOIN tmodule_cases c ON t.case_id = c.id
LEFT JOIN tmodule_customers cust ON t.customer_id = cust.id
WHERE t.customer_id = %s
"""
params = [customer_id]
if status:
query += " AND t.status = %s"
params.append(status)
query += " ORDER BY t.worked_date DESC, t.id DESC"
times = execute_query(query, tuple(params))
return {"times": times, "total": len(times)}
except Exception as e:
logger.error(f"❌ Error getting customer time entries: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/admin/uninstall", response_model=TModuleUninstallResult, tags=["Admin"])
async def uninstall_module(
request: TModuleUninstallRequest,

View File

@ -19,6 +19,7 @@ Safety Flags:
import logging
import hashlib
import json
import asyncio
from datetime import datetime
from typing import List, Dict, Optional, Any
from decimal import Decimal
@ -210,10 +211,49 @@ class TimeTrackingVTigerService:
logger.error(f"❌ vTiger connection error: {e}")
return False
async def _fetch_user_name(self, user_id: str) -> str:
"""
Fetch user name from vTiger using retrieve API.
Args:
user_id: vTiger user ID (e.g., "19x1")
Returns:
User's full name or user_id if not found
"""
try:
user_data = await self._retrieve(user_id)
if not user_data:
return user_id
# Build full name from first + last, fallback to username
first_name = user_data.get('first_name', '').strip()
last_name = user_data.get('last_name', '').strip()
user_name = user_data.get('user_name', '').strip()
if first_name and last_name:
return f"{first_name} {last_name}"
elif first_name:
return first_name
elif last_name:
return last_name
elif user_name:
return user_name
else:
return user_id
except Exception as e:
logger.debug(f"Could not fetch user {user_id}: {e}")
return user_id
return False
async def sync_customers(self, limit: int = 1000) -> Dict[str, int]:
"""
Sync Accounts (customers) from vTiger to tmodule_customers.
Uses ID-based pagination to fetch all accounts.
Returns: {imported: X, updated: Y, skipped: Z}
"""
logger.info("🔍 Syncing customers from vTiger...")
@ -221,14 +261,36 @@ class TimeTrackingVTigerService:
stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0}
try:
# Query vTiger for active accounts
# Start with simplest query to debug
query = "SELECT * FROM Accounts;"
accounts = await self._query(query)
# Fetch ALL accounts using pagination (vTiger has 200 record limit)
all_accounts = []
last_id = None
page = 1
logger.info(f"📥 Fetched {len(accounts)} accounts from vTiger")
while True:
if last_id:
query = f"SELECT * FROM Accounts WHERE id > '{last_id}' ORDER BY id LIMIT 200;"
else:
query = "SELECT * FROM Accounts ORDER BY id LIMIT 200;"
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:
vtiger_id = account.get('id', '')
if not vtiger_id:
@ -252,16 +314,18 @@ class TimeTrackingVTigerService:
logger.debug(f"⏭️ No changes for customer {vtiger_id}")
stats["skipped"] += 1
continue
if existing:
# Update existing
execute_update(
"""UPDATE tmodule_customers
SET name = %s, email = %s, vtiger_data = %s::jsonb,
sync_hash = %s, last_synced_at = CURRENT_TIMESTAMP
SET name = %s, email = %s, economic_customer_number = %s,
vtiger_data = %s::jsonb, sync_hash = %s,
last_synced_at = CURRENT_TIMESTAMP
WHERE vtiger_id = %s""",
(
account.get('accountname', 'Unknown'),
account.get('email1', None),
int(account.get('cf_854')) if account.get('cf_854') else None,
json.dumps(account),
data_hash,
vtiger_id
@ -273,12 +337,14 @@ class TimeTrackingVTigerService:
# Insert new
execute_insert(
"""INSERT INTO tmodule_customers
(vtiger_id, name, email, vtiger_data, sync_hash, last_synced_at)
VALUES (%s, %s, %s, %s::jsonb, %s, CURRENT_TIMESTAMP)""",
(vtiger_id, name, email, economic_customer_number,
vtiger_data, sync_hash, last_synced_at)
VALUES (%s, %s, %s, %s, %s::jsonb, %s, CURRENT_TIMESTAMP)""",
(
vtiger_id,
account.get('accountname', 'Unknown'),
account.get('email1', None),
int(account.get('cf_854')) if account.get('cf_854') else None,
json.dumps(account),
data_hash
)
@ -297,13 +363,20 @@ class TimeTrackingVTigerService:
logger.error(f"❌ Customer sync failed: {e}")
raise
async def sync_cases(self, limit: int = 5000) -> Dict[str, int]:
async def sync_cases(self, limit: int = 5000, fetch_comments: bool = False) -> Dict[str, int]:
"""
Sync HelpDesk tickets (cases) from vTiger to tmodule_cases.
Args:
limit: Maximum number of cases to sync
fetch_comments: Whether to fetch ModComments for each case (slow - rate limited)
Returns: {imported: X, updated: Y, skipped: Z}
"""
logger.info(f"🔍 Syncing up to {limit} cases from vTiger...")
if fetch_comments:
logger.info(f"🔍 Syncing up to {limit} cases from vTiger WITH comments (slow)...")
else:
logger.info(f"🔍 Syncing up to {limit} cases from vTiger WITHOUT comments (fast)...")
stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0}
@ -363,7 +436,21 @@ class TimeTrackingVTigerService:
continue
customer_id = customer['id']
data_hash = self._calculate_hash(ticket)
# Fetch internal comments for this case (with rate limiting) - ONLY if enabled
internal_comments = []
if fetch_comments:
internal_comments = await self._get_case_comments(vtiger_id)
# Small delay to avoid rate limiting (vTiger allows ~2-3 requests/sec)
await asyncio.sleep(0.4) # 400ms between comment fetches
# Merge comments into ticket data before storing
ticket_with_comments = ticket.copy()
if internal_comments:
ticket_with_comments['internal_comments'] = internal_comments
# Calculate hash AFTER adding comments (so changes to comments trigger update)
data_hash = self._calculate_hash(ticket_with_comments)
# Check if exists
existing = execute_query(
@ -392,7 +479,7 @@ class TimeTrackingVTigerService:
ticket.get('ticketstatus', None),
ticket.get('ticketpriorities', None),
'HelpDesk',
json.dumps(ticket),
json.dumps(ticket_with_comments),
data_hash,
vtiger_id
)
@ -413,7 +500,7 @@ class TimeTrackingVTigerService:
ticket.get('ticketstatus', None),
ticket.get('ticketpriorities', None),
'HelpDesk',
json.dumps(ticket),
json.dumps(ticket_with_comments),
data_hash
)
)
@ -430,6 +517,86 @@ class TimeTrackingVTigerService:
logger.error(f"❌ Case sync failed: {e}")
raise
async def _get_case_comments(self, case_id: str) -> List[Dict]:
"""
Fetch all ModComments (internal comments) for a specific case from vTiger.
Args:
case_id: vTiger case ID (format: "32x1234")
Returns:
List of comment dicts with structure: {text, author, date, created_at}
Sorted by creation date (newest first)
"""
try:
# Query ModComments where related_to = case_id
query = f"SELECT * FROM ModComments WHERE related_to = '{case_id}' ORDER BY createdtime DESC;"
comments = await self._query(query)
if not comments:
return []
# Transform vTiger format to internal format
formatted_comments = []
for comment in comments:
formatted_comments.append({
"text": comment.get("commentcontent", ""),
"author": comment.get("assigned_user_id", "Unknown"), # Will be user ID - could enhance with name lookup
"date": comment.get("createdtime", "")[:10], # Format: YYYY-MM-DD from YYYY-MM-DD HH:MM:SS
"created_at": comment.get("createdtime", "")
})
logger.info(f"📝 Fetched {len(formatted_comments)} comments for case {case_id}")
return formatted_comments
except HTTPException as e:
# Rate limit or API error - log but don't fail sync
if "429" in str(e.detail) or "TOO_MANY_REQUESTS" in str(e.detail):
logger.warning(f"⚠️ Rate limited fetching comments for case {case_id} - skipping")
else:
logger.error(f"❌ API error fetching comments for case {case_id}: {e.detail}")
return []
except Exception as e:
logger.error(f"❌ Failed to fetch comments for case {case_id}: {e}")
return [] # Return empty list on error - don't fail entire sync
async def sync_case_comments(self, case_vtiger_id: str) -> Dict[str, Any]:
"""
Sync comments for a specific case (for on-demand updates).
Args:
case_vtiger_id: vTiger case ID (format: "39x1234")
Returns:
Dict with success status and comment count
"""
try:
# Fetch comments
comments = await self._get_case_comments(case_vtiger_id)
if not comments:
return {"success": True, "comments": 0, "message": "No comments found"}
# Update case in database
execute_update(
"""UPDATE tmodule_cases
SET vtiger_data = jsonb_set(
COALESCE(vtiger_data, '{}'::jsonb),
'{internal_comments}',
%s::jsonb
),
last_synced_at = CURRENT_TIMESTAMP
WHERE vtiger_id = %s""",
(json.dumps(comments), case_vtiger_id)
)
logger.info(f"✅ Synced {len(comments)} comments for case {case_vtiger_id}")
return {"success": True, "comments": len(comments), "message": f"Synced {len(comments)} comments"}
except Exception as e:
logger.error(f"❌ Failed to sync comments for case {case_vtiger_id}: {e}")
return {"success": False, "comments": 0, "error": str(e)}
async def sync_time_entries(self, limit: int = 3000) -> Dict[str, int]:
"""
Sync time entries from vTiger Timelog module to tmodule_times.
@ -438,13 +605,24 @@ class TimeTrackingVTigerService:
- timelognumber: Unique ID (TL1234)
- duration: Time in seconds
- relatedto: Reference to Case/Account
- isbillable: Billable flag
- is_billable: '1' = yes, '0' = no
- cf_timelog_invoiced: '1' = has been invoiced
We only sync entries where:
- relatedto is not empty (linked to a Case or Account)
- Has valid duration > 0
NOTE: is_billable and cf_timelog_invoiced fields are not reliably populated in vTiger,
so we sync all timelogs and let the approval workflow decide what to bill.
"""
logger.info(f"🔍 Syncing up to {limit} time entries from vTiger Timelog...")
logger.info(f"🔍 Syncing all timelogs from vTiger with valid relatedto...")
stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0}
try:
# Cache for user names (avoid fetching same user multiple times)
user_name_cache = {}
# vTiger API doesn't support OFFSET - use id-based pagination instead
all_timelogs = []
last_id = "0x0" # Start from beginning
@ -453,7 +631,8 @@ class TimeTrackingVTigerService:
for batch_num in range(max_batches):
# Use id > last_id for pagination (vTiger format: 43x1234)
query = f"SELECT * FROM Timelog WHERE timelog_status = 'Completed' AND id > '{last_id}' ORDER BY id LIMIT {batch_size};"
# NOTE: vTiger query API ignores WHERE on custom fields, so we fetch all and filter later
query = f"SELECT * FROM Timelog WHERE id > '{last_id}' ORDER BY id LIMIT {batch_size};"
batch = await self._query(query)
if not batch: # No more records
@ -470,8 +649,15 @@ class TimeTrackingVTigerService:
if len(all_timelogs) >= limit: # Reached limit
break
logger.info(f"✅ Total fetched: {len(all_timelogs)} Timelog entries from vTiger")
# We don't filter here - the existing code already filters by:
# 1. duration > 0
# 2. relatedto not empty
# These filters happen in the processing loop below
timelogs = all_timelogs[:limit] # Trim to requested limit
logger.info(f"✅ Total fetched: {len(timelogs)} Timelog entries from vTiger")
logger.info(f"📊 Processing {len(timelogs)} timelogs...")
# NOTE: retrieve API is too slow for batch operations (1500+ individual calls)
# We'll work with query data and accept that relatedto might be empty for some
@ -494,36 +680,47 @@ class TimeTrackingVTigerService:
# Get related entity (Case or Account)
related_to = timelog.get('relatedto', '')
if not related_to:
logger.warning(f"⚠️ Timelog {vtiger_id} has no relatedto - RAW DATA: {timelog}")
stats["skipped"] += 1
continue
case_id = None
customer_id = None
# Try to find case first, then account
case = execute_query(
"SELECT id, customer_id FROM tmodule_cases WHERE vtiger_id = %s",
(related_to,),
fetchone=True
)
if case:
case_id = case['id']
customer_id = case['customer_id']
else:
# Try to find customer directly
customer = execute_query(
"SELECT id FROM tmodule_customers WHERE vtiger_id = %s",
if related_to:
# Try to find case first, then account
case = execute_query(
"SELECT id, customer_id FROM tmodule_cases WHERE vtiger_id = %s",
(related_to,),
fetchone=True
)
if not customer:
logger.debug(f"⏭️ Related entity {related_to} not found")
stats["skipped"] += 1
continue
if case:
case_id = case['id']
customer_id = case['customer_id']
else:
# Try to find customer directly
customer = execute_query(
"SELECT id FROM tmodule_customers WHERE vtiger_id = %s",
(related_to,),
fetchone=True
)
customer_id = customer['id']
case_id = None # No specific case, just customer
if customer:
customer_id = customer['id']
case_id = None # No specific case, just customer
else:
logger.debug(f"⏭️ Related entity {related_to} not found in our database - will skip")
stats["skipped"] += 1
continue
# If no customer found at all, skip this timelog
if not customer_id:
logger.warning(f"⚠️ Timelog {vtiger_id} has no valid customer reference - skipping")
stats["skipped"] += 1
continue
# Get user name with caching
assigned_user_id = timelog.get('assigned_user_id', '')
if assigned_user_id and assigned_user_id not in user_name_cache:
user_name_cache[assigned_user_id] = await self._fetch_user_name(assigned_user_id)
user_name = user_name_cache.get(assigned_user_id, assigned_user_id)
data_hash = self._calculate_hash(timelog)
@ -550,7 +747,7 @@ class TimeTrackingVTigerService:
timelog.get('name', ''),
hours,
timelog.get('startedon', None),
timelog.get('assigned_user_id', None),
user_name,
timelog.get('isbillable', '0') == '1',
json.dumps(timelog),
data_hash,
@ -578,7 +775,7 @@ class TimeTrackingVTigerService:
timelog.get('name', ''),
hours,
timelog.get('startedon', None),
timelog.get('assigned_user_id', None),
user_name,
timelog.get('isbillable', '0') == '1',
json.dumps(timelog),
data_hash
@ -599,14 +796,22 @@ class TimeTrackingVTigerService:
async def full_sync(
self,
user_id: Optional[int] = None
user_id: Optional[int] = None,
fetch_comments: bool = False
) -> TModuleSyncStats:
"""
Perform full sync of all data from vTiger.
Order: Customers -> Cases -> Time Entries (dependencies)
Args:
user_id: User performing the sync
fetch_comments: Whether to fetch ModComments (slow - adds ~0.4s per case)
"""
logger.info("🚀 Starting FULL vTiger sync...")
if fetch_comments:
logger.info("🚀 Starting FULL vTiger sync WITH comments (this will be slow)...")
else:
logger.info("🚀 Starting FULL vTiger sync WITHOUT comments (fast mode)...")
start_time = datetime.now()
@ -623,7 +828,7 @@ class TimeTrackingVTigerService:
# Sync in order of dependencies
customer_stats = await self.sync_customers()
case_stats = await self.sync_cases()
case_stats = await self.sync_cases(fetch_comments=fetch_comments)
time_stats = await self.sync_time_entries()
end_time = datetime.now()

View File

@ -7,7 +7,7 @@ Brugeren godkender én tidsregistrering ad gangen.
"""
import logging
from typing import Optional
from typing import Optional, List, Dict, Any
from decimal import Decimal
from datetime import datetime
@ -62,13 +62,15 @@ class WizardService:
@staticmethod
def get_next_pending_entry(
customer_id: Optional[int] = None
customer_id: Optional[int] = None,
exclude_time_card: bool = True
) -> TModuleWizardNextEntry:
"""
Hent næste pending tidsregistrering til godkendelse.
Args:
customer_id: Valgfri - filtrer til specifik kunde
exclude_time_card: Ekskluder klippekort-kunder (default: true)
Returns:
TModuleWizardNextEntry med has_next=True hvis der er flere
@ -84,7 +86,16 @@ class WizardService:
result = execute_query(query, (customer_id,), fetchone=True)
else:
# Hent næste generelt
query = "SELECT * FROM tmodule_next_pending LIMIT 1"
if exclude_time_card:
query = """
SELECT np.* FROM tmodule_next_pending np
JOIN tmodule_customers c ON np.customer_id = c.id
WHERE c.uses_time_card = false
LIMIT 1
"""
else:
query = "SELECT * FROM tmodule_next_pending LIMIT 1"
result = execute_query(query, fetchone=True)
if not result:
@ -281,6 +292,98 @@ class WizardService:
logger.error(f"❌ Error rejecting time entry: {e}")
raise HTTPException(status_code=500, detail=str(e))
@staticmethod
def approve_case_entries(
case_id: int,
user_id: Optional[int] = None,
exclude_time_card: bool = True
) -> Dict[str, Any]:
"""
Bulk-godkend alle pending tidsregistreringer for en case.
Args:
case_id: Case ID
user_id: ID brugeren der godkender
exclude_time_card: Ekskluder klippekort-kunder
Returns:
Dict med statistik: approved_count, total_hours, etc.
"""
try:
# Hent alle pending entries for case
entries = WizardService.get_case_entries(case_id, exclude_time_card)
if not entries:
return {
"approved_count": 0,
"total_hours": 0.0,
"case_id": case_id,
"entries": []
}
approved_entries = []
total_hours = 0.0
for entry in entries:
# Auto-approve med samme timer som original (eller afrundet hvis enabled)
from app.core.config import settings
from decimal import Decimal
approved_hours = Decimal(str(entry.original_hours))
# Afrund hvis enabled
if settings.TIMETRACKING_AUTO_ROUND:
increment = Decimal(str(settings.TIMETRACKING_ROUND_INCREMENT))
if settings.TIMETRACKING_ROUND_METHOD == "up":
# Afrund op
approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_UP') * increment
elif settings.TIMETRACKING_ROUND_METHOD == "down":
# Afrund ned
approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_DOWN') * increment
else:
# Nærmeste
approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_HALF_UP') * increment
# Godkend entry
approval = TModuleTimeApproval(
time_id=entry.id,
approved_hours=float(approved_hours)
)
approved = WizardService.approve_time_entry(approval, user_id)
approved_entries.append({
"id": approved.id,
"original_hours": float(approved.original_hours),
"approved_hours": float(approved.approved_hours)
})
total_hours += float(approved.approved_hours)
# Log bulk approval
audit.log_event(
entity_type="case",
entity_id=str(case_id),
event_type="bulk_approval",
details={
"approved_count": len(approved_entries),
"total_hours": total_hours
},
user_id=user_id
)
return {
"approved_count": len(approved_entries),
"total_hours": total_hours,
"case_id": case_id,
"entries": approved_entries
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error bulk approving case: {e}")
raise HTTPException(status_code=500, detail=str(e))
@staticmethod
def get_customer_progress(customer_id: int) -> TModuleWizardProgress:
"""Hent wizard progress for en kunde"""
@ -296,7 +399,7 @@ class WizardService:
if stats.pending_count > 0:
query = """
SELECT DISTINCT c.id, c.title
SELECT c.id, c.title
FROM tmodule_times t
JOIN tmodule_cases c ON t.case_id = c.id
WHERE t.customer_id = %s AND t.status = 'pending'
@ -325,6 +428,155 @@ class WizardService:
logger.error(f"❌ Error getting customer progress: {e}")
raise HTTPException(status_code=500, detail=str(e))
@staticmethod
def get_case_entries(
case_id: int,
exclude_time_card: bool = True
) -> List[TModuleTimeWithContext]:
"""
Hent alle pending tidsregistreringer for en specifik case.
Bruges til at vise alle timelogs i samme case samtidig.
Args:
case_id: Case ID
exclude_time_card: Ekskluder klippekort-kunder
Returns:
Liste af tidsregistreringer for casen
"""
try:
if exclude_time_card:
query = """
SELECT t.id, t.vtiger_id, t.case_id, t.customer_id, t.description,
t.original_hours, t.worked_date, t.user_name, t.status,
t.approved_hours, t.rounded_to, t.approval_note, t.billable,
t.approved_at, t.approved_by, t.vtiger_data, t.sync_hash,
t.created_at, t.updated_at, t.last_synced_at,
COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title,
c.description AS case_description,
c.status AS case_status,
c.vtiger_id AS case_vtiger_id,
cust.name AS customer_name,
cust.hourly_rate AS customer_rate,
CONCAT(cont.first_name, ' ', cont.last_name) AS contact_name,
cont.user_company AS contact_company,
c.vtiger_data AS case_vtiger_data
FROM tmodule_times t
JOIN tmodule_cases c ON t.case_id = c.id
JOIN tmodule_customers cust ON t.customer_id = cust.id
LEFT JOIN contacts cont ON cont.vtiger_id = c.vtiger_data->>'contact_id'
WHERE t.case_id = %s
AND t.status = 'pending'
AND t.billable = true
AND t.vtiger_data->>'cf_timelog_invoiced' = '0'
AND cust.uses_time_card = false
ORDER BY t.worked_date, t.id
"""
else:
query = """
SELECT t.id, t.vtiger_id, t.case_id, t.customer_id, t.description,
t.original_hours, t.worked_date, t.user_name, t.status,
t.approved_hours, t.rounded_to, t.approval_note, t.billable,
t.approved_at, t.approved_by, t.vtiger_data, t.sync_hash,
t.created_at, t.updated_at, t.last_synced_at,
COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title,
c.description AS case_description,
c.status AS case_status,
c.vtiger_id AS case_vtiger_id,
cust.name AS customer_name,
cust.hourly_rate AS customer_rate,
CONCAT(cont.first_name, ' ', cont.last_name) AS contact_name,
cont.user_company AS contact_company,
c.vtiger_data AS case_vtiger_data
FROM tmodule_times t
JOIN tmodule_cases c ON t.case_id = c.id
JOIN tmodule_customers cust ON t.customer_id = cust.id
LEFT JOIN contacts cont ON cont.vtiger_id = c.vtiger_data->>'contact_id'
WHERE t.case_id = %s
AND t.status = 'pending'
AND t.billable = true
AND t.vtiger_data->>'cf_timelog_invoiced' = '0'
ORDER BY t.worked_date, t.id
"""
results = execute_query(query, (case_id,))
return [TModuleTimeWithContext(**row) for row in results]
except Exception as e:
logger.error(f"❌ Error getting case entries: {e}")
raise HTTPException(status_code=500, detail=str(e))
@staticmethod
def get_case_details(case_id: int) -> Dict[str, Any]:
"""
Hent komplet case information inkl. alle timelogs og kommentarer.
Returns:
Dict med case info, timelogs (alle statuses), og kommentarer fra vtiger_data
"""
try:
# Hent case info
case_query = """
SELECT id, vtiger_id, title, description, status,
vtiger_data, customer_id
FROM tmodule_cases
WHERE id = %s
"""
case = execute_query(case_query, (case_id,), fetchone=True)
if not case:
raise HTTPException(status_code=404, detail="Case not found")
# Hent ALLE timelogs for casen (ikke kun pending)
timelogs_query = """
SELECT t.*,
COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title,
c.status AS case_status,
c.vtiger_id AS case_vtiger_id,
cust.name AS customer_name,
cust.hourly_rate AS customer_rate
FROM tmodule_times t
JOIN tmodule_cases c ON t.case_id = c.id
JOIN tmodule_customers cust ON t.customer_id = cust.id
WHERE t.case_id = %s
ORDER BY t.worked_date DESC, t.created_at DESC
"""
timelogs = execute_query(timelogs_query, (case_id,))
# Parse case comments from vtiger_data JSON
case_comments = []
if case.get('vtiger_data'):
vtiger_data = case['vtiger_data']
# vTiger gemmer comments som en array i JSON
if isinstance(vtiger_data, dict):
raw_comments = vtiger_data.get('comments', []) or vtiger_data.get('modcomments', [])
for comment in raw_comments:
if isinstance(comment, dict):
case_comments.append({
'id': comment.get('modcommentsid', comment.get('id')),
'comment_text': comment.get('commentcontent', comment.get('comment', '')),
'creator_name': comment.get('assigned_user_id', comment.get('creator', 'Unknown')),
'created_at': comment.get('createdtime', comment.get('created_at', ''))
})
return {
'case_id': case['id'],
'case_vtiger_id': case['vtiger_id'],
'case_title': case['title'],
'case_description': case['description'],
'case_status': case['status'],
'timelogs': [TModuleTimeWithContext(**t) for t in timelogs],
'case_comments': case_comments
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error getting case details: {e}")
raise HTTPException(status_code=500, detail=str(e))
# Singleton instance
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;
}
.nav-link:hover, .nav-link.active {
.nav-link:hover {
background-color: var(--accent-light);
color: var(--accent);
}
.nav-link.active {
background-color: var(--accent);
color: white;
font-weight: 600;
}
.card {
border: none;
border-radius: var(--border-radius);
@ -153,11 +159,23 @@
<a class="nav-link" href="/dashboard">Dashboard</a>
</li>
<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 class="nav-item">
<a class="nav-link active" href="/timetracking">
<i class="bi bi-clock-history"></i> Tidsregistrering
<a class="nav-link" href="/timetracking/wizard">
<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>
</li>
</ul>
@ -234,12 +252,19 @@
<div class="col-12">
<div class="card">
<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>
<h5 class="mb-1">Synkronisering</h5>
<p class="text-muted mb-0 small">Hent nye tidsregistreringer fra vTiger</p>
</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">
<i class="bi bi-arrow-repeat"></i> Synkroniser
</button>
@ -319,6 +344,9 @@
// Load customer stats
async function loadCustomerStats() {
try {
// Check if we should hide time card customers
const hideTimeCard = document.getElementById('hide-time-card')?.checked ?? true;
const response = await fetch('/api/v1/timetracking/wizard/stats');
if (!response.ok) {
@ -334,10 +362,15 @@
}
// Filtrer kunder uden tidsregistreringer eller kun med godkendte/afviste
const activeCustomers = customers.filter(c =>
let activeCustomers = customers.filter(c =>
c.pending_count > 0 || c.approved_count > 0
);
// Filtrer klippekort-kunder hvis toggled
if (hideTimeCard) {
activeCustomers = activeCustomers.filter(c => !c.uses_time_card);
}
if (activeCustomers.length === 0) {
document.getElementById('loading').classList.add('d-none');
document.getElementById('no-data').classList.remove('d-none');
@ -361,8 +394,13 @@
tbody.innerHTML = activeCustomers.map(customer => `
<tr>
<td>
<strong>${customer.customer_name || 'Ukendt kunde'}</strong>
<br><small class="text-muted">${customer.total_entries || 0} registreringer</small>
<div class="d-flex justify-content-between align-items-start">
<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>${parseFloat(customer.total_original_hours || 0).toFixed(1)}h</td>
<td class="text-center">
@ -375,9 +413,14 @@
<span class="badge bg-danger">${customer.rejected_count || 0}</span>
</td>
<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 ? `
<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
</a>
` : ''}
@ -462,13 +505,46 @@
const response = await fetch(`/api/v1/timetracking/orders/generate/${customerId}`, {
method: 'POST'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Fejl ved oprettelse af ordre');
}
const order = await response.json();
alert(`Ordre oprettet: ${order.order_number}\nTotal: ${order.total_amount} DKK`);
location.reload();
// Show success message and redirect to orders page
alert(`✅ Ordre oprettet!\n\nOrdrenummer: ${order.order_number}\nTotal: ${parseFloat(order.total_amount).toFixed(2)} DKK\n\nDu redirectes nu til ordre-siden...`);
// Redirect to orders page instead of reloading
window.location.href = '/timetracking/orders';
} catch (error) {
alert('Fejl ved oprettelse af ordre: ' + error.message);
alert('❌ Fejl ved oprettelse af ordre:\n\n' + error.message);
}
}
// Toggle time card for customer
async function toggleTimeCard(customerId, enabled) {
const action = enabled ? 'markere som klippekort' : 'fjerne klippekort-markering';
if (!confirm(`Er du sikker på at du vil ${action} for denne kunde?`)) {
return;
}
try {
const response = await fetch(`/api/v1/timetracking/customers/${customerId}/time-card?enabled=${enabled}`, {
method: 'PATCH'
});
if (!response.ok) {
throw new Error('Fejl ved opdatering');
}
// Reload customer list
loadCustomerStats();
} catch (error) {
alert('❌ Fejl: ' + error.message);
}
}

View File

@ -55,11 +55,17 @@
transition: all 0.2s;
}
.nav-link:hover, .nav-link.active {
.nav-link:hover {
background-color: var(--accent-light);
color: var(--accent);
}
.nav-link.active {
background-color: var(--accent);
color: white;
font-weight: 600;
}
.card {
border: none;
border-radius: var(--border-radius);
@ -131,11 +137,23 @@
<a class="nav-link" href="/dashboard">Dashboard</a>
</li>
<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 class="nav-item">
<a class="nav-link active" href="/timetracking">
<i class="bi bi-clock-history"></i> Tidsregistrering
<a class="nav-link" href="/timetracking/wizard">
<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>
</li>
</ul>
@ -391,36 +409,71 @@
<hr class="my-3">
<h6 class="mb-3">Ordrelinjer:</h6>
${order.lines.map(line => `
<div class="line-item">
<div class="d-flex justify-content-between mb-1">
<strong>${line.description}</strong>
<strong>${parseFloat(line.line_total).toFixed(2)} DKK</strong>
</div>
<div class="d-flex justify-content-between text-muted small">
<span>${line.quantity} timer × ${parseFloat(line.unit_price).toFixed(2)} DKK</span>
<span>${new Date(line.time_date).toLocaleDateString('da-DK')}</span>
${order.lines.map(line => {
// Parse data
const caseMatch = line.description.match(/CC(\d+)/);
const caseTitle = line.description.split(' - ').slice(1).join(' - ') || line.description;
const hours = parseFloat(line.quantity);
const unitPrice = parseFloat(line.unit_price);
const total = parseFloat(line.line_total);
const date = new Date(line.time_date).toLocaleDateString('da-DK');
// Extract contact name from case_contact if available
const contactName = line.case_contact || 'Ingen kontakt';
// Check if it's an on-site visit (udkørsel)
const isOnSite = line.description.toLowerCase().includes('udkørsel') ||
line.description.toLowerCase().includes('on-site');
return `
<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>
`).join('')}
`;
}).join('')}
${order.exported_to_economic ? `
${order.economic_draft_id ? `
<div class="alert alert-success mt-3 mb-0">
<i class="bi bi-check-circle"></i>
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>
` : ''}
`;
// Update export button
const exportBtn = document.getElementById('export-order-btn');
if (order.exported_to_economic) {
exportBtn.disabled = true;
exportBtn.innerHTML = '<i class="bi bi-check-circle"></i> Allerede eksporteret';
if (order.economic_draft_id) {
exportBtn.disabled = false;
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 {
exportBtn.disabled = false;
exportBtn.innerHTML = '<i class="bi bi-cloud-upload"></i> Eksporter til e-conomic';
exportBtn.onclick = exportCurrentOrder;
}
orderModal.show();
@ -432,25 +485,39 @@
// Export order
async function exportOrder(orderId) {
if (!confirm('Eksporter ordre til e-conomic?\n\nDette opretter en kladde-faktura i e-conomic.')) {
if (!confirm('Eksporter ordre til e-conomic?\n\nDette opretter en kladde-ordre i e-conomic.')) {
return;
}
try {
const response = await fetch(`/api/v1/timetracking/export/${orderId}`, {
method: 'POST'
const response = await fetch(`/api/v1/timetracking/export`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
order_id: orderId,
force: false
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Export failed');
}
const result = await response.json();
if (result.dry_run) {
alert(`DRY-RUN MODE:\n\nFakturaen ville blive oprettet med:\n- Kladde nr.: ${result.draft_invoice_number}\n- Total: ${result.total_amount} DKK\n\nIngen ændringer er foretaget i e-conomic.`);
alert(`DRY-RUN MODE:\n\n${result.message}\n\nDetails:\n- Ordre: ${result.details.order_number}\n- Kunde: ${result.details.customer_name}\n- Total: ${result.details.total_amount} DKK\n- Linjer: ${result.details.line_count}\n\n⚠ Ingen ændringer er foretaget i e-conomic (DRY-RUN mode aktiveret).`);
} else if (result.success) {
alert(`✅ Ordre eksporteret til e-conomic!\n\n- Draft Order nr.: ${result.economic_draft_id}\n- e-conomic ordre nr.: ${result.economic_order_number}\n\n${result.message}`);
loadOrders();
if (orderModal._isShown) {
orderModal.hide();
}
} else {
alert(`Ordre eksporteret!\n\nKladde nr.: ${result.draft_invoice_number}\nTotal: ${result.total_amount} DKK`);
}
loadOrders();
if (orderModal._isShown) {
orderModal.hide();
throw new Error(result.message || 'Export failed');
}
} catch (error) {
@ -464,6 +531,44 @@
exportOrder(currentOrderId);
}
}
// Force re-export order
async function exportOrderForce(orderId) {
try {
const response = await fetch(`/api/v1/timetracking/export`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
order_id: orderId,
force: true
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Export failed');
}
const result = await response.json();
if (result.dry_run) {
alert(`DRY-RUN MODE:\n\n${result.message}\n\n⚠ Ingen ændringer er foretaget i e-conomic (DRY-RUN mode aktiveret).`);
} else if (result.success) {
alert(`✅ Ordre re-eksporteret til e-conomic!\n\n- Draft Order nr.: ${result.economic_draft_id}\n- e-conomic ordre nr.: ${result.economic_order_number}`);
loadOrders();
if (orderModal._isShown) {
orderModal.hide();
}
} else {
throw new Error(result.message || 'Export failed');
}
} catch (error) {
alert('Fejl ved eksport: ' + error.message);
}
}
</script>
</body>
</html>

View File

@ -48,6 +48,20 @@ async def timetracking_wizard(request: Request):
return response
@router.get("/timetracking/customers", response_class=HTMLResponse, name="timetracking_customers")
async def timetracking_customers(request: Request):
"""Time Tracking Customers - manage hourly rates"""
template_path = TEMPLATE_DIR / "customers.html"
logger.info(f"Serving customers page from: {template_path}")
# Force no-cache headers
response = FileResponse(template_path)
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
@router.get("/timetracking/orders", response_class=HTMLResponse, name="timetracking_orders")
async def timetracking_orders(request: Request):
"""Order oversigt"""

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),
hub_customer_id INTEGER, -- Reference til customers.id (OPTIONAL, read-only)
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
sync_hash VARCHAR(64), -- SHA256 af data for change detection
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
@ -292,31 +293,35 @@ SELECT
c.id AS customer_id,
c.name AS customer_name,
c.vtiger_id AS customer_vtiger_id,
COUNT(t.id) AS total_entries,
COUNT(t.id) FILTER (WHERE t.status = 'pending') AS pending_count,
COUNT(t.id) FILTER (WHERE t.status = 'approved') AS approved_count,
COUNT(t.id) FILTER (WHERE t.status = 'rejected') AS rejected_count,
COUNT(t.id) FILTER (WHERE t.status = 'billed') AS billed_count,
SUM(t.original_hours) AS total_original_hours,
SUM(t.approved_hours) FILTER (WHERE t.status = 'approved') AS total_approved_hours,
MAX(t.worked_date) AS latest_work_date,
MAX(t.last_synced_at) AS last_sync
c.uses_time_card AS uses_time_card,
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.billable = true AND t.status = 'pending' AND t.vtiger_data->>'cf_timelog_invoiced' = '0') AS pending_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.billable = true AND t.status = 'rejected' AND t.vtiger_data->>'cf_timelog_invoiced' = '0') AS rejected_count,
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.original_hours) FILTER (WHERE t.billable = true AND t.vtiger_data->>'cf_timelog_invoiced' = '0') AS total_original_hours,
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.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
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)
CREATE OR REPLACE VIEW tmodule_next_pending AS
SELECT
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.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.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 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.';