diff --git a/VERSION b/VERSION
index 9b6e760..8ce3f6c 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2.2.52
+2.2.79
diff --git a/app/backups/backend/router.py b/app/backups/backend/router.py
index fabf791..d733ece 100644
--- a/app/backups/backend/router.py
+++ b/app/backups/backend/router.py
@@ -126,7 +126,25 @@ async def create_backup(backup: BackupCreate):
"message": "Full backup created successfully"
}
else:
- raise HTTPException(status_code=500, detail=f"Full backup partially failed: db={db_job_id}, files={files_job_id}")
+ db_error = None
+ failed_db_row = execute_query_single(
+ """
+ SELECT id, error_message
+ FROM backup_jobs
+ WHERE job_type = 'database'
+ AND status = 'failed'
+ ORDER BY created_at DESC
+ LIMIT 1
+ """
+ )
+ if failed_db_row:
+ db_error = failed_db_row.get("error_message")
+
+ detail = f"Full backup partially failed: db={db_job_id}, files={files_job_id}"
+ if db_error:
+ detail = f"{detail}. Database error: {db_error}"
+
+ raise HTTPException(status_code=500, detail=detail)
else:
raise HTTPException(status_code=400, detail="Invalid job_type. Must be: database, files, or full")
diff --git a/app/contacts/backend/router_simple.py b/app/contacts/backend/router_simple.py
index 0bcc023..349ddc1 100644
--- a/app/contacts/backend/router_simple.py
+++ b/app/contacts/backend/router_simple.py
@@ -131,7 +131,7 @@ async def get_contacts(
query = f"""
SELECT
c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
- c.title, c.department, c.is_active, c.created_at, c.updated_at,
+ c.title, c.department, c.user_company, c.is_active, c.created_at, c.updated_at,
COUNT(DISTINCT cc.customer_id) as company_count,
ARRAY_AGG(DISTINCT cu.name ORDER BY cu.name) FILTER (WHERE cu.name IS NOT NULL) as company_names
FROM contacts c
@@ -139,7 +139,7 @@ async def get_contacts(
LEFT JOIN customers cu ON cc.customer_id = cu.id
{where_sql}
GROUP BY c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
- c.title, c.department, c.is_active, c.created_at, c.updated_at
+ c.title, c.department, c.user_company, c.is_active, c.created_at, c.updated_at
ORDER BY company_count DESC, c.last_name, c.first_name
LIMIT %s OFFSET %s
"""
@@ -325,6 +325,114 @@ async def link_contact_to_company(contact_id: int, link: ContactCompanyLink):
raise HTTPException(status_code=500, detail=str(e))
+@router.post("/contacts/admin/backfill-company-links")
+async def backfill_contact_company_links(dry_run: bool = Query(default=True)):
+ """
+ Backfill missing contact_companies links by matching contacts.user_company to customers.name.
+
+ - Uses case-insensitive trimmed exact name matching
+ - Picks lowest customer ID if duplicate customer names exist
+ - Idempotent: will not create duplicate links
+ """
+ try:
+ # Contacts that have a company name on the contact row.
+ contacts_with_company = execute_query_single(
+ """
+ SELECT COUNT(*)::int AS count
+ FROM contacts c
+ WHERE c.user_company IS NOT NULL
+ AND TRIM(c.user_company) <> ''
+ """
+ )
+
+ # Contacts where the company name can be matched to a customer record.
+ matchable = execute_query_single(
+ """
+ WITH company_match AS (
+ SELECT LOWER(TRIM(name)) AS norm_name, MIN(id) AS customer_id
+ FROM customers
+ GROUP BY LOWER(TRIM(name))
+ )
+ SELECT COUNT(DISTINCT c.id)::int AS count
+ FROM contacts c
+ JOIN company_match cm ON LOWER(TRIM(c.user_company)) = cm.norm_name
+ WHERE c.user_company IS NOT NULL
+ AND TRIM(c.user_company) <> ''
+ """
+ )
+
+ # Contacts with no links at all (often the primary symptom).
+ unlinked = execute_query_single(
+ """
+ SELECT COUNT(*)::int AS count
+ FROM contacts c
+ WHERE c.user_company IS NOT NULL
+ AND TRIM(c.user_company) <> ''
+ AND NOT EXISTS (
+ SELECT 1 FROM contact_companies cc WHERE cc.contact_id = c.id
+ )
+ """
+ )
+
+ if dry_run:
+ return {
+ "dry_run": True,
+ "contacts_with_user_company": (contacts_with_company or {}).get("count", 0),
+ "matchable_contacts": (matchable or {}).get("count", 0),
+ "unlinked_contacts": (unlinked or {}).get("count", 0),
+ "message": "Dry run complete. Re-run with dry_run=false to insert links.",
+ }
+
+ inserted = execute_query(
+ """
+ WITH company_match AS (
+ SELECT LOWER(TRIM(name)) AS norm_name, MIN(id) AS customer_id
+ FROM customers
+ GROUP BY LOWER(TRIM(name))
+ ),
+ candidates AS (
+ SELECT
+ c.id AS contact_id,
+ cm.customer_id,
+ CASE
+ WHEN EXISTS (
+ SELECT 1 FROM contact_companies cc1
+ WHERE cc1.contact_id = c.id
+ ) THEN FALSE
+ ELSE TRUE
+ END AS is_primary
+ FROM contacts c
+ JOIN company_match cm ON LOWER(TRIM(c.user_company)) = cm.norm_name
+ WHERE c.user_company IS NOT NULL
+ AND TRIM(c.user_company) <> ''
+ )
+ INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
+ SELECT contact_id, customer_id, is_primary, 'inferred_user_company'
+ FROM candidates c
+ WHERE NOT EXISTS (
+ SELECT 1
+ FROM contact_companies cc
+ WHERE cc.contact_id = c.contact_id
+ AND cc.customer_id = c.customer_id
+ )
+ RETURNING contact_id, customer_id, is_primary
+ """
+ )
+
+ inserted_count = len(inserted or [])
+ logger.info("✅ Contact-company backfill inserted %s link(s)", inserted_count)
+
+ return {
+ "dry_run": False,
+ "inserted": inserted_count,
+ "sample": (inserted or [])[:20],
+ "message": "Backfill completed",
+ }
+ except Exception as e:
+ logger.error("Failed backfill_contact_company_links: %s", e, exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
@router.get("/contacts/{contact_id}/related-contacts")
async def get_related_contacts(contact_id: int):
"""Get contacts from the same companies as the contact (excluding itself)."""
diff --git a/app/contacts/frontend/contacts.html b/app/contacts/frontend/contacts.html
index b3464cf..706b593 100644
--- a/app/contacts/frontend/contacts.html
+++ b/app/contacts/frontend/contacts.html
@@ -989,9 +989,11 @@ function displayContacts(contacts) {
const companyCount = contact.company_count || 0;
const companyNames = contact.company_names || [];
- const companyDisplay = companyNames.length > 0
+ const fallbackCompany = (contact.user_company || '').trim();
+ const companyDisplay = companyNames.length > 0
? companyNames.slice(0, 2).join(', ') + (companyNames.length > 2 ? '...' : '')
- : '-';
+ : (fallbackCompany || '-');
+ const effectiveCompanyCount = companyCount > 0 ? companyCount : (fallbackCompany ? 1 : 0);
const fullName = `${contact.first_name || ''} ${contact.last_name || ''}`.trim();
const preferredPhone = contact.mobile || contact.phone || '';
const hasEmail = !!contact.email;
@@ -1001,7 +1003,7 @@ function displayContacts(contacts) {
const safeEmail = escapeHtml(contact.email || '-');
const safeTitle = escapeHtml(contact.title || '-');
const safePhone = escapeHtml(preferredPhone || '-');
- const companiesTitle = escapeHtml(companyNames.join(', '));
+ const companiesTitle = escapeHtml(companyNames.length ? companyNames.join(', ') : fallbackCompany);
const updatedAt = formatContactDate(contact.updated_at || contact.created_at);
return `
@@ -1040,7 +1042,7 @@ function displayContacts(contacts) {
${safeTitle} |
- ${companyCount}
+ ${effectiveCompanyCount}
${companyDisplay !== '-' ? ' ' + escapeHtml(companyDisplay) + ' ' : ''}
|
diff --git a/app/economy/__init__.py b/app/economy/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/economy/backend/__init__.py b/app/economy/backend/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/economy/backend/router.py b/app/economy/backend/router.py
new file mode 100644
index 0000000..5fe4ea4
--- /dev/null
+++ b/app/economy/backend/router.py
@@ -0,0 +1,530 @@
+import logging
+from collections import defaultdict
+from decimal import Decimal
+from typing import Any, Dict, List, Optional
+
+from fastapi import APIRouter, HTTPException, Query, Request
+from pydantic import BaseModel, Field
+
+from app.core.config import settings
+from app.core.database import execute_insert, execute_query, execute_query_single, execute_update
+from app.timetracking.backend.economic_export import economic_service
+from app.timetracking.backend.models import TModuleEconomicExportRequest
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/economy", tags=["Economy"])
+
+
+class BulkIdsRequest(BaseModel):
+ ids: List[int] = Field(..., min_length=1)
+
+
+class BulkUpdateRequest(BaseModel):
+ ids: List[int] = Field(..., min_length=1)
+ description: Optional[str] = None
+ original_hours: Optional[float] = Field(None, gt=0)
+ billable: Optional[bool] = None
+ billing_method: Optional[str] = None
+
+
+class BulkSoftDeleteRequest(BaseModel):
+ ids: List[int] = Field(..., min_length=1)
+ reason: Optional[str] = "Soft deleted from economy queue"
+
+
+class BulkApproveRequest(BaseModel):
+ ids: List[int] = Field(..., min_length=1)
+ billable: Optional[bool] = None
+ billing_method: Optional[str] = None
+
+
+class BulkPrepaidRequest(BaseModel):
+ ids: List[int] = Field(..., min_length=1)
+ prepaid_card_id: int = Field(..., gt=0)
+
+
+class BulkSendRequest(BaseModel):
+ ids: List[int] = Field(..., min_length=1)
+
+
+def _ensure_ids(ids: List[int]) -> List[int]:
+ clean = sorted(set(int(i) for i in ids if int(i) > 0))
+ if not clean:
+ raise HTTPException(status_code=400, detail="No valid ids provided")
+ return clean
+
+
+@router.get("/time-queue")
+async def list_hub_time_queue(
+ customer_id: Optional[int] = Query(None, gt=0),
+ status: Optional[str] = Query(None),
+ billable: Optional[bool] = Query(None),
+ q: Optional[str] = Query(None),
+ limit: int = Query(500, ge=1, le=2000),
+):
+ """List non-billed Hub-created time entries for the economy queue."""
+ try:
+ conditions = [
+ "t.vtiger_id IS NULL",
+ "t.billed_via_thehub_id IS NULL",
+ "t.status <> 'billed'",
+ ]
+ params: List[Any] = []
+
+ if customer_id is not None:
+ conditions.append("t.customer_id = %s")
+ params.append(customer_id)
+
+ if status:
+ conditions.append("t.status = %s")
+ params.append(status)
+
+ if billable is not None:
+ conditions.append("COALESCE(t.billable, true) = %s")
+ params.append(billable)
+
+ if q:
+ conditions.append(
+ "("
+ "COALESCE(t.description, '') ILIKE %s OR "
+ "COALESCE(cust.name, '') ILIKE %s OR "
+ "COALESCE(c.title, s.titel, '') ILIKE %s"
+ ")"
+ )
+ like = f"%{q}%"
+ params.extend([like, like, like])
+
+ where_sql = " AND ".join(conditions)
+
+ query = f"""
+ SELECT
+ t.id,
+ t.customer_id,
+ cust.name AS customer_name,
+ t.status,
+ t.entry_status,
+ t.billable,
+ t.billing_method,
+ t.prepaid_card_id,
+ t.fixed_price_agreement_id,
+ t.original_hours,
+ t.approved_hours,
+ t.rounded_to,
+ t.worked_date,
+ t.description,
+ t.entry_type,
+ t.kilde,
+ t.case_id,
+ t.sag_id,
+ COALESCE(c.title, s.titel, 'No title') AS case_title,
+ t.created_at,
+ t.updated_at
+ FROM tmodule_times t
+ LEFT JOIN tmodule_customers cust ON cust.id = t.customer_id
+ LEFT JOIN tmodule_cases c ON c.id = t.case_id
+ LEFT JOIN sag_sager s ON s.id = t.sag_id
+ WHERE {where_sql}
+ ORDER BY COALESCE(t.worked_date, DATE(t.created_at)) DESC, t.id DESC
+ LIMIT %s
+ """
+ params.append(limit)
+
+ rows = execute_query(query, tuple(params))
+ return {"items": rows, "count": len(rows)}
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error("Failed listing economy time queue: %s", e)
+ raise HTTPException(status_code=500, detail="Failed to list time queue")
+
+
+@router.get("/time-queue/customers")
+async def list_time_queue_customers():
+ """List customers that currently have queue-relevant (not billed) Hub entries."""
+ try:
+ rows = execute_query(
+ """
+ SELECT
+ t.customer_id,
+ COALESCE(cust.name, CONCAT('Kunde #', t.customer_id::text)) AS customer_name,
+ COUNT(*)::int AS open_count
+ FROM tmodule_times t
+ LEFT JOIN tmodule_customers cust ON cust.id = t.customer_id
+ WHERE t.customer_id IS NOT NULL
+ AND t.vtiger_id IS NULL
+ AND t.billed_via_thehub_id IS NULL
+ AND t.status = 'pending'
+ GROUP BY t.customer_id, cust.name
+ ORDER BY COALESCE(cust.name, CONCAT('Kunde #', t.customer_id::text)) ASC
+ """
+ )
+ return {"items": rows, "count": len(rows)}
+ except Exception as e:
+ logger.error("Failed listing time queue customers: %s", e)
+ raise HTTPException(status_code=500, detail="Failed listing customer filter options")
+
+
+@router.get("/time-queue/prepaid-cards")
+async def list_prepaid_cards():
+ try:
+ cards = execute_query(
+ """
+ SELECT id, card_number, customer_id, purchased_hours AS total_hours, used_hours, remaining_hours, status, expires_at
+ FROM tticket_prepaid_cards
+ WHERE status IN ('active', 'depleted')
+ ORDER BY remaining_hours DESC, id DESC
+ """
+ )
+ return {"items": cards, "count": len(cards)}
+ except Exception as e:
+ logger.error("Failed listing prepaid cards: %s", e)
+ raise HTTPException(status_code=500, detail="Failed to list prepaid cards")
+
+
+@router.patch("/time-queue/bulk-update")
+async def bulk_update_time_queue(payload: BulkUpdateRequest):
+ ids = _ensure_ids(payload.ids)
+
+ updates: List[str] = []
+ values: List[Any] = []
+
+ if payload.description is not None:
+ updates.append("description = %s")
+ values.append(payload.description)
+
+ if payload.original_hours is not None:
+ updates.append("original_hours = %s")
+ values.append(payload.original_hours)
+
+ if payload.billable is not None:
+ updates.append("billable = %s")
+ values.append(payload.billable)
+ if payload.billable is False and payload.billing_method is None:
+ updates.append("billing_method = 'internal'")
+
+ if payload.billing_method is not None:
+ updates.append("billing_method = %s")
+ values.append(payload.billing_method)
+
+ if not updates:
+ raise HTTPException(status_code=400, detail="No update fields provided")
+
+ try:
+ placeholders = ",".join(["%s"] * len(ids))
+ query = f"""
+ UPDATE tmodule_times
+ SET {", ".join(updates)}
+ WHERE id IN ({placeholders})
+ AND vtiger_id IS NULL
+ AND billed_via_thehub_id IS NULL
+ AND status <> 'billed'
+ """
+ execute_update(query, tuple(values + ids))
+ return {"success": True, "updated": len(ids)}
+ except Exception as e:
+ logger.error("Failed bulk update: %s", e)
+ raise HTTPException(status_code=500, detail="Failed bulk update")
+
+
+@router.post("/time-queue/bulk-soft-delete")
+async def bulk_soft_delete_time_queue(payload: BulkSoftDeleteRequest):
+ ids = _ensure_ids(payload.ids)
+ reason = (payload.reason or "Soft deleted from economy queue").strip()
+
+ try:
+ placeholders = ",".join(["%s"] * len(ids))
+ execute_update(
+ f"""
+ UPDATE tmodule_times
+ SET status = 'rejected',
+ entry_status = 'kladde',
+ approval_note = %s,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id IN ({placeholders})
+ AND vtiger_id IS NULL
+ AND billed_via_thehub_id IS NULL
+ AND status <> 'billed'
+ """,
+ tuple([reason] + ids),
+ )
+ return {"success": True, "soft_deleted": len(ids)}
+ except Exception as e:
+ logger.error("Failed bulk soft delete: %s", e)
+ raise HTTPException(status_code=500, detail="Failed bulk soft delete")
+
+
+@router.post("/time-queue/bulk-approve")
+async def bulk_approve_time_queue(payload: BulkApproveRequest):
+ ids = _ensure_ids(payload.ids)
+
+ try:
+ set_parts = [
+ "status = 'approved'",
+ "entry_status = 'godkendt'",
+ "approved_hours = COALESCE(approved_hours, original_hours)",
+ "approved_at = CURRENT_TIMESTAMP",
+ "updated_at = CURRENT_TIMESTAMP",
+ ]
+ params: List[Any] = []
+
+ if payload.billable is not None:
+ set_parts.append("billable = %s")
+ params.append(payload.billable)
+
+ if payload.billing_method is not None:
+ set_parts.append("billing_method = %s")
+ params.append(payload.billing_method)
+
+ placeholders = ",".join(["%s"] * len(ids))
+ query = f"""
+ UPDATE tmodule_times
+ SET {", ".join(set_parts)}
+ WHERE id IN ({placeholders})
+ AND vtiger_id IS NULL
+ AND billed_via_thehub_id IS NULL
+ AND status <> 'billed'
+ """
+ execute_update(query, tuple(params + ids))
+ return {"success": True, "approved": len(ids)}
+ except Exception as e:
+ logger.error("Failed bulk approve: %s", e)
+ raise HTTPException(status_code=500, detail="Failed bulk approve")
+
+
+@router.post("/time-queue/bulk-apply-prepaid")
+async def bulk_apply_prepaid(payload: BulkPrepaidRequest):
+ ids = _ensure_ids(payload.ids)
+
+ card = execute_query_single(
+ "SELECT id FROM tticket_prepaid_cards WHERE id = %s",
+ (payload.prepaid_card_id,),
+ )
+ if not card:
+ raise HTTPException(status_code=404, detail="Prepaid card not found")
+
+ try:
+ placeholders = ",".join(["%s"] * len(ids))
+ execute_update(
+ f"""
+ UPDATE tmodule_times
+ SET prepaid_card_id = %s,
+ billing_method = 'prepaid',
+ billable = TRUE,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id IN ({placeholders})
+ AND vtiger_id IS NULL
+ AND billed_via_thehub_id IS NULL
+ AND status <> 'billed'
+ """,
+ tuple([payload.prepaid_card_id] + ids),
+ )
+ return {"success": True, "updated": len(ids), "prepaid_card_id": payload.prepaid_card_id}
+ except Exception as e:
+ logger.error("Failed applying prepaid card: %s", e)
+ raise HTTPException(status_code=500, detail="Failed applying prepaid card")
+
+
+def _create_order_from_selected(customer_id: int, rows: List[Dict[str, Any]], user_id: Optional[int]) -> int:
+ customer = execute_query_single(
+ "SELECT id, hub_customer_id, name, hourly_rate FROM tmodule_customers WHERE id = %s",
+ (customer_id,),
+ )
+ if not customer:
+ raise HTTPException(status_code=404, detail=f"Customer {customer_id} not found")
+
+ hourly_rate = Decimal(str(customer.get("hourly_rate") or settings.TIMETRACKING_DEFAULT_HOURLY_RATE))
+
+ grouped: Dict[str, Dict[str, Any]] = defaultdict(lambda: {
+ "rows": [],
+ "case_title": "Time entries",
+ "case_id": None,
+ "sag_id": None,
+ })
+
+ for row in rows:
+ group_key = f"{row.get('case_id') or 0}:{row.get('sag_id') or 0}"
+ grouped[group_key]["rows"].append(row)
+ grouped[group_key]["case_title"] = row.get("case_title") or "Time entries"
+ grouped[group_key]["case_id"] = row.get("case_id")
+ grouped[group_key]["sag_id"] = row.get("sag_id")
+
+ line_payloads: List[Dict[str, Any]] = []
+ total_hours = Decimal("0")
+
+ for _, group in grouped.items():
+ qty = Decimal("0")
+ ids: List[int] = []
+ latest_date = None
+
+ for row in group["rows"]:
+ qty += Decimal(str(row.get("approved_hours") or row.get("original_hours") or 0))
+ ids.append(int(row["id"]))
+ wd = row.get("worked_date")
+ if wd and (latest_date is None or wd > latest_date):
+ latest_date = wd
+
+ line_total = (qty * hourly_rate).quantize(Decimal("0.01"))
+ line_payloads.append(
+ {
+ "description": group["case_title"],
+ "quantity": qty,
+ "line_total": line_total,
+ "time_entry_ids": ids,
+ "case_id": group["case_id"],
+ "sag_id": group["sag_id"],
+ "time_date": latest_date,
+ }
+ )
+ total_hours += qty
+
+ subtotal = (total_hours * hourly_rate).quantize(Decimal("0.01"))
+ vat_rate = Decimal("25.00")
+ vat_amount = (subtotal * vat_rate / Decimal("100")).quantize(Decimal("0.01"))
+ total_amount = subtotal + vat_amount
+
+ order_id = execute_insert(
+ """
+ INSERT INTO tmodule_orders
+ (customer_id, hub_customer_id, order_date, total_hours, hourly_rate,
+ subtotal, vat_rate, vat_amount, total_amount, status, created_by)
+ VALUES
+ (%s, %s, CURRENT_DATE, %s, %s, %s, %s, %s, %s, 'draft', %s)
+ RETURNING id
+ """,
+ (
+ customer_id,
+ customer.get("hub_customer_id"),
+ total_hours,
+ hourly_rate,
+ subtotal,
+ vat_rate,
+ vat_amount,
+ total_amount,
+ user_id,
+ ),
+ )
+
+ for idx, line in enumerate(line_payloads, start=1):
+ execute_insert(
+ """
+ INSERT INTO tmodule_order_lines
+ (order_id, case_id, sag_id, line_number, description, quantity, unit_price,
+ line_total, time_entry_ids, case_contact, time_date, is_travel)
+ VALUES
+ (%s, %s, %s, %s, %s, %s, %s, %s, %s, NULL, %s, FALSE)
+ RETURNING id
+ """,
+ (
+ order_id,
+ line["case_id"],
+ line["sag_id"],
+ idx,
+ line["description"],
+ line["quantity"],
+ hourly_rate,
+ line["line_total"],
+ line["time_entry_ids"],
+ line["time_date"],
+ ),
+ )
+
+ return int(order_id)
+
+
+@router.post("/time-queue/send-to-invoices")
+async def send_selected_to_invoices(payload: BulkSendRequest, request: Request):
+ ids = _ensure_ids(payload.ids)
+ user_id = getattr(request.state, "user_id", None)
+
+ try:
+ placeholders = ",".join(["%s"] * len(ids))
+ rows = execute_query(
+ f"""
+ SELECT
+ t.id,
+ t.customer_id,
+ t.case_id,
+ t.sag_id,
+ t.status,
+ t.billable,
+ t.billing_method,
+ t.original_hours,
+ t.approved_hours,
+ t.worked_date,
+ COALESCE(c.title, s.titel, 'Time entries') AS case_title
+ FROM tmodule_times t
+ LEFT JOIN tmodule_cases c ON c.id = t.case_id
+ LEFT JOIN sag_sager s ON s.id = t.sag_id
+ WHERE t.id IN ({placeholders})
+ AND t.vtiger_id IS NULL
+ AND t.billed_via_thehub_id IS NULL
+ AND t.status <> 'billed'
+ """,
+ tuple(ids),
+ )
+
+ if not rows:
+ raise HTTPException(status_code=400, detail="No eligible entries found")
+
+ # Ensure selected invoice candidates are approved and invoice-billable.
+ selected_invoice_ids = [
+ int(r["id"])
+ for r in rows
+ if bool(r.get("billable", True))
+ and (r.get("billing_method") or "invoice") == "invoice"
+ ]
+
+ if not selected_invoice_ids:
+ raise HTTPException(status_code=400, detail="No selected entries are invoice-billable")
+
+ placeholders_invoice = ",".join(["%s"] * len(selected_invoice_ids))
+ execute_update(
+ f"""
+ UPDATE tmodule_times
+ SET status = 'approved',
+ entry_status = 'godkendt',
+ approved_hours = COALESCE(approved_hours, original_hours),
+ approved_at = COALESCE(approved_at, CURRENT_TIMESTAMP),
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id IN ({placeholders_invoice})
+ AND status <> 'billed'
+ """,
+ tuple(selected_invoice_ids),
+ )
+
+ rows_by_customer: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
+ for row in rows:
+ if int(row["id"]) in selected_invoice_ids:
+ rows_by_customer[int(row["customer_id"])].append(row)
+
+ export_results = []
+ for cust_id, cust_rows in rows_by_customer.items():
+ order_id = _create_order_from_selected(cust_id, cust_rows, user_id)
+ export_result = await economic_service.export_order(
+ TModuleEconomicExportRequest(order_id=order_id, force=False),
+ user_id=user_id,
+ )
+ export_results.append(
+ {
+ "customer_id": cust_id,
+ "order_id": order_id,
+ "success": bool(export_result.success),
+ "dry_run": bool(export_result.dry_run),
+ "message": export_result.message,
+ "economic_draft_id": export_result.economic_draft_id,
+ "economic_order_number": export_result.economic_order_number,
+ }
+ )
+
+ return {
+ "success": True,
+ "selected": len(ids),
+ "invoice_candidates": len(selected_invoice_ids),
+ "exports": export_results,
+ }
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error("Failed send-to-invoices flow: %s", e)
+ raise HTTPException(status_code=500, detail="Failed sending selected entries to invoices")
diff --git a/app/economy/frontend/__init__.py b/app/economy/frontend/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/economy/frontend/time_queue.html b/app/economy/frontend/time_queue.html
new file mode 100644
index 0000000..7ecaf98
--- /dev/null
+++ b/app/economy/frontend/time_queue.html
@@ -0,0 +1,501 @@
+{% extends "shared/frontend/base.html" %}
+
+{% block title %}Economy Time Queue{% endblock %}
+
+{% block content %}
+
+
+
+
Economy Time Queue
+
Hub-created, non-billed time entries.
+
+
+ 0 selected
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/app/economy/frontend/views.py b/app/economy/frontend/views.py
new file mode 100644
index 0000000..b3d8581
--- /dev/null
+++ b/app/economy/frontend/views.py
@@ -0,0 +1,14 @@
+from fastapi import APIRouter, Request
+from fastapi.responses import HTMLResponse
+from fastapi.templating import Jinja2Templates
+
+router = APIRouter()
+templates = Jinja2Templates(directory="app")
+
+
+@router.get("/economy/time-queue", response_class=HTMLResponse)
+async def economy_time_queue_page(request: Request):
+ return templates.TemplateResponse(
+ "economy/frontend/time_queue.html",
+ {"request": request, "title": "Economy Time Queue"},
+ )
diff --git a/app/settings/frontend/settings.html b/app/settings/frontend/settings.html
index 5c3b1d2..336a72a 100644
--- a/app/settings/frontend/settings.html
+++ b/app/settings/frontend/settings.html
@@ -1084,6 +1084,7 @@
+
Klik på en virksomhed for at se feltændringer og rette dem on-the-fly.
@@ -1117,6 +1118,55 @@
+
+