feat: Implement fixed-price agreements frontend views and related templates

- Added views for listing fixed-price agreements, displaying agreement details, and a reporting dashboard.
- Created HTML templates for listing, detailing, and reporting on fixed-price agreements.
- Introduced API endpoint to fetch active customers for agreement creation.
- Added migration scripts for creating necessary database tables and views for fixed-price agreements, billing periods, and reporting.
- Implemented triggers for auto-generating agreement numbers and updating timestamps.
- Enhanced ticket management with archived ticket views and filtering capabilities.
This commit is contained in:
Christian 2026-02-08 01:45:00 +01:00
parent b43e9f797d
commit e4b9091a1b
34 changed files with 4950 additions and 214 deletions

View File

@ -175,7 +175,7 @@ class AuthService:
# Store session for token revocation (skip for shadow admin) # Store session for token revocation (skip for shadow admin)
if not is_shadow_admin: if not is_shadow_admin:
execute_insert( execute_update(
"""INSERT INTO sessions (user_id, token_jti, expires_at) """INSERT INTO sessions (user_id, token_jti, expires_at)
VALUES (%s, %s, %s)""", VALUES (%s, %s, %s)""",
(user_id, jti, expire) (user_id, jti, expire)

View File

@ -126,6 +126,12 @@ class Settings(BaseSettings):
SIMPLYCRM_URL: str = "" SIMPLYCRM_URL: str = ""
SIMPLYCRM_USERNAME: str = "" SIMPLYCRM_USERNAME: str = ""
SIMPLYCRM_API_KEY: str = "" SIMPLYCRM_API_KEY: str = ""
SIMPLYCRM_TICKET_MODULE: str = "Tickets"
SIMPLYCRM_TICKET_COMMENT_MODULE: str = "ModComments"
SIMPLYCRM_TICKET_COMMENT_RELATION_FIELD: str = "related_to"
SIMPLYCRM_TICKET_EMAIL_MODULE: str = "Emails"
SIMPLYCRM_TICKET_EMAIL_RELATION_FIELD: str = "parent_id"
SIMPLYCRM_TICKET_EMAIL_FALLBACK_RELATION_FIELD: str = "related_to"
# Backup System Configuration # Backup System Configuration
BACKUP_ENABLED: bool = True BACKUP_ENABLED: bool = True
@ -178,6 +184,9 @@ class Settings(BaseSettings):
REMINDERS_CHECK_INTERVAL_MINUTES: int = 5 REMINDERS_CHECK_INTERVAL_MINUTES: int = 5
REMINDERS_MAX_PER_USER_PER_HOUR: int = 5 REMINDERS_MAX_PER_USER_PER_HOUR: int = 5
REMINDERS_QUEUE_BATCH_SIZE: int = 10 REMINDERS_QUEUE_BATCH_SIZE: int = 10
# Dev-only shortcuts
DEV_ALLOW_ARCHIVED_IMPORT: bool = False
# Deployment Configuration (used by Docker/Podman) # Deployment Configuration (used by Docker/Podman)
POSTGRES_USER: str = "bmc_hub" POSTGRES_USER: str = "bmc_hub"

View File

@ -0,0 +1 @@
"""Fixed-Price Agreement Module"""

View File

@ -0,0 +1 @@
"""Backend package"""

View File

@ -0,0 +1,194 @@
"""
Fixed-Price Agreement Models
Pydantic schemas for API validation
"""
from typing import Optional, Literal
from pydantic import BaseModel, Field, field_validator, model_validator
from datetime import date, datetime
from decimal import Decimal
# Type aliases
PeriodType = Literal['calendar_month', 'rolling_30days', 'quarterly', 'yearly']
AgreementStatus = Literal['active', 'suspended', 'expired', 'cancelled', 'pending_cancellation']
BillingPeriodStatus = Literal['active', 'pending_approval', 'ready_to_bill', 'billed', 'cancelled']
class FixedPriceAgreementBase(BaseModel):
"""Base schema with common fields"""
customer_id: int
customer_name: Optional[str] = None
monthly_hours: Decimal = Field(gt=0)
monthly_amount: Optional[Decimal] = Field(default=None, ge=0) # Fast månedspris
hourly_rate: Optional[Decimal] = Field(default=None, ge=0) # Beregnes fra monthly_amount hvis ikke givet
overtime_rate: Decimal = Field(ge=0)
internal_cost_rate: Decimal = Field(default=Decimal("350.00"), ge=0)
rounding_minutes: int = Field(default=0, ge=0, le=60)
notes: Optional[str] = None
@field_validator('rounding_minutes')
@classmethod
def validate_rounding(cls, v: int) -> int:
if v not in (0, 15, 30, 60):
raise ValueError('rounding_minutes must be 0, 15, 30, or 60')
return v
@model_validator(mode='after')
def compute_hourly_rate(self):
"""Beregn hourly_rate fra monthly_amount hvis ikke direkte angivet"""
if self.hourly_rate is None:
if self.monthly_amount is not None and self.monthly_hours:
self.hourly_rate = self.monthly_amount / self.monthly_hours
else:
raise ValueError('Either hourly_rate or monthly_amount must be provided')
return self
class FixedPriceAgreementCreate(FixedPriceAgreementBase):
"""Schema for creating new agreement"""
subscription_id: Optional[int] = None
# Contract terms
start_date: date
binding_months: int = Field(default=0, ge=0)
end_date: Optional[date] = None
notice_period_days: int = Field(default=30, ge=0)
auto_renew: bool = False
# e-conomic integration
economic_product_number: Optional[str] = None
economic_overtime_product_number: Optional[str] = None
@field_validator('end_date')
@classmethod
def validate_end_date(cls, v: Optional[date], info) -> Optional[date]:
if v and 'start_date' in info.data and v < info.data['start_date']:
raise ValueError('end_date must be after start_date')
return v
class FixedPriceAgreementUpdate(BaseModel):
"""Schema for updating existing agreement"""
monthly_hours: Optional[Decimal] = Field(default=None, gt=0)
hourly_rate: Optional[Decimal] = Field(default=None, ge=0)
overtime_rate: Optional[Decimal] = Field(default=None, ge=0)
internal_cost_rate: Optional[Decimal] = Field(default=None, ge=0)
rounding_minutes: Optional[int] = Field(default=None, ge=0, le=60)
end_date: Optional[date] = None
notice_period_days: Optional[int] = Field(default=None, ge=0)
auto_renew: Optional[bool] = None
billing_enabled: Optional[bool] = None
notes: Optional[str] = None
@field_validator('rounding_minutes')
@classmethod
def validate_rounding(cls, v: Optional[int]) -> Optional[int]:
if v is not None and v not in (0, 15, 30, 60):
raise ValueError('rounding_minutes must be 0, 15, 30, or 60')
return v
class FixedPriceAgreement(FixedPriceAgreementBase):
"""Full agreement response schema"""
id: int
agreement_number: str
subscription_id: Optional[int]
start_date: date
binding_months: int
binding_end_date: Optional[date]
end_date: Optional[date]
notice_period_days: int
auto_renew: bool
status: AgreementStatus
cancellation_requested_date: Optional[date]
cancellation_effective_date: Optional[date]
cancelled_by_user_id: Optional[int]
cancellation_reason: Optional[str]
billing_enabled: bool
last_billed_period: Optional[date]
economic_product_number: Optional[str]
economic_overtime_product_number: Optional[str]
created_by_user_id: Optional[int]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class CancellationRequest(BaseModel):
"""Schema for agreement cancellation"""
reason: str = Field(min_length=1)
effective_date: Optional[date] = None
force: bool = False # Admin override for binding period
@field_validator('reason')
@classmethod
def validate_reason(cls, v: str) -> str:
if not v or not v.strip():
raise ValueError('cancellation reason is required')
return v.strip()
class BillingPeriodBase(BaseModel):
"""Base schema for billing periods"""
agreement_id: int
period_start: date
period_end: date
period_type: PeriodType = 'calendar_month'
included_hours: Decimal = Field(gt=0)
base_amount: Decimal = Field(ge=0)
class BillingPeriodCreate(BillingPeriodBase):
"""Schema for creating billing period"""
pass
class BillingPeriod(BillingPeriodBase):
"""Full billing period response"""
id: int
used_hours: Decimal
overtime_hours: Decimal
remaining_hours: Decimal
overtime_amount: Decimal
overtime_approved: bool
status: BillingPeriodStatus
billed_at: Optional[datetime]
economic_invoice_number: Optional[str]
invoice_id: Optional[int]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class BillingPeriodApproval(BaseModel):
"""Schema for approving overtime"""
overtime_hours: Decimal = Field(ge=0)
approved: bool
approved_by_user_id: Optional[int] = None
notes: Optional[str] = None
class AgreementPerformance(BaseModel):
"""Schema for performance metrics"""
id: int
agreement_number: str
customer_name: str
status: AgreementStatus
total_periods: int
total_used_hours: Decimal
total_revenue: Decimal
total_internal_cost: Decimal
total_profit: Decimal
utilization_percent: Decimal
class Config:
from_attributes = True

View File

@ -0,0 +1,769 @@
"""
Fixed-Price Agreement Router
CRUD operations, billing period management, and reporting
"""
from fastapi import APIRouter, HTTPException, Response
from fastapi.responses import StreamingResponse
from app.core.database import execute_query
from app.fixed_price.backend.models import (
FixedPriceAgreement,
FixedPriceAgreementCreate,
FixedPriceAgreementUpdate,
CancellationRequest,
BillingPeriod,
BillingPeriodCreate,
BillingPeriodApproval,
)
from typing import List, Optional, Dict, Any
from datetime import date, datetime, timedelta
from decimal import Decimal, ROUND_CEILING
from calendar import monthrange
import logging
import csv
from io import StringIO
logger = logging.getLogger(__name__)
router = APIRouter()
def _apply_rounding(hours: Decimal, rounding_minutes: int) -> Decimal:
"""Apply rounding to hours based on interval (same as prepaid cards)"""
if rounding_minutes <= 0:
return hours
interval = Decimal(rounding_minutes) / Decimal(60)
rounded = (hours / interval).to_integral_value(ROUND_CEILING) * interval
return rounded
def _last_day_of_month(dt: date) -> date:
"""Get last day of month for given date"""
last_day = monthrange(dt.year, dt.month)[1]
return date(dt.year, dt.month, last_day)
def _calculate_prorated_amount(monthly_hours: float, hourly_rate: float,
period_start: date, period_end: date) -> float:
"""
Calculate pro-rated amount based on actual days in period.
If period is a full calendar month, returns full monthly amount.
Otherwise, calculates daily rate and multiplies by days in period.
"""
# Full month amount
monthly_amount = monthly_hours * hourly_rate
# Check if period is a full month (starts on 1st and ends on last day)
last_day_of_month = monthrange(period_start.year, period_start.month)[1]
if period_start.day == 1 and period_end.day == last_day_of_month and period_start.month == period_end.month:
return monthly_amount
# Calculate pro-rated amount for partial month
days_in_month = monthrange(period_start.year, period_start.month)[1]
days_in_period = (period_end - period_start).days + 1 # +1 to include both start and end
daily_rate = monthly_amount / days_in_month
prorated_amount = daily_rate * days_in_period
return prorated_amount
# ============================================================================
# CRUD Operations
# ============================================================================
@router.get("/fixed-price-agreements", response_model=List[Dict[str, Any]])
async def get_agreements(
customer_id: Optional[int] = None,
status: Optional[str] = None,
include_current_period: bool = True
):
"""
Get all fixed-price agreements with optional filters
"""
try:
filters = []
params = []
if customer_id:
filters.append("customer_id = %s")
params.append(customer_id)
if status:
filters.append("status = %s")
params.append(status)
where_clause = "WHERE " + " AND ".join(filters) if filters else ""
agreements = execute_query(f"""
SELECT * FROM customer_fixed_price_agreements
{where_clause}
ORDER BY created_at DESC
""", params if params else None)
# Enrich with current period info
if include_current_period and agreements:
for agr in agreements:
period = execute_query("""
SELECT
used_hours,
remaining_hours,
overtime_hours,
status
FROM fixed_price_billing_periods
WHERE agreement_id = %s
AND period_start <= CURRENT_DATE
AND period_end >= CURRENT_DATE
ORDER BY period_start DESC
LIMIT 1
""", (agr['id'],))
if period and len(period) > 0:
agr['current_period'] = period[0]
agr['remaining_hours_this_month'] = float(period[0]['remaining_hours'])
else:
agr['current_period'] = None
agr['remaining_hours_this_month'] = float(agr['monthly_hours'])
return agreements or []
except Exception as e:
logger.error(f"❌ Error fetching agreements: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/fixed-price-agreements/{agreement_id}", response_model=Dict[str, Any])
async def get_agreement(agreement_id: int):
"""
Get single agreement with full details including periods and timelogs
"""
try:
agreements = execute_query("""
SELECT * FROM customer_fixed_price_agreements
WHERE id = %s
""", (agreement_id,))
if not agreements or len(agreements) == 0:
raise HTTPException(status_code=404, detail="Agreement not found")
agreement = agreements[0]
# Get billing periods
periods = execute_query("""
SELECT * FROM fixed_price_billing_periods
WHERE agreement_id = %s
ORDER BY period_start DESC
""", (agreement_id,))
agreement['billing_periods'] = periods or []
# Get timelogs (similar to prepaid card detail)
sag_logs = execute_query("""
SELECT
t.id,
t.worked_date,
t.original_hours as actual_hours,
t.approved_hours as rounded_hours,
t.description,
t.sag_id as source_id,
'sag' as source,
s.titel as source_title
FROM tmodule_times t
LEFT JOIN tmodule_sag s ON t.sag_id = s.id
WHERE t.fixed_price_agreement_id = %s
ORDER BY t.worked_date DESC
""", (agreement_id,))
ticket_logs = execute_query("""
SELECT
w.id,
w.created_at::date as worked_date,
w.hours as actual_hours,
w.rounded_hours,
w.description,
w.ticket_id as source_id,
'ticket' as source,
t.ticket_number,
t.subject as source_title
FROM tticket_worklog w
LEFT JOIN tticket_tickets t ON w.ticket_id = t.id
WHERE w.fixed_price_agreement_id = %s
ORDER BY w.created_at DESC
""", (agreement_id,))
# Combine and sort timelogs
timelogs = []
for log in (sag_logs or []):
timelogs.append(log)
for log in (ticket_logs or []):
timelogs.append(log)
timelogs.sort(key=lambda x: x['worked_date'], reverse=True)
agreement['timelogs'] = timelogs
return agreement
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error fetching agreement {agreement_id}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/fixed-price-agreements", response_model=Dict[str, Any])
async def create_agreement(data: FixedPriceAgreementCreate):
"""
Create new fixed-price agreement and initialize first billing period
"""
try:
# Validate rounding
if data.rounding_minutes not in (0, 15, 30, 60):
raise HTTPException(status_code=400, detail="Invalid rounding_minutes")
# Insert agreement
from app.core.database import get_db_connection, release_db_connection
from psycopg2.extras import RealDictCursor
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
# Create agreement
cursor.execute("""
INSERT INTO customer_fixed_price_agreements (
customer_id, customer_name, subscription_id,
monthly_hours, hourly_rate, overtime_rate, internal_cost_rate,
rounding_minutes, start_date, binding_months, end_date,
notice_period_days, auto_renew, economic_product_number,
economic_overtime_product_number, notes
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
""", (
data.customer_id,
data.customer_name,
data.subscription_id,
data.monthly_hours,
data.hourly_rate,
data.overtime_rate,
data.internal_cost_rate,
data.rounding_minutes,
data.start_date,
data.binding_months,
data.end_date,
data.notice_period_days,
data.auto_renew,
data.economic_product_number,
data.economic_overtime_product_number,
data.notes
))
agreement = cursor.fetchone()
# Create first billing period
period_start = data.start_date
period_end = _last_day_of_month(period_start)
# Calculate pro-rated amount for first period
base_amount = _calculate_prorated_amount(
data.monthly_hours,
data.hourly_rate,
period_start,
period_end
)
# Pro-rate included hours as well for partial months
days_in_month = monthrange(period_start.year, period_start.month)[1]
days_in_period = (period_end - period_start).days + 1
last_day_of_month = monthrange(period_start.year, period_start.month)[1]
if period_start.day == 1 and period_end.day == last_day_of_month:
# Full month
included_hours = data.monthly_hours
else:
# Pro-rate hours for partial month
included_hours = (data.monthly_hours / days_in_month) * days_in_period
cursor.execute("""
INSERT INTO fixed_price_billing_periods (
agreement_id, period_start, period_end,
included_hours, base_amount
) VALUES (%s, %s, %s, %s, %s)
RETURNING *
""", (
agreement['id'],
period_start,
period_end,
included_hours,
base_amount
))
first_period = cursor.fetchone()
conn.commit()
logger.info(f"✅ Created fixed-price agreement {agreement['agreement_number']} for customer {data.customer_id}")
agreement['first_period'] = first_period
return agreement
finally:
release_db_connection(conn)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error creating agreement: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/fixed-price-agreements/{agreement_id}", response_model=Dict[str, Any])
async def update_agreement(agreement_id: int, data: FixedPriceAgreementUpdate):
"""
Update agreement terms (does not affect existing periods)
"""
try:
# Build UPDATE query dynamically
updates = []
params = []
for field, value in data.model_dump(exclude_unset=True).items():
if value is not None:
updates.append(f"{field} = %s")
params.append(value)
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
params.append(agreement_id)
result = execute_query(f"""
UPDATE customer_fixed_price_agreements
SET {", ".join(updates)}
WHERE id = %s
RETURNING *
""", params)
if not result or len(result) == 0:
raise HTTPException(status_code=404, detail="Agreement not found")
logger.info(f"✅ Updated fixed-price agreement {agreement_id}")
return result[0]
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error updating agreement {agreement_id}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.put("/fixed-price-agreements/{agreement_id}/status")
async def update_status(agreement_id: int, status: str):
"""
Update agreement status (active, suspended, etc.)
"""
try:
valid_statuses = ['active', 'suspended', 'expired', 'cancelled', 'pending_cancellation']
if status not in valid_statuses:
raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of {valid_statuses}")
result = execute_query("""
UPDATE customer_fixed_price_agreements
SET status = %s
WHERE id = %s
RETURNING *
""", (status, agreement_id))
if not result or len(result) == 0:
raise HTTPException(status_code=404, detail="Agreement not found")
logger.info(f"✅ Agreement {agreement_id} status → {status}")
return result[0]
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error updating status: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/fixed-price-agreements/{agreement_id}/cancel")
async def cancel_agreement(agreement_id: int, request: CancellationRequest):
"""
Request cancellation with binding period validation
"""
try:
agreements = execute_query("""
SELECT * FROM customer_fixed_price_agreements
WHERE id = %s
""", (agreement_id,))
if not agreements or len(agreements) == 0:
raise HTTPException(status_code=404, detail="Agreement not found")
agreement = agreements[0]
today = date.today()
binding_end = agreement['binding_end_date']
# Check binding period
if binding_end and today < binding_end and not request.force:
raise HTTPException(
status_code=400,
detail=f"Aftale er bundet til {binding_end}. Kontakt administrator for tvungen opsigelse."
)
# Calculate effective date
effective_date = request.effective_date or (today + timedelta(days=agreement['notice_period_days']))
# Update agreement
result = execute_query("""
UPDATE customer_fixed_price_agreements
SET status = 'pending_cancellation',
cancellation_requested_date = %s,
cancellation_effective_date = %s,
cancellation_reason = %s
WHERE id = %s
RETURNING *
""", (today, effective_date, request.reason, agreement_id))
logger.info(f"⚠️ Agreement {agreement_id} cancellation requested, effective {effective_date}")
return result[0]
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error cancelling agreement: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# Billing Period Management
# ============================================================================
@router.get("/fixed-price-agreements/{agreement_id}/periods", response_model=List[Dict[str, Any]])
async def get_periods(agreement_id: int):
"""Get all billing periods for agreement"""
try:
periods = execute_query("""
SELECT * FROM fixed_price_billing_periods
WHERE agreement_id = %s
ORDER BY period_start DESC
""", (agreement_id,))
return periods or []
except Exception as e:
logger.error(f"❌ Error fetching periods: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.put("/fixed-price-agreements/{agreement_id}/periods/{period_id}/approve-overtime")
async def approve_overtime(agreement_id: int, period_id: int, approval: BillingPeriodApproval):
"""
Approve overtime hours for billing
"""
try:
# Calculate overtime amount
agreements = execute_query("""
SELECT overtime_rate FROM customer_fixed_price_agreements
WHERE id = %s
""", (agreement_id,))
if not agreements or len(agreements) == 0:
raise HTTPException(status_code=404, detail="Agreement not found")
overtime_rate = agreements[0]['overtime_rate']
overtime_amount = approval.overtime_hours * Decimal(str(overtime_rate))
# Update period
result = execute_query("""
UPDATE fixed_price_billing_periods
SET overtime_amount = %s,
overtime_approved = %s,
status = CASE
WHEN %s THEN 'ready_to_bill'
ELSE status
END
WHERE id = %s AND agreement_id = %s
RETURNING *
""", (overtime_amount, approval.approved, approval.approved, period_id, agreement_id))
if not result or len(result) == 0:
raise HTTPException(status_code=404, detail="Period not found")
logger.info(f"✅ Overtime approved for period {period_id}: {approval.overtime_hours}t = {overtime_amount} DKK")
return result[0]
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error approving overtime: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# Reporting & Analytics
# ============================================================================
@router.get("/fixed-price-agreements/stats/summary")
async def get_stats_summary():
"""
Overall fixed-price system statistics
"""
try:
result = execute_query("""
SELECT
COUNT(DISTINCT id) as total_agreements,
COUNT(DISTINCT id) FILTER (WHERE status = 'active') as active_agreements,
COUNT(DISTINCT id) FILTER (WHERE status = 'cancelled') as cancelled_agreements,
COUNT(DISTINCT id) FILTER (WHERE status = 'expired') as expired_agreements,
SUM(monthly_hours) FILTER (WHERE status = 'active') as total_active_monthly_hours,
AVG(hourly_rate) FILTER (WHERE status = 'active') as avg_hourly_rate,
COUNT(DISTINCT customer_id) as unique_customers
FROM customer_fixed_price_agreements
""")[0]
# Get revenue and profit from performance view
performance = execute_query("""
SELECT
COALESCE(SUM(total_revenue), 0) as total_revenue,
COALESCE(SUM(total_internal_cost), 0) as total_cost,
COALESCE(SUM(total_profit), 0) as total_profit,
COALESCE(AVG(utilization_percent), 0) as avg_utilization
FROM fixed_price_agreement_performance
WHERE status = 'active'
""")[0]
return {**result, **performance}
except Exception as e:
logger.error(f"❌ Error fetching stats: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/fixed-price-agreements/reports/profitability")
async def get_profitability_report(
customer_id: Optional[int] = None,
start_date: Optional[date] = None,
end_date: Optional[date] = None
):
"""
Detailed profitability analysis with filters
"""
try:
filters = []
params = []
if customer_id:
filters.append("a.customer_id = %s")
params.append(customer_id)
if start_date:
filters.append("bp.period_start >= %s")
params.append(start_date)
if end_date:
filters.append("bp.period_end <= %s")
params.append(end_date)
where_clause = "WHERE " + " AND ".join(filters) if filters else ""
return execute_query(f"""
SELECT
a.id,
a.agreement_number,
a.customer_name,
a.monthly_hours,
a.hourly_rate,
a.internal_cost_rate,
COUNT(bp.id) as period_count,
COALESCE(SUM(bp.used_hours), 0) as total_hours,
COALESCE(SUM(bp.overtime_hours) FILTER (WHERE bp.overtime_approved), 0) as overtime_hours,
COALESCE(SUM(bp.base_amount) FILTER (WHERE bp.status = 'billed'), 0) as base_revenue,
COALESCE(SUM(bp.overtime_amount) FILTER (WHERE bp.status = 'billed' AND bp.overtime_approved), 0) as overtime_revenue,
COALESCE(SUM(bp.base_amount + COALESCE(bp.overtime_amount, 0)) FILTER (WHERE bp.status = 'billed'), 0) as total_revenue,
COALESCE(SUM(bp.used_hours), 0) * a.internal_cost_rate as internal_cost,
COALESCE(SUM(bp.base_amount + COALESCE(bp.overtime_amount, 0)) FILTER (WHERE bp.status = 'billed'), 0) -
COALESCE(SUM(bp.used_hours), 0) * a.internal_cost_rate as profit,
CASE
WHEN SUM(bp.base_amount + COALESCE(bp.overtime_amount, 0)) FILTER (WHERE bp.status = 'billed') > 0
THEN ROUND((
(SUM(bp.base_amount + COALESCE(bp.overtime_amount, 0)) FILTER (WHERE bp.status = 'billed') -
SUM(bp.used_hours) * a.internal_cost_rate) /
SUM(bp.base_amount + COALESCE(bp.overtime_amount, 0)) FILTER (WHERE bp.status = 'billed') * 100
)::numeric, 1)
ELSE 0
END as profit_margin_percent
FROM customer_fixed_price_agreements a
LEFT JOIN fixed_price_billing_periods bp ON a.id = bp.agreement_id
{where_clause}
GROUP BY a.id
ORDER BY profit DESC
""", params if params else None) or []
except Exception as e:
logger.error(f"❌ Error generating profitability report: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/fixed-price-agreements/reports/monthly-trends")
async def get_monthly_trends(months: int = 12):
"""
Month-over-month trend analysis
"""
try:
return execute_query("""
SELECT * FROM fixed_price_monthly_trends
ORDER BY month DESC
LIMIT %s
""", (months,)) or []
except Exception as e:
logger.error(f"❌ Error fetching trends: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/fixed-price-agreements/reports/customer-breakdown")
async def get_customer_breakdown():
"""
Per-customer revenue and profitability
"""
try:
return execute_query("""
SELECT * FROM fixed_price_customer_summary
ORDER BY total_revenue DESC
""") or []
except Exception as e:
logger.error(f"❌ Error fetching customer breakdown: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/fixed-price-agreements/reports/overtime-analysis")
async def get_overtime_analysis():
"""
Analyze overtime patterns to identify agreements with frequent overruns
"""
try:
return execute_query("""
SELECT
a.id,
a.agreement_number,
a.customer_name,
a.monthly_hours,
COUNT(bp.id) as total_periods,
COUNT(bp.id) FILTER (WHERE bp.overtime_hours > 0) as periods_with_overtime,
ROUND((COUNT(bp.id) FILTER (WHERE bp.overtime_hours > 0)::numeric /
NULLIF(COUNT(bp.id), 0) * 100), 1) as overtime_frequency_percent,
AVG(bp.overtime_hours) FILTER (WHERE bp.overtime_hours > 0) as avg_overtime_per_period,
MAX(bp.overtime_hours) as max_overtime_single_period,
COALESCE(SUM(bp.overtime_hours), 0) as total_overtime_hours,
COALESCE(SUM(bp.overtime_amount) FILTER (WHERE bp.overtime_approved), 0) as total_overtime_revenue
FROM customer_fixed_price_agreements a
LEFT JOIN fixed_price_billing_periods bp ON a.id = bp.agreement_id
WHERE a.status = 'active'
GROUP BY a.id
HAVING COUNT(bp.id) > 0
ORDER BY overtime_frequency_percent DESC, total_overtime_hours DESC
""") or []
except Exception as e:
logger.error(f"❌ Error analyzing overtime: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/fixed-price-agreements/{agreement_id}/reports/period-detail")
async def get_period_detail_report(agreement_id: int):
"""
Detailed period-by-period breakdown for single agreement
"""
try:
return execute_query("""
SELECT
bp.id,
bp.period_start,
bp.period_end,
bp.included_hours,
bp.used_hours,
bp.overtime_hours,
bp.base_amount,
bp.overtime_amount,
bp.overtime_approved,
bp.status,
bp.economic_invoice_number,
-- Calculate profit for this period
a.internal_cost_rate,
bp.used_hours * a.internal_cost_rate as period_cost,
(bp.base_amount + COALESCE(bp.overtime_amount, 0)) -
(bp.used_hours * a.internal_cost_rate) as period_profit,
-- Time entry breakdown
(SELECT COUNT(*) FROM tmodule_times
WHERE fixed_price_agreement_id = a.id
AND worked_date BETWEEN bp.period_start AND bp.period_end) as sag_entries,
(SELECT COUNT(*) FROM tticket_worklog
WHERE fixed_price_agreement_id = a.id
AND created_at::date BETWEEN bp.period_start AND bp.period_end) as ticket_entries
FROM fixed_price_billing_periods bp
JOIN customer_fixed_price_agreements a ON bp.agreement_id = a.id
WHERE a.id = %s
ORDER BY bp.period_start DESC
""", (agreement_id,)) or []
except Exception as e:
logger.error(f"❌ Error generating period detail: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/fixed-price-agreements/reports/export/csv")
async def export_profitability_csv():
"""
Export full profitability report as CSV
"""
try:
data = execute_query("""
SELECT
a.agreement_number,
a.customer_name,
a.status,
a.monthly_hours,
a.hourly_rate,
a.overtime_rate,
a.internal_cost_rate,
a.start_date,
perf.total_periods,
perf.total_used_hours,
perf.total_approved_overtime,
perf.total_revenue,
perf.total_internal_cost,
perf.total_profit,
perf.utilization_percent
FROM customer_fixed_price_agreements a
LEFT JOIN fixed_price_agreement_performance perf ON a.id = perf.id
ORDER BY a.customer_name, a.agreement_number
""")
# Generate CSV
output = StringIO()
if data and len(data) > 0:
writer = csv.DictWriter(output, fieldnames=data[0].keys())
writer.writeheader()
writer.writerows(data)
return Response(
content=output.getvalue(),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=fixed_price_profitability.csv"}
)
except Exception as e:
logger.error(f"❌ Error exporting CSV: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))

View File

@ -0,0 +1,322 @@
{% extends "shared/frontend/base.html" %}
{% block title %}{{ agreement.agreement_number }} - Fastpris Aftale{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Header -->
<div class="row mb-4">
<div class="col">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/fixed-price-agreements">Fastpris Aftaler</a></li>
<li class="breadcrumb-item active">{{ agreement.agreement_number }}</li>
</ol>
</nav>
<h1 class="h3 mb-0">📋 {{ agreement.agreement_number }}</h1>
<p class="text-muted">{{ agreement.customer_name }}</p>
</div>
<div class="col-auto">
{% if agreement.status == 'active' %}
<span class="badge bg-success fs-6">Aktiv</span>
{% elif agreement.status == 'suspended' %}
<span class="badge bg-warning fs-6">Suspenderet</span>
{% elif agreement.status == 'expired' %}
<span class="badge bg-danger fs-6">Udløbet</span>
{% elif agreement.status == 'cancelled' %}
<span class="badge bg-secondary fs-6">Annulleret</span>
{% endif %}
</div>
</div>
<!-- Overview Cards -->
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<h6 class="text-muted mb-2">Månedlige Timer</h6>
<h3 class="mb-0">{{ '%.0f'|format(agreement.monthly_hours or 0) }} t</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<h6 class="text-muted mb-2">Timepris</h6>
<h3 class="mb-0">{{ '%.0f'|format(agreement.hourly_rate or 0) }} kr</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<h6 class="text-muted mb-2">Denne Måned</h6>
<h3 class="mb-0">{{ '%.1f'|format(agreement.current_used_hours or 0) }} / {{ '%.0f'|format(agreement.monthly_hours or 0) }} t</h3>
<small class="text-muted">{{ '%.1f'|format(agreement.current_remaining_hours or 0) }}t tilbage</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<h6 class="text-muted mb-2">Binding</h6>
<h3 class="mb-0">{{ agreement.binding_months }} mdr</h3>
{% if agreement.binding_end_date %}
<small class="text-muted">Til {{ agreement.binding_end_date }}</small>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Tabs -->
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#details-tab">Detaljer</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#periods-tab">
Perioder <span class="badge bg-secondary">{{ periods|length }}</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#sager-tab">
Sager <span class="badge bg-secondary">{{ sager|length }}</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#time-tab">
Tidsregistreringer <span class="badge bg-secondary">{{ time_entries|length }}</span>
</button>
</li>
</ul>
<!-- Tab Content -->
<div class="tab-content">
<!-- Details Tab -->
<div class="tab-pane fade show active" id="details-tab">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h5 class="mb-3">Aftale Information</h5>
<table class="table table-sm">
<tr>
<th width="40%">Kunde ID:</th>
<td>{{ agreement.customer_id }}</td>
</tr>
<tr>
<th>Kunde:</th>
<td>{{ agreement.customer_name }}</td>
</tr>
<tr>
<th>Start Dato:</th>
<td>{{ agreement.start_date }}</td>
</tr>
{% if agreement.end_date %}
<tr>
<th>Slut Dato:</th>
<td>{{ agreement.end_date }}</td>
</tr>
{% endif %}
<tr>
<th>Oprettet:</th>
<td>{{ agreement.created_at }}</td>
</tr>
</table>
</div>
<div class="col-md-6">
<h5 class="mb-3">Priser & Vilkår</h5>
<table class="table table-sm">
<tr>
<th width="40%">Månedlige Timer:</th>
<td>{{ '%.0f'|format(agreement.monthly_hours or 0) }} timer</td>
</tr>
<tr>
<th>Normal Timepris:</th>
<td>{{ '%.0f'|format(agreement.hourly_rate or 0) }} kr</td>
</tr>
<tr>
<th>Overtid Timepris:</th>
<td>{{ '%.0f'|format(agreement.overtime_rate or 0) }} kr {% if agreement.overtime_rate and agreement.hourly_rate %}({{ '%.0f'|format((agreement.overtime_rate / agreement.hourly_rate - 1) * 100) }}%){% endif %}</td>
</tr>
<tr>
<th>Afrunding:</th>
<td>{% if agreement.rounding_minutes == 0 %}Ingen{% else %}{{ agreement.rounding_minutes }} min{% endif %}</td>
</tr>
<tr>
<th>Bindingsperiode:</th>
<td>{{ agreement.binding_months }} måneder</td>
</tr>
<tr>
<th>Opsigelsesfrist:</th>
<td>{{ agreement.notice_period_days }} dage</td>
</tr>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Periods Tab -->
<div class="tab-pane fade" id="periods-tab">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>Periode</th>
<th>Status</th>
<th>Brugte Timer</th>
<th>Resterende Timer</th>
<th>Overtid</th>
<th>Månedlig Værdi</th>
</tr>
</thead>
<tbody>
{% for period in periods %}
<tr>
<td>
<strong>{{ period.period_start }}</strong> til {{ period.period_end }}
</td>
<td>
{% if period.status == 'active' %}
<span class="badge bg-success">Aktiv</span>
{% elif period.status == 'pending_approval' %}
<span class="badge bg-warning">⚠️ Overtid</span>
{% elif period.status == 'ready_to_bill' %}
<span class="badge bg-info">Klar til faktura</span>
{% elif period.status == 'billed' %}
<span class="badge bg-secondary">Faktureret</span>
{% endif %}
</td>
<td>{{ '%.1f'|format(period.used_hours or 0) }}t</td>
<td>{{ '%.1f'|format(period.remaining_hours or 0) }}t</td>
<td>
{% if period.overtime_hours and period.overtime_hours > 0 %}
<span class="text-danger">+{{ '%.1f'|format(period.overtime_hours) }}t</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>{{ '%.0f'|format(period.base_amount or 0) }} kr</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Sager Tab -->
<div class="tab-pane fade" id="sager-tab">
<div class="card border-0 shadow-sm">
<div class="card-body">
{% if sager %}
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>Sag ID</th>
<th>Titel</th>
<th>Status</th>
<th>Oprettet</th>
<th>Handlinger</th>
</tr>
</thead>
<tbody>
{% for sag in sager %}
<tr>
<td><strong>#{{ sag.id }}</strong></td>
<td>{{ sag.titel }}</td>
<td>
{% if sag.status == 'open' %}
<span class="badge bg-success">Åben</span>
{% elif sag.status == 'in_progress' %}
<span class="badge bg-primary">I gang</span>
{% elif sag.status == 'closed' %}
<span class="badge bg-secondary">Lukket</span>
{% endif %}
</td>
<td>{{ sag.created_at.strftime('%Y-%m-%d') if sag.created_at else '-' }}</td>
<td>
<a href="/sag/{{ sag.id }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> Vis
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-inbox fs-1 mb-3"></i>
<p>Ingen sager endnu</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Time Entries Tab -->
<div class="tab-pane fade" id="time-tab">
<div class="card border-0 shadow-sm">
<div class="card-body">
{% if time_entries %}
<div class="table-responsive">
<table class="table table-sm table-hover">
<thead class="table-light">
<tr>
<th>Dato</th>
<th>Sag</th>
<th>Beskrivelse</th>
<th>Timer</th>
<th>Afrundet</th>
</tr>
</thead>
<tbody>
{% for entry in time_entries %}
<tr>
<td>{{ entry.created_at.strftime('%Y-%m-%d') if entry.created_at else '-' }}</td>
<td>
{% if entry.sag_id %}
<a href="/sag/{{ entry.sag_id }}">#{{ entry.sag_id }}</a>
{% if entry.sag_titel %}
<br><small class="text-muted">{{ entry.sag_titel[:30] }}</small>
{% endif %}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
<small>{{ entry.note[:50] if entry.note else '-' }}</small>
</td>
<td>{{ '%.2f'|format(entry.approved_hours or entry.original_hours or 0) }}t</td>
<td>
{% if entry.rounded_to %}
<span class="badge bg-info">{{ entry.rounded_to }} min</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-clock fs-1 mb-3"></i>
<p>Ingen tidsregistreringer endnu</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,616 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Fastpris Aftaler - BMC Hub{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Header -->
<div class="row mb-4">
<div class="col">
<h1 class="h3 mb-0">📋 Fastpris Aftaler</h1>
<p class="text-muted">Månedlige timer aftaler med overtid håndtering</p>
</div>
<div class="col-auto">
<a href="/fixed-price-agreements/reports/dashboard" class="btn btn-outline-primary me-2">
<i class="bi bi-graph-up"></i> Rapporter
</a>
<button class="btn btn-primary" onclick="openCreateModal()">
<i class="bi bi-plus-circle"></i> Opret Ny Aftale
</button>
</div>
</div>
<!-- Stats Row -->
<div class="row g-3 mb-4" id="statsCards">
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="rounded-circle bg-success bg-opacity-10 p-3">
<i class="bi bi-calendar-check text-success fs-4"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<p class="text-muted small mb-1">Aktive Aftaler</p>
<h3 class="mb-0" id="activeCount">-</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="rounded-circle bg-primary bg-opacity-10 p-3">
<i class="bi bi-currency-dollar text-primary fs-4"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<p class="text-muted small mb-1">Total Omsætning</p>
<h3 class="mb-0" id="totalRevenue">-</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="rounded-circle bg-info bg-opacity-10 p-3">
<i class="bi bi-pie-chart text-info fs-4"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<p class="text-muted small mb-1">Total Profit</p>
<h3 class="mb-0" id="totalProfit">-</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="rounded-circle bg-warning bg-opacity-10 p-3">
<i class="bi bi-clock text-warning fs-4"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<p class="text-muted small mb-1">Brugte Timer</p>
<h3 class="mb-0" id="totalHours">-</h3>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Agreements Table -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3">
<div class="row align-items-center">
<div class="col">
<h5 class="mb-0">Alle Aftaler</h5>
</div>
<div class="col-auto">
<input type="text" class="form-control form-control-sm" id="searchInput" placeholder="Søg...">
</div>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle" id="agreementsTable">
<thead class="table-light">
<tr>
<th>Aftale Nr.</th>
<th>Kunde</th>
<th>Månedlige Timer</th>
<th>Status</th>
<th>Denne Måned</th>
<th>Start</th>
<th class="text-end">Handlinger</th>
</tr>
</thead>
<tbody id="agreementsBody">
<tr>
<td colspan="7" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
// Load customers from server-side render
const customersCache = {{ customers | tojson }};
console.log('✅ Loaded customers from template:', customersCache?.length || 0);
async function loadAgreements() {
try {
// Load stats
const stats = await fetch('/api/v1/fixed-price-agreements/stats/summary').then(r => r.json());
document.getElementById('activeCount').textContent = stats.active_agreements || 0;
document.getElementById('totalRevenue').textContent = formatCurrency(stats.total_revenue || 0);
document.getElementById('totalProfit').textContent = formatCurrency(stats.total_profit || 0);
document.getElementById('totalHours').textContent = (stats.total_used_hours || 0).toFixed(1) + ' t';
// Load agreements
const agreements = await fetch('/api/v1/fixed-price-agreements?include_current_period=true').then(r => r.json());
renderAgreements(agreements);
} catch (e) {
console.error('Error loading agreements:', e);
document.getElementById('agreementsBody').innerHTML = `
<tr><td colspan="7" class="text-center text-danger py-5">
<i class="bi bi-exclamation-triangle fs-1 mb-3"></i>
<p>Fejl ved indlæsning</p>
</td></tr>
`;
}
}
function renderAgreements(agreements) {
const tbody = document.getElementById('agreementsBody');
if (!agreements || agreements.length === 0) {
tbody.innerHTML = `
<tr><td colspan="7" class="text-center text-muted py-5">
<i class="bi bi-inbox fs-1 mb-3"></i>
<p>Ingen aftaler endnu</p>
</td></tr>
`;
return;
}
tbody.innerHTML = agreements.map(a => {
const statusBadge = getStatusBadge(a.status);
const currentPeriod = a.current_period;
const usedHours = currentPeriod ? parseFloat(currentPeriod.used_hours || 0).toFixed(1) : '0.0';
const remainingHours = a.remaining_hours_this_month ? a.remaining_hours_this_month.toFixed(1) : parseFloat(a.monthly_hours).toFixed(1);
return `
<tr onclick="window.location.href='/fixed-price-agreements/${a.id}'" style="cursor: pointer;">
<td><strong>${a.agreement_number}</strong></td>
<td>${a.customer_name || '-'}</td>
<td>${parseFloat(a.monthly_hours).toFixed(0)} t/md</td>
<td>${statusBadge}</td>
<td>
<small class="text-muted">${usedHours}t brugt / ${remainingHours}t tilbage</small>
${currentPeriod && currentPeriod.status === 'pending_approval' ? '<span class="badge bg-warning ms-1">⚠️ Overtid</span>' : ''}
</td>
<td>${new Date(a.start_date).toLocaleDateString('da-DK')}</td>
<td class="text-end" onclick="event.stopPropagation();">
<a href="/fixed-price-agreements/${a.id}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> Detaljer
</a>
</td>
</tr>
`;
}).join('');
}
function getStatusBadge(status) {
const badges = {
'active': '<span class="badge bg-success">Aktiv</span>',
'suspended': '<span class="badge bg-warning">Suspenderet</span>',
'expired': '<span class="badge bg-danger">Udløbet</span>',
'cancelled': '<span class="badge bg-secondary">Annulleret</span>',
'pending_cancellation': '<span class="badge bg-warning">Opsagt</span>'
};
return badges[status] || status;
}
function formatCurrency(amount) {
return new Intl.NumberFormat('da-DK', {
style: 'currency',
currency: 'DKK',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount);
}
function calculateHourlyRate() {
const monthlyAmount = parseFloat(document.getElementById('createMonthlyAmount')?.value || 0);
const monthlyHours = parseFloat(document.getElementById('createMonthlyHours')?.value || 1);
if (monthlyAmount > 0 && monthlyHours > 0) {
const hourlyRate = (monthlyAmount / monthlyHours).toFixed(2);
document.getElementById('calculatedHourlyRate').textContent = `(≈ ${hourlyRate} kr/t)`;
} else {
document.getElementById('calculatedHourlyRate').textContent = '';
}
}
async function openCreateModal() {
console.log('🚀 openCreateModal() called');
console.log('📊 Customers cache:', customersCache?.length || 0);
// Show loading state
const customerList = document.getElementById('createCustomerList');
if (!customerList) {
console.error('❌ createCustomerList element not found!');
return;
}
console.log('✅ Found createCustomerList element');
customerList.innerHTML = '<div class="list-group-item text-center"><span class="spinner-border spinner-border-sm me-2"></span>Indlæser kunder...</div>';
// Check if customers are loaded
if (!customersCache || customersCache.length === 0) {
console.error('❌ No customers available');
customerList.innerHTML = '<div class="list-group-item text-danger"><i class="bi bi-exclamation-triangle me-2"></i>Ingen kunder tilgængelige</div>';
return;
}
console.log('✅ Customers ready, rendering...');
// Populate customer list
const searchInput = document.getElementById('createCustomerSearch');
// Reset search and render all customers
searchInput.value = '';
renderCustomerOptions(customersCache);
// Reset form
document.getElementById('createAgreementForm').reset();
// Set default values
document.getElementById('createStartDate').value = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().split('T')[0];
// Calculate initial hourly rate display
setTimeout(() => calculateHourlyRate(), 100);
// Show modal
console.log('📋 Opening modal...');
const modalElement = document.getElementById('createAgreementModal');
if (!modalElement) {
console.error('❌ Modal element not found!');
return;
}
console.log('✅ Found modal element');
const modal = new bootstrap.Modal(modalElement);
modal.show();
console.log('✅ Modal.show() called');
}
function renderCustomerOptions(customers) {
const listGroup = document.getElementById('createCustomerList');
console.log('📋 Rendering customers:', customers?.length || 0);
console.log('First customer:', customers?.[0]);
if (!customers || customers.length === 0) {
console.warn('⚠️ No customers to render');
listGroup.innerHTML = '<div class="list-group-item text-muted"><i class="bi bi-inbox me-2"></i>Ingen kunder fundet</div>';
return;
}
console.log('✅ Rendering', customers.length, 'customers');
listGroup.innerHTML = '';
customers.forEach((c, idx) => {
if (idx < 3) console.log(`Customer ${idx}:`, c);
const item = document.createElement('a');
item.href = '#';
item.className = 'list-group-item list-group-item-action';
item.dataset.customerId = c.id;
item.dataset.customerName = c.name || 'Ukendt';
item.innerHTML = `
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>${escapeHtml(c.name || 'Ukendt')}</strong>
${c.cvr_number ? `<br><small class="text-muted">CVR: ${escapeHtml(c.cvr_number)}</small>` : ''}
</div>
${c.is_active === false ? '<span class="badge bg-secondary">Inaktiv</span>' : ''}
</div>
`;
item.addEventListener('click', (e) => {
e.preventDefault();
selectCustomer(c.id, c.name || 'Ukendt');
});
listGroup.appendChild(item);
});
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function selectCustomer(customerId, customerName) {
// Set hidden input
document.getElementById('createCustomerId').value = customerId;
// Update display
const selectedDiv = document.getElementById('selectedCustomerName');
selectedDiv.innerHTML = '';
const alertDiv = document.createElement('div');
alertDiv.className = 'alert alert-success mb-2';
alertDiv.innerHTML = `
<i class="bi bi-check-circle me-2"></i>
<strong>Valgt kunde:</strong> ${escapeHtml(customerName)}
`;
const clearBtn = document.createElement('button');
clearBtn.type = 'button';
clearBtn.className = 'btn btn-sm btn-link float-end text-decoration-none';
clearBtn.innerHTML = '<i class="bi bi-x"></i> Ryd';
clearBtn.addEventListener('click', clearCustomerSelection);
alertDiv.appendChild(clearBtn);
selectedDiv.appendChild(alertDiv);
// Hide list
document.getElementById('createCustomerList').style.display = 'none';
document.getElementById('createCustomerSearch').style.display = 'none';
}
function clearCustomerSelection() {
document.getElementById('createCustomerId').value = '';
document.getElementById('selectedCustomerName').innerHTML = '';
document.getElementById('createCustomerList').style.display = 'block';
document.getElementById('createCustomerSearch').style.display = 'block';
renderCustomerOptions(customersCache);
}
function searchCustomers() {
const searchTerm = document.getElementById('createCustomerSearch').value.toLowerCase();
const filtered = customersCache.filter(c =>
(c.name || '').toLowerCase().includes(searchTerm) ||
(c.cvr_number || '').toLowerCase().includes(searchTerm) ||
(c.email || '').toLowerCase().includes(searchTerm)
);
renderCustomerOptions(filtered);
}
async function submitCreateAgreement(event) {
event.preventDefault();
const form = event.target;
const submitBtn = form.querySelector('button[type="submit"]');
const originalBtnText = submitBtn.innerHTML;
// Get form data
const customerIdInput = document.getElementById('createCustomerId');
const customerId = parseInt(customerIdInput.value);
// Validate customer selection
if (!customerId || isNaN(customerId)) {
alert('⚠️ Vælg venligst en kunde først');
return;
}
const customer = customersCache.find(c => c.id === customerId);
const customerName = customer?.name || '';
const data = {
customer_id: customerId,
customer_name: customerName,
monthly_hours: parseFloat(form.monthlyHours.value),
monthly_amount: parseFloat(form.monthlyAmount.value),
overtime_rate: parseFloat(form.overtimeRate.value),
rounding_minutes: parseInt(form.roundingMinutes.value),
start_date: form.startDate.value,
binding_months: parseInt(form.bindingMonths.value),
notice_period_days: parseInt(form.noticePeriodDays.value)
};
// Validate
if (!data.customer_id || data.monthly_hours <= 0 || data.monthly_amount <= 0) {
alert('Udfyld venligst alle påkrævede felter');
return;
}
try {
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Opretter...';
const response = await fetch('/api/v1/fixed-price-agreements', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Fejl ved oprettelse');
}
const result = await response.json();
// Close modal
bootstrap.Modal.getInstance(document.getElementById('createAgreementModal')).hide();
// Show success message with details
const successMsg = `✅ Aftale oprettet!\n\nAftalenummer: ${result.agreement_number}\nKunde: ${customerName}\nMånedlige timer: ${data.monthly_hours}t\n\nAftalen er nu tilgængelig i listen.`;
// Reload list to show new agreement
await loadAgreements();
alert(successMsg);
} catch (error) {
console.error('Create error:', error);
alert('❌ Fejl: ' + error.message);
submitBtn.disabled = false;
submitBtn.innerHTML = originalBtnText;
}
}
// Search functionality
document.getElementById('searchInput')?.addEventListener('input', (e) => {
const term = e.target.value.toLowerCase();
const rows = document.querySelectorAll('#agreementsBody tr');
rows.forEach(row => {
const text = row.textContent.toLowerCase();
row.style.display = text.includes(term) ? '' : 'none';
});
});
// Initialize after DOM is loaded
loadAgreements();
// Setup event listeners after modal is in DOM
setTimeout(() => {
// Auto-calculate overtime rate (125%)
document.getElementById('createHourlyRate')?.addEventListener('input', (e) => {
const hourlyRate = parseFloat(e.target.value);
if (hourlyRate > 0) {
document.getElementById('createOvertimeRate').value = (hourlyRate * 1.25).toFixed(2);
}
});
// Customer search listener
const searchInput = document.getElementById('createCustomerSearch');
if (searchInput) {
searchInput.addEventListener('input', searchCustomers);
}
}, 100);
</script>
<!-- Create Agreement Modal -->
<div class="modal fade" id="createAgreementModal" tabindex="-1" aria-labelledby="createAgreementModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="createAgreementModalLabel">
<i class="bi bi-plus-circle text-primary me-2"></i>Opret Ny Fastpris Aftale
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="createAgreementForm" onsubmit="submitCreateAgreement(event)">
<div class="modal-body">
<div class="row g-3">
<!-- Customer Selection -->
<div class="col-12">
<label class="form-label">Kunde <span class="text-danger">*</span></label>
<input type="hidden" id="createCustomerId" name="customerId" required>
<!-- Selected Customer Display -->
<div id="selectedCustomerName"></div>
<!-- Search Input -->
<input type="text"
class="form-control mb-2"
id="createCustomerSearch"
placeholder="🔍 Søg kunde (navn, CVR, email)..."
autocomplete="off">
<!-- Customer List -->
<div class="border rounded" style="max-height: 300px; overflow-y: auto;">
<div class="list-group list-group-flush" id="createCustomerList">
<div class="list-group-item text-center text-muted">
<span class="spinner-border spinner-border-sm me-2"></span>
Indlæser kunder...
</div>
</div>
</div>
</div>
<!-- Monthly Hours -->
<div class="col-md-6">
<label for="createMonthlyHours" class="form-label">Månedlige Timer <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="createMonthlyHours" name="monthlyHours"
min="1" step="0.5" value="160" required oninput="calculateHourlyRate()">
<div class="form-text">Timer inkluderet per måned</div>
</div>
<!-- Rounding Minutes -->
<div class="col-md-6">
<label for="createRoundingMinutes" class="form-label">Afrunding <span class="text-danger">*</span></label>
<select class="form-select" id="createRoundingMinutes" name="roundingMinutes" required>
<option value="0">Ingen afrunding</option>
<option value="15" selected>15 minutter</option>
<option value="30">30 minutter</option>
<option value="60">60 minutter</option>
</select>
<div class="form-text">Afrund tid ved registrering</div>
</div>
<!-- Monthly Amount -->
<div class="col-md-6">
<label for="createMonthlyAmount" class="form-label">Månedspris (DKK) <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="createMonthlyAmount" name="monthlyAmount"
min="0" step="0.01" value="80000" required oninput="calculateHourlyRate()">
<div class="form-text">
Fast pris pr. måned
<span id="calculatedHourlyRate" class="text-primary fw-bold"></span>
</div>
</div>
<!-- Overtime Rate -->
<div class="col-md-6">
<label for="createOvertimeRate" class="form-label">Overtid Timepris (DKK) <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="createOvertimeRate" name="overtimeRate"
min="0" step="0.01" value="625" required>
<div class="form-text">Pris for overtid (typisk 125%)</div>
</div>
<!-- Start Date -->
<div class="col-md-6">
<label for="createStartDate" class="form-label">Start Dato <span class="text-danger">*</span></label>
<input type="date" class="form-control" id="createStartDate" name="startDate" required>
<div class="form-text">Aftalens første dag</div>
</div>
<!-- Binding Months -->
<div class="col-md-6">
<label for="createBindingMonths" class="form-label">Binding (måneder) <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="createBindingMonths" name="bindingMonths"
min="0" step="1" value="12" required>
<div class="form-text">0 = ingen binding</div>
</div>
<!-- Notice Period -->
<div class="col-md-6">
<label for="createNoticePeriodDays" class="form-label">Opsigelsesfrist (dage)</label>
<input type="number" class="form-control" id="createNoticePeriodDays" name="noticePeriodDays"
min="0" step="1" value="30">
<div class="form-text">Varslingsfrist ved opsigelse</div>
</div>
<!-- End Date (Optional) -->
<div class="col-md-6">
<label for="createEndDate" class="form-label">Slut Dato (valgfri)</label>
<input type="date" class="form-control" id="createEndDate" name="endDate">
<div class="form-text">Lad blank for løbende aftale</div>
</div>
<div class="col-12">
<div class="alert alert-info mb-0">
<i class="bi bi-info-circle me-2"></i>
<strong>Note:</strong> Aftalen tildeles automatisk et unikt nummer (FPA-YYYYMMDD-XXX) ved oprettelse.
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="bi bi-x-circle me-1"></i>Annuller
</button>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle me-1"></i>Opret Aftale
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,285 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Fastpris Rapporter - BMC Hub{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Header -->
<div class="row mb-4">
<div class="col">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/fixed-price-agreements">Fastpris Aftaler</a></li>
<li class="breadcrumb-item active">Rapporter</li>
</ol>
</nav>
<h1 class="h3 mb-0">📊 Fastpris Rapporter</h1>
<p class="text-muted">Profitabilitet og performance analyse</p>
</div>
</div>
{% if error %}
<div class="alert alert-danger">
<i class="bi bi-exclamation-triangle me-2"></i>
<strong>Fejl:</strong> {{ error }}
</div>
{% endif %}
<!-- Summary Stats -->
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<h6 class="text-muted mb-2">Aktive Aftaler</h6>
<h3 class="mb-0">{{ stats.active_agreements or 0 }}</h3>
<small class="text-muted">af {{ stats.total_agreements or 0 }} total</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<h6 class="text-muted mb-2">Total Omsætning</h6>
<h3 class="mb-0">{{ "{:,.0f}".format(stats.total_revenue or 0) }} kr</h3>
<small class="text-muted">Månedlig værdi</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<h6 class="text-muted mb-2">Estimeret Profit</h6>
<h3 class="mb-0">{{ "{:,.0f}".format(stats.estimated_profit or 0) }} kr</h3>
<small class="text-muted">Ved 300 kr/t kostpris</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<h6 class="text-muted mb-2">Profit Margin</h6>
{% set profit_margin = ((stats.estimated_profit|float / stats.total_revenue|float * 100)|round(1)) if stats.total_revenue and stats.total_revenue > 0 else 0 %}
<h3 class="mb-0">{{ profit_margin }}%</h3>
<small class="text-muted">Gennemsnitlig margin</small>
</div>
</div>
</div>
</div>
<!-- Tabs -->
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#performance-tab">Performance</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#trends-tab">Trends</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#customers-tab">Kunder</button>
</li>
</ul>
<!-- Tab Content -->
<div class="tab-content">
<!-- Performance Tab -->
<div class="tab-pane fade show active" id="performance-tab">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white">
<h5 class="mb-0">Aftale Performance</h5>
<small class="text-muted">Sorteret efter profit margin</small>
</div>
<div class="card-body">
{% if performance %}
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>Aftale</th>
<th>Kunde</th>
<th class="text-end">Total Timer</th>
<th class="text-end">Månedlig Værdi</th>
<th class="text-end">Profit</th>
<th class="text-end">Margin</th>
<th class="text-end">Udnyttelse</th>
</tr>
</thead>
<tbody>
{% for agr in performance %}
<tr>
<td>
<a href="/fixed-price-agreements/{{ agr.agreement_id }}">
<strong>{{ agr.agreement_number }}</strong>
</a>
</td>
<td>{{ agr.customer_name }}</td>
<td class="text-end">{{ '%.1f'|format(agr.total_used_hours or 0) }}t</td>
<td class="text-end">{{ "{:,.0f}".format(agr.total_base_revenue or 0) }} kr</td>
<td class="text-end">
{% if agr.estimated_profit and agr.estimated_profit > 0 %}
<span class="text-success">{{ "{:,.0f}".format(agr.estimated_profit) }} kr</span>
{% else %}
<span class="text-danger">{{ "{:,.0f}".format(agr.estimated_profit or 0) }} kr</span>
{% endif %}
</td>
<td class="text-end">
{% if agr.profit_margin and agr.profit_margin >= 30 %}
<span class="badge bg-success">{{ '%.1f'|format(agr.profit_margin) }}%</span>
{% elif agr.profit_margin and agr.profit_margin >= 15 %}
<span class="badge bg-warning">{{ '%.1f'|format(agr.profit_margin) }}%</span>
{% else %}
<span class="badge bg-danger">{{ '%.1f'|format(agr.profit_margin or 0) }}%</span>
{% endif %}
</td>
<td class="text-end">
{% set utilization = ((agr.total_used_hours or 0) / (agr.total_allocated_hours or 1) * 100) if agr.total_allocated_hours else 0 %}
{% if utilization >= 80 %}
<span class="badge bg-success">{{ '%.0f'|format(utilization) }}%</span>
{% elif utilization >= 50 %}
<span class="badge bg-info">{{ '%.0f'|format(utilization) }}%</span>
{% else %}
<span class="badge bg-secondary">{{ '%.0f'|format(utilization) }}%</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-graph-down fs-1 mb-3"></i>
<p>Ingen performance data tilgængelig</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Trends Tab -->
<div class="tab-pane fade" id="trends-tab">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white">
<h5 class="mb-0">Månedlige Trends</h5>
<small class="text-muted">Seneste 12 måneder</small>
</div>
<div class="card-body">
{% if trends %}
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>Måned</th>
<th class="text-end">Aktive Aftaler</th>
<th class="text-end">Brugte Timer</th>
<th class="text-end">Overtid Timer</th>
<th class="text-end">Total Værdi</th>
<th class="text-end">Profit</th>
<th class="text-end">Margin</th>
</tr>
</thead>
<tbody>
{% for trend in trends %}
<tr>
<td><strong>{{ trend.period_month }}</strong></td>
<td class="text-end">{{ trend.active_agreements }}</td>
<td class="text-end">{{ '%.1f'|format(trend.total_used_hours or 0) }}t</td>
<td class="text-end">
{% if trend.total_overtime_hours and trend.total_overtime_hours > 0 %}
<span class="text-warning">{{ '%.1f'|format(trend.total_overtime_hours) }}t</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td class="text-end">{{ "{:,.0f}".format(trend.monthly_total_revenue or 0) }} kr</td>
<td class="text-end">
{% if trend.total_profit and trend.total_profit > 0 %}
<span class="text-success">{{ "{:,.0f}".format(trend.total_profit) }} kr</span>
{% else %}
<span class="text-danger">{{ "{:,.0f}".format(trend.total_profit or 0) }} kr</span>
{% endif %}
</td>
<td class="text-end">
{% if trend.avg_profit_margin and trend.avg_profit_margin >= 30 %}
<span class="badge bg-success">{{ '%.1f'|format(trend.avg_profit_margin) }}%</span>
{% elif trend.avg_profit_margin and trend.avg_profit_margin >= 15 %}
<span class="badge bg-warning">{{ '%.1f'|format(trend.avg_profit_margin) }}%</span>
{% else %}
<span class="badge bg-danger">{{ '%.1f'|format(trend.avg_profit_margin or 0) }}%</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-calendar3 fs-1 mb-3"></i>
<p>Ingen trend data tilgængelig</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Customers Tab -->
<div class="tab-pane fade" id="customers-tab">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white">
<h5 class="mb-0">Top Kunder</h5>
<small class="text-muted">Sorteret efter total forbrug</small>
</div>
<div class="card-body">
{% if customers %}
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>Kunde</th>
<th class="text-end">Aftaler</th>
<th class="text-end">Total Timer</th>
<th class="text-end">Overtid</th>
<th class="text-end">Total Værdi</th>
<th class="text-end">Avg Margin</th>
</tr>
</thead>
<tbody>
{% for customer in customers %}
<tr>
<td><strong>{{ customer.customer_name }}</strong></td>
<td class="text-end">{{ customer.agreement_count }}</td>
<td class="text-end">{{ '%.1f'|format(customer.total_used_hours or 0) }}t</td>
<td class="text-end">
{% if customer.total_overtime_hours and customer.total_overtime_hours > 0 %}
<span class="text-warning">{{ '%.1f'|format(customer.total_overtime_hours) }}t</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td class="text-end">{{ "{:,.0f}".format(customer.total_revenue or 0) }} kr</td>
<td class="text-end">
{% if customer.avg_profit_margin and customer.avg_profit_margin >= 30 %}
<span class="badge bg-success">{{ '%.1f'|format(customer.avg_profit_margin) }}%</span>
{% elif customer.avg_profit_margin and customer.avg_profit_margin >= 15 %}
<span class="badge bg-warning">{{ '%.1f'|format(customer.avg_profit_margin) }}%</span>
{% else %}
<span class="badge bg-danger">{{ '%.1f'|format(customer.avg_profit_margin or 0) }}%</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-people fs-1 mb-3"></i>
<p>Ingen kunde data tilgængelig</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,220 @@
"""
Fixed-Price Agreement Frontend Views
"""
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from app.core.database import execute_query
import logging
logger = logging.getLogger(__name__)
router = APIRouter()
templates = Jinja2Templates(directory="app")
@router.get("/fixed-price-agreements", response_class=HTMLResponse)
async def list_agreements(request: Request):
"""List all fixed-price agreements"""
# Load customers for the create modal
try:
customers_query = """
SELECT
id,
name,
cvr_number,
email,
phone,
city,
is_active
FROM customers
WHERE deleted_at IS NULL
AND is_active = true
ORDER BY name
LIMIT 1000
"""
customers = execute_query(customers_query)
logger.info(f"📋 Loaded {len(customers)} customers for modal")
except Exception as e:
logger.error(f"❌ Error loading customers: {e}")
customers = []
return templates.TemplateResponse("fixed_price/frontend/list.html", {
"request": request,
"customers": customers
})
@router.get("/fixed-price-agreements/{agreement_id}", response_class=HTMLResponse)
async def agreement_detail(request: Request, agreement_id: int):
"""Agreement detail page with periods and related sager"""
from fastapi import HTTPException
try:
# Fetch agreement
agr_query = """
SELECT a.*,
COALESCE(p.used_hours, 0) as current_used_hours,
COALESCE(p.remaining_hours, a.monthly_hours) as current_remaining_hours,
p.status as current_period_status
FROM customer_fixed_price_agreements a
LEFT JOIN fixed_price_billing_periods p ON p.agreement_id = a.id
AND p.period_start <= CURRENT_DATE
AND p.period_end >= CURRENT_DATE
WHERE a.id = %s
"""
agreement = execute_query(agr_query, (agreement_id,))
if not agreement:
raise HTTPException(status_code=404, detail="Aftale ikke fundet")
agreement = agreement[0]
# Fetch all billing periods
periods_query = """
SELECT * FROM fixed_price_billing_periods
WHERE agreement_id = %s
ORDER BY period_start DESC
"""
periods = execute_query(periods_query, (agreement_id,))
# Fetch related sager
sager_query = """
SELECT DISTINCT s.id, s.titel, s.status, s.created_at
FROM sag_sager s
INNER JOIN tmodule_times t ON t.sag_id = s.id
WHERE t.fixed_price_agreement_id = %s
AND s.deleted_at IS NULL
ORDER BY s.created_at DESC
LIMIT 50
"""
sager = execute_query(sager_query, (agreement_id,))
# Fetch time entries
time_query = """
SELECT t.*, s.titel as sag_titel
FROM tmodule_times t
LEFT JOIN sag_sager s ON s.id = t.sag_id
WHERE t.fixed_price_agreement_id = %s
ORDER BY t.created_at DESC
LIMIT 100
"""
time_entries = execute_query(time_query, (agreement_id,))
return templates.TemplateResponse("fixed_price/frontend/detail.html", {
"request": request,
"agreement": agreement,
"periods": periods,
"sager": sager,
"time_entries": time_entries
})
except Exception as e:
logger.error(f"❌ Error loading agreement detail: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/fixed-price-agreements/reports/dashboard", response_class=HTMLResponse)
async def reports_dashboard(request: Request):
"""Reporting dashboard with profitability analysis"""
try:
# Get summary stats
stats_query = """
SELECT
COUNT(*) FILTER (WHERE status = 'active') as active_agreements,
COUNT(*) as total_agreements,
SUM(monthly_hours * hourly_rate) as total_revenue,
SUM(monthly_hours * (hourly_rate - 300)) as estimated_profit
FROM customer_fixed_price_agreements
"""
stats = execute_query(stats_query)
# Get performance data from view
performance_query = """
SELECT
*,
CASE
WHEN total_revenue > 0 THEN (total_profit / total_revenue * 100)
ELSE 0
END as profit_margin
FROM fixed_price_agreement_performance
ORDER BY total_profit DESC
LIMIT 50
"""
performance = execute_query(performance_query)
# Get monthly trends
trends_query = """
SELECT
*,
month as period_month,
CASE
WHEN monthly_total_revenue > 0 THEN (monthly_profit / monthly_total_revenue * 100)
ELSE 0
END as avg_profit_margin
FROM fixed_price_monthly_trends
ORDER BY month DESC
LIMIT 12
"""
trends = execute_query(trends_query)
# Get customer breakdown
customer_query = """
SELECT
*,
total_hours_used as total_used_hours,
CASE
WHEN total_revenue > 0 THEN (total_profit / total_revenue * 100)
ELSE 0
END as avg_profit_margin
FROM fixed_price_customer_summary
ORDER BY total_used_hours DESC
LIMIT 20
"""
customers = execute_query(customer_query)
return templates.TemplateResponse("fixed_price/frontend/reports.html", {
"request": request,
"stats": stats[0] if stats else {},
"performance": performance,
"trends": trends,
"customers": customers
})
except Exception as e:
logger.error(f"❌ Error loading reports: {e}")
return templates.TemplateResponse("fixed_price/frontend/reports.html", {
"request": request,
"stats": {},
"performance": [],
"trends": [],
"customers": [],
"error": str(e)
})
@router.get("/api/fixed-price-agreements/customers")
async def get_customers_for_agreements():
"""Get all active customers for fixed-price agreement creation"""
try:
query = """
SELECT
id,
name,
cvr_number,
email,
phone,
city,
is_active
FROM customers
WHERE deleted_at IS NULL
AND is_active = true
ORDER BY name
LIMIT 1000
"""
customers = execute_query(query)
logger.info(f"📋 Loaded {len(customers)} customers for fixed-price agreements")
return customers
except Exception as e:
logger.error(f"❌ Error loading customers: {e}")
return []

View File

@ -18,12 +18,12 @@ Each view:
""" """
from fastapi import APIRouter, Query, HTTPException, Path, Request from fastapi import APIRouter, Query, HTTPException, Path, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, RedirectResponse
from jinja2 import Environment, FileSystemLoader, TemplateNotFound from jinja2 import Environment, FileSystemLoader, TemplateNotFound
from pathlib import Path as PathlibPath from pathlib import Path as PathlibPath
import requests
import logging import logging
from typing import Optional from typing import Optional
from app.core.database import execute_query, execute_update
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -43,9 +43,7 @@ env = Environment(
lstrip_blocks=True lstrip_blocks=True
) )
# Backend API base URL # Use direct database access instead of API calls to avoid auth issues
# Inside container: localhost:8000, externally: localhost:8001
API_BASE_URL = "http://localhost:8000"
# Location type options for dropdowns # Location type options for dropdowns
LOCATION_TYPES = [ LOCATION_TYPES = [
@ -83,36 +81,6 @@ def render_template(template_name: str, **context) -> str:
raise HTTPException(status_code=500, detail=f"Error rendering template: {str(e)}") raise HTTPException(status_code=500, detail=f"Error rendering template: {str(e)}")
def call_api(method: str, endpoint: str, **kwargs) -> dict:
"""
Call backend API endpoint using requests (synchronous).
Args:
method: HTTP method (GET, POST, PATCH, DELETE)
endpoint: API endpoint path (e.g., "/api/v1/locations")
**kwargs: Additional arguments for requests call (params, json, etc.)
Returns:
Response JSON or dict
Raises:
HTTPException: If API call fails
"""
try:
url = f"{API_BASE_URL}{endpoint}" if not endpoint.startswith("http") else endpoint
response = requests.request(method, url, timeout=30, **kwargs)
response.raise_for_status()
return response.json()
except requests.exceptions.HTTPError as e:
if e.response.status_code == 404:
logger.warning(f"⚠️ API 404: {method} {endpoint}")
raise HTTPException(status_code=404, detail="Resource not found")
logger.error(f"❌ API error {e.response.status_code}: {method} {endpoint}")
raise HTTPException(status_code=500, detail=f"API error: {e.response.status_code}")
except requests.exceptions.RequestException as e:
logger.error(f"❌ API call failed {method} {endpoint}: {str(e)}")
raise HTTPException(status_code=500, detail=f"API connection error: {str(e)}")
def calculate_pagination(total: int, limit: int, skip: int) -> dict: def calculate_pagination(total: int, limit: int, skip: int) -> dict:
""" """
@ -147,7 +115,7 @@ def calculate_pagination(total: int, limit: int, skip: int) -> dict:
@router.get("/app/locations", response_class=HTMLResponse) @router.get("/app/locations", response_class=HTMLResponse)
def list_locations_view( def list_locations_view(
location_type: Optional[str] = Query(None, description="Filter by type"), location_type: Optional[str] = Query(None, description="Filter by type"),
is_active: Optional[bool] = Query(None, description="Filter by active status"), is_active: Optional[str] = Query(None, description="Filter by active status"),
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100) limit: int = Query(50, ge=1, le=100)
): ):
@ -169,18 +137,35 @@ def list_locations_view(
try: try:
logger.info(f"🔍 Rendering locations list view (skip={skip}, limit={limit})") logger.info(f"🔍 Rendering locations list view (skip={skip}, limit={limit})")
# Build API call parameters # Convert is_active from string to boolean or None
params = { is_active_bool = None
"skip": skip, if is_active and is_active.lower() in ('true', '1', 'yes'):
"limit": limit, is_active_bool = True
} elif is_active and is_active.lower() in ('false', '0', 'no'):
if location_type: is_active_bool = False
params["location_type"] = location_type
if is_active is not None:
params["is_active"] = is_active
# Call backend API to get locations # Query locations directly from database
locations = call_api("GET", "/api/v1/locations", params=params) where_clauses = []
query_params = []
if location_type:
where_clauses.append("location_type = %s")
query_params.append(location_type)
if is_active_bool is not None:
where_clauses.append("is_active = %s")
query_params.append(is_active_bool)
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
query = f"""
SELECT * FROM locations_locations
WHERE {where_sql}
ORDER BY name
LIMIT %s OFFSET %s
"""
query_params.extend([limit, skip])
locations = execute_query(query, tuple(query_params))
def build_tree(items: list) -> list: def build_tree(items: list) -> list:
nodes = {} nodes = {}
@ -234,7 +219,7 @@ def list_locations_view(
skip=skip, skip=skip,
limit=limit, limit=limit,
location_type=location_type, location_type=location_type,
is_active=is_active, is_active=is_active_bool, # Use boolean value for template
page_number=pagination["page_number"], page_number=pagination["page_number"],
total_pages=pagination["total_pages"], total_pages=pagination["total_pages"],
has_prev=pagination["has_prev"], has_prev=pagination["has_prev"],
@ -281,53 +266,23 @@ def create_location_view():
try: try:
logger.info("🆕 Rendering create location form") logger.info("🆕 Rendering create location form")
parent_locations = call_api( # Query parent locations
"GET", parent_locations = execute_query("""
"/api/v1/locations", SELECT id, name, location_type
params={"skip": 0, "limit": 1000} FROM locations_locations
) WHERE is_active = true
ORDER BY name
LIMIT 1000
""")
customers = call_api( # Query customers
"GET", customers = execute_query("""
"/api/v1/customers", SELECT id, name, email, phone
params={"offset": 0, "limit": 1000} FROM customers
) WHERE deleted_at IS NULL AND is_active = true
ORDER BY name
customers = call_api( LIMIT 1000
"GET", """)
"/api/v1/customers",
params={"offset": 0, "limit": 1000}
)
customers = call_api(
"GET",
"/api/v1/customers",
params={"offset": 0, "limit": 1000}
)
customers = call_api(
"GET",
"/api/v1/customers",
params={"offset": 0, "limit": 1000}
)
customers = call_api(
"GET",
"/api/v1/customers",
params={"offset": 0, "limit": 1000}
)
customers = call_api(
"GET",
"/api/v1/customers",
params={"offset": 0, "limit": 1000}
)
customers = call_api(
"GET",
"/api/v1/customers",
params={"offset": 0, "limit": 1000}
)
# Render template with context # Render template with context
html = render_template( html = render_template(
@ -374,19 +329,27 @@ def detail_location_view(id: int = Path(..., gt=0)):
try: try:
logger.info(f"📍 Rendering detail view for location {id}") logger.info(f"📍 Rendering detail view for location {id}")
# Call backend API to get location details # Query location details directly
location = call_api("GET", f"/api/v1/locations/{id}") location = execute_query(
"SELECT * FROM locations_locations WHERE id = %s",
customers = call_api( (id,)
"GET",
"/api/v1/customers",
params={"offset": 0, "limit": 1000}
) )
if not location: if not location:
logger.warning(f"⚠️ Location {id} not found") logger.warning(f"⚠️ Location {id} not found")
raise HTTPException(status_code=404, detail=f"Location {id} not found") raise HTTPException(status_code=404, detail=f"Location {id} not found")
location = location[0] # Get first result
# Query customers
customers = execute_query("""
SELECT id, name, email, phone
FROM customers
WHERE deleted_at IS NULL AND is_active = true
ORDER BY name
LIMIT 1000
""")
# Optionally fetch related data if available from API # Optionally fetch related data if available from API
# contacts = call_api("GET", f"/api/v1/locations/{id}/contacts") # contacts = call_api("GET", f"/api/v1/locations/{id}/contacts")
# hours = call_api("GET", f"/api/v1/locations/{id}/hours") # hours = call_api("GET", f"/api/v1/locations/{id}/hours")
@ -429,30 +392,36 @@ def edit_location_view(id: int = Path(..., gt=0)):
try: try:
logger.info(f"✏️ Rendering edit form for location {id}") logger.info(f"✏️ Rendering edit form for location {id}")
# Call backend API to get current location data # Query location details
location = call_api("GET", f"/api/v1/locations/{id}") location = execute_query(
"SELECT * FROM locations_locations WHERE id = %s",
parent_locations = call_api( (id,)
"GET",
"/api/v1/locations",
params={"skip": 0, "limit": 1000}
)
parent_locations = [
loc for loc in parent_locations
if isinstance(loc, dict) and loc.get("id") != id
]
customers = call_api(
"GET",
"/api/v1/customers",
params={"offset": 0, "limit": 1000}
) )
if not location: if not location:
logger.warning(f"⚠️ Location {id} not found for edit") logger.warning(f"⚠️ Location {id} not found for edit")
raise HTTPException(status_code=404, detail=f"Location {id} not found") raise HTTPException(status_code=404, detail=f"Location {id} not found")
location = location[0] # Get first result
# Query parent locations (exclude self)
parent_locations = execute_query("""
SELECT id, name, location_type
FROM locations_locations
WHERE is_active = true AND id != %s
ORDER BY name
LIMIT 1000
""", (id,))
# Query customers
customers = execute_query("""
SELECT id, name, email, phone
FROM customers
WHERE deleted_at IS NULL AND is_active = true
ORDER BY name
LIMIT 1000
""")
# Render template with context # Render template with context
# Note: HTML forms don't support PATCH, so we use POST with a hidden _method field # Note: HTML forms don't support PATCH, so we use POST with a hidden _method field
html = render_template( html = render_template(
@ -487,24 +456,44 @@ async def update_location_view(request: Request, id: int = Path(..., gt=0)):
"""Handle edit form submission and redirect to detail page.""" """Handle edit form submission and redirect to detail page."""
try: try:
form = await request.form() form = await request.form()
payload = {
"name": form.get("name"), # Update location directly in database
"location_type": form.get("location_type"), execute_update("""
"parent_location_id": int(form.get("parent_location_id")) if form.get("parent_location_id") else None, UPDATE locations_locations SET
"customer_id": int(form.get("customer_id")) if form.get("customer_id") else None, name = %s,
"is_active": form.get("is_active") == "on", location_type = %s,
"address_street": form.get("address_street"), parent_location_id = %s,
"address_city": form.get("address_city"), customer_id = %s,
"address_postal_code": form.get("address_postal_code"), is_active = %s,
"address_country": form.get("address_country"), address_street = %s,
"phone": form.get("phone"), address_city = %s,
"email": form.get("email"), address_postal_code = %s,
"latitude": float(form.get("latitude")) if form.get("latitude") else None, address_country = %s,
"longitude": float(form.get("longitude")) if form.get("longitude") else None, phone = %s,
"notes": form.get("notes"), email = %s,
} latitude = %s,
longitude = %s,
call_api("PATCH", f"/api/v1/locations/{id}", json=payload) notes = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""", (
form.get("name"),
form.get("location_type"),
int(form.get("parent_location_id")) if form.get("parent_location_id") else None,
int(form.get("customer_id")) if form.get("customer_id") else None,
form.get("is_active") == "on",
form.get("address_street"),
form.get("address_city"),
form.get("address_postal_code"),
form.get("address_country"),
form.get("phone"),
form.get("email"),
float(form.get("latitude")) if form.get("latitude") else None,
float(form.get("longitude")) if form.get("longitude") else None,
form.get("notes"),
id
))
return RedirectResponse(url=f"/app/locations/{id}", status_code=303) return RedirectResponse(url=f"/app/locations/{id}", status_code=303)
except HTTPException: except HTTPException:
@ -535,16 +524,24 @@ def map_locations_view(
try: try:
logger.info("🗺️ Rendering map view") logger.info("🗺️ Rendering map view")
# Build API call parameters # Query all locations with filters
params = { where_clauses = []
"skip": 0, query_params = []
"limit": 1000, # Get all locations for map
}
if location_type:
params["location_type"] = location_type
# Call backend API to get all locations if location_type:
locations = call_api("GET", "/api/v1/locations", params=params) where_clauses.append("location_type = %s")
query_params.append(location_type)
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
query = f"""
SELECT * FROM locations_locations
WHERE {where_sql}
ORDER BY name
LIMIT 1000
"""
locations = execute_query(query, tuple(query_params) if query_params else None)
# Filter to locations with coordinates # Filter to locations with coordinates
locations_with_coords = [ locations_with_coords = [

View File

@ -303,16 +303,42 @@ async def sag_detaljer(request: Request, sag_id: int):
logger.info(f"🔎 Looking up prepaid cards for Sag {sag_id}, Customer ID: {cid} (Type: {type(cid)})") logger.info(f"🔎 Looking up prepaid cards for Sag {sag_id}, Customer ID: {cid} (Type: {type(cid)})")
pc_query = """ pc_query = """
SELECT id, card_number, CAST(remaining_hours AS FLOAT) as remaining_hours SELECT id, card_number, CAST(remaining_hours AS FLOAT) as remaining_hours, expires_at
FROM tticket_prepaid_cards FROM tticket_prepaid_cards
WHERE customer_id = %s WHERE customer_id = %s
AND status = 'active' AND status = 'active'
AND remaining_hours > 0 AND remaining_hours > 0
ORDER BY created_at DESC ORDER BY created_at DESC
""" """
prepaid_cards = execute_query(pc_query, (cid,)) prepaid_cards = execute_query(pc_query, (cid,))
logger.info(f"💳 Found {len(prepaid_cards)} prepaid cards for customer {cid}") logger.info(f"💳 Found {len(prepaid_cards)} prepaid cards for customer {cid}")
# Fetch fixed-price agreements for customer
fixed_price_agreements = []
if sag.get('customer_id'):
cid = sag.get('customer_id')
logger.info(f"🔎 Looking up fixed-price agreements for Sag {sag_id}, Customer ID: {cid}")
fpa_query = """
SELECT
a.id,
a.agreement_number,
a.monthly_hours,
COALESCE(bp.remaining_hours, a.monthly_hours) as remaining_hours_this_month
FROM customer_fixed_price_agreements a
LEFT JOIN fixed_price_billing_periods bp ON (
a.id = bp.agreement_id
AND bp.period_start <= CURRENT_DATE
AND bp.period_end >= CURRENT_DATE
)
WHERE a.customer_id = %s
AND a.status = 'active'
AND (a.end_date IS NULL OR a.end_date >= CURRENT_DATE)
ORDER BY a.created_at DESC
"""
fixed_price_agreements = execute_query(fpa_query, (cid,))
logger.info(f"📋 Found {len(fixed_price_agreements)} fixed-price agreements for customer {cid}")
# Fetch Nextcloud Instance for this customer # Fetch Nextcloud Instance for this customer
nextcloud_instance = None nextcloud_instance = None
if customer: if customer:
@ -397,6 +423,7 @@ async def sag_detaljer(request: Request, sag_id: int):
"contacts": contacts, "contacts": contacts,
"customers": customers, "customers": customers,
"prepaid_cards": prepaid_cards, "prepaid_cards": prepaid_cards,
"fixed_price_agreements": fixed_price_agreements,
"tags": tags, "tags": tags,
"relationer": relationer, "relationer": relationer,

View File

@ -1909,6 +1909,21 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="border-top px-3 py-2 small text-muted">
<div class="fw-semibold text-primary mb-1"><i class="bi bi-credit-card me-1"></i>Klippekort</div>
{% if prepaid_cards %}
<div class="d-flex flex-column gap-1">
{% for card in prepaid_cards %}
<div class="d-flex justify-content-between">
<span>#{{ card.card_number or card.id }}</span>
<span>{{ '%.2f' % card.remaining_hours }}t</span>
</div>
{% endfor %}
</div>
{% else %}
<div>Ingen aktive klippekort</div>
{% endif %}
</div>
</div> </div>
</div> </div>
</div> </div>
@ -2987,6 +3002,74 @@
</div> </div>
<!-- Modal for Internal Time --> <!-- Modal for Internal Time -->
<div class="modal fade" id="createTimeModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-clock-history"></i> Registrer Tid</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="timeForm">
<input type="hidden" id="time_sag_id" value="{{ case.id }}">
<div class="row g-3">
<div class="col-6">
<label class="form-label">Dato *</label>
<input type="date" class="form-control" id="time_date" required>
</div>
<div class="col-6">
<label class="form-label">Tid brugt *</label>
<div class="input-group">
<input type="number" class="form-control" id="time_hours_input" min="0" placeholder="tt" step="1">
<span class="input-group-text">:</span>
<input type="number" class="form-control" id="time_minutes_input" min="0" max="59" placeholder="mm" step="1">
</div>
<div class="form-text text-end" id="timeTotalCalc">Total: 0.00 timer</div>
</div>
<div class="col-6">
<label class="form-label">Type</label>
<select class="form-select" id="time_work_type">
<option value="support" selected>Support</option>
<option value="troubleshooting">Fejlsøgning</option>
<option value="development">Udvikling</option>
<option value="on_site">Kørsel / On-site</option>
<option value="meeting">Møde</option>
<option value="other">Andet</option>
</select>
</div>
<div class="col-6">
<label class="form-label">Afregning</label>
<select class="form-select" id="time_billing_method">
<option value="invoice" selected>Faktura</option>
{% if prepaid_cards %}
<optgroup label="Klippekort">
{% for card in prepaid_cards %}
<option value="card_{{ card.id }}">💳 Klippekort #{{ card.card_number or card.id }} ({{ '%.2f' % card.remaining_hours }}t tilbage{% if card.expires_at %} • Udløber {{ card.expires_at }}{% endif %})</option>
{% endfor %}
</optgroup>
{% endif %}
{% if fixed_price_agreements %}
<optgroup label="Fastpris Aftaler">
{% for agr in fixed_price_agreements %}
<option value="fpa_{{ agr.id }}">📋 Fastpris #{{ agr.agreement_number }} ({{ '%.1f' % agr.remaining_hours_this_month }}t tilbage / {{ '%.0f' % agr.monthly_hours }}t/måned)</option>
{% endfor %}
</optgroup>
{% endif %}
<option value="internal">Internt / Ingen faktura</option>
<option value="warranty">Garanti / Reklamation</option>
</select>
</div>
<div class="col-12">
<label class="form-label">Beskrivelse</label>
<textarea class="form-control" id="time_desc" rows="3" placeholder="Hvad er der brugt tid på?"></textarea>
</div>
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="time_internal">
<label class="form-check-label text-muted" for="time_internal">
Skjul for kunde (Intern registrering)
</label>
</div>
</div> </div>
</div> </div>
</form> </form>
@ -3147,12 +3230,19 @@
const billingSelect = document.getElementById('time_billing_method'); const billingSelect = document.getElementById('time_billing_method');
let billingMethod = billingSelect ? billingSelect.value : 'invoice'; let billingMethod = billingSelect ? billingSelect.value : 'invoice';
let prepaidCardId = null; let prepaidCardId = null;
let fixedPriceAgreementId = null;
// Handle prepaid card selection formatting (card_123) // Handle prepaid card selection formatting (card_123)
if (billingMethod.startsWith('card_')) { if (billingMethod.startsWith('card_')) {
prepaidCardId = parseInt(billingMethod.split('_')[1]); prepaidCardId = parseInt(billingMethod.split('_')[1]);
billingMethod = 'prepaid'; billingMethod = 'prepaid';
} }
// Handle fixed-price agreement selection formatting (fpa_123)
if (billingMethod.startsWith('fpa_')) {
fixedPriceAgreementId = parseInt(billingMethod.split('_')[1]);
billingMethod = 'fixed_price';
}
const workTypeSelect = document.getElementById('time_work_type'); const workTypeSelect = document.getElementById('time_work_type');
const internalCheck = document.getElementById('time_internal'); const internalCheck = document.getElementById('time_internal');
@ -3170,6 +3260,10 @@
if (prepaidCardId) { if (prepaidCardId) {
data.prepaid_card_id = prepaidCardId; data.prepaid_card_id = prepaidCardId;
} }
if (fixedPriceAgreementId) {
data.fixed_price_agreement_id = fixedPriceAgreementId;
}
try { try {
const res = await fetch(`/api/v1/timetracking/entries/internal`, { const res = await fetch(`/api/v1/timetracking/entries/internal`, {

View File

@ -3,6 +3,7 @@ from app.core.database import execute_query
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
from pydantic import BaseModel from pydantic import BaseModel
from datetime import datetime, date from datetime import datetime, date
from decimal import Decimal, ROUND_CEILING
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -25,6 +26,7 @@ class PrepaidCard(BaseModel):
economic_invoice_number: Optional[str] = None economic_invoice_number: Optional[str] = None
economic_product_number: Optional[str] = None economic_product_number: Optional[str] = None
notes: Optional[str] = None notes: Optional[str] = None
rounding_minutes: Optional[int] = None
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
@ -34,6 +36,26 @@ class PrepaidCardCreate(BaseModel):
price_per_hour: float price_per_hour: float
expires_at: Optional[date] = None expires_at: Optional[date] = None
notes: Optional[str] = None notes: Optional[str] = None
rounding_minutes: Optional[int] = None
class PrepaidCardRoundingUpdate(BaseModel):
rounding_minutes: int
def _normalize_rounding_minutes(value: Optional[int]) -> int:
if value is None:
return 0
return int(value)
def _apply_rounding(hours: float, rounding_minutes: int) -> float:
if rounding_minutes <= 0:
return float(hours)
interval = Decimal(rounding_minutes) / Decimal(60)
raw = Decimal(str(hours))
rounded = (raw / interval).to_integral_value(rounding=ROUND_CEILING) * interval
return float(rounded)
@router.get("/prepaid-cards", response_model=List[Dict[str, Any]]) @router.get("/prepaid-cards", response_model=List[Dict[str, Any]])
@ -65,6 +87,46 @@ async def get_prepaid_cards(status: Optional[str] = None, customer_id: Optional[
query += " ORDER BY pc.created_at DESC" query += " ORDER BY pc.created_at DESC"
cards = execute_query(query, tuple(params) if params else None) cards = execute_query(query, tuple(params) if params else None)
# Recalculate used_hours and remaining_hours from actual timelogs for each card
for card in cards or []:
card_id = card['id']
rounding_minutes = _normalize_rounding_minutes(card.get('rounding_minutes'))
# Get sag timelogs
sag_logs = execute_query("""
SELECT original_hours, approved_hours
FROM tmodule_times
WHERE prepaid_card_id = %s
""", (card_id,))
# Get ticket timelogs
ticket_logs = execute_query("""
SELECT hours, rounded_hours
FROM tticket_worklog
WHERE prepaid_card_id = %s
""", (card_id,))
# Calculate total rounded hours
total_rounded = 0.0
for log in sag_logs or []:
actual = float(log['original_hours'])
if log.get('approved_hours'):
total_rounded += float(log['approved_hours'])
else:
total_rounded += _apply_rounding(actual, rounding_minutes)
for log in ticket_logs or []:
if log.get('rounded_hours'):
total_rounded += float(log['rounded_hours'])
else:
total_rounded += float(log['hours'])
# Override with calculated values
purchased = float(card['purchased_hours'])
card['used_hours'] = total_rounded
card['remaining_hours'] = purchased - total_rounded
logger.info(f"✅ Retrieved {len(cards) if cards else 0} prepaid cards") logger.info(f"✅ Retrieved {len(cards) if cards else 0} prepaid cards")
return cards or [] return cards or []
@ -93,6 +155,8 @@ async def get_prepaid_card(card_id: int):
raise HTTPException(status_code=404, detail="Prepaid card not found") raise HTTPException(status_code=404, detail="Prepaid card not found")
card = result[0] card = result[0]
rounding_minutes = _normalize_rounding_minutes(card.get('rounding_minutes'))
# Get transactions # Get transactions
transactions = execute_query(""" transactions = execute_query("""
@ -108,6 +172,92 @@ async def get_prepaid_card(card_id: int):
""", (card_id,)) """, (card_id,))
card['transactions'] = transactions or [] card['transactions'] = transactions or []
# Timelogs from Sag + Ticket worklog (all entries tied to this prepaid card)
sag_logs = execute_query("""
SELECT
tm.id,
tm.sag_id,
tm.worked_date,
tm.description,
tm.original_hours,
tm.approved_hours,
tm.created_at,
tm.user_name,
s.titel AS sag_title
FROM tmodule_times tm
LEFT JOIN sag_sager s ON tm.sag_id = s.id
WHERE tm.prepaid_card_id = %s
""", (card_id,))
ticket_logs = execute_query("""
SELECT
w.id,
w.ticket_id,
w.work_date,
w.description,
w.hours,
w.rounded_hours,
w.created_at,
t.subject AS ticket_title,
t.ticket_number
FROM tticket_worklog w
LEFT JOIN tticket_tickets t ON w.ticket_id = t.id
WHERE w.prepaid_card_id = %s
""", (card_id,))
timelogs = []
for log in sag_logs or []:
actual_hours = float(log['original_hours'])
# Use stored approved_hours if available, else calculate
rounded_hours = float(log.get('approved_hours') or 0) if log.get('approved_hours') else _apply_rounding(actual_hours, rounding_minutes)
timelogs.append({
"source": "sag",
"source_id": log.get("sag_id"),
"source_title": log.get("sag_title"),
"worked_date": log.get("worked_date"),
"created_at": log.get("created_at"),
"description": log.get("description"),
"user_name": log.get("user_name"),
"actual_hours": actual_hours,
"rounded_hours": rounded_hours
})
for log in ticket_logs or []:
actual_hours = float(log['hours'])
# Use stored rounded_hours if available, else use actual
rounded_hours = float(log.get('rounded_hours') or 0) if log.get('rounded_hours') else actual_hours
ticket_label = log.get("ticket_number") or log.get("ticket_id")
timelogs.append({
"source": "ticket",
"source_id": log.get("ticket_id"),
"source_title": log.get("ticket_title") or "Ticket",
"ticket_number": ticket_label,
"worked_date": log.get("work_date"),
"created_at": log.get("created_at"),
"description": log.get("description"),
"user_name": None,
"actual_hours": actual_hours,
"rounded_hours": rounded_hours
})
timelogs.sort(
key=lambda item: (
item.get("worked_date") or date.min,
item.get("created_at") or datetime.min
),
reverse=True
)
# Calculate actual totals from timelogs (sum of rounded hours)
total_rounded_hours = sum(log['rounded_hours'] for log in timelogs)
purchased_hours = float(card['purchased_hours'])
# Override DB values with calculated values based on actual timelogs
card['used_hours'] = total_rounded_hours
card['remaining_hours'] = purchased_hours - total_rounded_hours
card['timelogs'] = timelogs
card['rounding_minutes'] = rounding_minutes
return card return card
@ -126,6 +276,10 @@ async def create_prepaid_card(card: PrepaidCardCreate):
Note: As of migration 065, customers can have multiple active cards simultaneously. Note: As of migration 065, customers can have multiple active cards simultaneously.
""" """
try: try:
rounding_minutes = _normalize_rounding_minutes(card.rounding_minutes)
if rounding_minutes not in (0, 15, 30, 60):
raise HTTPException(status_code=400, detail="Invalid rounding minutes")
# Calculate total amount # Calculate total amount
total_amount = card.purchased_hours * card.price_per_hour total_amount = card.purchased_hours * card.price_per_hour
@ -139,8 +293,8 @@ async def create_prepaid_card(card: PrepaidCardCreate):
with conn.cursor(cursor_factory=RealDictCursor) as cursor: with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(""" cursor.execute("""
INSERT INTO tticket_prepaid_cards INSERT INTO tticket_prepaid_cards
(customer_id, purchased_hours, price_per_hour, total_amount, expires_at, notes) (customer_id, purchased_hours, price_per_hour, total_amount, expires_at, notes, rounding_minutes)
VALUES (%s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING * RETURNING *
""", ( """, (
card.customer_id, card.customer_id,
@ -149,6 +303,7 @@ async def create_prepaid_card(card: PrepaidCardCreate):
total_amount, total_amount,
card.expires_at, card.expires_at,
card.notes card.notes
, rounding_minutes
)) ))
conn.commit() conn.commit()
result = cursor.fetchone() result = cursor.fetchone()
@ -207,6 +362,38 @@ async def update_card_status(card_id: int, status: str):
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.put("/prepaid-cards/{card_id}/rounding", response_model=Dict[str, Any])
async def update_card_rounding(card_id: int, payload: PrepaidCardRoundingUpdate):
"""
Update rounding interval for a prepaid card (minutes)
"""
try:
rounding_minutes = _normalize_rounding_minutes(payload.rounding_minutes)
if rounding_minutes not in (0, 15, 30, 60):
raise HTTPException(status_code=400, detail="Invalid rounding minutes")
result = execute_query(
"""
UPDATE tticket_prepaid_cards
SET rounding_minutes = %s
WHERE id = %s
RETURNING *
""",
(rounding_minutes, card_id)
)
if not result:
raise HTTPException(status_code=404, detail="Card not found")
return result[0]
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error updating rounding for card {card_id}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/prepaid-cards/{card_id}") @router.delete("/prepaid-cards/{card_id}")
async def delete_prepaid_card(card_id: int): async def delete_prepaid_card(card_id: int):
""" """
@ -240,23 +427,78 @@ async def delete_prepaid_card(card_id: int):
@router.get("/prepaid-cards/stats/summary", response_model=Dict[str, Any]) @router.get("/prepaid-cards/stats/summary", response_model=Dict[str, Any])
async def get_prepaid_stats(): async def get_prepaid_stats():
""" """
Get prepaid cards statistics Get prepaid cards statistics (calculated from actual timelogs)
""" """
try: try:
result = execute_query(""" # Get all cards
SELECT cards = execute_query("""
COUNT(*) FILTER (WHERE status = 'active') as active_count, SELECT id, status, purchased_hours, total_amount, rounding_minutes
COUNT(*) FILTER (WHERE status = 'depleted') as depleted_count,
COUNT(*) FILTER (WHERE status = 'expired') as expired_count,
COUNT(*) FILTER (WHERE status = 'cancelled') as cancelled_count,
COALESCE(SUM(remaining_hours) FILTER (WHERE status = 'active'), 0) as total_remaining_hours,
COALESCE(SUM(used_hours), 0) as total_used_hours,
COALESCE(SUM(purchased_hours), 0) as total_purchased_hours,
COALESCE(SUM(total_amount), 0) as total_revenue
FROM tticket_prepaid_cards FROM tticket_prepaid_cards
""") """)
return result[0] if result and len(result) > 0 else {} stats = {
'active_count': 0,
'depleted_count': 0,
'expired_count': 0,
'cancelled_count': 0,
'total_remaining_hours': 0.0,
'total_used_hours': 0.0,
'total_purchased_hours': 0.0,
'total_revenue': 0.0
}
for card in cards or []:
card_id = card['id']
status = card['status']
purchased = float(card['purchased_hours'])
rounding_minutes = _normalize_rounding_minutes(card.get('rounding_minutes'))
# Count by status
if status == 'active':
stats['active_count'] += 1
elif status == 'depleted':
stats['depleted_count'] += 1
elif status == 'expired':
stats['expired_count'] += 1
elif status == 'cancelled':
stats['cancelled_count'] += 1
# Calculate actual used hours from timelogs
sag_logs = execute_query("""
SELECT original_hours, approved_hours
FROM tmodule_times
WHERE prepaid_card_id = %s
""", (card_id,))
ticket_logs = execute_query("""
SELECT hours, rounded_hours
FROM tticket_worklog
WHERE prepaid_card_id = %s
""", (card_id,))
used = 0.0
for log in sag_logs or []:
if log.get('approved_hours'):
used += float(log['approved_hours'])
else:
used += _apply_rounding(float(log['original_hours']), rounding_minutes)
for log in ticket_logs or []:
if log.get('rounded_hours'):
used += float(log['rounded_hours'])
else:
used += float(log['hours'])
remaining = purchased - used
stats['total_purchased_hours'] += purchased
stats['total_used_hours'] += used
stats['total_revenue'] += float(card['total_amount'])
if status == 'active':
stats['total_remaining_hours'] += remaining
return stats
except Exception as e: except Exception as e:
logger.error(f"❌ Error fetching prepaid stats: {e}", exc_info=True) logger.error(f"❌ Error fetching prepaid stats: {e}", exc_info=True)

View File

@ -92,24 +92,24 @@
</div> </div>
</div> </div>
<!-- Transactions --> <!-- Timelogs -->
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3"> <div class="card-header bg-white border-0 py-3">
<h5 class="mb-0">Transaktioner</h5> <h5 class="mb-0">Timelogs</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover align-middle" id="transactionsTable"> <table class="table table-hover align-middle" id="timelogsTable">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th>Dato</th> <th>Dato</th>
<th>Ticket</th> <th>Sag / Ticket</th>
<th>Beskrivelse</th> <th>Beskrivelse</th>
<th class="text-end">Timer</th> <th class="text-end">Faktisk tid</th>
<th class="text-end">Beløb</th> <th class="text-end">Afrundet</th>
</tr> </tr>
</thead> </thead>
<tbody id="transactionsBody"> <tbody id="timelogsBody">
<tr> <tr>
<td colspan="5" class="text-center py-5"> <td colspan="5" class="text-center py-5">
<div class="spinner-border text-primary" role="status"> <div class="spinner-border text-primary" role="status">
@ -118,6 +118,13 @@
</td> </td>
</tr> </tr>
</tbody> </tbody>
<tfoot class="table-light">
<tr>
<td colspan="3" class="text-end fw-bold">I alt:</td>
<td class="text-end fw-bold" id="totalActualHours">-</td>
<td class="text-end fw-bold" id="totalRoundedHours">-</td>
</tr>
</tfoot>
</table> </table>
</div> </div>
</div> </div>
@ -211,6 +218,18 @@ async function loadCardDetails() {
<label class="small text-muted">Pris pr. Time</label> <label class="small text-muted">Pris pr. Time</label>
<p class="mb-0"><strong>${parseFloat(card.price_per_hour).toFixed(2)} kr</strong></p> <p class="mb-0"><strong>${parseFloat(card.price_per_hour).toFixed(2)} kr</strong></p>
</div> </div>
<div class="col-md-6">
<label class="small text-muted">Afrunding</label>
<div class="input-group input-group-sm">
<select class="form-select" id="roundingMinutes">
<option value="0">Ingen afrunding</option>
<option value="15">15 min</option>
<option value="30">30 min</option>
<option value="60">60 min</option>
</select>
<button class="btn btn-outline-primary" type="button" onclick="saveRounding()">Gem</button>
</div>
</div>
<div class="col-md-6"> <div class="col-md-6">
<label class="small text-muted">Købt Dato</label> <label class="small text-muted">Købt Dato</label>
<p class="mb-0">${new Date(card.purchased_at).toLocaleDateString('da-DK')}</p> <p class="mb-0">${new Date(card.purchased_at).toLocaleDateString('da-DK')}</p>
@ -245,8 +264,13 @@ async function loadCardDetails() {
document.getElementById('actionButtons').innerHTML = actions.join('') || document.getElementById('actionButtons').innerHTML = actions.join('') ||
'<p class="text-muted text-center mb-0">Ingen handlinger tilgængelige</p>'; '<p class="text-muted text-center mb-0">Ingen handlinger tilgængelige</p>';
// Render transactions const roundingSelect = document.getElementById('roundingMinutes');
renderTransactions(card.transactions || []); if (roundingSelect) {
roundingSelect.value = String(card.rounding_minutes || 0);
}
// Render timelogs
renderTimelogs(card.timelogs || []);
} catch (error) { } catch (error) {
console.error('Error loading card:', error); console.error('Error loading card:', error);
@ -262,32 +286,53 @@ async function loadCardDetails() {
} }
} }
function renderTransactions(transactions) { function renderTimelogs(timelogs) {
const tbody = document.getElementById('transactionsBody'); const tbody = document.getElementById('timelogsBody');
if (!transactions || transactions.length === 0) { if (!timelogs || timelogs.length === 0) {
tbody.innerHTML = ` tbody.innerHTML = `
<tr><td colspan="5" class="text-center text-muted py-5"> <tr><td colspan="5" class="text-center text-muted py-5">
Ingen transaktioner endnu Ingen timelogs endnu
</td></tr> </td></tr>
`; `;
document.getElementById('totalActualHours').textContent = '0.00 t';
document.getElementById('totalRoundedHours').textContent = '0.00 t';
return; return;
} }
tbody.innerHTML = transactions.map(t => ` let totalActual = 0;
<tr> let totalRounded = 0;
<td>${new Date(t.created_at).toLocaleDateString('da-DK')}</td>
<td> tbody.innerHTML = timelogs.map(t => {
${t.ticket_id ? const dateValue = t.worked_date || t.created_at;
`<a href="/ticket/tickets/${t.ticket_id}" class="text-decoration-none"> const dateText = dateValue ? new Date(dateValue).toLocaleDateString('da-DK') : '-';
#${t.ticket_id} - ${t.ticket_title || 'Ticket'} let sourceHtml = '-';
</a>` : '-'} if (t.source === 'sag' && t.source_id) {
</td> sourceHtml = `<a href="/sag/${t.source_id}" class="text-decoration-none">Sag #${t.source_id}${t.source_title ? ' - ' + t.source_title : ''}</a>`;
<td>${t.description || '-'}</td> } else if (t.source === 'ticket' && t.source_id) {
<td class="text-end">${parseFloat(t.hours_used).toFixed(2)} t</td> const ticketLabel = t.ticket_number ? `#${t.ticket_number}` : `#${t.source_id}`;
<td class="text-end">${parseFloat(t.amount).toFixed(2)} kr</td> sourceHtml = `<a href="/ticket/tickets/${t.source_id}" class="text-decoration-none">${ticketLabel} - ${t.source_title || 'Ticket'}</a>`;
</tr> }
`).join('');
const actual = parseFloat(t.actual_hours) || 0;
const rounded = parseFloat(t.rounded_hours) || 0;
totalActual += actual;
totalRounded += rounded;
return `
<tr>
<td>${dateText}</td>
<td>${sourceHtml}</td>
<td>${t.description || '-'}</td>
<td class="text-end">${actual.toFixed(2)} t</td>
<td class="text-end">${rounded.toFixed(2)} t</td>
</tr>
`;
}).join('');
// Update totals in footer
document.getElementById('totalActualHours').textContent = totalActual.toFixed(2) + ' t';
document.getElementById('totalRoundedHours').textContent = totalRounded.toFixed(2) + ' t';
} }
function getStatusBadge(status) { function getStatusBadge(status) {
@ -322,6 +367,28 @@ async function cancelCard() {
alert('❌ Fejl: ' + error.message); alert('❌ Fejl: ' + error.message);
} }
} }
async function saveRounding() {
const select = document.getElementById('roundingMinutes');
if (!select) return;
const roundingMinutes = parseInt(select.value, 10) || 0;
try {
const response = await fetch(`/api/v1/prepaid-cards/${cardId}/rounding`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rounding_minutes: roundingMinutes })
});
if (!response.ok) throw new Error('Fejl ved opdatering');
alert('✅ Afrunding opdateret');
loadCardDetails();
} catch (error) {
console.error('Error updating rounding:', error);
alert('❌ Fejl: ' + error.message);
}
}
</script> </script>
<style> <style>

View File

@ -211,6 +211,17 @@
</div> </div>
</div> </div>
<div class="mb-3">
<label class="form-label fw-bold">Afrunding</label>
<select class="form-select" id="roundingMinutes">
<option value="0">Ingen afrunding</option>
<option value="15" selected>15 min</option>
<option value="30">30 min</option>
<option value="60">60 min</option>
</select>
<div class="form-text">Timer afrundes op til intervallet ved klippekort-forbrug</div>
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-bold">Udløbsdato <small class="text-muted fw-normal">(valgfri)</small></label> <label class="form-label fw-bold">Udløbsdato <small class="text-muted fw-normal">(valgfri)</small></label>
<div class="input-group"> <div class="input-group">
@ -518,7 +529,8 @@ async function createCard() {
purchased_hours: parseFloat(document.getElementById('purchasedHours').value), purchased_hours: parseFloat(document.getElementById('purchasedHours').value),
price_per_hour: parseFloat(document.getElementById('pricePerHour').value), price_per_hour: parseFloat(document.getElementById('pricePerHour').value),
expires_at: document.getElementById('expiresAt').value || null, expires_at: document.getElementById('expiresAt').value || null,
notes: document.getElementById('notes').value || null notes: document.getElementById('notes').value || null,
rounding_minutes: parseInt(document.getElementById('roundingMinutes').value, 10) || 0
}; };
try { try {

View File

@ -179,6 +179,88 @@ class SimplyCRMService:
except Exception as e: except Exception as e:
logger.error(f"❌ Simply-CRM query error: {e}") logger.error(f"❌ Simply-CRM query error: {e}")
return [] return []
# =========================================================================
# TICKETS (ARCHIVED IMPORT)
# =========================================================================
async def fetch_tickets(self, limit: int = 5000) -> List[Dict]:
"""
Fetch tickets from Simply-CRM (module configurable in settings).
Args:
limit: Maximum number of tickets to return
Returns:
List of ticket records
"""
module_name = getattr(settings, "SIMPLYCRM_TICKET_MODULE", "Tickets")
all_records: List[Dict] = []
offset = 0
batch_size = 200
while len(all_records) < limit:
query = f"SELECT * FROM {module_name} LIMIT {offset}, {batch_size};"
batch = await self.query(query)
if not batch:
break
all_records.extend(batch)
offset += batch_size
if len(batch) < batch_size:
break
return all_records[:limit]
async def fetch_ticket_comments(self, ticket_id: str) -> List[Dict]:
"""
Fetch comments for a ticket from Simply-CRM.
Args:
ticket_id: Record ID for the ticket
Returns:
List of comment records
"""
module_name = getattr(settings, "SIMPLYCRM_TICKET_COMMENT_MODULE", "ModComments")
relation_field = getattr(settings, "SIMPLYCRM_TICKET_COMMENT_RELATION_FIELD", "related_to")
query = f"SELECT * FROM {module_name} WHERE {relation_field} = '{ticket_id}' ORDER BY createdtime ASC;"
return await self.query(query)
async def fetch_ticket_emails(self, ticket_id: str) -> List[Dict]:
"""
Fetch email records for a ticket from Simply-CRM.
Args:
ticket_id: Record ID for the ticket
Returns:
List of email records
"""
module_name = getattr(settings, "SIMPLYCRM_TICKET_EMAIL_MODULE", "Emails")
relation_field = getattr(settings, "SIMPLYCRM_TICKET_EMAIL_RELATION_FIELD", "parent_id")
fallback_relation_field = getattr(settings, "SIMPLYCRM_TICKET_EMAIL_FALLBACK_RELATION_FIELD", "related_to")
records: List[Dict] = []
query = f"SELECT * FROM {module_name} WHERE {relation_field} = '{ticket_id}';"
records.extend(await self.query(query))
if not records and fallback_relation_field:
query = f"SELECT * FROM {module_name} WHERE {fallback_relation_field} = '{ticket_id}';"
records.extend(await self.query(query))
# De-duplicate by record id if present
seen_ids = set()
unique_records: List[Dict] = []
for record in records:
record_id = record.get("id")
if record_id and record_id in seen_ids:
continue
if record_id:
seen_ids.add(record_id)
unique_records.append(record)
return unique_records
async def retrieve(self, record_id: str) -> Optional[Dict]: async def retrieve(self, record_id: str) -> Optional[Dict]:
""" """
@ -215,6 +297,41 @@ class SimplyCRMService:
except Exception as e: except Exception as e:
logger.error(f"❌ Simply-CRM retrieve error: {e}") logger.error(f"❌ Simply-CRM retrieve error: {e}")
return None return None
async def list_types(self) -> List[str]:
"""
List available module types from Simply-CRM.
"""
await self._ensure_session()
if not self.session_name or not self.session:
logger.error("❌ Not logged in to Simply-CRM")
return []
try:
async with self.session.get(
f"{self.base_url}/webservice.php",
params={
"operation": "listtypes",
"sessionName": self.session_name
},
timeout=aiohttp.ClientTimeout(total=30)
) as response:
if not response.ok:
logger.error(f"❌ Simply-CRM listtypes failed: {response.status}")
return []
data = await response.json()
if not data.get("success"):
logger.error(f"❌ Simply-CRM listtypes error: {data}")
return []
result = data.get("result", {})
return result.get("types", [])
except Exception as e:
logger.error(f"❌ Simply-CRM listtypes error: {e}")
return []
# ========================================================================= # =========================================================================
# SUBSCRIPTIONS # SUBSCRIPTIONS

View File

@ -238,6 +238,7 @@
<ul class="dropdown-menu mt-2"> <ul class="dropdown-menu mt-2">
<li><a class="dropdown-item py-2" href="/ticket/dashboard"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li> <li><a class="dropdown-item py-2" href="/ticket/dashboard"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li>
<li><a class="dropdown-item py-2" href="/ticket/tickets"><i class="bi bi-ticket-detailed me-2"></i>Alle Tickets</a></li> <li><a class="dropdown-item py-2" href="/ticket/tickets"><i class="bi bi-ticket-detailed me-2"></i>Alle Tickets</a></li>
<li><a class="dropdown-item py-2" href="/ticket/archived"><i class="bi bi-archive me-2"></i>Arkiverede Tickets</a></li>
<li><a class="dropdown-item py-2" href="/ticket/worklog/review"><i class="bi bi-clock-history me-2"></i>Godkend Worklog</a></li> <li><a class="dropdown-item py-2" href="/ticket/worklog/review"><i class="bi bi-clock-history me-2"></i>Godkend Worklog</a></li>
<li><a class="dropdown-item py-2" href="/conversations/my"><i class="bi bi-mic me-2"></i>Mine Samtaler</a></li> <li><a class="dropdown-item py-2" href="/conversations/my"><i class="bi bi-mic me-2"></i>Mine Samtaler</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
@ -246,6 +247,7 @@
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="#">Ny Ticket</a></li> <li><a class="dropdown-item py-2" href="#">Ny Ticket</a></li>
<li><a class="dropdown-item py-2" href="/prepaid-cards"><i class="bi bi-credit-card-2-front me-2"></i>Prepaid Cards</a></li> <li><a class="dropdown-item py-2" href="/prepaid-cards"><i class="bi bi-credit-card-2-front me-2"></i>Prepaid Cards</a></li>
<li><a class="dropdown-item py-2" href="/fixed-price-agreements"><i class="bi bi-calendar-check me-2"></i>Fastpris Aftaler</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="#">Knowledge Base</a></li> <li><a class="dropdown-item py-2" href="#">Knowledge Base</a></li>
</ul> </ul>

View File

@ -237,25 +237,29 @@ class KlippekortService:
Raises: Raises:
ValueError: If insufficient balance or no active card ValueError: If insufficient balance or no active card
""" """
# Check if deduction is possible
can_deduct, error = KlippekortService.can_deduct(customer_id, hours)
if not can_deduct:
raise ValueError(error)
# Get active card # Get active card
card = KlippekortService.get_active_card_for_customer(customer_id) card = KlippekortService.get_active_card_for_customer(customer_id)
rounding_minutes = int(card.get('rounding_minutes') or 0)
rounded_hours = hours
if rounding_minutes > 0:
interval = Decimal(rounding_minutes) / Decimal(60)
rounded_hours = (Decimal(str(hours)) / interval).to_integral_value(rounding=ROUND_CEILING) * interval
if Decimal(str(card['remaining_hours'])) < Decimal(str(rounded_hours)):
raise ValueError(f"Insufficient hours on prepaid card (Remaining: {card['remaining_hours']})")
logger.info(f"⏱️ Deducting {hours}h from card {card['card_number']} for worklog {worklog_id}") logger.info(f"⏱️ Deducting {rounded_hours}h from card {card['card_number']} for worklog {worklog_id}")
# Update card usage # Update card usage
new_used = Decimal(str(card['used_hours'])) + hours new_used = Decimal(str(card['used_hours'])) + rounded_hours
execute_update( execute_update(
"UPDATE tticket_prepaid_cards SET used_hours = %s WHERE id = %s", "UPDATE tticket_prepaid_cards SET used_hours = %s WHERE id = %s",
(new_used, card['id']) (new_used, card['id'])
) )
# Calculate new balance # Calculate new balance
new_balance = Decimal(str(card['remaining_hours'])) - hours new_balance = Decimal(str(card['remaining_hours'])) - rounded_hours
# Create transaction # Create transaction
transaction_id = execute_insert( transaction_id = execute_insert(
@ -268,9 +272,9 @@ class KlippekortService:
card['id'], card['id'],
worklog_id, worklog_id,
'usage', 'usage',
-hours, # Negative for deduction -rounded_hours, # Negative for deduction
new_balance, new_balance,
description or f"Worklog #{worklog_id}: {hours}h", description or f"Worklog #{worklog_id}: {hours}h (rounded to {rounded_hours}h)",
user_id user_id
) )
) )

View File

@ -57,6 +57,7 @@ class WorkType(str, Enum):
class BillingMethod(str, Enum): class BillingMethod(str, Enum):
"""Afregningsmetode""" """Afregningsmetode"""
PREPAID_CARD = "prepaid_card" PREPAID_CARD = "prepaid_card"
FIXED_PRICE = "fixed_price"
INVOICE = "invoice" INVOICE = "invoice"
INTERNAL = "internal" INTERNAL = "internal"
WARRANTY = "warranty" WARRANTY = "warranty"

View File

@ -6,11 +6,16 @@ REST API endpoints for ticket system.
""" """
import logging import logging
import hashlib
import json
import re
from typing import List, Optional from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query, status from fastapi import APIRouter, HTTPException, Query, status
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from app.ticket.backend.ticket_service import TicketService from app.ticket.backend.ticket_service import TicketService
from app.services.simplycrm_service import SimplyCRMService
from app.core.config import settings
from app.ticket.backend.economic_export import ticket_economic_service from app.ticket.backend.economic_export import ticket_economic_service
from app.ticket.backend.models import ( from app.ticket.backend.models import (
TTicket, TTicket,
@ -48,13 +53,78 @@ from app.ticket.backend.models import (
TicketDeadlineUpdateRequest TicketDeadlineUpdateRequest
) )
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
from datetime import date from datetime import date, datetime
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
def _get_first_value(data: dict, keys: List[str]) -> Optional[str]:
for key in keys:
value = data.get(key)
if value not in (None, ""):
return value
return None
def _parse_datetime(value: Optional[str]) -> Optional[datetime]:
if not value:
return None
if isinstance(value, datetime):
return value
if isinstance(value, date):
return datetime.combine(value, datetime.min.time())
value_str = str(value).strip()
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
try:
return datetime.strptime(value_str, fmt)
except ValueError:
continue
try:
return datetime.fromisoformat(value_str.replace("Z", "+00:00"))
except ValueError:
return None
def _parse_hours(value: Optional[str]) -> Optional[float]:
if value in (None, ""):
return None
if isinstance(value, (int, float)):
return float(value)
value_str = str(value).strip()
if ":" in value_str:
parts = value_str.split(":")
if len(parts) == 2:
try:
hours = float(parts[0])
minutes = float(parts[1])
return hours + minutes / 60.0
except ValueError:
return None
try:
return float(value_str)
except ValueError:
return None
def _looks_like_external_id(value: Optional[str]) -> bool:
if not value:
return False
return bool(re.match(r"^\d+x\d+$", str(value)))
def _calculate_hash(data: dict) -> str:
payload = json.dumps(data, sort_keys=True, default=str).encode("utf-8")
return hashlib.sha256(payload).hexdigest()
def _escape_simply_value(value: str) -> str:
return value.replace("'", "''")
# ============================================================================ # ============================================================================
# TICKET ENDPOINTS # TICKET ENDPOINTS
# ============================================================================ # ============================================================================
@ -563,11 +633,29 @@ async def create_worklog(
status_code=400, status_code=400,
detail="Valgt klippekort er ikke aktivt eller tilhører ikke kunden") detail="Valgt klippekort er ikke aktivt eller tilhører ikke kunden")
# Calculate rounded hours if prepaid
rounded_hours = None
if prepaid_card_id:
card = execute_query_single(
"SELECT rounding_minutes FROM tticket_prepaid_cards WHERE id = %s",
(prepaid_card_id,)
)
if card:
rounding_minutes = int(card.get('rounding_minutes') or 0)
if rounding_minutes > 0:
from decimal import Decimal, ROUND_CEILING
interval = Decimal(rounding_minutes) / Decimal(60)
rounded_hours = float(
(Decimal(str(worklog_data.hours)) / interval).to_integral_value(rounding=ROUND_CEILING) * interval
)
else:
rounded_hours = float(worklog_data.hours)
worklog_id = execute_insert( worklog_id = execute_insert(
""" """
INSERT INTO tticket_worklog INSERT INTO tticket_worklog
(ticket_id, work_date, hours, work_type, description, billing_method, status, user_id, prepaid_card_id, is_internal) (ticket_id, work_date, hours, work_type, description, billing_method, status, user_id, prepaid_card_id, is_internal, rounded_hours)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id RETURNING id
""", """,
( (
@ -580,7 +668,8 @@ async def create_worklog(
'draft', 'draft',
user_id or worklog_data.user_id, user_id or worklog_data.user_id,
prepaid_card_id, prepaid_card_id,
worklog_data.is_internal worklog_data.is_internal,
rounded_hours
) )
) )
@ -1705,3 +1794,397 @@ async def get_audit_log(
except Exception as e: except Exception as e:
logger.error(f"❌ Error fetching audit log: {e}") logger.error(f"❌ Error fetching audit log: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# ARCHIVED TICKETS (SIMPLY-CRM IMPORT)
# ============================================================================
@router.post("/archived/simply/import", tags=["Archived Tickets"])
async def import_simply_archived_tickets(
limit: int = Query(5000, ge=1, le=50000, description="Maximum tickets to import"),
include_messages: bool = Query(True, description="Include comments and emails"),
ticket_number: Optional[str] = Query(None, description="Import a single ticket by number"),
force: bool = Query(False, description="Update even if sync hash matches")
):
"""
One-time import of archived tickets from Simply-CRM.
"""
stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0}
try:
async with SimplyCRMService() as service:
if ticket_number:
module_name = getattr(settings, "SIMPLYCRM_TICKET_MODULE", "HelpDesk")
sanitized = _escape_simply_value(ticket_number)
tickets = []
for field in ("ticket_no", "ticketnumber", "ticket_number"):
query = f"SELECT * FROM {module_name} WHERE {field} = '{sanitized}';"
tickets = await service.query(query)
if tickets:
break
else:
tickets = await service.fetch_tickets(limit=limit)
logger.info(f"🔍 Importing {len(tickets)} archived tickets from Simply-CRM")
account_cache: dict[str, Optional[str]] = {}
contact_cache: dict[str, Optional[str]] = {}
for ticket in tickets:
try:
external_id = _get_first_value(ticket, ["id", "ticketid", "ticket_id"])
if not external_id:
stats["skipped"] += 1
continue
data_hash = _calculate_hash(ticket)
existing = execute_query_single(
"""SELECT id, sync_hash
FROM tticket_archived_tickets
WHERE source_system = %s AND external_id = %s""",
("simplycrm", external_id)
)
ticket_number = _get_first_value(
ticket,
["ticket_no", "ticketnumber", "ticket_number", "ticketid", "ticket_id", "id"]
)
title = _get_first_value(
ticket,
["title", "subject", "ticket_title", "tickettitle", "summary"]
)
organization_name = _get_first_value(
ticket,
["accountname", "account_name", "organization", "company"]
)
account_id = _get_first_value(
ticket,
["parent_id", "account_id", "accountid", "account"]
)
if not organization_name and _looks_like_external_id(account_id):
if account_id not in account_cache:
related = await service.retrieve(account_id)
account_cache[account_id] = _get_first_value(
related or {},
["accountname", "account_name", "name"]
)
if not account_cache[account_id]:
first_name = _get_first_value(related or {}, ["firstname", "first_name", "first"])
last_name = _get_first_value(related or {}, ["lastname", "last_name", "last"])
combined = " ".join([name for name in [first_name, last_name] if name])
if combined:
account_cache[account_id] = None
if not contact_name:
contact_name = combined
related_account_id = _get_first_value(
related or {},
["account_id", "accountid", "account"]
)
if related_account_id and _looks_like_external_id(related_account_id):
if related_account_id not in account_cache:
account = await service.retrieve(related_account_id)
account_cache[related_account_id] = _get_first_value(
account or {},
["accountname", "account_name", "name"]
)
organization_name = account_cache.get(related_account_id)
if not organization_name:
organization_name = account_cache.get(account_id)
contact_name = _get_first_value(
ticket,
["contactname", "contact_name", "contact"]
)
contact_id = _get_first_value(
ticket,
["contact_id", "contactid"]
)
if not contact_name and _looks_like_external_id(contact_id):
if contact_id not in contact_cache:
contact = await service.retrieve(contact_id)
first_name = _get_first_value(contact or {}, ["firstname", "first_name", "first"])
last_name = _get_first_value(contact or {}, ["lastname", "last_name", "last"])
combined = " ".join([name for name in [first_name, last_name] if name])
contact_cache[contact_id] = combined or _get_first_value(
contact or {},
["contactname", "name"]
)
if not organization_name:
related_account_id = _get_first_value(
contact or {},
["account_id", "accountid", "account"]
)
if related_account_id and _looks_like_external_id(related_account_id):
if related_account_id not in account_cache:
account = await service.retrieve(related_account_id)
account_cache[related_account_id] = _get_first_value(
account or {},
["accountname", "account_name", "name"]
)
organization_name = account_cache.get(related_account_id)
contact_name = contact_cache.get(contact_id)
email_from = _get_first_value(
ticket,
["email_from", "from_email", "from", "email", "email_from_address"]
)
time_spent_hours = _parse_hours(
_get_first_value(ticket, ["time_spent", "hours", "time_spent_hours", "spent_time", "cf_time_spent", "cf_tid_brugt"])
)
description = _get_first_value(
ticket,
["description", "ticket_description", "comments", "issue"]
)
solution = _get_first_value(
ticket,
["solution", "resolution", "solutiontext", "resolution_text"]
)
status = _get_first_value(
ticket,
["status", "ticketstatus", "state"]
)
priority = _get_first_value(
ticket,
["priority", "ticketpriorities", "ticketpriority"]
)
source_created_at = _parse_datetime(
_get_first_value(ticket, ["createdtime", "created_at", "createdon", "created_time"])
)
source_updated_at = _parse_datetime(
_get_first_value(ticket, ["modifiedtime", "updated_at", "modified_time", "updatedtime"])
)
if existing:
if not force and existing.get("sync_hash") == data_hash:
stats["skipped"] += 1
continue
execute_update(
"""
UPDATE tticket_archived_tickets
SET ticket_number = %s,
title = %s,
organization_name = %s,
contact_name = %s,
email_from = %s,
time_spent_hours = %s,
description = %s,
solution = %s,
status = %s,
priority = %s,
source_created_at = %s,
source_updated_at = %s,
last_synced_at = CURRENT_TIMESTAMP,
sync_hash = %s,
raw_data = %s::jsonb
WHERE id = %s
""",
(
ticket_number,
title,
organization_name,
contact_name,
email_from,
time_spent_hours,
description,
solution,
status,
priority,
source_created_at,
source_updated_at,
data_hash,
json.dumps(ticket, default=str),
existing["id"]
)
)
archived_ticket_id = existing["id"]
stats["updated"] += 1
else:
archived_ticket_id = execute_insert(
"""
INSERT INTO tticket_archived_tickets (
source_system,
external_id,
ticket_number,
title,
organization_name,
contact_name,
email_from,
time_spent_hours,
description,
solution,
status,
priority,
source_created_at,
source_updated_at,
last_synced_at,
sync_hash,
raw_data
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
CURRENT_TIMESTAMP, %s, %s::jsonb
)
RETURNING id
""",
(
"simplycrm",
external_id,
ticket_number,
title,
organization_name,
contact_name,
email_from,
time_spent_hours,
description,
solution,
status,
priority,
source_created_at,
source_updated_at,
data_hash,
json.dumps(ticket, default=str)
)
)
stats["imported"] += 1
if include_messages and archived_ticket_id:
execute_update(
"DELETE FROM tticket_archived_messages WHERE archived_ticket_id = %s",
(archived_ticket_id,)
)
comments = await service.fetch_ticket_comments(external_id)
emails = await service.fetch_ticket_emails(external_id)
for comment in comments:
execute_insert(
"""
INSERT INTO tticket_archived_messages (
archived_ticket_id,
message_type,
subject,
body,
author_name,
author_email,
source_created_at,
raw_data
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
RETURNING id
""",
(
archived_ticket_id,
"comment",
None,
_get_first_value(comment, ["commentcontent", "comment", "content", "description"]),
_get_first_value(comment, ["author", "assigned_user_id", "created_by", "creator"]),
_get_first_value(comment, ["email", "author_email", "from_email"]),
_parse_datetime(_get_first_value(comment, ["createdtime", "created_at", "created_time"])),
json.dumps(comment, default=str)
)
)
for email in emails:
execute_insert(
"""
INSERT INTO tticket_archived_messages (
archived_ticket_id,
message_type,
subject,
body,
author_name,
author_email,
source_created_at,
raw_data
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
RETURNING id
""",
(
archived_ticket_id,
"email",
_get_first_value(email, ["subject", "title"]),
_get_first_value(email, ["description", "body", "email_body", "content"]),
_get_first_value(email, ["from_name", "sender", "assigned_user_id"]),
_get_first_value(email, ["from_email", "email", "sender_email"]),
_parse_datetime(_get_first_value(email, ["createdtime", "created_at", "created_time"])),
json.dumps(email, default=str)
)
)
except Exception as e:
logger.error(f"❌ Archived ticket import failed: {e}")
stats["errors"] += 1
logger.info(f"✅ Archived ticket import complete: {stats}")
return stats
except Exception as e:
logger.error(f"❌ Archived ticket import failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/archived/simply/modules", tags=["Archived Tickets"])
async def list_simply_modules():
"""
List available Simply-CRM modules (debug helper).
"""
try:
async with SimplyCRMService() as service:
modules = await service.list_types()
return {"modules": modules}
except Exception as e:
logger.error(f"❌ Failed to list Simply-CRM modules: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/archived/simply/ticket", tags=["Archived Tickets"])
async def fetch_simply_ticket(
ticket_number: Optional[str] = Query(None, description="Ticket number, e.g. TT934"),
external_id: Optional[str] = Query(None, description="VTiger record ID, e.g. 17x1234")
):
"""
Fetch a single HelpDesk ticket from Simply-CRM by ticket number or record id.
"""
if not ticket_number and not external_id:
raise HTTPException(status_code=400, detail="Provide ticket_number or external_id")
try:
async with SimplyCRMService() as service:
module_name = getattr(settings, "SIMPLYCRM_TICKET_MODULE", "HelpDesk")
if external_id:
record = await service.retrieve(external_id)
return {"module": module_name, "records": [record] if record else []}
sanitized = _escape_simply_value(ticket_number or "")
fields = ["ticket_no", "ticketnumber", "ticket_number"]
for field in fields:
query = f"SELECT * FROM {module_name} WHERE {field} = '{sanitized}';"
records = await service.query(query)
if records:
return {"module": module_name, "match_field": field, "records": records}
return {"module": module_name, "records": []}
except Exception as e:
logger.error(f"❌ Failed to fetch Simply-CRM ticket: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/archived/simply/record", tags=["Archived Tickets"])
async def fetch_simply_record(
record_id: str = Query(..., description="VTiger record ID, e.g. 11x2601"),
module: Optional[str] = Query(None, description="Optional module name for context")
):
"""
Fetch a single record from Simply-CRM by record id.
"""
try:
async with SimplyCRMService() as service:
record = await service.retrieve(record_id)
return {"module": module, "record": record}
except Exception as e:
logger.error(f"❌ Failed to fetch Simply-CRM record: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@ -0,0 +1,226 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Arkiveret Ticket - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.detail-card {
background: var(--bg-card);
border-radius: var(--border-radius);
box-shadow: 0 2px 15px rgba(0,0,0,0.05);
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.meta-label {
font-size: 0.8rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.meta-value {
font-weight: 600;
color: var(--text-primary);
}
.ticket-number {
font-family: 'Monaco', 'Courier New', monospace;
background: var(--accent-light);
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.85rem;
color: var(--accent);
font-weight: 600;
}
.message-card {
border: 1px solid var(--accent-light);
border-radius: 10px;
padding: 1rem;
margin-bottom: 1rem;
background: var(--bg-body);
}
.message-meta {
font-size: 0.85rem;
color: var(--text-secondary);
}
.message-type {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.4px;
background: var(--accent-light);
color: var(--accent);
padding: 0.2rem 0.5rem;
border-radius: 6px;
}
.long-text {
white-space: pre-wrap;
word-break: break-word;
line-height: 1.6;
font-size: 1rem;
}
.long-text p {
margin: 0 0 0.75rem;
}
.long-text ul {
margin: 0.5rem 0 0.75rem 1.25rem;
}
.long-text li {
margin-bottom: 0.35rem;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid px-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="mb-2">
<i class="bi bi-archive"></i> Arkiveret Ticket
</h1>
<p class="text-muted">Detaljer fra Simply-CRM import</p>
</div>
<a href="/ticket/archived" class="btn btn-outline-primary">
<i class="bi bi-arrow-left"></i> Tilbage til liste
</a>
</div>
<div class="detail-card">
<div class="row g-3">
<div class="col-md-8">
{% if ticket.ticket_number %}
<span class="ticket-number">{{ ticket.ticket_number }}</span>
{% endif %}
<h2 class="mt-2">{{ ticket.title or 'Ingen titel' }}</h2>
</div>
<div class="col-md-4 text-md-end">
{% if ticket.status %}
<span class="message-type">{{ ticket.status.replace('_', ' ').title() }}</span>
{% endif %}
</div>
</div>
<div class="row g-4 mt-3">
<div class="col-md-3">
<div class="meta-label">Organisation</div>
<div class="meta-value">{{ ticket.organization_name or '-' }}</div>
</div>
<div class="col-md-3">
<div class="meta-label">Kontakt</div>
<div class="meta-value">{{ ticket.contact_name or '-' }}</div>
</div>
<div class="col-md-3">
<div class="meta-label">Email From</div>
<div class="meta-value">{{ ticket.email_from or '-' }}</div>
</div>
<div class="col-md-3">
<div class="meta-label">Tid brugt</div>
<div class="meta-value">
{% if ticket.time_spent_hours is not none %}
{{ '%.2f'|format(ticket.time_spent_hours) }} t
{% else %}
-
{% endif %}
</div>
</div>
<div class="col-md-3">
<div class="meta-label">Prioritet</div>
<div class="meta-value">{{ ticket.priority or '-' }}</div>
</div>
<div class="col-md-3">
<div class="meta-label">Oprettet</div>
<div class="meta-value">
{% if ticket.source_created_at %}
{{ ticket.source_created_at.strftime('%Y-%m-%d %H:%M') }}
{% else %}
-
{% endif %}
</div>
</div>
<div class="col-md-3">
<div class="meta-label">Opdateret</div>
<div class="meta-value">
{% if ticket.source_updated_at %}
{{ ticket.source_updated_at.strftime('%Y-%m-%d %H:%M') }}
{% else %}
-
{% endif %}
</div>
</div>
</div>
</div>
<div class="detail-card">
<h5>Beskrivelse</h5>
{% if description_html %}
<div class="text-muted long-text">{{ description_html | safe }}</div>
{% elif ticket.description %}
<div class="text-muted long-text">{{ ticket.description | e | replace('\n', '<br>') | safe }}</div>
{% else %}
<div class="text-muted long-text">Ingen beskrivelse</div>
{% endif %}
</div>
<div class="detail-card">
<h5>Løsning</h5>
{% if solution_html %}
<div class="text-muted long-text">{{ solution_html | safe }}</div>
{% elif ticket.solution %}
<div class="text-muted long-text">{{ ticket.solution | e | replace('\n', '<br>') | safe }}</div>
{% else %}
<div class="text-muted long-text">Ingen løsning angivet</div>
{% endif %}
</div>
<div class="detail-card">
<h5>Kommentarer og Emails</h5>
{% if messages %}
{% for message in messages %}
<div class="message-card">
<div class="d-flex justify-content-between align-items-start">
<div>
<span class="message-type">{{ message.message_type }}</span>
{% if message.subject %}
<strong class="ms-2">{{ message.subject }}</strong>
{% endif %}
</div>
<div class="message-meta">
{% if message.source_created_at %}
{{ message.source_created_at.strftime('%Y-%m-%d %H:%M') }}
{% else %}
-
{% endif %}
</div>
</div>
<div class="message-meta mt-2">
{% if message.author_name %}
{{ message.author_name }}
{% endif %}
{% if message.author_email %}
({{ message.author_email }})
{% endif %}
</div>
<div class="mt-3">
{% if message.body_html %}
<div class="mb-0 long-text">{{ message.body_html | safe }}</div>
{% elif message.body %}
<div class="mb-0 long-text">{{ message.body | e | replace('\n', '<br>') | safe }}</div>
{% else %}
<div class="mb-0 long-text">Ingen tekst</div>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<p class="text-muted">Ingen kommentarer eller emails fundet.</p>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,341 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Arkiverede Tickets - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.filter-bar {
background: var(--bg-card);
padding: 1.5rem;
border-radius: var(--border-radius);
margin-bottom: 1.5rem;
box-shadow: 0 2px 15px rgba(0,0,0,0.05);
border: 1px solid var(--accent-light);
}
.filter-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
align-items: end;
}
.filter-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.ticket-table th {
font-weight: 600;
color: var(--text-secondary);
border-bottom: 2px solid var(--accent-light);
padding: 1rem 0.75rem;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.ticket-table td {
padding: 1rem 0.75rem;
vertical-align: middle;
border-bottom: 1px solid var(--accent-light);
}
.ticket-row {
transition: background-color 0.2s;
cursor: pointer;
}
.ticket-row:hover {
background-color: var(--accent-light);
}
.badge {
padding: 0.4rem 0.8rem;
font-weight: 500;
border-radius: 6px;
font-size: 0.75rem;
}
.ticket-number {
font-family: 'Monaco', 'Courier New', monospace;
background: var(--accent-light);
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.85rem;
color: var(--accent);
font-weight: 600;
}
.search-box {
position: relative;
}
.search-box input {
padding-left: 2.5rem;
background: var(--bg-body);
border: 1px solid var(--accent-light);
color: var(--text-primary);
}
.search-box i {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
color: var(--text-secondary);
}
.filter-label {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--text-secondary);
margin-bottom: 0.35rem;
}
.filter-chip {
background: var(--accent-light);
color: var(--accent);
font-size: 0.75rem;
padding: 0.2rem 0.55rem;
border-radius: 999px;
margin-left: 0.5rem;
}
.empty-state {
text-align: center;
padding: 4rem 2rem;
color: var(--text-secondary);
}
.empty-state i {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.3;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid px-4">
<!-- Page Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="mb-2">
<i class="bi bi-archive"></i> Arkiverede Tickets
</h1>
<p class="text-muted">Historiske tickets importeret fra Simply-CRM</p>
</div>
</div>
<!-- Filter Bar -->
<div class="filter-bar">
<form method="get" action="/ticket/archived" id="archivedFilters">
<div class="filter-grid">
<div>
<div class="filter-label">Søg</div>
<div class="search-box">
<i class="bi bi-search"></i>
<input
type="text"
name="search"
id="search"
class="form-control"
placeholder="Ticket nr, titel eller beskrivelse..."
value="{{ search_query or '' }}">
</div>
</div>
<div>
<div class="filter-label">Organisation</div>
<input
type="text"
name="organization"
id="organization"
class="form-control"
placeholder="fx Norva24"
value="{{ organization_query or '' }}">
</div>
<div>
<div class="filter-label">Kontakt</div>
<input
type="text"
name="contact"
id="contact"
class="form-control"
placeholder="fx Kennie"
value="{{ contact_query or '' }}">
</div>
<div>
<div class="filter-label">Dato fra</div>
<input
type="date"
name="date_from"
id="date_from"
class="form-control"
value="{{ date_from or '' }}">
</div>
<div>
<div class="filter-label">Dato til</div>
<input
type="date"
name="date_to"
id="date_to"
class="form-control"
value="{{ date_to or '' }}">
</div>
<div class="filter-actions">
<button class="btn btn-primary" type="submit">
<i class="bi bi-funnel"></i> Filtrer
</button>
<a class="btn btn-outline-secondary" href="/ticket/archived">
<i class="bi bi-x-circle"></i> Nulstil
</a>
</div>
</div>
</form>
</div>
<!-- Tickets Table -->
<div id="archivedResults">
{% if tickets %}
<div class="card">
<div class="table-responsive">
<table class="table ticket-table mb-0">
<thead>
<tr>
<th>Ticket</th>
<th>Organisation</th>
<th>Kontakt</th>
<th>Email From</th>
<th>Tid brugt</th>
<th>Status</th>
<th>Oprettet</th>
</tr>
</thead>
<tbody>
{% for ticket in tickets %}
<tr class="ticket-row" onclick="window.location='/ticket/archived/{{ ticket.id }}'">
<td>
{% if ticket.ticket_number %}
<span class="ticket-number">{{ ticket.ticket_number }}</span>
<br>
{% endif %}
<strong>{{ ticket.title or '-' }}</strong>
</td>
<td>{{ ticket.organization_name or '-' }}</td>
<td>{{ ticket.contact_name or '-' }}</td>
<td>{{ ticket.email_from or '-' }}</td>
<td>
{% if ticket.time_spent_hours is not none %}
{{ '%.2f'|format(ticket.time_spent_hours) }} t
{% else %}
-
{% endif %}
</td>
<td>
{% if ticket.status %}
<span class="badge" style="background: var(--accent-light); color: var(--accent);">
{{ ticket.status.replace('_', ' ').title() }}
</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{% if ticket.source_created_at %}
{{ ticket.source_created_at.strftime('%Y-%m-%d') }}
{% else %}
-
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="empty-state">
<i class="bi bi-archive"></i>
<h4>Ingen arkiverede tickets fundet</h4>
<p>Prøv at justere din søgning eller importer data fra Simply-CRM.</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
const filterForm = document.getElementById('archivedFilters');
const debounceMs = 500;
let debounceTimer;
async function fetchResults() {
if (!filterForm) {
return;
}
const formData = new FormData(filterForm);
const params = new URLSearchParams(formData);
const url = `/ticket/archived?${params.toString()}`;
history.replaceState(null, '', url);
try {
const response = await fetch(url, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
if (response.redirected) {
window.location.href = response.url;
return;
}
if (!response.ok) {
filterForm.submit();
return;
}
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const newResults = doc.getElementById('archivedResults');
const currentResults = document.getElementById('archivedResults');
if (newResults && currentResults) {
currentResults.replaceWith(newResults);
} else {
filterForm.submit();
}
} catch (error) {
filterForm.submit();
}
}
function submitFilters() {
fetchResults();
}
function debounceSubmit() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(submitFilters, debounceMs);
}
const inputs = [
document.getElementById('search'),
document.getElementById('organization'),
document.getElementById('contact'),
document.getElementById('date_from'),
document.getElementById('date_to')
];
inputs.forEach((input) => {
if (!input) {
return;
}
if (input.type === 'date') {
input.addEventListener('change', debounceSubmit);
} else {
input.addEventListener('input', debounceSubmit);
}
});
</script>
{% endblock %}

View File

@ -18,6 +18,63 @@ router = APIRouter()
templates = Jinja2Templates(directory="app") templates = Jinja2Templates(directory="app")
def _format_long_text(value: Optional[str]) -> str:
if not value:
return ""
lines = value.splitlines()
blocks = []
buffer = []
in_list = False
def flush_paragraph():
nonlocal buffer
if buffer:
blocks.append("<p>" + " ".join(buffer).strip() + "</p>")
buffer = []
def close_list():
nonlocal in_list
if in_list:
blocks.append("</ul>")
in_list = False
for raw_line in lines:
line = raw_line.strip()
if not line:
flush_paragraph()
close_list()
continue
if line.startswith(("- ", "* ", "")):
flush_paragraph()
if not in_list:
blocks.append("<ul>")
in_list = True
item = line[2:].strip()
blocks.append(f"<li>{item}</li>")
continue
if line[0].isdigit() and "." in line[:4]:
flush_paragraph()
if not in_list:
blocks.append("<ul>")
in_list = True
item = line.split(".", 1)[1].strip()
blocks.append(f"<li>{item}</li>")
continue
close_list()
buffer.append(line)
flush_paragraph()
close_list()
return "\n".join(blocks)
templates.env.filters["format_long_text"] = _format_long_text
# ============================================================================ # ============================================================================
# MOCKUP ROUTES (TEMPORARY) # MOCKUP ROUTES (TEMPORARY)
# ============================================================================ # ============================================================================
@ -443,6 +500,141 @@ async def ticket_list_page(
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/archived", response_class=HTMLResponse)
async def archived_ticket_list_page(
request: Request,
search: Optional[str] = None,
organization: Optional[str] = None,
contact: Optional[str] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None
):
"""
Archived ticket list page (Simply-CRM import)
"""
try:
query = """
SELECT
id,
ticket_number,
title,
organization_name,
contact_name,
email_from,
time_spent_hours,
status,
priority,
source_created_at,
description
FROM tticket_archived_tickets
WHERE 1=1
"""
params = []
if search:
query += " AND (ticket_number ILIKE %s OR title ILIKE %s OR description ILIKE %s)"
search_pattern = f"%{search}%"
params.extend([search_pattern] * 3)
if organization:
query += " AND organization_name ILIKE %s"
params.append(f"%{organization}%")
if contact:
query += " AND contact_name ILIKE %s"
params.append(f"%{contact}%")
if date_from:
query += " AND source_created_at >= %s"
params.append(date_from)
if date_to:
query += " AND source_created_at <= %s"
params.append(date_to)
query += " ORDER BY source_created_at DESC NULLS LAST, imported_at DESC LIMIT 200"
tickets = execute_query(query, tuple(params)) if params else execute_query(query)
return templates.TemplateResponse(
"ticket/frontend/archived_ticket_list.html",
{
"request": request,
"tickets": tickets,
"search_query": search,
"organization_query": organization,
"contact_query": contact,
"date_from": date_from,
"date_to": date_to
}
)
except Exception as e:
logger.error(f"❌ Failed to load archived ticket list: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/archived/{archived_ticket_id}", response_class=HTMLResponse)
async def archived_ticket_detail_page(request: Request, archived_ticket_id: int):
"""
Archived ticket detail page with messages
"""
try:
ticket = execute_query_single(
"SELECT * FROM tticket_archived_tickets WHERE id = %s",
(archived_ticket_id,)
)
if not ticket:
raise HTTPException(status_code=404, detail="Archived ticket not found")
messages = execute_query(
"""
SELECT * FROM tticket_archived_messages
WHERE archived_ticket_id = %s
ORDER BY source_created_at ASC NULLS LAST, imported_at ASC
""",
(archived_ticket_id,)
)
formatted_messages = []
for message in messages or []:
formatted = dict(message)
body_value = message.get("body") or ""
body_html = _format_long_text(body_value)
if body_value and not body_html:
body_html = f"<p>{html.escape(body_value)}</p>"
formatted["body_html"] = body_html
formatted_messages.append(formatted)
description_value = ticket.get("description") or ""
description_html = _format_long_text(description_value)
if description_value and not description_html:
description_html = f"<p>{html.escape(description_value)}</p>"
solution_value = ticket.get("solution") or ""
solution_html = _format_long_text(solution_value)
if solution_value and not solution_html:
solution_html = f"<p>{html.escape(solution_value)}</p>"
return templates.TemplateResponse(
"ticket/frontend/archived_ticket_detail.html",
{
"request": request,
"ticket": ticket,
"messages": formatted_messages,
"description_html": description_html,
"solution_html": solution_html
}
)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Failed to load archived ticket detail: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/tickets/{ticket_id}", response_class=HTMLResponse) @router.get("/tickets/{ticket_id}", response_class=HTMLResponse)
async def ticket_detail_page(request: Request, ticket_id: int): async def ticket_detail_page(request: Request, ticket_id: int):
""" """

View File

@ -8,7 +8,8 @@ Isoleret routing uden påvirkning af existing Hub endpoints.
import logging import logging
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from datetime import datetime from datetime import datetime, date
from calendar import monthrange
from fastapi import APIRouter, HTTPException, Depends, Body from fastapi import APIRouter, HTTPException, Depends, Body
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@ -1779,6 +1780,7 @@ async def create_internal_time_entry(entry: Dict[str, Any] = Body(...)):
worked_date = entry.get("worked_date") or datetime.now().date() worked_date = entry.get("worked_date") or datetime.now().date()
user_name = entry.get("user_name", "Hub User") user_name = entry.get("user_name", "Hub User")
prepaid_card_id = entry.get("prepaid_card_id") prepaid_card_id = entry.get("prepaid_card_id")
fixed_price_agreement_id = entry.get("fixed_price_agreement_id")
work_type = entry.get("work_type", "support") work_type = entry.get("work_type", "support")
is_internal = entry.get("is_internal", False) is_internal = entry.get("is_internal", False)
@ -1828,13 +1830,22 @@ async def create_internal_time_entry(entry: Dict[str, Any] = Body(...)):
card = execute_query_single("SELECT * FROM tticket_prepaid_cards WHERE id = %s", (prepaid_card_id,)) card = execute_query_single("SELECT * FROM tticket_prepaid_cards WHERE id = %s", (prepaid_card_id,))
if not card: if not card:
raise HTTPException(status_code=404, detail="Prepaid card not found") raise HTTPException(status_code=404, detail="Prepaid card not found")
if float(card['remaining_hours']) < hours_decimal: rounding_minutes = int(card.get('rounding_minutes') or 0)
rounded_hours = hours_decimal
rounded_to = None
if rounding_minutes > 0:
from decimal import Decimal, ROUND_CEILING
interval = Decimal(rounding_minutes) / Decimal(60)
rounded_hours = float((Decimal(str(hours_decimal)) / interval).to_integral_value(rounding=ROUND_CEILING) * interval)
rounded_to = float(interval)
if float(card['remaining_hours']) < rounded_hours:
# Optional: Allow overdraft? For now, block. # Optional: Allow overdraft? For now, block.
raise HTTPException(status_code=400, detail=f"Insufficient hours on prepaid card (Remaining: {card['remaining_hours']})") raise HTTPException(status_code=400, detail=f"Insufficient hours on prepaid card (Remaining: {card['remaining_hours']})")
# Deduct hours (remaining_hours is generated; update used_hours instead) # Deduct hours (remaining_hours is generated; update used_hours instead)
new_used = float(card['used_hours']) + hours_decimal new_used = float(card['used_hours']) + rounded_hours
execute_update( execute_update(
"UPDATE tticket_prepaid_cards SET used_hours = %s WHERE id = %s", "UPDATE tticket_prepaid_cards SET used_hours = %s WHERE id = %s",
(new_used, prepaid_card_id) (new_used, prepaid_card_id)
@ -1848,11 +1859,107 @@ async def create_internal_time_entry(entry: Dict[str, Any] = Body(...)):
"UPDATE tticket_prepaid_cards SET status = 'depleted' WHERE id = %s", "UPDATE tticket_prepaid_cards SET status = 'depleted' WHERE id = %s",
(prepaid_card_id,) (prepaid_card_id,)
) )
logger.info(f"💳 Deducted {hours_decimal} hours from prepaid card {prepaid_card_id}") if rounded_to:
entry['approved_hours'] = rounded_hours
entry['rounded_to'] = rounded_to
logger.info(f"💳 Deducted {rounded_hours} hours from prepaid card {prepaid_card_id}")
billing_method = 'prepaid' billing_method = 'prepaid'
status = 'billed' # Mark as processed/billed so it skips invoicing status = 'billed' # Mark as processed/billed so it skips invoicing
elif fixed_price_agreement_id:
# Verify agreement
agreement = execute_query_single("""
SELECT a.*, bp.id as period_id, bp.used_hours, bp.included_hours
FROM customer_fixed_price_agreements a
LEFT JOIN fixed_price_billing_periods bp ON (
a.id = bp.agreement_id
AND bp.period_start <= CURRENT_DATE
AND bp.period_end >= CURRENT_DATE
)
WHERE a.id = %s AND a.status = 'active'
""", (fixed_price_agreement_id,))
if not agreement:
raise HTTPException(status_code=404, detail="Fixed-price agreement not found or inactive")
if not agreement.get('period_id'):
# Auto-create billing period for current month
today = datetime.now().date()
period_start = date(today.year, today.month, 1)
last_day = monthrange(today.year, today.month)[1]
period_end = date(today.year, today.month, last_day)
# Full month amount (auto-created periods are always full months)
base_amount = float(agreement['monthly_hours']) * float(agreement['hourly_rate'])
included_hours = float(agreement['monthly_hours'])
execute_query("""
INSERT INTO fixed_price_billing_periods (
agreement_id, period_start, period_end,
included_hours, base_amount, status
) VALUES (%s, %s, %s, %s, %s, %s)
RETURNING *
""", (
fixed_price_agreement_id,
period_start,
period_end,
included_hours,
base_amount,
'active'
))
# Re-query to get the new period
agreement = execute_query_single("""
SELECT a.*, bp.id as period_id, bp.used_hours, bp.included_hours
FROM customer_fixed_price_agreements a
LEFT JOIN fixed_price_billing_periods bp ON (
a.id = bp.agreement_id
AND bp.period_start <= CURRENT_DATE
AND bp.period_end >= CURRENT_DATE
)
WHERE a.id = %s AND a.status = 'active'
""", (fixed_price_agreement_id,))
logger.info(f"📅 Auto-created billing period for agreement {fixed_price_agreement_id}: {period_start} to {period_end}")
# Apply rounding
rounding_minutes = int(agreement.get('rounding_minutes') or 0)
rounded_hours = hours_decimal
rounded_to = None
if rounding_minutes > 0:
from decimal import Decimal, ROUND_CEILING
interval = Decimal(rounding_minutes) / Decimal(60)
rounded_hours = float((Decimal(str(hours_decimal)) / interval).to_integral_value(rounding=ROUND_CEILING) * interval)
rounded_to = float(interval)
# Update period used_hours
new_used = float(agreement['used_hours'] or 0) + rounded_hours
execute_update(
"UPDATE fixed_price_billing_periods SET used_hours = %s WHERE id = %s",
(new_used, agreement['period_id'])
)
# Check if overtime
if new_used > float(agreement['included_hours']):
overtime = new_used - float(agreement['included_hours'])
logger.warning(f"⚠️ Fixed-price agreement {fixed_price_agreement_id} has {overtime:.2f}h overtime")
# Update period status to pending_approval if not already
execute_update("""
UPDATE fixed_price_billing_periods
SET status = 'pending_approval'
WHERE id = %s AND status = 'active'
""", (agreement['period_id'],))
if rounded_to:
entry['approved_hours'] = rounded_hours
entry['rounded_to'] = rounded_to
logger.info(f"📋 Logged {rounded_hours} hours to fixed-price agreement {fixed_price_agreement_id}")
billing_method = 'fixed_price'
status = 'billed' # Tracked in agreement, not invoiced separately
elif billing_method == 'internal' or billing_method == 'warranty': elif billing_method == 'internal' or billing_method == 'warranty':
billable = False billable = False
@ -1860,18 +1967,21 @@ async def create_internal_time_entry(entry: Dict[str, Any] = Body(...)):
INSERT INTO tmodule_times ( INSERT INTO tmodule_times (
sag_id, solution_id, customer_id, description, sag_id, solution_id, customer_id, description,
original_hours, worked_date, user_name, original_hours, worked_date, user_name,
status, billable, billing_method, prepaid_card_id, work_type status, billable, billing_method, prepaid_card_id, fixed_price_agreement_id, work_type,
approved_hours, rounded_to
) VALUES ( ) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s %s, %s, %s, %s, %s, %s,
%s, %s
) RETURNING * ) RETURNING *
""" """
params = ( params = (
sag_id, solution_id, customer_id, description, sag_id, solution_id, customer_id, description,
hours, worked_date, user_name, hours, worked_date, user_name,
status, billable, billing_method, prepaid_card_id, work_type status, billable, billing_method, prepaid_card_id, fixed_price_agreement_id, work_type,
entry.get('approved_hours'), entry.get('rounded_to')
) )
result = execute_query(query, params) result = execute_query(query, params)
if result: if result:

11
main.py
View File

@ -37,6 +37,8 @@ from app.dashboard.backend import views as dashboard_views
from app.dashboard.backend import router as dashboard_api from app.dashboard.backend import router as dashboard_api
from app.prepaid.backend import router as prepaid_api from app.prepaid.backend import router as prepaid_api
from app.prepaid.backend import views as prepaid_views from app.prepaid.backend import views as prepaid_views
from app.fixed_price.backend import router as fixed_price_api
from app.fixed_price.frontend import views as fixed_price_views
from app.ticket.backend import router as ticket_api from app.ticket.backend import router as ticket_api
from app.ticket.frontend import views as ticket_views from app.ticket.frontend import views as ticket_views
from app.vendors.backend import router as vendors_api from app.vendors.backend import router as vendors_api
@ -75,6 +77,7 @@ from app.modules.locations.backend import router as locations_api
from app.modules.locations.frontend import views as locations_views from app.modules.locations.frontend import views as locations_views
from app.modules.nextcloud.backend import router as nextcloud_api from app.modules.nextcloud.backend import router as nextcloud_api
from app.modules.search.backend import router as search_api from app.modules.search.backend import router as search_api
from app.fixed_price.backend import router as fixed_price_api
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(
@ -160,6 +163,12 @@ async def auth_middleware(request: Request, call_next):
"/api/v1/auth/login" "/api/v1/auth/login"
} }
if settings.DEV_ALLOW_ARCHIVED_IMPORT:
public_paths.add("/api/v1/ticket/archived/simply/import")
public_paths.add("/api/v1/ticket/archived/simply/modules")
public_paths.add("/api/v1/ticket/archived/simply/ticket")
public_paths.add("/api/v1/ticket/archived/simply/record")
if path in public_paths or path.startswith("/static") or path.startswith("/docs"): if path in public_paths or path.startswith("/static") or path.startswith("/docs"):
return await call_next(request) return await call_next(request)
@ -225,6 +234,7 @@ app.include_router(system_api.router, prefix="/api/v1", tags=["System"])
app.include_router(dashboard_api.router, prefix="/api/v1", tags=["Dashboard"]) app.include_router(dashboard_api.router, prefix="/api/v1", tags=["Dashboard"])
app.include_router(sync_router.router, prefix="/api/v1/system", tags=["System Sync"]) app.include_router(sync_router.router, prefix="/api/v1/system", tags=["System Sync"])
app.include_router(prepaid_api.router, prefix="/api/v1", tags=["Prepaid Cards"]) app.include_router(prepaid_api.router, prefix="/api/v1", tags=["Prepaid Cards"])
app.include_router(fixed_price_api.router, prefix="/api/v1", tags=["Fixed-Price Agreements"])
app.include_router(ticket_api.router, prefix="/api/v1/ticket", tags=["Tickets"]) app.include_router(ticket_api.router, prefix="/api/v1/ticket", tags=["Tickets"])
app.include_router(vendors_api.router, prefix="/api/v1", tags=["Vendors"]) app.include_router(vendors_api.router, prefix="/api/v1", tags=["Vendors"])
app.include_router(contacts_api.router, prefix="/api/v1", tags=["Contacts"]) app.include_router(contacts_api.router, prefix="/api/v1", tags=["Contacts"])
@ -252,6 +262,7 @@ app.include_router(search_api.router, prefix="/api/v1", tags=["Search"])
app.include_router(dashboard_views.router, tags=["Frontend"]) app.include_router(dashboard_views.router, tags=["Frontend"])
app.include_router(customers_views.router, tags=["Frontend"]) app.include_router(customers_views.router, tags=["Frontend"])
app.include_router(prepaid_views.router, tags=["Frontend"]) app.include_router(prepaid_views.router, tags=["Frontend"])
app.include_router(fixed_price_views.router, tags=["Frontend"])
app.include_router(vendors_views.router, tags=["Frontend"]) app.include_router(vendors_views.router, tags=["Frontend"])
app.include_router(timetracking_views.router, tags=["Frontend"]) app.include_router(timetracking_views.router, tags=["Frontend"])
app.include_router(billing_views.router, tags=["Frontend"]) app.include_router(billing_views.router, tags=["Frontend"])

View File

@ -0,0 +1,48 @@
-- ==========================================================================
-- Migration 097: Archived Tickets (Simply-CRM import)
-- ==========================================================================
CREATE TABLE IF NOT EXISTS tticket_archived_tickets (
id SERIAL PRIMARY KEY,
source_system VARCHAR(50) NOT NULL DEFAULT 'simplycrm',
external_id VARCHAR(100) NOT NULL,
ticket_number VARCHAR(100),
title VARCHAR(500),
organization_name VARCHAR(255),
contact_name VARCHAR(255),
email_from VARCHAR(255),
time_spent_hours DECIMAL(10,2),
description TEXT,
solution TEXT,
status VARCHAR(50),
priority VARCHAR(50),
source_created_at TIMESTAMP,
source_updated_at TIMESTAMP,
imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
sync_hash VARCHAR(64),
raw_data JSONB DEFAULT '{}'::jsonb,
UNIQUE (source_system, external_id)
);
CREATE INDEX IF NOT EXISTS idx_tticket_archived_external ON tticket_archived_tickets(source_system, external_id);
CREATE INDEX IF NOT EXISTS idx_tticket_archived_ticket_number ON tticket_archived_tickets(ticket_number);
CREATE INDEX IF NOT EXISTS idx_tticket_archived_org ON tticket_archived_tickets(organization_name);
CREATE INDEX IF NOT EXISTS idx_tticket_archived_created ON tticket_archived_tickets(source_created_at DESC);
CREATE TABLE IF NOT EXISTS tticket_archived_messages (
id SERIAL PRIMARY KEY,
archived_ticket_id INTEGER NOT NULL REFERENCES tticket_archived_tickets(id) ON DELETE CASCADE,
message_type VARCHAR(50) NOT NULL,
subject VARCHAR(500),
body TEXT,
author_name VARCHAR(255),
author_email VARCHAR(255),
source_created_at TIMESTAMP,
imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
raw_data JSONB DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_tticket_archived_messages_ticket ON tticket_archived_messages(archived_ticket_id);
CREATE INDEX IF NOT EXISTS idx_tticket_archived_messages_type ON tticket_archived_messages(message_type);
CREATE INDEX IF NOT EXISTS idx_tticket_archived_messages_created ON tticket_archived_messages(source_created_at DESC);

View File

@ -0,0 +1,17 @@
-- Migration: Add rounding interval to prepaid cards
ALTER TABLE tticket_prepaid_cards
ADD COLUMN IF NOT EXISTS rounding_minutes INTEGER DEFAULT 0;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_tticket_prepaid_rounding_minutes'
) THEN
ALTER TABLE tticket_prepaid_cards
ADD CONSTRAINT chk_tticket_prepaid_rounding_minutes
CHECK (rounding_minutes IN (0, 15, 30, 60));
END IF;
END $$;
COMMENT ON COLUMN tticket_prepaid_cards.rounding_minutes IS 'Round up prepaid usage to this interval in minutes (0 = no rounding)';

View File

@ -0,0 +1,13 @@
-- Migration: Add rounded_hours to tticket_worklog for prepaid card billing
-- Add rounded_hours column (actual hours deducted from prepaid card after rounding)
ALTER TABLE tticket_worklog
ADD COLUMN IF NOT EXISTS rounded_hours DECIMAL(5,2);
COMMENT ON COLUMN tticket_worklog.rounded_hours IS 'Rounded hours (actual amount deducted from prepaid card). NULL if not prepaid or no rounding applied.';
-- Backfill: Set rounded_hours = hours for existing prepaid entries
UPDATE tticket_worklog
SET rounded_hours = hours
WHERE prepaid_card_id IS NOT NULL
AND rounded_hours IS NULL;

View File

@ -0,0 +1,108 @@
-- Migration 100: Fixed-Price Agreements
-- Creates table for monthly fixed-price hour agreements with binding periods
-- Ready for future subscription system integration (v2)
CREATE TABLE IF NOT EXISTS customer_fixed_price_agreements (
id SERIAL PRIMARY KEY,
agreement_number VARCHAR(50) UNIQUE NOT NULL,
-- Future subscription system integration
subscription_id INTEGER DEFAULT NULL,
-- Customer linkage
customer_id INTEGER NOT NULL,
customer_name VARCHAR(255),
-- Product definition (v2: move to subscription_products table)
monthly_hours DECIMAL(8,2) NOT NULL CHECK (monthly_hours > 0),
hourly_rate DECIMAL(10,2) NOT NULL CHECK (hourly_rate >= 0),
overtime_rate DECIMAL(10,2) NOT NULL CHECK (overtime_rate >= 0),
internal_cost_rate DECIMAL(10,2) DEFAULT 350.00, -- For profit calculation
rounding_minutes INTEGER DEFAULT 0 CHECK (rounding_minutes IN (0, 15, 30, 60)),
-- Contract lifecycle
start_date DATE NOT NULL,
binding_months INTEGER DEFAULT 0 CHECK (binding_months >= 0),
binding_end_date DATE,
end_date DATE,
notice_period_days INTEGER DEFAULT 30 CHECK (notice_period_days >= 0),
auto_renew BOOLEAN DEFAULT false,
-- Status tracking
status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'suspended', 'expired', 'cancelled', 'pending_cancellation')),
cancellation_requested_date DATE,
cancellation_effective_date DATE,
cancelled_by_user_id INTEGER,
cancellation_reason TEXT,
-- Billing integration (v2: subscription_billing handles this)
billing_enabled BOOLEAN DEFAULT true,
last_billed_period DATE,
-- e-conomic integration (v2: move to subscription product mapping)
economic_product_number VARCHAR(50),
economic_overtime_product_number VARCHAR(50),
-- Metadata
notes TEXT,
created_by_user_id INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_fpa_customer ON customer_fixed_price_agreements(customer_id);
CREATE INDEX IF NOT EXISTS idx_fpa_subscription ON customer_fixed_price_agreements(subscription_id);
CREATE INDEX IF NOT EXISTS idx_fpa_status ON customer_fixed_price_agreements(status);
CREATE INDEX IF NOT EXISTS idx_fpa_dates ON customer_fixed_price_agreements(start_date, end_date);
-- Trigger to auto-generate agreement_number
CREATE OR REPLACE FUNCTION generate_fpa_number()
RETURNS TRIGGER AS $$
DECLARE
new_number VARCHAR(50);
day_count INTEGER;
BEGIN
-- Count agreements created today
SELECT COUNT(*) INTO day_count
FROM customer_fixed_price_agreements
WHERE DATE(created_at) = CURRENT_DATE;
-- Generate FPA-YYYYMMDD-XXX
new_number := 'FPA-' || TO_CHAR(CURRENT_DATE, 'YYYYMMDD') || '-' || LPAD((day_count + 1)::TEXT, 3, '0');
NEW.agreement_number := new_number;
-- Calculate binding_end_date
IF NEW.binding_months > 0 THEN
NEW.binding_end_date := NEW.start_date + (NEW.binding_months || ' months')::INTERVAL;
ELSE
NEW.binding_end_date := NULL;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_generate_fpa_number
BEFORE INSERT OR UPDATE ON customer_fixed_price_agreements
FOR EACH ROW
EXECUTE FUNCTION generate_fpa_number();
-- Update timestamp trigger
CREATE TRIGGER trigger_fpa_updated_at
BEFORE UPDATE ON customer_fixed_price_agreements
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
COMMENT ON TABLE customer_fixed_price_agreements IS
'Fixed-price monthly hour agreements. Ready for v2 subscription system integration via subscription_id column.';
COMMENT ON COLUMN customer_fixed_price_agreements.subscription_id IS
'Future: Links to unified subscription system (Phase 2)';
COMMENT ON COLUMN customer_fixed_price_agreements.internal_cost_rate IS
'Internal hourly cost for profit margin calculation in reporting';
COMMENT ON COLUMN customer_fixed_price_agreements.binding_end_date IS
'Auto-calculated from start_date + binding_months. Enforced during cancellation.';

View File

@ -0,0 +1,22 @@
-- Migration 101: Add fixed_price_agreement_id to time tracking tables
-- Links time entries to fixed-price agreements for billing and reporting
-- Add to ticket worklog
ALTER TABLE tticket_worklog
ADD COLUMN IF NOT EXISTS fixed_price_agreement_id INTEGER
REFERENCES customer_fixed_price_agreements(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_worklog_fpa ON tticket_worklog(fixed_price_agreement_id);
-- Add to sag/solutions time tracking
ALTER TABLE tmodule_times
ADD COLUMN IF NOT EXISTS fixed_price_agreement_id INTEGER
REFERENCES customer_fixed_price_agreements(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_times_fpa ON tmodule_times(fixed_price_agreement_id);
COMMENT ON COLUMN tticket_worklog.fixed_price_agreement_id IS
'Links worklog entry to fixed-price agreement for billing tracking';
COMMENT ON COLUMN tmodule_times.fixed_price_agreement_id IS
'Links time entry to fixed-price agreement for billing tracking';

View File

@ -0,0 +1,65 @@
-- Migration 102: Fixed-Price Billing Periods
-- Tracks monthly usage and billing status per agreement
-- Structure compatible with future subscription_billing_periods table
CREATE TABLE IF NOT EXISTS fixed_price_billing_periods (
id SERIAL PRIMARY KEY,
agreement_id INTEGER NOT NULL REFERENCES customer_fixed_price_agreements(id) ON DELETE CASCADE,
-- Period definition (compatible with future subscription_billing_periods)
period_start DATE NOT NULL,
period_end DATE NOT NULL,
period_type VARCHAR(20) DEFAULT 'calendar_month',
-- Usage tracking
included_hours DECIMAL(8,2) NOT NULL,
used_hours DECIMAL(8,2) DEFAULT 0,
overtime_hours DECIMAL(8,2) GENERATED ALWAYS AS (
CASE WHEN used_hours > included_hours THEN used_hours - included_hours ELSE 0 END
) STORED,
remaining_hours DECIMAL(8,2) GENERATED ALWAYS AS (
CASE WHEN used_hours < included_hours THEN included_hours - used_hours ELSE 0 END
) STORED,
-- Billing amounts (v2: integrate with subscription_line_items)
base_amount DECIMAL(12,2) NOT NULL,
overtime_amount DECIMAL(12,2) DEFAULT 0,
overtime_approved BOOLEAN DEFAULT false,
-- Billing status (compatible with subscription billing workflow)
status VARCHAR(20) DEFAULT 'active' CHECK (status IN (
'active', -- Currently accumulating usage
'pending_approval', -- Overtime needs approval
'ready_to_bill', -- Approved, ready for invoice generation
'billed', -- Invoice created
'cancelled' -- Period cancelled (e.g. early termination)
)),
-- Invoice tracking
billed_at TIMESTAMP,
economic_invoice_number VARCHAR(50),
invoice_id INTEGER,
-- Metadata
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(agreement_id, period_start)
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_fpbp_agreement ON fixed_price_billing_periods(agreement_id);
CREATE INDEX IF NOT EXISTS idx_fpbp_period ON fixed_price_billing_periods(period_start, period_end);
CREATE INDEX IF NOT EXISTS idx_fpbp_status ON fixed_price_billing_periods(status);
-- Update timestamp trigger
CREATE TRIGGER trigger_fpbp_updated_at
BEFORE UPDATE ON fixed_price_billing_periods
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
COMMENT ON TABLE fixed_price_billing_periods IS
'Billing periods for fixed-price agreements. Structure compatible with future unified subscription_billing_periods table.';
COMMENT ON COLUMN fixed_price_billing_periods.overtime_approved IS
'Overtime must be approved before billing. Periods with unapproved overtime → status=pending_approval';

View File

@ -0,0 +1,113 @@
-- Migration 103: Fixed-Price Reporting Views
-- Pre-aggregated views for dashboard and reporting performance
-- Agreement performance aggregation
CREATE OR REPLACE VIEW fixed_price_agreement_performance AS
SELECT
a.id,
a.agreement_number,
a.customer_id,
a.customer_name,
a.status,
a.monthly_hours,
a.hourly_rate,
a.overtime_rate,
a.internal_cost_rate,
-- Period counts
COUNT(DISTINCT bp.id) as total_periods,
COUNT(DISTINCT bp.id) FILTER (WHERE bp.status = 'billed') as billed_periods,
-- Hour aggregations
COALESCE(SUM(bp.included_hours), 0) as total_included_hours,
COALESCE(SUM(bp.used_hours), 0) as total_used_hours,
COALESCE(SUM(bp.overtime_hours) FILTER (WHERE bp.overtime_approved), 0) as total_approved_overtime,
-- Revenue (only billed periods)
COALESCE(SUM(bp.base_amount) FILTER (WHERE bp.status = 'billed'), 0) as total_base_revenue,
COALESCE(SUM(bp.overtime_amount) FILTER (WHERE bp.status = 'billed' AND bp.overtime_approved), 0) as total_overtime_revenue,
COALESCE(
SUM(bp.base_amount) FILTER (WHERE bp.status = 'billed') +
SUM(bp.overtime_amount) FILTER (WHERE bp.status = 'billed' AND bp.overtime_approved),
0
) as total_revenue,
-- Cost calculation
COALESCE(SUM(bp.used_hours) * a.internal_cost_rate, 0) as total_internal_cost,
-- Profit
COALESCE(
SUM(bp.base_amount) FILTER (WHERE bp.status = 'billed') +
SUM(bp.overtime_amount) FILTER (WHERE bp.status = 'billed' AND bp.overtime_approved) -
SUM(bp.used_hours) * a.internal_cost_rate,
0
) as total_profit,
-- Utilization (used hours / included hours)
CASE
WHEN SUM(bp.included_hours) > 0
THEN ROUND((SUM(bp.used_hours) / SUM(bp.included_hours) * 100)::numeric, 1)
ELSE 0
END as utilization_percent,
-- Latest period
MAX(bp.period_end) as latest_period_end
FROM customer_fixed_price_agreements a
LEFT JOIN fixed_price_billing_periods bp ON a.id = bp.agreement_id
GROUP BY a.id;
-- Monthly trend view
CREATE OR REPLACE VIEW fixed_price_monthly_trends AS
SELECT
DATE_TRUNC('month', bp.period_start)::date as month,
COUNT(DISTINCT a.id) as active_agreements,
COUNT(DISTINCT bp.id) as total_periods,
SUM(bp.included_hours) as total_included_hours,
SUM(bp.used_hours) as total_used_hours,
SUM(bp.overtime_hours) FILTER (WHERE bp.overtime_approved) as total_approved_overtime,
SUM(bp.base_amount) FILTER (WHERE bp.status = 'billed') as monthly_base_revenue,
SUM(bp.overtime_amount) FILTER (WHERE bp.status = 'billed' AND bp.overtime_approved) as monthly_overtime_revenue,
SUM(bp.base_amount + COALESCE(bp.overtime_amount, 0)) FILTER (WHERE bp.status = 'billed') as monthly_total_revenue,
AVG(a.internal_cost_rate) as avg_internal_cost_rate,
SUM(bp.used_hours * a.internal_cost_rate) as monthly_internal_cost,
SUM(bp.base_amount + COALESCE(bp.overtime_amount, 0)) FILTER (WHERE bp.status = 'billed') -
SUM(bp.used_hours * a.internal_cost_rate) as monthly_profit
FROM fixed_price_billing_periods bp
JOIN customer_fixed_price_agreements a ON bp.agreement_id = a.id
GROUP BY DATE_TRUNC('month', bp.period_start)
ORDER BY month DESC;
-- Customer aggregation
CREATE OR REPLACE VIEW fixed_price_customer_summary AS
SELECT
a.customer_id,
a.customer_name,
COUNT(DISTINCT a.id) as agreement_count,
COUNT(DISTINCT a.id) FILTER (WHERE a.status = 'active') as active_agreements,
SUM(bp.used_hours) as total_hours_used,
SUM(bp.base_amount + COALESCE(bp.overtime_amount, 0)) FILTER (WHERE bp.status = 'billed') as total_revenue,
SUM(bp.used_hours * a.internal_cost_rate) as total_cost,
SUM(bp.base_amount + COALESCE(bp.overtime_amount, 0)) FILTER (WHERE bp.status = 'billed') -
SUM(bp.used_hours * a.internal_cost_rate) as total_profit,
MAX(bp.period_end) as last_billing_date
FROM customer_fixed_price_agreements a
LEFT JOIN fixed_price_billing_periods bp ON a.id = bp.agreement_id
GROUP BY a.customer_id, a.customer_name;
COMMENT ON VIEW fixed_price_agreement_performance IS
'Aggregated performance metrics per agreement for reporting';
COMMENT ON VIEW fixed_price_monthly_trends IS
'Month-over-month revenue and profitability trends';
COMMENT ON VIEW fixed_price_customer_summary IS
'Customer-level aggregation of all fixed-price agreements';

View File

@ -9,3 +9,10 @@ python-dateutil==2.8.2
jinja2==3.1.4 jinja2==3.1.4
aiohttp==3.10.10 aiohttp==3.10.10
aiosmtplib==3.0.2 aiosmtplib==3.0.2
PyJWT==2.10.1
pyotp==2.9.0
passlib[bcrypt]==1.7.4
pandas==2.2.3
msal==1.31.0
paramiko==3.5.0
APScheduler==3.10.4