-
-
Model:
-
${escapeHtml(prompt.model)}
-
-
-
Endpoint:
-
${escapeHtml(prompt.endpoint)}
-
-
-
Parametre:
-
${JSON.stringify(prompt.parameters)}
+
+ const accordionHtml = `
+
+ ${Object.entries(prompts).map(([key, prompt], index) => `
+
+
+
+
+
+
+
+
+
Model
+
${escapeHtml(prompt.model)}
+
+
+
+
+
+
+
Endpoint
+
${escapeHtml(prompt.endpoint)}
+
+
+
+
+
+
+
Parametre
+
${JSON.stringify(prompt.parameters)}
+
+
+
+
+
+
+
+
+
${escapeHtml(prompt.prompt)}
+
+
+
+
+
+
+
+
+
-
-
System Prompt:
-
${escapeHtml(prompt.prompt)}
-
-
+ `).join('')}
- `).join('');
+ `;
+
+ container.innerHTML = accordionHtml;
} catch (error) {
console.error('Error loading AI prompts:', error);
@@ -1023,6 +1074,79 @@ async function loadAIPrompts() {
}
}
+function editPrompt(key) {
+ document.getElementById(`prompt_${key}`).classList.add('d-none');
+ document.getElementById(`edit_prompt_${key}`).classList.remove('d-none');
+ document.getElementById(`editActions_${key}`).classList.remove('d-none');
+ document.getElementById(`editBtn_${key}`).disabled = true;
+}
+
+function cancelEdit(key) {
+ document.getElementById(`prompt_${key}`).classList.remove('d-none');
+ document.getElementById(`edit_prompt_${key}`).classList.add('d-none');
+ document.getElementById(`editActions_${key}`).classList.add('d-none');
+ document.getElementById(`editBtn_${key}`).disabled = false;
+
+ // Reset value
+ document.getElementById(`edit_prompt_${key}`).value = document.getElementById(`prompt_${key}`).textContent;
+}
+
+async function savePrompt(key) {
+ const newText = document.getElementById(`edit_prompt_${key}`).value;
+
+ try {
+ const response = await fetch(`/api/v1/ai-prompts/${key}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ prompt_text: newText })
+ });
+
+ if (!response.ok) throw new Error('Failed to update prompt');
+
+ // Reload to show update
+ await loadAIPrompts();
+ // Re-open accordion
+ setTimeout(() => {
+ const collapse = document.getElementById(`collapse_${key}`);
+ if (collapse) {
+ new bootstrap.Collapse(collapse, { toggle: false }).show();
+ }
+ }, 100);
+
+ } catch (error) {
+ console.error('Error saving prompt:', error);
+ alert('Kunne ikke gemme prompt');
+ }
+}
+
+async function resetPrompt(key) {
+ if (!confirm('Er du sikker på at du vil nulstille denne prompt til standard?')) return;
+
+ try {
+ const response = await fetch(`/api/v1/ai-prompts/${key}`, {
+ method: 'DELETE'
+ });
+
+ if (!response.ok) throw new Error('Failed to reset prompt');
+
+ // Reload to show update
+ await loadAIPrompts();
+ // Re-open accordion
+ setTimeout(() => {
+ const collapse = document.getElementById(`collapse_${key}`);
+ if (collapse) {
+ new bootstrap.Collapse(collapse, { toggle: false }).show();
+ }
+ }, 100);
+
+ } catch (error) {
+ console.error('Error resetting prompt:', error);
+ alert('Kunne ikke nulstille prompt');
+ }
+}
+
+
+
function copyPrompt(key) {
const promptElement = document.getElementById(`prompt_${key}`);
const text = promptElement.textContent;
diff --git a/app/shared/frontend/base.html b/app/shared/frontend/base.html
index 8aa55f8..9c19bfb 100644
--- a/app/shared/frontend/base.html
+++ b/app/shared/frontend/base.html
@@ -234,6 +234,7 @@
Dashboard
Alle Tickets
Godkend Worklog
+
Mine Samtaler
Ny Ticket
Prepaid Cards
diff --git a/app/ticket/frontend/ticket_detail.html b/app/ticket/frontend/ticket_detail.html
index 6c20df9..3f1a563 100644
--- a/app/ticket/frontend/ticket_detail.html
+++ b/app/ticket/frontend/ticket_detail.html
@@ -631,7 +631,7 @@
let prepaidOptions = '';
let activePrepaidCards = [];
try {
- const response = await fetch('/api/v1/prepaid/prepaid-cards?status=active&customer_id={{ ticket.customer_id }}');
+ const response = await fetch('/api/v1/prepaid-cards?status=active&customer_id={{ ticket.customer_id }}');
if (response.ok) {
const cards = await response.json();
activePrepaidCards = cards || [];
diff --git a/docs/ORDRE_SYSTEM_IMPLEMENTATION.md b/docs/ORDRE_SYSTEM_IMPLEMENTATION.md
new file mode 100644
index 0000000..514c68b
--- /dev/null
+++ b/docs/ORDRE_SYSTEM_IMPLEMENTATION.md
@@ -0,0 +1,1052 @@
+ # Ordre System Implementation Plan
+
+**Status:** 📋 Planlagt (ikke implementeret endnu)
+**Dato:** 10. januar 2026
+**Formål:** Implementer e-conomic ordre integration for ticket worklogs og klippekort
+
+## 📋 Oversigt
+
+Dette dokument beskriver implementeringen af et komplet ordre system der:
+1. Eksporterer ticket worklogs til e-conomic som **draft orders** (ikke invoices)
+2. Opretter lokale ordrer ved klippekort køb (manuelt eksporteres senere)
+3. Auto-grupperer worklogs per kunde ved batch export
+4. Viser multi-kunde preview som Bootstrap cards
+5. Håndterer partial failures (markerer succesfulde, viser fejl separat)
+
+## 🎯 Business Requirements
+
+### Ticket Worklog Export
+- Bruger vælger worklogs via checkboxes i worklog review siden
+- System grupperer automatisk per kunde (også hvis man vælger fra flere kunder)
+- Preview modal viser cards (én per kunde) med total timer + beløb
+- Execute opretter én ordre per kunde i e-conomic
+- `economic_order_number` gemmes i worklog tabellen og vises
+- Ved fejl på én kunde fortsættes med resten (partial success)
+
+### Klippekort Ordrer
+- Ved oprettelse af klippekort skal pris indtastes manuelt (optional felt)
+- System opretter lokal ordre i `tmodule_orders` automatisk
+- INGEN automatisk eksport til e-conomic (skal manuelt eksporteres fra orders siden)
+- Reason: Man skal kunne tilføje flere varelinjer før eksport
+- Description i e-conomic: "Klippekort - XXtimer"
+- Intern reference gemmes så ordre kan spores tilbage til klippekortet
+
+### Order Type Filtering
+- Alle ordrer (timetracking + klippekort) vises på samme side
+- Filter dropdown: "Alle ordrer", "Kun timetracking", "Kun klippekort"
+- Order type vises som badge i tabellen
+
+## 🗄️ Database Ændringer
+
+### Migration 066: Tilføj order_type og reference til tmodule_orders
+
+```sql
+-- migrations/066_add_order_type_and_reference.sql
+
+-- Add order_type column with default
+ALTER TABLE tmodule_orders
+ADD COLUMN order_type VARCHAR(50) DEFAULT 'timetracking';
+
+-- Add reference column for linking back to source (prepaid_card_id, manual reference, etc)
+ALTER TABLE tmodule_orders
+ADD COLUMN reference VARCHAR(255);
+
+-- Add index for filtering
+CREATE INDEX idx_tmodule_orders_order_type ON tmodule_orders(order_type);
+CREATE INDEX idx_tmodule_orders_reference ON tmodule_orders(reference);
+
+-- Update existing rows to have explicit order_type
+UPDATE tmodule_orders SET order_type = 'timetracking' WHERE order_type IS NULL;
+
+COMMENT ON COLUMN tmodule_orders.order_type IS 'Type of order: timetracking, prepaid_card, manual';
+COMMENT ON COLUMN tmodule_orders.reference IS 'Reference to source entity (prepaid_card_id, ticket_id, etc)';
+```
+
+### Eksisterende Worklog Felt
+`tticket_worklog.economic_order_number` **findes allerede** (eller skal oprettes hvis ikke):
+
+```sql
+-- Verificer felt eksisterer, ellers tilføj:
+ALTER TABLE tticket_worklog
+ADD COLUMN IF NOT EXISTS economic_order_number INTEGER;
+
+CREATE INDEX IF NOT EXISTS idx_tticket_worklog_economic_order
+ON tticket_worklog(economic_order_number);
+```
+
+## 🔧 Backend Implementation
+
+### 1. E-conomic Service: Ordre Metoder
+
+**Fil:** `app/services/economic_service.py`
+**Indsæt efter:** Linje ~440 (mellem customer/supplier metoder og kassekladde sektion)
+
+```python
+# ========== ORDERS ==========
+
+async def create_draft_order(self, order_data: Dict) -> Dict:
+ """
+ Create draft order in e-conomic
+
+ POST /orders/drafts
+
+ Args:
+ order_data: {
+ 'date': '2026-01-10',
+ 'currency': 'DKK',
+ 'customer': {'customerNumber': 123},
+ 'recipient': {
+ 'name': '...',
+ 'address': '...',
+ 'zip': '...',
+ 'city': '...',
+ 'vatZone': {'vatZoneNumber': 1}
+ },
+ 'layout': {'layoutNumber': 19},
+ 'paymentTerms': {'paymentTermsNumber': 1},
+ 'lines': [
+ {
+ 'product': {'productNumber': '1000'},
+ 'description': 'Support arbejde',
+ 'quantity': 5.0,
+ 'unitNetPrice': 850.00,
+ 'unit': {'unitNumber': 1}
+ }
+ ]
+ }
+
+ Returns:
+ {
+ 'orderNumber': 12345,
+ 'grossAmount': 5312.50,
+ 'netAmount': 4250.00,
+ 'vatAmount': 1062.50,
+ ...
+ }
+ """
+ # Safety check
+ if not self._check_write_permission("Create draft order"):
+ return {
+ 'orderNumber': -1,
+ 'blocked': True,
+ 'reason': 'READ_ONLY or DRY_RUN mode enabled',
+ 'mock': True
+ }
+
+ self._log_api_call("POST", "/orders/drafts", payload=order_data)
+
+ try:
+ async with aiohttp.ClientSession() as session:
+ async with session.post(
+ f"{self.api_url}/orders/drafts",
+ headers=self._get_headers(),
+ json=order_data
+ ) as response:
+ response_text = await response.text()
+
+ if response.status in (200, 201):
+ result = json.loads(response_text)
+ order_number = result.get('orderNumber')
+ gross_amount = result.get('grossAmount', 0)
+
+ self._log_api_call(
+ "POST", "/orders/drafts",
+ payload=order_data,
+ response_data=result,
+ status_code=response.status
+ )
+
+ logger.info(f"✅ Created draft order #{order_number} - Amount: {gross_amount} DKK")
+ return result
+ else:
+ logger.error(f"❌ Failed to create draft order: {response.status} - {response_text}")
+ raise Exception(f"E-conomic API error: {response.status} - {response_text}")
+
+ except Exception as e:
+ logger.error(f"❌ Error creating draft order: {e}")
+ raise
+
+async def book_draft_order(self, draft_order_number: int) -> Dict:
+ """
+ Book draft order → sent order
+
+ POST /orders/sent
+
+ Args:
+ draft_order_number: Draft order number from e-conomic
+
+ Returns:
+ {
+ 'orderNumber': 54321,
+ ...
+ }
+ """
+ # Safety check
+ if not self._check_write_permission(f"Book draft order {draft_order_number}"):
+ return {
+ 'orderNumber': -1,
+ 'blocked': True,
+ 'reason': 'READ_ONLY or DRY_RUN mode enabled',
+ 'mock': True
+ }
+
+ payload = {
+ 'draftOrder': {
+ 'orderNumber': draft_order_number
+ }
+ }
+
+ self._log_api_call("POST", "/orders/sent", payload=payload)
+
+ try:
+ async with aiohttp.ClientSession() as session:
+ async with session.post(
+ f"{self.api_url}/orders/sent",
+ headers=self._get_headers(),
+ json=payload
+ ) as response:
+ response_text = await response.text()
+
+ if response.status in (200, 201):
+ result = json.loads(response_text)
+ order_number = result.get('orderNumber')
+
+ self._log_api_call(
+ "POST", "/orders/sent",
+ payload=payload,
+ response_data=result,
+ status_code=response.status
+ )
+
+ logger.info(f"✅ Booked order #{order_number} (was draft #{draft_order_number})")
+ return result
+ else:
+ logger.error(f"❌ Failed to book order: {response.status} - {response_text}")
+ raise Exception(f"E-conomic API error: {response.status} - {response_text}")
+
+ except Exception as e:
+ logger.error(f"❌ Error booking draft order: {e}")
+ raise
+
+async def get_order_pdf(self, order_number: int) -> bytes:
+ """
+ Download order PDF from e-conomic
+
+ GET /orders/sent/{orderNumber}/pdf
+
+ Args:
+ order_number: Sent order number
+
+ Returns:
+ PDF bytes
+ """
+ try:
+ async with aiohttp.ClientSession() as session:
+ async with session.get(
+ f"{self.api_url}/orders/sent/{order_number}/pdf",
+ headers=self._get_headers()
+ ) as response:
+ if response.status == 200:
+ pdf_bytes = await response.read()
+ logger.info(f"✅ Downloaded PDF for order #{order_number} ({len(pdf_bytes)} bytes)")
+ return pdf_bytes
+ else:
+ error_text = await response.text()
+ logger.error(f"❌ Failed to download PDF: {response.status} - {error_text}")
+ raise Exception(f"E-conomic API error: {response.status} - {error_text}")
+
+ except Exception as e:
+ logger.error(f"❌ Error downloading order PDF: {e}")
+ raise
+```
+
+### 2. Economic Export: Ret til Orders + Auto-Gruppering
+
+**Fil:** `app/ticket/backend/economic_export.py`
+**Ændringer:**
+
+```python
+# Linje ~306: Ret metode kald
+# FRA: result = await self.economic.create_draft_invoice(invoice_data)
+# TIL: result = await self.economic.create_draft_order(order_data)
+
+# Linje ~233-274: Opdatér payload struktur
+# FRA: "date": invoice_date, "customer": {...}, "recipient": {...}, "lines": [...]
+# TIL: "date": order_date, "customer": {...}, "recipient": {...}, "deliveryLocation": {...}, "lines": [...]
+
+# Tilføj auto-gruppering i export_billable_worklog_batch():
+async def export_billable_worklog_batch(self, worklog_ids: List[int]) -> Dict:
+ """
+ Export selected worklogs to e-conomic as draft orders
+ Auto-groups by customer (one order per customer)
+
+ Returns:
+ {
+ 'success': [
+ {
+ 'customer_id': 123,
+ 'customer_name': 'Kunde A',
+ 'order_number': 12345,
+ 'total_hours': 12.5,
+ 'total_amount': 10625.00,
+ 'worklog_count': 5
+ }
+ ],
+ 'failed': [
+ {
+ 'customer_id': 456,
+ 'customer_name': 'Kunde B',
+ 'error': 'Customer not found in e-conomic'
+ }
+ ]
+ }
+ """
+ # 1. Fetch all worklogs
+ worklogs = self._get_worklogs_by_ids(worklog_ids)
+
+ # 2. Group by customer_id
+ grouped = {}
+ for wl in worklogs:
+ customer_id = wl['customer_id']
+ if customer_id not in grouped:
+ grouped[customer_id] = []
+ grouped[customer_id].append(wl)
+
+ # 3. Process each customer separately
+ success = []
+ failed = []
+
+ for customer_id, customer_worklogs in grouped.items():
+ try:
+ # Create order for this customer
+ order_data = self._create_economic_order(customer_id, customer_worklogs)
+ result = await self.economic.create_draft_order(order_data)
+
+ if result.get('blocked') or result.get('mock'):
+ # DRY_RUN mode - still mark as success but with note
+ order_number = -1
+ else:
+ order_number = result.get('orderNumber')
+
+ # Mark worklogs as billed
+ self._mark_worklogs_as_billed(customer_worklogs, order_number)
+
+ # Add to success list
+ success.append({
+ 'customer_id': customer_id,
+ 'customer_name': customer_worklogs[0]['customer_name'],
+ 'order_number': order_number,
+ 'total_hours': sum(wl['hours'] for wl in customer_worklogs),
+ 'total_amount': sum(wl['hours'] * wl['hourly_rate'] for wl in customer_worklogs),
+ 'worklog_count': len(customer_worklogs)
+ })
+
+ except Exception as e:
+ # Log error and continue with next customer
+ logger.error(f"❌ Failed to export worklogs for customer {customer_id}: {e}")
+ failed.append({
+ 'customer_id': customer_id,
+ 'customer_name': customer_worklogs[0]['customer_name'],
+ 'error': str(e)
+ })
+
+ return {
+ 'success': success,
+ 'failed': failed
+ }
+```
+
+### 3. Klippekort: Automatisk Ordre Oprettelse
+
+**Fil:** `app/prepaid/backend/router.py`
+**Ændring i `create_prepaid_card()` endpoint:**
+
+```python
+@router.post("/prepaid-cards", response_model=PrepaidCardFull)
+async def create_prepaid_card(card: PrepaidCardCreate):
+ """Create new prepaid card + automatic local order"""
+
+ # 1. Create prepaid card (existing logic)
+ query = """
+ INSERT INTO tticket_prepaid_cards
+ (customer_id, purchased_hours, price, expiry_date, active)
+ VALUES (%s, %s, %s, %s, %s)
+ RETURNING *
+ """
+ result = execute_query(query, (
+ card.customer_id,
+ card.purchased_hours,
+ card.price,
+ card.expiry_date,
+ True
+ ))
+
+ if not result:
+ raise HTTPException(status_code=500, detail="Failed to create prepaid card")
+
+ created_card = result[0]
+ prepaid_card_id = created_card['id']
+
+ # 2. Create local order automatically
+ if card.price and card.price > 0:
+ order_query = """
+ INSERT INTO tmodule_orders
+ (customer_id, amount, order_type, status, reference, description, created_at)
+ VALUES (%s, %s, %s, %s, %s, %s, NOW())
+ RETURNING id
+ """
+ order_result = execute_query(order_query, (
+ card.customer_id,
+ card.price,
+ 'prepaid_card',
+ 'pending',
+ str(prepaid_card_id), # Reference to prepaid card
+ f"Klippekort - {card.purchased_hours}timer"
+ ))
+
+ if order_result:
+ logger.info(f"✅ Created local order for prepaid card #{prepaid_card_id}")
+ else:
+ logger.warning(f"⚠️ Failed to create local order for prepaid card #{prepaid_card_id}")
+
+ return created_card
+```
+
+### 4. Config Flags
+
+**Fil:** `app/core/config.py`
+**Tilføj i Settings klassen:**
+
+```python
+class Settings(BaseSettings):
+ # ... existing fields ...
+
+ # Ticket E-conomic Integration
+ TICKET_ECONOMIC_READ_ONLY: bool = Field(default=True, description="Prevent actual writes to e-conomic (ticket module)")
+ TICKET_ECONOMIC_DRY_RUN: bool = Field(default=True, description="Log API calls but don't execute (ticket module)")
+ TICKET_ECONOMIC_AUTO_EXPORT: bool = Field(default=False, description="Auto-export worklogs when approved")
+ TICKET_ECONOMIC_LAYOUT: int = Field(default=19, description="E-conomic layout number for ticket orders")
+ TICKET_ECONOMIC_PRODUCT: str = Field(default="1000", description="E-conomic product number for ticket worklogs")
+```
+
+## 🎨 Frontend Implementation
+
+### 1. Prepaid Cards: Price Input Field
+
+**Fil:** `app/prepaid/frontend/index.html`
+**Create Modal - Tilføj efter purchased_hours input:**
+
+```html
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Hvis udfyldt oprettes der automatisk en lokal ordre
+
+```
+
+**JavaScript - Opdatér createCard() funktion:**
+
+```javascript
+async function createCard() {
+ const customer_id = document.getElementById('customer_id').value;
+ const purchased_hours = document.getElementById('purchased_hours').value;
+ const price = document.getElementById('price').value;
+ const expiry_date = document.getElementById('expiry_date').value;
+
+ const response = await fetch('/api/v1/prepaid-cards', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({
+ customer_id: parseInt(customer_id),
+ purchased_hours: parseFloat(purchased_hours),
+ price: price ? parseFloat(price) : null, // Send null hvis tom
+ expiry_date: expiry_date
+ })
+ });
+
+ if (response.ok) {
+ bootstrap.Modal.getInstance(document.getElementById('createModal')).hide();
+ loadCards();
+ showToast('Klippekort oprettet' + (price ? ' + ordre oprettet' : ''), 'success');
+ }
+}
+```
+
+### 2. Timetracking Orders: Order Type Filter
+
+**Fil:** `app/timetracking/frontend/orders.html`
+**Tilføj filter i toolbar (omkring linje 50):**
+
+```html
+
+
Ordrer
+
+
+
+
+
+
+```
+
+**Opdatér tabel columns (omkring linje 100):**
+
+```html
+
+
+ | Order # |
+ Type |
+ Kunde |
+ Beskrivelse |
+ Beløb |
+ Status |
+ Oprettet |
+ E-conomic # |
+ Handlinger |
+
+
+```
+
+**JavaScript:**
+
+```javascript
+function renderOrders(orders) {
+ const tbody = document.getElementById('ordersTableBody');
+ tbody.innerHTML = '';
+
+ orders.forEach(order => {
+ // Order type badge
+ let typeBadge = '';
+ if (order.order_type === 'prepaid_card') {
+ typeBadge = '
Klippekort';
+ } else if (order.order_type === 'timetracking') {
+ typeBadge = '
Timetracking';
+ } else {
+ typeBadge = '
Manuel';
+ }
+
+ const row = `
+
+ | ${order.id} |
+ ${typeBadge} |
+ ${order.customer_name} |
+ ${order.description || '-'} |
+ ${order.amount.toFixed(2)} DKK |
+ ${getStatusBadge(order.status)} |
+ ${formatDate(order.created_at)} |
+ ${order.economic_order_number || '-'} |
+
+ ${order.status === 'pending' ? `
+
+ ` : ''}
+ |
+
+ `;
+ tbody.innerHTML += row;
+ });
+}
+
+function filterOrdersByType() {
+ const filter = document.getElementById('orderTypeFilter').value;
+ const rows = document.querySelectorAll('#ordersTableBody tr');
+
+ rows.forEach(row => {
+ if (filter === 'all') {
+ row.style.display = '';
+ } else {
+ const badge = row.querySelector('.badge');
+ const type = badge.textContent.toLowerCase();
+
+ if (filter === 'timetracking' && type === 'timetracking') {
+ row.style.display = '';
+ } else if (filter === 'prepaid_card' && type === 'klippekort') {
+ row.style.display = '';
+ } else {
+ row.style.display = 'none';
+ }
+ }
+ });
+}
+```
+
+### 3. Worklog Review: Checkboxes + Export Button
+
+**Fil:** `app/ticket/frontend/worklog_review.html`
+**Tilføj checkbox kolonne i tabel header:**
+
+```html
+
+
+ |
+
+ |
+ Ticket |
+ Kunde |
+ Agent |
+ Timer |
+ Timepris |
+ Beløb |
+ Faktureringsmetode |
+ E-conomic # |
+ Dato |
+ Status |
+ Handlinger |
+
+
+```
+
+**Tilføj checkbox i hver row (i renderWorklogs()):**
+
+```javascript
+function renderWorklogs(worklogs) {
+ const tbody = document.getElementById('worklogTableBody');
+ tbody.innerHTML = '';
+
+ worklogs.forEach(wl => {
+ const row = `
+
+ |
+
+ |
+ #${wl.ticket_id} |
+ ${wl.customer_name} |
+ ${wl.agent_name} |
+ ${wl.hours} |
+ ${wl.hourly_rate} DKK |
+ ${(wl.hours * wl.hourly_rate).toFixed(2)} DKK |
+ ${getBillingMethodBadge(wl.billing_method)} |
+
+ ${wl.economic_order_number ?
+ `#${wl.economic_order_number}` :
+ '-'}
+ |
+ ${formatDate(wl.work_date)} |
+ ${getStatusBadge(wl.status)} |
+
+
+
+
+ |
+
+ `;
+ tbody.innerHTML += row;
+ });
+}
+```
+
+**Tilføj export knap i toolbar:**
+
+```html
+
+
Worklog Review
+
+
+
+
+
+
+
+```
+
+**JavaScript functions:**
+
+```javascript
+function toggleSelectAll() {
+ const selectAll = document.getElementById('selectAllCheckbox').checked;
+ document.querySelectorAll('.worklog-checkbox').forEach(cb => {
+ cb.checked = selectAll;
+ });
+ updateExportButtonState();
+}
+
+function updateExportButtonState() {
+ const checkboxes = document.querySelectorAll('.worklog-checkbox:checked');
+ const exportBtn = document.getElementById('exportSelectedBtn');
+ const countBadge = document.getElementById('selectedCount');
+
+ exportBtn.disabled = checkboxes.length === 0;
+ countBadge.textContent = checkboxes.length;
+}
+
+async function showExportPreview() {
+ const selected = Array.from(document.querySelectorAll('.worklog-checkbox:checked'))
+ .map(cb => parseInt(cb.value));
+
+ if (selected.length === 0) {
+ showToast('Vælg mindst ét worklog', 'warning');
+ return;
+ }
+
+ // Call preview API
+ const response = await fetch('/api/v1/tickets/worklog/export/preview', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({worklog_ids: selected})
+ });
+
+ if (response.ok) {
+ const preview = await response.json();
+ showPreviewModal(preview);
+ } else {
+ showToast('Fejl ved preview', 'danger');
+ }
+}
+
+function showPreviewModal(preview) {
+ // preview = [{customer_id, customer_name, total_hours, total_amount, worklog_count}, ...]
+
+ let cardsHtml = '';
+ preview.forEach(customer => {
+ cardsHtml += `
+
+
+
${customer.customer_name}
+
+
+ Timer: ${customer.total_hours}
+
+
+ Beløb: ${customer.total_amount.toFixed(2)} DKK
+
+
+ Antal worklogs: ${customer.worklog_count}
+
+
+
+
+ `;
+ });
+
+ document.getElementById('previewCardsContainer').innerHTML = cardsHtml;
+
+ const modal = new bootstrap.Modal(document.getElementById('exportPreviewModal'));
+ modal.show();
+}
+
+async function executeExport() {
+ const selected = Array.from(document.querySelectorAll('.worklog-checkbox:checked'))
+ .map(cb => parseInt(cb.value));
+
+ const response = await fetch('/api/v1/tickets/worklog/export/execute', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({worklog_ids: selected})
+ });
+
+ if (response.ok) {
+ const result = await response.json();
+ showResultModal(result);
+
+ // Reload worklogs to show economic_order_number
+ loadWorklogs();
+
+ // Clear selections
+ document.querySelectorAll('.worklog-checkbox').forEach(cb => cb.checked = false);
+ updateExportButtonState();
+ } else {
+ showToast('Fejl ved eksport', 'danger');
+ }
+}
+
+function showResultModal(result) {
+ // result = {success: [...], failed: [...]}
+
+ let html = '';
+
+ // Success cards (green)
+ if (result.success.length > 0) {
+ html += '
✅ Succesfulde eksporter
';
+ result.success.forEach(s => {
+ html += `
+
+
+ ${s.customer_name} -
+ Ordre #${s.order_number} -
+ ${s.total_hours} timer -
+ ${s.total_amount.toFixed(2)} DKK
+
+
+ `;
+ });
+ }
+
+ // Failed cards (red)
+ if (result.failed.length > 0) {
+ html += '
❌ Fejlede eksporter
';
+ result.failed.forEach(f => {
+ html += `
+
+
+ ${f.customer_name}
+ ${f.error}
+
+
+ `;
+ });
+ }
+
+ document.getElementById('resultCardsContainer').innerHTML = html;
+
+ // Close preview modal, open result modal
+ bootstrap.Modal.getInstance(document.getElementById('exportPreviewModal')).hide();
+ const resultModal = new bootstrap.Modal(document.getElementById('exportResultModal'));
+ resultModal.show();
+}
+```
+
+**Tilføj modals i HTML:**
+
+```html
+
+
+
+
+
+
+
Følgende ordrer vil blive oprettet i e-conomic:
+
+
+
+
+
+
+
+
+
+```
+
+## 🧪 Testing Plan
+
+### 1. E-conomic Service Testing
+
+```bash
+# Test draft order creation (DRY_RUN mode)
+curl -X POST http://localhost:8001/api/v1/test/economic/draft-order \
+ -H "Content-Type: application/json" \
+ -d '{
+ "customer_id": 123,
+ "lines": [
+ {"product": "1000", "description": "Support", "quantity": 5, "unitNetPrice": 850}
+ ]
+ }'
+
+# Expected: Log entry but no actual e-conomic call
+# Response: {"orderNumber": -1, "mock": true, "blocked": true}
+```
+
+### 2. Klippekort Ordre Testing
+
+```bash
+# 1. Opret klippekort MED pris
+curl -X POST http://localhost:8001/api/v1/prepaid-cards \
+ -H "Content-Type: application/json" \
+ -d '{
+ "customer_id": 10,
+ "purchased_hours": 25,
+ "price": 15000,
+ "expiry_date": "2026-12-31"
+ }'
+
+# 2. Verificer ordre oprettet
+curl http://localhost:8001/api/v1/timetracking/orders?order_type=prepaid_card
+
+# Expected: Ordre med order_type='prepaid_card', description='Klippekort - 25timer'
+
+# 3. Opret klippekort UDEN pris
+curl -X POST http://localhost:8001/api/v1/prepaid-cards \
+ -H "Content-Type: application/json" \
+ -d '{
+ "customer_id": 10,
+ "purchased_hours": 10,
+ "expiry_date": "2026-06-30"
+ }'
+
+# Expected: Ordre IKKE oprettet (price=null)
+```
+
+### 3. Worklog Export Testing
+
+```bash
+# 1. Opret test worklogs
+# - 3 worklogs for kunde A (total 12.5 timer)
+# - 2 worklogs for kunde B (total 8 timer)
+
+# 2. Preview export
+curl -X POST http://localhost:8001/api/v1/tickets/worklog/export/preview \
+ -H "Content-Type: application/json" \
+ -d '{"worklog_ids": [1, 2, 3, 4, 5]}'
+
+# Expected:
+# [
+# {customer_id: 10, customer_name: "Kunde A", total_hours: 12.5, total_amount: 10625, worklog_count: 3},
+# {customer_id: 11, customer_name: "Kunde B", total_hours: 8, total_amount: 6800, worklog_count: 2}
+# ]
+
+# 3. Execute export (DRY_RUN=true)
+curl -X POST http://localhost:8001/api/v1/tickets/worklog/export/execute \
+ -H "Content-Type: application/json" \
+ -d '{"worklog_ids": [1, 2, 3, 4, 5]}'
+
+# Expected:
+# {
+# "success": [
+# {customer_id: 10, order_number: -1, ...},
+# {customer_id: 11, order_number: -1, ...}
+# ],
+# "failed": []
+# }
+
+# 4. Verificer economic_order_number gemt
+curl http://localhost:8001/api/v1/tickets/worklogs?ids=1,2,3,4,5
+
+# Expected: Alle worklogs har economic_order_number=-1 (DRY_RUN mode)
+```
+
+### 4. Frontend Testing
+
+1. **Prepaid Cards**
+ - Naviger til `/prepaid-cards`
+ - Klik "Opret nyt klippekort"
+ - Udfyld: Kunde, 25 timer, 15000 DKK, udløbsdato
+ - Submit → success toast "Klippekort oprettet + ordre oprettet"
+ - Naviger til `/timetracking/orders`
+ - Filtrer på "Kun klippekort"
+ - Verificer ordre vises med description "Klippekort - 25timer"
+
+2. **Worklog Export**
+ - Naviger til `/tickets/worklogs/review`
+ - Vælg 3 worklogs via checkboxes (2 fra kunde A, 1 fra kunde B)
+ - Klik "Eksporter valgte til e-conomic" (badge viser "3")
+ - Preview modal viser 2 cards (Kunde A, Kunde B) med timer + beløb
+ - Klik "Bekræft eksport"
+ - Result modal viser 2 grønne success cards
+ - Luk modal → tabel opdateret med economic_order_number i ny kolonne
+
+3. **Order Type Filter**
+ - Naviger til `/timetracking/orders`
+ - Dropdown viser: "Alle ordrer" (default)
+ - Skift til "Kun klippekort" → kun prepaid_card ordrer vises
+ - Skift til "Kun timetracking" → kun timetracking ordrer vises
+ - Skift til "Alle ordrer" → alle vises igen
+
+## 🔐 Safety Checklist
+
+- [ ] `TICKET_ECONOMIC_READ_ONLY=True` by default
+- [ ] `TICKET_ECONOMIC_DRY_RUN=True` by default
+- [ ] Alle API calls logger med `_log_api_call()` før execution
+- [ ] DRY_RUN mode returnerer mock data med `order_number=-1`
+- [ ] Partial failures markerer succesfulde kunder før error
+- [ ] Economic_order_number gemmes i DB efter success
+- [ ] Frontend viser tydelig feedback (success/failed cards)
+
+## 📝 Deployment Steps
+
+1. **Database Migration**
+ ```bash
+ docker-compose exec db psql -U postgres -d bmc_hub -f /app/migrations/066_add_order_type_and_reference.sql
+ ```
+
+2. **Update Config**
+ ```bash
+ # Add to .env
+ TICKET_ECONOMIC_READ_ONLY=true
+ TICKET_ECONOMIC_DRY_RUN=true
+ TICKET_ECONOMIC_AUTO_EXPORT=false
+ TICKET_ECONOMIC_LAYOUT=19
+ TICKET_ECONOMIC_PRODUCT=1000
+ ```
+
+3. **Restart Services**
+ ```bash
+ docker-compose restart api
+ ```
+
+4. **Smoke Test**
+ ```bash
+ # Test health endpoint
+ curl http://localhost:8001/health
+
+ # Test prepaid card creation
+ curl -X POST http://localhost:8001/api/v1/prepaid-cards ...
+ ```
+
+5. **Enable Write Mode (Production)**
+ ```bash
+ # Efter testing i DRY_RUN mode:
+ TICKET_ECONOMIC_READ_ONLY=false
+ TICKET_ECONOMIC_DRY_RUN=false
+ ```
+
+## 🎯 Success Criteria
+
+- ✅ Klippekort oprettelse skaber lokal ordre automatisk
+- ✅ Worklog export grupperer automatisk per kunde
+- ✅ Preview viser multi-kunde cards korrekt
+- ✅ Execute håndterer partial failures (markerer succesfulde)
+- ✅ Economic_order_number vises i worklog review tabel
+- ✅ Order type filter fungerer på orders siden
+- ✅ DRY_RUN mode logger men sender ikke til e-conomic
+- ✅ Ingen data sendes til e-conomic med default config (safety first)
+
+## 📚 Reference Links
+
+- [E-conomic REST API Docs](https://restdocs.e-conomic.com/)
+- [E-conomic Orders Endpoint](https://restdocs.e-conomic.com/#orders-drafts)
+- [Timetracking Module Reference](app/timetracking/backend/router.py)
+- [Economic Export Service Reference](app/ticket/backend/economic_export.py)
+- [Economic Service Reference](app/services/economic_service.py)
+
+---
+
+**Status:** 📋 Klar til implementering
+**Estimeret tid:** 6-8 timer (backend 3-4h, frontend 3-4h)
+**Prioritet:** Høj (blokerer worklog fakturering)
diff --git a/main.py b/main.py
index 93ccb29..5650a28 100644
--- a/main.py
+++ b/main.py
@@ -51,6 +51,8 @@ from app.settings.backend import views as settings_views
from app.backups.backend.router import router as backups_api
from app.backups.frontend import views as backups_views
from app.backups.backend.scheduler import backup_scheduler
+from app.conversations.backend import router as conversations_api
+from app.conversations.frontend import views as conversations_views
# Configure logging
logging.basicConfig(
@@ -127,6 +129,7 @@ app.include_router(tags_api.router, prefix="/api/v1", tags=["Tags"])
app.include_router(emails_api.router, prefix="/api/v1", tags=["Emails"])
app.include_router(settings_api.router, prefix="/api/v1", tags=["Settings"])
app.include_router(backups_api, prefix="/api/v1", tags=["Backups"])
+app.include_router(conversations_api.router, prefix="/api/v1", tags=["Conversations"])
# Frontend Routers
app.include_router(dashboard_views.router, tags=["Frontend"])
@@ -141,9 +144,11 @@ app.include_router(tags_views.router, tags=["Frontend"])
app.include_router(settings_views.router, tags=["Frontend"])
app.include_router(emails_views.router, tags=["Frontend"])
app.include_router(backups_views.router, tags=["Frontend"])
+app.include_router(conversations_views.router, tags=["Frontend"])
# Serve static files (UI)
app.mount("/static", StaticFiles(directory="static", html=True), name="static")
+app.mount("/docs", StaticFiles(directory="docs"), name="docs")
@app.get("/health")
async def health_check():
diff --git a/migrations/066_ai_prompts.sql b/migrations/066_ai_prompts.sql
new file mode 100644
index 0000000..f0e1fab
--- /dev/null
+++ b/migrations/066_ai_prompts.sql
@@ -0,0 +1,9 @@
+-- Create table for storing custom AI prompts
+CREATE TABLE IF NOT EXISTS ai_prompts (
+ key VARCHAR(100) PRIMARY KEY,
+ prompt_text TEXT NOT NULL,
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ updated_by INTEGER REFERENCES users(user_id)
+);
+
+-- Note: We only store overrides here. If a key is missing, we use the hardcoded default.
diff --git a/migrations/067_add_regex_action.sql b/migrations/067_add_regex_action.sql
new file mode 100644
index 0000000..5bf03d5
--- /dev/null
+++ b/migrations/067_add_regex_action.sql
@@ -0,0 +1,30 @@
+-- Add Regex Extract and Link Action
+-- Allows configurable regex extraction and database linking workflows
+
+INSERT INTO email_workflow_actions (action_code, name, description, category, parameter_schema, example_config)
+VALUES (
+ 'regex_extract_and_link',
+ 'Regex Ekstrahering & Linking',
+ 'Søg efter mønstre (Regex) og link email til database matches',
+ 'linking',
+ '{
+ "type": "object",
+ "properties": {
+ "regex_pattern": {"type": "string", "title": "Regex Pattern (med 1 gruppe)"},
+ "target_table": {"type": "string", "enum": ["customers", "vendors", "users"], "title": "Tabel"},
+ "target_column": {"type": "string", "title": "Søge Kolonne"},
+ "link_column": {"type": "string", "title": "Link Kolonne i Email", "default": "customer_id"},
+ "value_column": {"type": "string", "title": "Værdi Kolonne", "default": "id"},
+ "on_match": {"type": "string", "enum": ["update_email", "none"], "default": "update_email", "title": "Handling"}
+ },
+ "required": ["regex_pattern", "target_table", "target_column"]
+ }',
+ '{
+ "regex_pattern": "CVR-nr\\.?:?\\s*(\\d{8})",
+ "target_table": "customers",
+ "target_column": "cvr_number",
+ "link_column": "customer_id",
+ "value_column": "id",
+ "on_match": "update_email"
+ }'
+) ON CONFLICT (action_code) DO NOTHING;
diff --git a/migrations/068_conversations_module.sql b/migrations/068_conversations_module.sql
new file mode 100644
index 0000000..11e3f44
--- /dev/null
+++ b/migrations/068_conversations_module.sql
@@ -0,0 +1,38 @@
+-- 068_conversations_module.sql
+
+-- Table for storing transcribed conversations (calls, voice notes)
+CREATE TABLE IF NOT EXISTS conversations (
+ id SERIAL PRIMARY KEY,
+ customer_id INTEGER REFERENCES customers(id) ON DELETE CASCADE,
+ ticket_id INTEGER REFERENCES tticket_tickets(id) ON DELETE SET NULL,
+ user_id INTEGER REFERENCES auth_users(id) ON DELETE SET NULL,
+ email_message_id INTEGER REFERENCES email_messages(id) ON DELETE SET NULL,
+
+ title VARCHAR(255) NOT NULL,
+ transcript TEXT, -- The full transcribed text
+ summary TEXT, -- AI generated summary (optional)
+
+ audio_file_path VARCHAR(500) NOT NULL,
+ duration_seconds INTEGER DEFAULT 0,
+
+ -- Privacy and Deletion
+ is_private BOOLEAN DEFAULT FALSE,
+ deleted_at TIMESTAMP, -- Soft delete
+
+ source VARCHAR(50) DEFAULT 'email', -- 'email', 'upload', 'phone_system'
+
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Index for linkage
+CREATE INDEX idx_conversations_customer ON conversations(customer_id);
+CREATE INDEX idx_conversations_ticket ON conversations(ticket_id);
+CREATE INDEX idx_conversations_user ON conversations(user_id);
+
+-- Full Text Search Index for Danish
+ALTER TABLE conversations ADD COLUMN search_vector tsvector GENERATED ALWAYS AS (
+ to_tsvector('danish', coalesce(title, '') || ' ' || coalesce(transcript, ''))
+) STORED;
+
+CREATE INDEX idx_conversations_search ON conversations USING GIN(search_vector);
diff --git a/migrations/069_conversation_category.sql b/migrations/069_conversation_category.sql
new file mode 100644
index 0000000..f7675d0
--- /dev/null
+++ b/migrations/069_conversation_category.sql
@@ -0,0 +1,5 @@
+-- 069_conversation_category.sql
+-- Add category column for conversation classification
+
+ALTER TABLE conversations ADD COLUMN category VARCHAR(50) DEFAULT 'General';
+COMMENT ON COLUMN conversations.category IS 'Conversation Category: General, Support, Sales, Internal, Meeting';
diff --git a/migrations/072_add_category_to_conversations.sql b/migrations/072_add_category_to_conversations.sql
new file mode 100644
index 0000000..5845aea
--- /dev/null
+++ b/migrations/072_add_category_to_conversations.sql
@@ -0,0 +1,4 @@
+-- 072_add_category_to_conversations.sql
+
+ALTER TABLE conversations ADD COLUMN category VARCHAR(50) DEFAULT 'General';
+COMMENT ON COLUMN conversations.category IS 'Category of the conversation (e.g. Sales, Support, General)';