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:
parent
34555d1e36
commit
a230071632
20
.env.bak
20
.env.bak
@ -38,7 +38,7 @@ GITHUB_REPO=ct/bmc_hub
|
||||
# OLLAMA AI INTEGRATION
|
||||
# =====================================================
|
||||
OLLAMA_ENDPOINT=http://ai_direct.cs.blaahund.dk
|
||||
OLLAMA_MODEL=qwen2.5:3b
|
||||
OLLAMA_MODEL=qwen2.5-coder:7b
|
||||
|
||||
# =====================================================
|
||||
# e-conomic Integration (Optional)
|
||||
@ -51,3 +51,21 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_agreement_grant_token_here
|
||||
# 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede ændringer
|
||||
ECONOMIC_READ_ONLY=true # Set to false ONLY after testing
|
||||
ECONOMIC_DRY_RUN=true # Set to false ONLY when ready for production writes
|
||||
|
||||
# vTiger CRM Integration (for Time Tracking Module)
|
||||
VTIGER_URL=https://bmcnetworks.od2.vtiger.com
|
||||
VTIGER_USERNAME=ct@bmcnetworks.dk
|
||||
VTIGER_API_KEY=bD8cW8zRFuKpPZ2S
|
||||
|
||||
# Time Tracking Module Settings
|
||||
TIMETRACKING_DEFAULT_HOURLY_RATE=1200.00 # Standard timepris i DKK
|
||||
TIMETRACKING_AUTO_ROUND=true
|
||||
TIMETRACKING_ROUND_INCREMENT=0.5
|
||||
TIMETRACKING_ROUND_METHOD=up
|
||||
|
||||
# Time Tracking Safety Switches
|
||||
TIMETRACKING_VTIGER_READ_ONLY=true
|
||||
TIMETRACKING_VTIGER_DRY_RUN=true
|
||||
TIMETRACKING_ECONOMIC_READ_ONLY=true
|
||||
TIMETRACKING_ECONOMIC_DRY_RUN=true
|
||||
|
||||
|
||||
45
.env.example
45
.env.example
@ -45,3 +45,48 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_agreement_grant_token_here
|
||||
# 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede ændringer
|
||||
ECONOMIC_READ_ONLY=true # Set to false ONLY after testing
|
||||
ECONOMIC_DRY_RUN=true # Set to false ONLY when ready for production writes
|
||||
|
||||
# =====================================================
|
||||
# vTiger CRM Integration (Optional)
|
||||
# =====================================================
|
||||
VTIGER_URL=https://your-instance.od2.vtiger.com
|
||||
VTIGER_USERNAME=your_username@yourdomain.com
|
||||
VTIGER_API_KEY=your_api_key_or_access_key
|
||||
VTIGER_PASSWORD=your_password_if_using_basic_auth
|
||||
|
||||
# =====================================================
|
||||
# TIME TRACKING MODULE - Isolated Settings
|
||||
# =====================================================
|
||||
|
||||
# vTiger Integration Safety Flags
|
||||
TIMETRACKING_VTIGER_READ_ONLY=true # 🚨 Bloker ALLE skrivninger til vTiger
|
||||
TIMETRACKING_VTIGER_DRY_RUN=true # 🚨 Log uden at synkronisere
|
||||
|
||||
# e-conomic Integration Safety Flags
|
||||
TIMETRACKING_ECONOMIC_READ_ONLY=true # 🚨 Bloker ALLE skrivninger til e-conomic
|
||||
TIMETRACKING_ECONOMIC_DRY_RUN=true # 🚨 Log uden at eksportere
|
||||
TIMETRACKING_EXPORT_TYPE=draft # draft|booked (draft er sikrest)
|
||||
|
||||
# Business Logic Settings
|
||||
TIMETRACKING_DEFAULT_HOURLY_RATE=850.00 # DKK pr. time (fallback hvis kunde ikke har rate)
|
||||
TIMETRACKING_AUTO_ROUND=true # Auto-afrund til nærmeste interval
|
||||
TIMETRACKING_ROUND_INCREMENT=0.5 # Afrundingsinterval (0.25, 0.5, 1.0)
|
||||
TIMETRACKING_ROUND_METHOD=up # up (op til), nearest (nærmeste), down (ned til)
|
||||
TIMETRACKING_REQUIRE_APPROVAL=true # Kræv manuel godkendelse
|
||||
|
||||
# =====================================================
|
||||
# OLLAMA AI Integration (Optional - for document extraction)
|
||||
# =====================================================
|
||||
OLLAMA_ENDPOINT=http://ai_direct.cs.blaahund.dk
|
||||
OLLAMA_MODEL=qwen2.5-coder:7b
|
||||
|
||||
# =====================================================
|
||||
# COMPANY INFO
|
||||
# =====================================================
|
||||
OWN_CVR=29522790 # BMC Denmark ApS - ignore when detecting vendors
|
||||
|
||||
# =====================================================
|
||||
# FILE UPLOAD
|
||||
# =====================================================
|
||||
UPLOAD_DIR=uploads
|
||||
MAX_FILE_SIZE_MB=50
|
||||
|
||||
39
README.md
39
README.md
@ -7,6 +7,12 @@ Et centralt management system til BMC Networks - håndterer kunder, services, ha
|
||||
## 🌟 Features
|
||||
|
||||
- **Customer Management**: Komplet kundedatabase med CRM integration
|
||||
- **Time Tracking Module**: vTiger integration med tidsregistrering og fakturering
|
||||
- Automatisk sync fra vTiger (billable timelogs)
|
||||
- Step-by-step godkendelses-wizard
|
||||
- Auto-afrunding til 0.5 timer
|
||||
- Klippekort-funktionalitet
|
||||
- e-conomic export (draft orders)
|
||||
- **Hardware Tracking**: Registrering og sporing af kundeudstyr
|
||||
- **Service Management**: Håndtering af services og abonnementer
|
||||
- **Billing Integration**: Automatisk fakturering via e-conomic
|
||||
@ -123,12 +129,43 @@ bmc_hub/
|
||||
|
||||
## 🔌 API Endpoints
|
||||
|
||||
### Main API
|
||||
- `GET /api/v1/customers` - List customers
|
||||
- `GET /api/v1/hardware` - List hardware
|
||||
- `GET /api/v1/billing/invoices` - List invoices
|
||||
- `GET /health` - Health check
|
||||
|
||||
Se fuld dokumentation: http://localhost:8000/api/docs
|
||||
### Time Tracking Module
|
||||
- `POST /api/v1/timetracking/sync` - Sync from vTiger (read-only)
|
||||
- `GET /api/v1/timetracking/wizard/next` - Get next pending timelog
|
||||
- `POST /api/v1/timetracking/wizard/approve/{id}` - Approve timelog
|
||||
- `POST /api/v1/timetracking/orders/generate` - Generate invoice order
|
||||
- `POST /api/v1/timetracking/export` - Export to e-conomic (with safety flags)
|
||||
- `GET /api/v1/timetracking/export/test-connection` - Test e-conomic connection
|
||||
|
||||
Se fuld dokumentation: http://localhost:8001/api/docs
|
||||
|
||||
## 🚨 e-conomic Write Mode
|
||||
|
||||
Time Tracking modulet kan eksportere ordrer til e-conomic med **safety-first approach**:
|
||||
|
||||
### Safety Flags (default: SAFE)
|
||||
```bash
|
||||
TIMETRACKING_ECONOMIC_READ_ONLY=true # Block all writes
|
||||
TIMETRACKING_ECONOMIC_DRY_RUN=true # Simulate writes (log only)
|
||||
```
|
||||
|
||||
### Enable Write Mode
|
||||
Se detaljeret guide: [docs/ECONOMIC_WRITE_MODE.md](docs/ECONOMIC_WRITE_MODE.md)
|
||||
|
||||
**Quick steps:**
|
||||
1. Test connection: `GET /api/v1/timetracking/export/test-connection`
|
||||
2. Test dry-run: Set `READ_ONLY=false`, keep `DRY_RUN=true`
|
||||
3. Export test order: `POST /api/v1/timetracking/export`
|
||||
4. Enable production: Set **both** flags to `false`
|
||||
5. Verify first order in e-conomic before bulk operations
|
||||
|
||||
**CRITICAL**: All customers must have `economic_customer_number` (synced from vTiger `cf_854` field).
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -44,7 +44,10 @@ router = APIRouter()
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/sync", response_model=TModuleSyncStats, tags=["Sync"])
|
||||
async def sync_from_vtiger(user_id: Optional[int] = None):
|
||||
async def sync_from_vtiger(
|
||||
user_id: Optional[int] = None,
|
||||
fetch_comments: bool = False
|
||||
):
|
||||
"""
|
||||
🔍 Synkroniser data fra vTiger (READ-ONLY).
|
||||
|
||||
@ -54,16 +57,53 @@ async def sync_from_vtiger(user_id: Optional[int] = None):
|
||||
- ModComments (tidsregistreringer)
|
||||
|
||||
Gemmes i tmodule_* tabeller (isoleret).
|
||||
|
||||
Args:
|
||||
user_id: ID på bruger der kører sync
|
||||
fetch_comments: Hent også interne kommentarer (langsomt - ~0.4s pr case)
|
||||
"""
|
||||
try:
|
||||
logger.info("🚀 Starting vTiger sync...")
|
||||
result = await vtiger_service.full_sync(user_id=user_id)
|
||||
result = await vtiger_service.full_sync(user_id=user_id, fetch_comments=fetch_comments)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Sync failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/sync/case/{case_id}/comments", tags=["Sync"])
|
||||
async def sync_case_comments(case_id: int):
|
||||
"""
|
||||
🔍 Synkroniser kommentarer for en specifik case fra vTiger.
|
||||
|
||||
Bruges til on-demand opdatering når man ser på en case i wizard.
|
||||
"""
|
||||
try:
|
||||
# Hent case fra database
|
||||
case = execute_query(
|
||||
"SELECT vtiger_id FROM tmodule_cases WHERE id = %s",
|
||||
(case_id,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail="Case not found")
|
||||
|
||||
# Sync comments
|
||||
result = await vtiger_service.sync_case_comments(case['vtiger_id'])
|
||||
|
||||
if not result['success']:
|
||||
raise HTTPException(status_code=500, detail=result.get('error', 'Failed to sync comments'))
|
||||
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to sync comments for case {case_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/sync/test-connection", tags=["Sync"])
|
||||
async def test_vtiger_connection():
|
||||
"""Test forbindelse til vTiger"""
|
||||
@ -93,37 +133,99 @@ async def get_all_customer_stats():
|
||||
|
||||
|
||||
@router.get("/wizard/next", response_model=TModuleWizardNextEntry, tags=["Wizard"])
|
||||
async def get_next_pending_entry(customer_id: Optional[int] = None):
|
||||
async def get_next_pending_entry(
|
||||
customer_id: Optional[int] = None,
|
||||
exclude_time_card: bool = True
|
||||
):
|
||||
"""
|
||||
Hent næste pending tidsregistrering til godkendelse.
|
||||
|
||||
Query params:
|
||||
- customer_id: Valgfri - filtrer til specifik kunde
|
||||
- customer_id: Filtrer til specifik kunde (optional)
|
||||
- exclude_time_card: Ekskluder klippekort-kunder (default: true)
|
||||
"""
|
||||
try:
|
||||
return wizard.get_next_pending_entry(customer_id=customer_id)
|
||||
return wizard.get_next_pending_entry(
|
||||
customer_id=customer_id,
|
||||
exclude_time_card=exclude_time_card
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/wizard/approve", response_model=TModuleTimeWithContext, tags=["Wizard"])
|
||||
@router.post("/wizard/approve/{time_id}", response_model=TModuleTimeWithContext, tags=["Wizard"])
|
||||
async def approve_time_entry(
|
||||
approval: TModuleTimeApproval,
|
||||
time_id: int,
|
||||
billable_hours: Optional[float] = None,
|
||||
hourly_rate: Optional[float] = None,
|
||||
rounding_method: Optional[str] = None,
|
||||
user_id: Optional[int] = None
|
||||
):
|
||||
"""
|
||||
Godkend en tidsregistrering.
|
||||
|
||||
Body:
|
||||
Path params:
|
||||
- time_id: ID på tidsregistreringen
|
||||
- approved_hours: Timer efter godkendelse (kan være afrundet)
|
||||
- rounded_to: Afrundingsinterval (0.5, 1.0, etc.)
|
||||
- approval_note: Valgfri note
|
||||
- billable: Skal faktureres? (default: true)
|
||||
|
||||
Body (optional):
|
||||
- billable_hours: Timer efter godkendelse (hvis ikke angivet, bruges original_hours med auto-rounding)
|
||||
- hourly_rate: Timepris i DKK (override customer rate)
|
||||
- rounding_method: "up", "down", "nearest" (override default)
|
||||
"""
|
||||
try:
|
||||
from app.core.config import settings
|
||||
from decimal import Decimal
|
||||
|
||||
# Hent timelog
|
||||
query = """
|
||||
SELECT t.*, c.title as case_title, c.status as case_status,
|
||||
cust.name as customer_name, cust.hourly_rate as customer_rate
|
||||
FROM tmodule_times t
|
||||
JOIN tmodule_cases c ON t.case_id = c.id
|
||||
JOIN tmodule_customers cust ON t.customer_id = cust.id
|
||||
WHERE t.id = %s
|
||||
"""
|
||||
entry = execute_query(query, (time_id,), fetchone=True)
|
||||
|
||||
if not entry:
|
||||
raise HTTPException(status_code=404, detail="Time entry not found")
|
||||
|
||||
# Beregn approved_hours
|
||||
if billable_hours is None:
|
||||
approved_hours = Decimal(str(entry['original_hours']))
|
||||
|
||||
# Auto-afrund hvis enabled
|
||||
if settings.TIMETRACKING_AUTO_ROUND:
|
||||
increment = Decimal(str(settings.TIMETRACKING_ROUND_INCREMENT))
|
||||
method = rounding_method or settings.TIMETRACKING_ROUND_METHOD
|
||||
|
||||
if method == "up":
|
||||
approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_UP') * increment
|
||||
elif method == "down":
|
||||
approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_DOWN') * increment
|
||||
else:
|
||||
approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_HALF_UP') * increment
|
||||
else:
|
||||
approved_hours = Decimal(str(billable_hours))
|
||||
|
||||
# Opdater med hourly_rate hvis angivet
|
||||
if hourly_rate is not None:
|
||||
execute_update(
|
||||
"UPDATE tmodule_times SET hourly_rate = %s WHERE id = %s",
|
||||
(Decimal(str(hourly_rate)), time_id)
|
||||
)
|
||||
|
||||
# Godkend
|
||||
approval = TModuleTimeApproval(
|
||||
time_id=time_id,
|
||||
approved_hours=float(approved_hours)
|
||||
)
|
||||
|
||||
return wizard.approve_time_entry(approval, user_id=user_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error approving entry: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@ -140,6 +242,59 @@ async def reject_time_entry(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/wizard/case/{case_id}/entries", response_model=List[TModuleTimeWithContext], tags=["Wizard"])
|
||||
async def get_case_entries(
|
||||
case_id: int,
|
||||
exclude_time_card: bool = True
|
||||
):
|
||||
"""
|
||||
Hent alle pending timelogs for en case.
|
||||
|
||||
Bruges til at vise alle tidsregistreringer i samme case grupperet.
|
||||
"""
|
||||
try:
|
||||
return wizard.get_case_entries(case_id, exclude_time_card=exclude_time_card)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/wizard/case/{case_id}/details", tags=["Wizard"])
|
||||
async def get_case_details(case_id: int):
|
||||
"""
|
||||
Hent komplet case information inkl. alle timelogs og kommentarer.
|
||||
|
||||
Returnerer:
|
||||
- case_id, case_title, case_description, case_status
|
||||
- timelogs: ALLE tidsregistreringer (pending, approved, rejected)
|
||||
- case_comments: Kommentarer fra vTiger
|
||||
"""
|
||||
try:
|
||||
return wizard.get_case_details(case_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/wizard/case/{case_id}/approve-all", tags=["Wizard"])
|
||||
async def approve_all_case_entries(
|
||||
case_id: int,
|
||||
user_id: Optional[int] = None,
|
||||
exclude_time_card: bool = True
|
||||
):
|
||||
"""
|
||||
Bulk-godkend alle pending timelogs for en case.
|
||||
|
||||
Afr under automatisk efter configured settings.
|
||||
"""
|
||||
try:
|
||||
return wizard.approve_case_entries(
|
||||
case_id=case_id,
|
||||
user_id=user_id,
|
||||
exclude_time_card=exclude_time_card
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/wizard/progress/{customer_id}", response_model=TModuleWizardProgress, tags=["Wizard"])
|
||||
async def get_customer_progress(customer_id: int):
|
||||
"""Hent wizard progress for en kunde"""
|
||||
@ -336,14 +491,223 @@ async def module_health():
|
||||
except Exception as e:
|
||||
logger.error(f"Health check error: {e}")
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"status": "unhealthy",
|
||||
"error": str(e)
|
||||
}
|
||||
status_code=500,
|
||||
content={"status": "error", "message": str(e)}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/config", tags=["Admin"])
|
||||
async def get_config():
|
||||
"""Hent modul konfiguration"""
|
||||
from app.core.config import settings
|
||||
|
||||
return {
|
||||
"default_hourly_rate": float(settings.TIMETRACKING_DEFAULT_HOURLY_RATE),
|
||||
"auto_round": settings.TIMETRACKING_AUTO_ROUND,
|
||||
"round_increment": float(settings.TIMETRACKING_ROUND_INCREMENT),
|
||||
"round_method": settings.TIMETRACKING_ROUND_METHOD,
|
||||
"vtiger_read_only": settings.TIMETRACKING_VTIGER_READ_ONLY,
|
||||
"vtiger_dry_run": settings.TIMETRACKING_VTIGER_DRY_RUN,
|
||||
"economic_read_only": settings.TIMETRACKING_ECONOMIC_READ_ONLY,
|
||||
"economic_dry_run": settings.TIMETRACKING_ECONOMIC_DRY_RUN
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CUSTOMER MANAGEMENT ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@router.patch("/customers/{customer_id}/hourly-rate", tags=["Customers"])
|
||||
async def update_customer_hourly_rate(customer_id: int, hourly_rate: float, user_id: Optional[int] = None):
|
||||
"""
|
||||
Opdater timepris for en kunde.
|
||||
|
||||
Args:
|
||||
customer_id: Kunde ID
|
||||
hourly_rate: Ny timepris i DKK (f.eks. 850.00)
|
||||
"""
|
||||
try:
|
||||
from decimal import Decimal
|
||||
|
||||
# Validate rate
|
||||
if hourly_rate < 0:
|
||||
raise HTTPException(status_code=400, detail="Hourly rate must be positive")
|
||||
|
||||
rate_decimal = Decimal(str(hourly_rate))
|
||||
|
||||
# Update customer hourly rate
|
||||
execute_update(
|
||||
"UPDATE tmodule_customers SET hourly_rate = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
|
||||
(rate_decimal, customer_id)
|
||||
)
|
||||
|
||||
# Audit log
|
||||
audit.log_event(
|
||||
entity_type="customer",
|
||||
entity_id=str(customer_id),
|
||||
event_type="hourly_rate_updated",
|
||||
details={"hourly_rate": float(hourly_rate)},
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
# Return updated customer
|
||||
customer = execute_query(
|
||||
"SELECT id, name, hourly_rate FROM tmodule_customers WHERE id = %s",
|
||||
(customer_id,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail="Customer not found")
|
||||
|
||||
return {
|
||||
"customer_id": customer_id,
|
||||
"name": customer['name'],
|
||||
"hourly_rate": float(customer['hourly_rate']) if customer['hourly_rate'] else None,
|
||||
"updated": True
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error updating hourly rate: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/customers/{customer_id}/time-card", tags=["Customers"])
|
||||
async def toggle_customer_time_card(customer_id: int, enabled: bool, user_id: Optional[int] = None):
|
||||
"""
|
||||
Skift klippekort-status for kunde.
|
||||
|
||||
Klippekort-kunder faktureres eksternt og skal kunne skjules i godkendelsesflow.
|
||||
"""
|
||||
try:
|
||||
# Update customer time card flag
|
||||
execute_update(
|
||||
"UPDATE tmodule_customers SET uses_time_card = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
|
||||
(enabled, customer_id)
|
||||
)
|
||||
|
||||
# Audit log
|
||||
audit.log_event(
|
||||
entity_type="customer",
|
||||
entity_id=str(customer_id),
|
||||
event_type="time_card_toggled",
|
||||
details={"enabled": enabled},
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
# Return updated customer
|
||||
customer = execute_query(
|
||||
"SELECT * FROM tmodule_customers WHERE id = %s",
|
||||
(customer_id,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail="Customer not found")
|
||||
|
||||
return {
|
||||
"customer_id": customer_id,
|
||||
"name": customer['name'],
|
||||
"uses_time_card": customer['uses_time_card'],
|
||||
"updated": True
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error toggling time card: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/customers", tags=["Customers"])
|
||||
async def list_customers(
|
||||
include_time_card: bool = True,
|
||||
only_with_entries: bool = False
|
||||
):
|
||||
"""
|
||||
List kunder med filtrering.
|
||||
|
||||
Query params:
|
||||
- include_time_card: Inkluder klippekort-kunder (default: true)
|
||||
- only_with_entries: Kun kunder med pending tidsregistreringer (default: false)
|
||||
"""
|
||||
try:
|
||||
if only_with_entries:
|
||||
# Use view that includes entry counts
|
||||
query = """
|
||||
SELECT customer_id, customer_name, customer_vtiger_id, uses_time_card,
|
||||
total_entries, pending_count
|
||||
FROM tmodule_approval_stats
|
||||
WHERE total_entries > 0
|
||||
"""
|
||||
|
||||
if not include_time_card:
|
||||
query += " AND uses_time_card = false"
|
||||
|
||||
query += " ORDER BY customer_name"
|
||||
|
||||
customers = execute_query(query)
|
||||
else:
|
||||
# Simple customer list
|
||||
query = "SELECT * FROM tmodule_customers"
|
||||
|
||||
if not include_time_card:
|
||||
query += " WHERE uses_time_card = false"
|
||||
|
||||
query += " ORDER BY name"
|
||||
|
||||
customers = execute_query(query)
|
||||
|
||||
return {"customers": customers, "total": len(customers)}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error listing customers: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/customers/{customer_id}/times", tags=["Customers"])
|
||||
async def get_customer_time_entries(customer_id: int, status: Optional[str] = None):
|
||||
"""
|
||||
Hent alle tidsregistreringer for en kunde.
|
||||
|
||||
Path params:
|
||||
- customer_id: Kunde ID
|
||||
|
||||
Query params:
|
||||
- status: Filtrer på status (pending, approved, rejected, billed)
|
||||
"""
|
||||
try:
|
||||
query = """
|
||||
SELECT t.*,
|
||||
COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title,
|
||||
c.vtiger_id AS case_vtiger_id,
|
||||
c.description AS case_description,
|
||||
cust.name AS customer_name
|
||||
FROM tmodule_times t
|
||||
LEFT JOIN tmodule_cases c ON t.case_id = c.id
|
||||
LEFT JOIN tmodule_customers cust ON t.customer_id = cust.id
|
||||
WHERE t.customer_id = %s
|
||||
"""
|
||||
|
||||
params = [customer_id]
|
||||
|
||||
if status:
|
||||
query += " AND t.status = %s"
|
||||
params.append(status)
|
||||
|
||||
query += " ORDER BY t.worked_date DESC, t.id DESC"
|
||||
|
||||
times = execute_query(query, tuple(params))
|
||||
|
||||
return {"times": times, "total": len(times)}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error getting customer time entries: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/admin/uninstall", response_model=TModuleUninstallResult, tags=["Admin"])
|
||||
async def uninstall_module(
|
||||
request: TModuleUninstallRequest,
|
||||
|
||||
@ -19,6 +19,7 @@ Safety Flags:
|
||||
import logging
|
||||
import hashlib
|
||||
import json
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Optional, Any
|
||||
from decimal import Decimal
|
||||
@ -210,10 +211,49 @@ class TimeTrackingVTigerService:
|
||||
logger.error(f"❌ vTiger connection error: {e}")
|
||||
return False
|
||||
|
||||
async def _fetch_user_name(self, user_id: str) -> str:
|
||||
"""
|
||||
Fetch user name from vTiger using retrieve API.
|
||||
|
||||
Args:
|
||||
user_id: vTiger user ID (e.g., "19x1")
|
||||
|
||||
Returns:
|
||||
User's full name or user_id if not found
|
||||
"""
|
||||
try:
|
||||
user_data = await self._retrieve(user_id)
|
||||
|
||||
if not user_data:
|
||||
return user_id
|
||||
|
||||
# Build full name from first + last, fallback to username
|
||||
first_name = user_data.get('first_name', '').strip()
|
||||
last_name = user_data.get('last_name', '').strip()
|
||||
user_name = user_data.get('user_name', '').strip()
|
||||
|
||||
if first_name and last_name:
|
||||
return f"{first_name} {last_name}"
|
||||
elif first_name:
|
||||
return first_name
|
||||
elif last_name:
|
||||
return last_name
|
||||
elif user_name:
|
||||
return user_name
|
||||
else:
|
||||
return user_id
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not fetch user {user_id}: {e}")
|
||||
return user_id
|
||||
return False
|
||||
|
||||
async def sync_customers(self, limit: int = 1000) -> Dict[str, int]:
|
||||
"""
|
||||
Sync Accounts (customers) from vTiger to tmodule_customers.
|
||||
|
||||
Uses ID-based pagination to fetch all accounts.
|
||||
|
||||
Returns: {imported: X, updated: Y, skipped: Z}
|
||||
"""
|
||||
logger.info("🔍 Syncing customers from vTiger...")
|
||||
@ -221,14 +261,36 @@ class TimeTrackingVTigerService:
|
||||
stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0}
|
||||
|
||||
try:
|
||||
# Query vTiger for active accounts
|
||||
# Start with simplest query to debug
|
||||
query = "SELECT * FROM Accounts;"
|
||||
accounts = await self._query(query)
|
||||
# Fetch ALL accounts using pagination (vTiger has 200 record limit)
|
||||
all_accounts = []
|
||||
last_id = None
|
||||
page = 1
|
||||
|
||||
logger.info(f"📥 Fetched {len(accounts)} accounts from vTiger")
|
||||
while True:
|
||||
if last_id:
|
||||
query = f"SELECT * FROM Accounts WHERE id > '{last_id}' ORDER BY id LIMIT 200;"
|
||||
else:
|
||||
query = "SELECT * FROM Accounts ORDER BY id LIMIT 200;"
|
||||
|
||||
accounts = await self._query(query)
|
||||
|
||||
if not accounts:
|
||||
break
|
||||
|
||||
all_accounts.extend(accounts)
|
||||
last_id = accounts[-1]['id']
|
||||
|
||||
logger.info(f"📥 Fetched page {page}: {len(accounts)} accounts (last_id: {last_id})")
|
||||
|
||||
# Safety: if we got less than 200, we're done
|
||||
if len(accounts) < 200:
|
||||
break
|
||||
|
||||
page += 1
|
||||
|
||||
for account in accounts:
|
||||
logger.info(f"📥 Total fetched: {len(all_accounts)} accounts from vTiger")
|
||||
|
||||
for account in all_accounts:
|
||||
try:
|
||||
vtiger_id = account.get('id', '')
|
||||
if not vtiger_id:
|
||||
@ -252,16 +314,18 @@ class TimeTrackingVTigerService:
|
||||
logger.debug(f"⏭️ No changes for customer {vtiger_id}")
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
|
||||
if existing:
|
||||
# Update existing
|
||||
execute_update(
|
||||
"""UPDATE tmodule_customers
|
||||
SET name = %s, email = %s, vtiger_data = %s::jsonb,
|
||||
sync_hash = %s, last_synced_at = CURRENT_TIMESTAMP
|
||||
SET name = %s, email = %s, economic_customer_number = %s,
|
||||
vtiger_data = %s::jsonb, sync_hash = %s,
|
||||
last_synced_at = CURRENT_TIMESTAMP
|
||||
WHERE vtiger_id = %s""",
|
||||
(
|
||||
account.get('accountname', 'Unknown'),
|
||||
account.get('email1', None),
|
||||
int(account.get('cf_854')) if account.get('cf_854') else None,
|
||||
json.dumps(account),
|
||||
data_hash,
|
||||
vtiger_id
|
||||
@ -273,12 +337,14 @@ class TimeTrackingVTigerService:
|
||||
# Insert new
|
||||
execute_insert(
|
||||
"""INSERT INTO tmodule_customers
|
||||
(vtiger_id, name, email, vtiger_data, sync_hash, last_synced_at)
|
||||
VALUES (%s, %s, %s, %s::jsonb, %s, CURRENT_TIMESTAMP)""",
|
||||
(vtiger_id, name, email, economic_customer_number,
|
||||
vtiger_data, sync_hash, last_synced_at)
|
||||
VALUES (%s, %s, %s, %s, %s::jsonb, %s, CURRENT_TIMESTAMP)""",
|
||||
(
|
||||
vtiger_id,
|
||||
account.get('accountname', 'Unknown'),
|
||||
account.get('email1', None),
|
||||
int(account.get('cf_854')) if account.get('cf_854') else None,
|
||||
json.dumps(account),
|
||||
data_hash
|
||||
)
|
||||
@ -297,13 +363,20 @@ class TimeTrackingVTigerService:
|
||||
logger.error(f"❌ Customer sync failed: {e}")
|
||||
raise
|
||||
|
||||
async def sync_cases(self, limit: int = 5000) -> Dict[str, int]:
|
||||
async def sync_cases(self, limit: int = 5000, fetch_comments: bool = False) -> Dict[str, int]:
|
||||
"""
|
||||
Sync HelpDesk tickets (cases) from vTiger to tmodule_cases.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of cases to sync
|
||||
fetch_comments: Whether to fetch ModComments for each case (slow - rate limited)
|
||||
|
||||
Returns: {imported: X, updated: Y, skipped: Z}
|
||||
"""
|
||||
logger.info(f"🔍 Syncing up to {limit} cases from vTiger...")
|
||||
if fetch_comments:
|
||||
logger.info(f"🔍 Syncing up to {limit} cases from vTiger WITH comments (slow)...")
|
||||
else:
|
||||
logger.info(f"🔍 Syncing up to {limit} cases from vTiger WITHOUT comments (fast)...")
|
||||
|
||||
stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0}
|
||||
|
||||
@ -363,7 +436,21 @@ class TimeTrackingVTigerService:
|
||||
continue
|
||||
|
||||
customer_id = customer['id']
|
||||
data_hash = self._calculate_hash(ticket)
|
||||
|
||||
# Fetch internal comments for this case (with rate limiting) - ONLY if enabled
|
||||
internal_comments = []
|
||||
if fetch_comments:
|
||||
internal_comments = await self._get_case_comments(vtiger_id)
|
||||
# Small delay to avoid rate limiting (vTiger allows ~2-3 requests/sec)
|
||||
await asyncio.sleep(0.4) # 400ms between comment fetches
|
||||
|
||||
# Merge comments into ticket data before storing
|
||||
ticket_with_comments = ticket.copy()
|
||||
if internal_comments:
|
||||
ticket_with_comments['internal_comments'] = internal_comments
|
||||
|
||||
# Calculate hash AFTER adding comments (so changes to comments trigger update)
|
||||
data_hash = self._calculate_hash(ticket_with_comments)
|
||||
|
||||
# Check if exists
|
||||
existing = execute_query(
|
||||
@ -392,7 +479,7 @@ class TimeTrackingVTigerService:
|
||||
ticket.get('ticketstatus', None),
|
||||
ticket.get('ticketpriorities', None),
|
||||
'HelpDesk',
|
||||
json.dumps(ticket),
|
||||
json.dumps(ticket_with_comments),
|
||||
data_hash,
|
||||
vtiger_id
|
||||
)
|
||||
@ -413,7 +500,7 @@ class TimeTrackingVTigerService:
|
||||
ticket.get('ticketstatus', None),
|
||||
ticket.get('ticketpriorities', None),
|
||||
'HelpDesk',
|
||||
json.dumps(ticket),
|
||||
json.dumps(ticket_with_comments),
|
||||
data_hash
|
||||
)
|
||||
)
|
||||
@ -430,6 +517,86 @@ class TimeTrackingVTigerService:
|
||||
logger.error(f"❌ Case sync failed: {e}")
|
||||
raise
|
||||
|
||||
async def _get_case_comments(self, case_id: str) -> List[Dict]:
|
||||
"""
|
||||
Fetch all ModComments (internal comments) for a specific case from vTiger.
|
||||
|
||||
Args:
|
||||
case_id: vTiger case ID (format: "32x1234")
|
||||
|
||||
Returns:
|
||||
List of comment dicts with structure: {text, author, date, created_at}
|
||||
Sorted by creation date (newest first)
|
||||
"""
|
||||
try:
|
||||
# Query ModComments where related_to = case_id
|
||||
query = f"SELECT * FROM ModComments WHERE related_to = '{case_id}' ORDER BY createdtime DESC;"
|
||||
comments = await self._query(query)
|
||||
|
||||
if not comments:
|
||||
return []
|
||||
|
||||
# Transform vTiger format to internal format
|
||||
formatted_comments = []
|
||||
for comment in comments:
|
||||
formatted_comments.append({
|
||||
"text": comment.get("commentcontent", ""),
|
||||
"author": comment.get("assigned_user_id", "Unknown"), # Will be user ID - could enhance with name lookup
|
||||
"date": comment.get("createdtime", "")[:10], # Format: YYYY-MM-DD from YYYY-MM-DD HH:MM:SS
|
||||
"created_at": comment.get("createdtime", "")
|
||||
})
|
||||
|
||||
logger.info(f"📝 Fetched {len(formatted_comments)} comments for case {case_id}")
|
||||
return formatted_comments
|
||||
|
||||
except HTTPException as e:
|
||||
# Rate limit or API error - log but don't fail sync
|
||||
if "429" in str(e.detail) or "TOO_MANY_REQUESTS" in str(e.detail):
|
||||
logger.warning(f"⚠️ Rate limited fetching comments for case {case_id} - skipping")
|
||||
else:
|
||||
logger.error(f"❌ API error fetching comments for case {case_id}: {e.detail}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to fetch comments for case {case_id}: {e}")
|
||||
return [] # Return empty list on error - don't fail entire sync
|
||||
|
||||
async def sync_case_comments(self, case_vtiger_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Sync comments for a specific case (for on-demand updates).
|
||||
|
||||
Args:
|
||||
case_vtiger_id: vTiger case ID (format: "39x1234")
|
||||
|
||||
Returns:
|
||||
Dict with success status and comment count
|
||||
"""
|
||||
try:
|
||||
# Fetch comments
|
||||
comments = await self._get_case_comments(case_vtiger_id)
|
||||
|
||||
if not comments:
|
||||
return {"success": True, "comments": 0, "message": "No comments found"}
|
||||
|
||||
# Update case in database
|
||||
execute_update(
|
||||
"""UPDATE tmodule_cases
|
||||
SET vtiger_data = jsonb_set(
|
||||
COALESCE(vtiger_data, '{}'::jsonb),
|
||||
'{internal_comments}',
|
||||
%s::jsonb
|
||||
),
|
||||
last_synced_at = CURRENT_TIMESTAMP
|
||||
WHERE vtiger_id = %s""",
|
||||
(json.dumps(comments), case_vtiger_id)
|
||||
)
|
||||
|
||||
logger.info(f"✅ Synced {len(comments)} comments for case {case_vtiger_id}")
|
||||
return {"success": True, "comments": len(comments), "message": f"Synced {len(comments)} comments"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to sync comments for case {case_vtiger_id}: {e}")
|
||||
return {"success": False, "comments": 0, "error": str(e)}
|
||||
|
||||
async def sync_time_entries(self, limit: int = 3000) -> Dict[str, int]:
|
||||
"""
|
||||
Sync time entries from vTiger Timelog module to tmodule_times.
|
||||
@ -438,13 +605,24 @@ class TimeTrackingVTigerService:
|
||||
- timelognumber: Unique ID (TL1234)
|
||||
- duration: Time in seconds
|
||||
- relatedto: Reference to Case/Account
|
||||
- isbillable: Billable flag
|
||||
- is_billable: '1' = yes, '0' = no
|
||||
- cf_timelog_invoiced: '1' = has been invoiced
|
||||
|
||||
We only sync entries where:
|
||||
- relatedto is not empty (linked to a Case or Account)
|
||||
- Has valid duration > 0
|
||||
|
||||
NOTE: is_billable and cf_timelog_invoiced fields are not reliably populated in vTiger,
|
||||
so we sync all timelogs and let the approval workflow decide what to bill.
|
||||
"""
|
||||
logger.info(f"🔍 Syncing up to {limit} time entries from vTiger Timelog...")
|
||||
logger.info(f"🔍 Syncing all timelogs from vTiger with valid relatedto...")
|
||||
|
||||
stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0}
|
||||
|
||||
try:
|
||||
# Cache for user names (avoid fetching same user multiple times)
|
||||
user_name_cache = {}
|
||||
|
||||
# vTiger API doesn't support OFFSET - use id-based pagination instead
|
||||
all_timelogs = []
|
||||
last_id = "0x0" # Start from beginning
|
||||
@ -453,7 +631,8 @@ class TimeTrackingVTigerService:
|
||||
|
||||
for batch_num in range(max_batches):
|
||||
# Use id > last_id for pagination (vTiger format: 43x1234)
|
||||
query = f"SELECT * FROM Timelog WHERE timelog_status = 'Completed' AND id > '{last_id}' ORDER BY id LIMIT {batch_size};"
|
||||
# NOTE: vTiger query API ignores WHERE on custom fields, so we fetch all and filter later
|
||||
query = f"SELECT * FROM Timelog WHERE id > '{last_id}' ORDER BY id LIMIT {batch_size};"
|
||||
batch = await self._query(query)
|
||||
|
||||
if not batch: # No more records
|
||||
@ -470,8 +649,15 @@ class TimeTrackingVTigerService:
|
||||
if len(all_timelogs) >= limit: # Reached limit
|
||||
break
|
||||
|
||||
logger.info(f"✅ Total fetched: {len(all_timelogs)} Timelog entries from vTiger")
|
||||
|
||||
# We don't filter here - the existing code already filters by:
|
||||
# 1. duration > 0
|
||||
# 2. relatedto not empty
|
||||
# These filters happen in the processing loop below
|
||||
|
||||
timelogs = all_timelogs[:limit] # Trim to requested limit
|
||||
logger.info(f"✅ Total fetched: {len(timelogs)} Timelog entries from vTiger")
|
||||
logger.info(f"📊 Processing {len(timelogs)} timelogs...")
|
||||
|
||||
# NOTE: retrieve API is too slow for batch operations (1500+ individual calls)
|
||||
# We'll work with query data and accept that relatedto might be empty for some
|
||||
@ -494,36 +680,47 @@ class TimeTrackingVTigerService:
|
||||
|
||||
# Get related entity (Case or Account)
|
||||
related_to = timelog.get('relatedto', '')
|
||||
if not related_to:
|
||||
logger.warning(f"⚠️ Timelog {vtiger_id} has no relatedto - RAW DATA: {timelog}")
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
case_id = None
|
||||
customer_id = None
|
||||
|
||||
# Try to find case first, then account
|
||||
case = execute_query(
|
||||
"SELECT id, customer_id FROM tmodule_cases WHERE vtiger_id = %s",
|
||||
(related_to,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
if case:
|
||||
case_id = case['id']
|
||||
customer_id = case['customer_id']
|
||||
else:
|
||||
# Try to find customer directly
|
||||
customer = execute_query(
|
||||
"SELECT id FROM tmodule_customers WHERE vtiger_id = %s",
|
||||
if related_to:
|
||||
# Try to find case first, then account
|
||||
case = execute_query(
|
||||
"SELECT id, customer_id FROM tmodule_cases WHERE vtiger_id = %s",
|
||||
(related_to,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
if not customer:
|
||||
logger.debug(f"⏭️ Related entity {related_to} not found")
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
|
||||
customer_id = customer['id']
|
||||
case_id = None # No specific case, just customer
|
||||
if case:
|
||||
case_id = case['id']
|
||||
customer_id = case['customer_id']
|
||||
else:
|
||||
# Try to find customer directly
|
||||
customer = execute_query(
|
||||
"SELECT id FROM tmodule_customers WHERE vtiger_id = %s",
|
||||
(related_to,),
|
||||
fetchone=True
|
||||
)
|
||||
|
||||
if customer:
|
||||
customer_id = customer['id']
|
||||
case_id = None # No specific case, just customer
|
||||
else:
|
||||
logger.debug(f"⏭️ Related entity {related_to} not found in our database - will skip")
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
|
||||
# If no customer found at all, skip this timelog
|
||||
if not customer_id:
|
||||
logger.warning(f"⚠️ Timelog {vtiger_id} has no valid customer reference - skipping")
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
|
||||
# Get user name with caching
|
||||
assigned_user_id = timelog.get('assigned_user_id', '')
|
||||
if assigned_user_id and assigned_user_id not in user_name_cache:
|
||||
user_name_cache[assigned_user_id] = await self._fetch_user_name(assigned_user_id)
|
||||
user_name = user_name_cache.get(assigned_user_id, assigned_user_id)
|
||||
|
||||
data_hash = self._calculate_hash(timelog)
|
||||
|
||||
@ -550,7 +747,7 @@ class TimeTrackingVTigerService:
|
||||
timelog.get('name', ''),
|
||||
hours,
|
||||
timelog.get('startedon', None),
|
||||
timelog.get('assigned_user_id', None),
|
||||
user_name,
|
||||
timelog.get('isbillable', '0') == '1',
|
||||
json.dumps(timelog),
|
||||
data_hash,
|
||||
@ -578,7 +775,7 @@ class TimeTrackingVTigerService:
|
||||
timelog.get('name', ''),
|
||||
hours,
|
||||
timelog.get('startedon', None),
|
||||
timelog.get('assigned_user_id', None),
|
||||
user_name,
|
||||
timelog.get('isbillable', '0') == '1',
|
||||
json.dumps(timelog),
|
||||
data_hash
|
||||
@ -599,14 +796,22 @@ class TimeTrackingVTigerService:
|
||||
|
||||
async def full_sync(
|
||||
self,
|
||||
user_id: Optional[int] = None
|
||||
user_id: Optional[int] = None,
|
||||
fetch_comments: bool = False
|
||||
) -> TModuleSyncStats:
|
||||
"""
|
||||
Perform full sync of all data from vTiger.
|
||||
|
||||
Order: Customers -> Cases -> Time Entries (dependencies)
|
||||
|
||||
Args:
|
||||
user_id: User performing the sync
|
||||
fetch_comments: Whether to fetch ModComments (slow - adds ~0.4s per case)
|
||||
"""
|
||||
logger.info("🚀 Starting FULL vTiger sync...")
|
||||
if fetch_comments:
|
||||
logger.info("🚀 Starting FULL vTiger sync WITH comments (this will be slow)...")
|
||||
else:
|
||||
logger.info("🚀 Starting FULL vTiger sync WITHOUT comments (fast mode)...")
|
||||
|
||||
start_time = datetime.now()
|
||||
|
||||
@ -623,7 +828,7 @@ class TimeTrackingVTigerService:
|
||||
|
||||
# Sync in order of dependencies
|
||||
customer_stats = await self.sync_customers()
|
||||
case_stats = await self.sync_cases()
|
||||
case_stats = await self.sync_cases(fetch_comments=fetch_comments)
|
||||
time_stats = await self.sync_time_entries()
|
||||
|
||||
end_time = datetime.now()
|
||||
|
||||
@ -7,7 +7,7 @@ Brugeren godkender én tidsregistrering ad gangen.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
from typing import Optional, List, Dict, Any
|
||||
from decimal import Decimal
|
||||
from datetime import datetime
|
||||
|
||||
@ -62,13 +62,15 @@ class WizardService:
|
||||
|
||||
@staticmethod
|
||||
def get_next_pending_entry(
|
||||
customer_id: Optional[int] = None
|
||||
customer_id: Optional[int] = None,
|
||||
exclude_time_card: bool = True
|
||||
) -> TModuleWizardNextEntry:
|
||||
"""
|
||||
Hent næste pending tidsregistrering til godkendelse.
|
||||
|
||||
Args:
|
||||
customer_id: Valgfri - filtrer til specifik kunde
|
||||
exclude_time_card: Ekskluder klippekort-kunder (default: true)
|
||||
|
||||
Returns:
|
||||
TModuleWizardNextEntry med has_next=True hvis der er flere
|
||||
@ -84,7 +86,16 @@ class WizardService:
|
||||
result = execute_query(query, (customer_id,), fetchone=True)
|
||||
else:
|
||||
# Hent næste generelt
|
||||
query = "SELECT * FROM tmodule_next_pending LIMIT 1"
|
||||
if exclude_time_card:
|
||||
query = """
|
||||
SELECT np.* FROM tmodule_next_pending np
|
||||
JOIN tmodule_customers c ON np.customer_id = c.id
|
||||
WHERE c.uses_time_card = false
|
||||
LIMIT 1
|
||||
"""
|
||||
else:
|
||||
query = "SELECT * FROM tmodule_next_pending LIMIT 1"
|
||||
|
||||
result = execute_query(query, fetchone=True)
|
||||
|
||||
if not result:
|
||||
@ -281,6 +292,98 @@ class WizardService:
|
||||
logger.error(f"❌ Error rejecting time entry: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@staticmethod
|
||||
def approve_case_entries(
|
||||
case_id: int,
|
||||
user_id: Optional[int] = None,
|
||||
exclude_time_card: bool = True
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Bulk-godkend alle pending tidsregistreringer for en case.
|
||||
|
||||
Args:
|
||||
case_id: Case ID
|
||||
user_id: ID på brugeren der godkender
|
||||
exclude_time_card: Ekskluder klippekort-kunder
|
||||
|
||||
Returns:
|
||||
Dict med statistik: approved_count, total_hours, etc.
|
||||
"""
|
||||
try:
|
||||
# Hent alle pending entries for case
|
||||
entries = WizardService.get_case_entries(case_id, exclude_time_card)
|
||||
|
||||
if not entries:
|
||||
return {
|
||||
"approved_count": 0,
|
||||
"total_hours": 0.0,
|
||||
"case_id": case_id,
|
||||
"entries": []
|
||||
}
|
||||
|
||||
approved_entries = []
|
||||
total_hours = 0.0
|
||||
|
||||
for entry in entries:
|
||||
# Auto-approve med samme timer som original (eller afrundet hvis enabled)
|
||||
from app.core.config import settings
|
||||
from decimal import Decimal
|
||||
|
||||
approved_hours = Decimal(str(entry.original_hours))
|
||||
|
||||
# Afrund hvis enabled
|
||||
if settings.TIMETRACKING_AUTO_ROUND:
|
||||
increment = Decimal(str(settings.TIMETRACKING_ROUND_INCREMENT))
|
||||
|
||||
if settings.TIMETRACKING_ROUND_METHOD == "up":
|
||||
# Afrund op
|
||||
approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_UP') * increment
|
||||
elif settings.TIMETRACKING_ROUND_METHOD == "down":
|
||||
# Afrund ned
|
||||
approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_DOWN') * increment
|
||||
else:
|
||||
# Nærmeste
|
||||
approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_HALF_UP') * increment
|
||||
|
||||
# Godkend entry
|
||||
approval = TModuleTimeApproval(
|
||||
time_id=entry.id,
|
||||
approved_hours=float(approved_hours)
|
||||
)
|
||||
|
||||
approved = WizardService.approve_time_entry(approval, user_id)
|
||||
approved_entries.append({
|
||||
"id": approved.id,
|
||||
"original_hours": float(approved.original_hours),
|
||||
"approved_hours": float(approved.approved_hours)
|
||||
})
|
||||
total_hours += float(approved.approved_hours)
|
||||
|
||||
# Log bulk approval
|
||||
audit.log_event(
|
||||
entity_type="case",
|
||||
entity_id=str(case_id),
|
||||
event_type="bulk_approval",
|
||||
details={
|
||||
"approved_count": len(approved_entries),
|
||||
"total_hours": total_hours
|
||||
},
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
return {
|
||||
"approved_count": len(approved_entries),
|
||||
"total_hours": total_hours,
|
||||
"case_id": case_id,
|
||||
"entries": approved_entries
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error bulk approving case: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@staticmethod
|
||||
def get_customer_progress(customer_id: int) -> TModuleWizardProgress:
|
||||
"""Hent wizard progress for en kunde"""
|
||||
@ -296,7 +399,7 @@ class WizardService:
|
||||
|
||||
if stats.pending_count > 0:
|
||||
query = """
|
||||
SELECT DISTINCT c.id, c.title
|
||||
SELECT c.id, c.title
|
||||
FROM tmodule_times t
|
||||
JOIN tmodule_cases c ON t.case_id = c.id
|
||||
WHERE t.customer_id = %s AND t.status = 'pending'
|
||||
@ -324,6 +427,155 @@ class WizardService:
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error getting customer progress: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@staticmethod
|
||||
def get_case_entries(
|
||||
case_id: int,
|
||||
exclude_time_card: bool = True
|
||||
) -> List[TModuleTimeWithContext]:
|
||||
"""
|
||||
Hent alle pending tidsregistreringer for en specifik case.
|
||||
|
||||
Bruges til at vise alle timelogs i samme case samtidig.
|
||||
|
||||
Args:
|
||||
case_id: Case ID
|
||||
exclude_time_card: Ekskluder klippekort-kunder
|
||||
|
||||
Returns:
|
||||
Liste af tidsregistreringer for casen
|
||||
"""
|
||||
try:
|
||||
if exclude_time_card:
|
||||
query = """
|
||||
SELECT t.id, t.vtiger_id, t.case_id, t.customer_id, t.description,
|
||||
t.original_hours, t.worked_date, t.user_name, t.status,
|
||||
t.approved_hours, t.rounded_to, t.approval_note, t.billable,
|
||||
t.approved_at, t.approved_by, t.vtiger_data, t.sync_hash,
|
||||
t.created_at, t.updated_at, t.last_synced_at,
|
||||
COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title,
|
||||
c.description AS case_description,
|
||||
c.status AS case_status,
|
||||
c.vtiger_id AS case_vtiger_id,
|
||||
cust.name AS customer_name,
|
||||
cust.hourly_rate AS customer_rate,
|
||||
CONCAT(cont.first_name, ' ', cont.last_name) AS contact_name,
|
||||
cont.user_company AS contact_company,
|
||||
c.vtiger_data AS case_vtiger_data
|
||||
FROM tmodule_times t
|
||||
JOIN tmodule_cases c ON t.case_id = c.id
|
||||
JOIN tmodule_customers cust ON t.customer_id = cust.id
|
||||
LEFT JOIN contacts cont ON cont.vtiger_id = c.vtiger_data->>'contact_id'
|
||||
WHERE t.case_id = %s
|
||||
AND t.status = 'pending'
|
||||
AND t.billable = true
|
||||
AND t.vtiger_data->>'cf_timelog_invoiced' = '0'
|
||||
AND cust.uses_time_card = false
|
||||
ORDER BY t.worked_date, t.id
|
||||
"""
|
||||
else:
|
||||
query = """
|
||||
SELECT t.id, t.vtiger_id, t.case_id, t.customer_id, t.description,
|
||||
t.original_hours, t.worked_date, t.user_name, t.status,
|
||||
t.approved_hours, t.rounded_to, t.approval_note, t.billable,
|
||||
t.approved_at, t.approved_by, t.vtiger_data, t.sync_hash,
|
||||
t.created_at, t.updated_at, t.last_synced_at,
|
||||
COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title,
|
||||
c.description AS case_description,
|
||||
c.status AS case_status,
|
||||
c.vtiger_id AS case_vtiger_id,
|
||||
cust.name AS customer_name,
|
||||
cust.hourly_rate AS customer_rate,
|
||||
CONCAT(cont.first_name, ' ', cont.last_name) AS contact_name,
|
||||
cont.user_company AS contact_company,
|
||||
c.vtiger_data AS case_vtiger_data
|
||||
FROM tmodule_times t
|
||||
JOIN tmodule_cases c ON t.case_id = c.id
|
||||
JOIN tmodule_customers cust ON t.customer_id = cust.id
|
||||
LEFT JOIN contacts cont ON cont.vtiger_id = c.vtiger_data->>'contact_id'
|
||||
WHERE t.case_id = %s
|
||||
AND t.status = 'pending'
|
||||
AND t.billable = true
|
||||
AND t.vtiger_data->>'cf_timelog_invoiced' = '0'
|
||||
ORDER BY t.worked_date, t.id
|
||||
"""
|
||||
|
||||
results = execute_query(query, (case_id,))
|
||||
return [TModuleTimeWithContext(**row) for row in results]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error getting case entries: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@staticmethod
|
||||
def get_case_details(case_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Hent komplet case information inkl. alle timelogs og kommentarer.
|
||||
|
||||
Returns:
|
||||
Dict med case info, timelogs (alle statuses), og kommentarer fra vtiger_data
|
||||
"""
|
||||
try:
|
||||
# Hent case info
|
||||
case_query = """
|
||||
SELECT id, vtiger_id, title, description, status,
|
||||
vtiger_data, customer_id
|
||||
FROM tmodule_cases
|
||||
WHERE id = %s
|
||||
"""
|
||||
case = execute_query(case_query, (case_id,), fetchone=True)
|
||||
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail="Case not found")
|
||||
|
||||
# Hent ALLE timelogs for casen (ikke kun pending)
|
||||
timelogs_query = """
|
||||
SELECT t.*,
|
||||
COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title,
|
||||
c.status AS case_status,
|
||||
c.vtiger_id AS case_vtiger_id,
|
||||
cust.name AS customer_name,
|
||||
cust.hourly_rate AS customer_rate
|
||||
FROM tmodule_times t
|
||||
JOIN tmodule_cases c ON t.case_id = c.id
|
||||
JOIN tmodule_customers cust ON t.customer_id = cust.id
|
||||
WHERE t.case_id = %s
|
||||
ORDER BY t.worked_date DESC, t.created_at DESC
|
||||
"""
|
||||
timelogs = execute_query(timelogs_query, (case_id,))
|
||||
|
||||
# Parse case comments from vtiger_data JSON
|
||||
case_comments = []
|
||||
if case.get('vtiger_data'):
|
||||
vtiger_data = case['vtiger_data']
|
||||
# vTiger gemmer comments som en array i JSON
|
||||
if isinstance(vtiger_data, dict):
|
||||
raw_comments = vtiger_data.get('comments', []) or vtiger_data.get('modcomments', [])
|
||||
|
||||
for comment in raw_comments:
|
||||
if isinstance(comment, dict):
|
||||
case_comments.append({
|
||||
'id': comment.get('modcommentsid', comment.get('id')),
|
||||
'comment_text': comment.get('commentcontent', comment.get('comment', '')),
|
||||
'creator_name': comment.get('assigned_user_id', comment.get('creator', 'Unknown')),
|
||||
'created_at': comment.get('createdtime', comment.get('created_at', ''))
|
||||
})
|
||||
|
||||
return {
|
||||
'case_id': case['id'],
|
||||
'case_vtiger_id': case['vtiger_id'],
|
||||
'case_title': case['title'],
|
||||
'case_description': case['description'],
|
||||
'case_status': case['status'],
|
||||
'timelogs': [TModuleTimeWithContext(**t) for t in timelogs],
|
||||
'case_comments': case_comments
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error getting case details: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# Singleton instance
|
||||
|
||||
893
app/timetracking/frontend/customers.html
Normal file
893
app/timetracking/frontend/customers.html
Normal 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>
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
296
docs/ECONOMIC_WRITE_MODE.md
Normal 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
|
||||
@ -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'
|
||||
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
|
||||
|
||||
11
migrations/014_add_contact_user_company.sql
Normal file
11
migrations/014_add_contact_user_company.sql
Normal 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';
|
||||
15
migrations/014_economic_customer_number.sql
Normal file
15
migrations/014_economic_customer_number.sql
Normal 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.';
|
||||
Loading…
Reference in New Issue
Block a user