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 + + + +
+
+ +
+
+
+ + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + +
+
+
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + +
IDCustomerDateCaseHoursStatusBillableMethodHours editDescriptionActions
Loading...
+
+
+
+
+ + +{% 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 @@
Ville blive opdateret
+
Klik på en virksomhed for at se feltændringer og rette dem on-the-fly.
@@ -1117,6 +1118,55 @@ + +