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:
parent
b43e9f797d
commit
e4b9091a1b
@ -175,7 +175,7 @@ class AuthService:
|
||||
|
||||
# Store session for token revocation (skip for shadow admin)
|
||||
if not is_shadow_admin:
|
||||
execute_insert(
|
||||
execute_update(
|
||||
"""INSERT INTO sessions (user_id, token_jti, expires_at)
|
||||
VALUES (%s, %s, %s)""",
|
||||
(user_id, jti, expire)
|
||||
|
||||
@ -126,6 +126,12 @@ class Settings(BaseSettings):
|
||||
SIMPLYCRM_URL: str = ""
|
||||
SIMPLYCRM_USERNAME: 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_ENABLED: bool = True
|
||||
@ -178,6 +184,9 @@ class Settings(BaseSettings):
|
||||
REMINDERS_CHECK_INTERVAL_MINUTES: int = 5
|
||||
REMINDERS_MAX_PER_USER_PER_HOUR: int = 5
|
||||
REMINDERS_QUEUE_BATCH_SIZE: int = 10
|
||||
|
||||
# Dev-only shortcuts
|
||||
DEV_ALLOW_ARCHIVED_IMPORT: bool = False
|
||||
|
||||
# Deployment Configuration (used by Docker/Podman)
|
||||
POSTGRES_USER: str = "bmc_hub"
|
||||
|
||||
1
app/fixed_price/__init__.py
Normal file
1
app/fixed_price/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Fixed-Price Agreement Module"""
|
||||
1
app/fixed_price/backend/__init__.py
Normal file
1
app/fixed_price/backend/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Backend package"""
|
||||
194
app/fixed_price/backend/models.py
Normal file
194
app/fixed_price/backend/models.py
Normal 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
|
||||
769
app/fixed_price/backend/router.py
Normal file
769
app/fixed_price/backend/router.py
Normal 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))
|
||||
322
app/fixed_price/frontend/detail.html
Normal file
322
app/fixed_price/frontend/detail.html
Normal 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 %}
|
||||
616
app/fixed_price/frontend/list.html
Normal file
616
app/fixed_price/frontend/list.html
Normal 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 %}
|
||||
285
app/fixed_price/frontend/reports.html
Normal file
285
app/fixed_price/frontend/reports.html
Normal 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 %}
|
||||
220
app/fixed_price/frontend/views.py
Normal file
220
app/fixed_price/frontend/views.py
Normal 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 []
|
||||
@ -18,12 +18,12 @@ Each view:
|
||||
"""
|
||||
|
||||
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 pathlib import Path as PathlibPath
|
||||
import requests
|
||||
import logging
|
||||
from typing import Optional
|
||||
from app.core.database import execute_query, execute_update
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -43,9 +43,7 @@ env = Environment(
|
||||
lstrip_blocks=True
|
||||
)
|
||||
|
||||
# Backend API base URL
|
||||
# Inside container: localhost:8000, externally: localhost:8001
|
||||
API_BASE_URL = "http://localhost:8000"
|
||||
# Use direct database access instead of API calls to avoid auth issues
|
||||
|
||||
# Location type options for dropdowns
|
||||
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)}")
|
||||
|
||||
|
||||
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:
|
||||
"""
|
||||
@ -147,7 +115,7 @@ def calculate_pagination(total: int, limit: int, skip: int) -> dict:
|
||||
@router.get("/app/locations", response_class=HTMLResponse)
|
||||
def list_locations_view(
|
||||
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),
|
||||
limit: int = Query(50, ge=1, le=100)
|
||||
):
|
||||
@ -169,18 +137,35 @@ def list_locations_view(
|
||||
try:
|
||||
logger.info(f"🔍 Rendering locations list view (skip={skip}, limit={limit})")
|
||||
|
||||
# Build API call parameters
|
||||
params = {
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
}
|
||||
if location_type:
|
||||
params["location_type"] = location_type
|
||||
if is_active is not None:
|
||||
params["is_active"] = is_active
|
||||
# Convert is_active from string to boolean or None
|
||||
is_active_bool = None
|
||||
if is_active and is_active.lower() in ('true', '1', 'yes'):
|
||||
is_active_bool = True
|
||||
elif is_active and is_active.lower() in ('false', '0', 'no'):
|
||||
is_active_bool = False
|
||||
|
||||
# Call backend API to get locations
|
||||
locations = call_api("GET", "/api/v1/locations", params=params)
|
||||
# Query locations directly from database
|
||||
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:
|
||||
nodes = {}
|
||||
@ -234,7 +219,7 @@ def list_locations_view(
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
location_type=location_type,
|
||||
is_active=is_active,
|
||||
is_active=is_active_bool, # Use boolean value for template
|
||||
page_number=pagination["page_number"],
|
||||
total_pages=pagination["total_pages"],
|
||||
has_prev=pagination["has_prev"],
|
||||
@ -281,53 +266,23 @@ def create_location_view():
|
||||
try:
|
||||
logger.info("🆕 Rendering create location form")
|
||||
|
||||
parent_locations = call_api(
|
||||
"GET",
|
||||
"/api/v1/locations",
|
||||
params={"skip": 0, "limit": 1000}
|
||||
)
|
||||
# Query parent locations
|
||||
parent_locations = execute_query("""
|
||||
SELECT id, name, location_type
|
||||
FROM locations_locations
|
||||
WHERE is_active = true
|
||||
ORDER BY name
|
||||
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}
|
||||
)
|
||||
|
||||
customers = call_api(
|
||||
"GET",
|
||||
"/api/v1/customers",
|
||||
params={"offset": 0, "limit": 1000}
|
||||
)
|
||||
|
||||
customers = call_api(
|
||||
"GET",
|
||||
"/api/v1/customers",
|
||||
params={"offset": 0, "limit": 1000}
|
||||
)
|
||||
# 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
|
||||
html = render_template(
|
||||
@ -374,19 +329,27 @@ def detail_location_view(id: int = Path(..., gt=0)):
|
||||
try:
|
||||
logger.info(f"📍 Rendering detail view for location {id}")
|
||||
|
||||
# Call backend API to get location details
|
||||
location = call_api("GET", f"/api/v1/locations/{id}")
|
||||
|
||||
customers = call_api(
|
||||
"GET",
|
||||
"/api/v1/customers",
|
||||
params={"offset": 0, "limit": 1000}
|
||||
# Query location details directly
|
||||
location = execute_query(
|
||||
"SELECT * FROM locations_locations WHERE id = %s",
|
||||
(id,)
|
||||
)
|
||||
|
||||
if not location:
|
||||
logger.warning(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
|
||||
# contacts = call_api("GET", f"/api/v1/locations/{id}/contacts")
|
||||
# hours = call_api("GET", f"/api/v1/locations/{id}/hours")
|
||||
@ -429,30 +392,36 @@ def edit_location_view(id: int = Path(..., gt=0)):
|
||||
try:
|
||||
logger.info(f"✏️ Rendering edit form for location {id}")
|
||||
|
||||
# Call backend API to get current location data
|
||||
location = call_api("GET", f"/api/v1/locations/{id}")
|
||||
|
||||
parent_locations = call_api(
|
||||
"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}
|
||||
# Query location details
|
||||
location = execute_query(
|
||||
"SELECT * FROM locations_locations WHERE id = %s",
|
||||
(id,)
|
||||
)
|
||||
|
||||
if not location:
|
||||
logger.warning(f"⚠️ Location {id} not found for edit")
|
||||
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
|
||||
# Note: HTML forms don't support PATCH, so we use POST with a hidden _method field
|
||||
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."""
|
||||
try:
|
||||
form = await request.form()
|
||||
payload = {
|
||||
"name": form.get("name"),
|
||||
"location_type": form.get("location_type"),
|
||||
"parent_location_id": int(form.get("parent_location_id")) if form.get("parent_location_id") else None,
|
||||
"customer_id": int(form.get("customer_id")) if form.get("customer_id") else None,
|
||||
"is_active": form.get("is_active") == "on",
|
||||
"address_street": form.get("address_street"),
|
||||
"address_city": form.get("address_city"),
|
||||
"address_postal_code": form.get("address_postal_code"),
|
||||
"address_country": form.get("address_country"),
|
||||
"phone": form.get("phone"),
|
||||
"email": form.get("email"),
|
||||
"latitude": float(form.get("latitude")) if form.get("latitude") else None,
|
||||
"longitude": float(form.get("longitude")) if form.get("longitude") else None,
|
||||
"notes": form.get("notes"),
|
||||
}
|
||||
|
||||
call_api("PATCH", f"/api/v1/locations/{id}", json=payload)
|
||||
|
||||
# Update location directly in database
|
||||
execute_update("""
|
||||
UPDATE locations_locations SET
|
||||
name = %s,
|
||||
location_type = %s,
|
||||
parent_location_id = %s,
|
||||
customer_id = %s,
|
||||
is_active = %s,
|
||||
address_street = %s,
|
||||
address_city = %s,
|
||||
address_postal_code = %s,
|
||||
address_country = %s,
|
||||
phone = %s,
|
||||
email = %s,
|
||||
latitude = %s,
|
||||
longitude = %s,
|
||||
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)
|
||||
|
||||
except HTTPException:
|
||||
@ -535,16 +524,24 @@ def map_locations_view(
|
||||
try:
|
||||
logger.info("🗺️ Rendering map view")
|
||||
|
||||
# Build API call parameters
|
||||
params = {
|
||||
"skip": 0,
|
||||
"limit": 1000, # Get all locations for map
|
||||
}
|
||||
if location_type:
|
||||
params["location_type"] = location_type
|
||||
# Query all locations with filters
|
||||
where_clauses = []
|
||||
query_params = []
|
||||
|
||||
# Call backend API to get all locations
|
||||
locations = call_api("GET", "/api/v1/locations", params=params)
|
||||
if location_type:
|
||||
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
|
||||
locations_with_coords = [
|
||||
|
||||
@ -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)})")
|
||||
|
||||
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
|
||||
WHERE customer_id = %s
|
||||
AND status = 'active'
|
||||
WHERE customer_id = %s
|
||||
AND status = 'active'
|
||||
AND remaining_hours > 0
|
||||
ORDER BY created_at DESC
|
||||
"""
|
||||
prepaid_cards = execute_query(pc_query, (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
|
||||
nextcloud_instance = None
|
||||
if customer:
|
||||
@ -397,6 +423,7 @@ async def sag_detaljer(request: Request, sag_id: int):
|
||||
"contacts": contacts,
|
||||
"customers": customers,
|
||||
"prepaid_cards": prepaid_cards,
|
||||
"fixed_price_agreements": fixed_price_agreements,
|
||||
"tags": tags,
|
||||
|
||||
"relationer": relationer,
|
||||
|
||||
@ -1909,6 +1909,21 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
@ -2987,6 +3002,74 @@
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
</form>
|
||||
@ -3147,12 +3230,19 @@
|
||||
const billingSelect = document.getElementById('time_billing_method');
|
||||
let billingMethod = billingSelect ? billingSelect.value : 'invoice';
|
||||
let prepaidCardId = null;
|
||||
let fixedPriceAgreementId = null;
|
||||
|
||||
// Handle prepaid card selection formatting (card_123)
|
||||
if (billingMethod.startsWith('card_')) {
|
||||
prepaidCardId = parseInt(billingMethod.split('_')[1]);
|
||||
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 internalCheck = document.getElementById('time_internal');
|
||||
@ -3170,6 +3260,10 @@
|
||||
if (prepaidCardId) {
|
||||
data.prepaid_card_id = prepaidCardId;
|
||||
}
|
||||
|
||||
if (fixedPriceAgreementId) {
|
||||
data.fixed_price_agreement_id = fixedPriceAgreementId;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/timetracking/entries/internal`, {
|
||||
|
||||
@ -3,6 +3,7 @@ from app.core.database import execute_query
|
||||
from typing import List, Optional, Dict, Any
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal, ROUND_CEILING
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -25,6 +26,7 @@ class PrepaidCard(BaseModel):
|
||||
economic_invoice_number: Optional[str] = None
|
||||
economic_product_number: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
rounding_minutes: Optional[int] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
@ -34,6 +36,26 @@ class PrepaidCardCreate(BaseModel):
|
||||
price_per_hour: float
|
||||
expires_at: Optional[date] = 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]])
|
||||
@ -65,6 +87,46 @@ async def get_prepaid_cards(status: Optional[str] = None, customer_id: Optional[
|
||||
query += " ORDER BY pc.created_at DESC"
|
||||
|
||||
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")
|
||||
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")
|
||||
|
||||
card = result[0]
|
||||
|
||||
rounding_minutes = _normalize_rounding_minutes(card.get('rounding_minutes'))
|
||||
|
||||
# Get transactions
|
||||
transactions = execute_query("""
|
||||
@ -108,6 +172,92 @@ async def get_prepaid_card(card_id: int):
|
||||
""", (card_id,))
|
||||
|
||||
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
|
||||
|
||||
@ -126,6 +276,10 @@ async def create_prepaid_card(card: PrepaidCardCreate):
|
||||
Note: As of migration 065, customers can have multiple active cards simultaneously.
|
||||
"""
|
||||
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
|
||||
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:
|
||||
cursor.execute("""
|
||||
INSERT INTO tticket_prepaid_cards
|
||||
(customer_id, purchased_hours, price_per_hour, total_amount, expires_at, notes)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
(customer_id, purchased_hours, price_per_hour, total_amount, expires_at, notes, rounding_minutes)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING *
|
||||
""", (
|
||||
card.customer_id,
|
||||
@ -149,6 +303,7 @@ async def create_prepaid_card(card: PrepaidCardCreate):
|
||||
total_amount,
|
||||
card.expires_at,
|
||||
card.notes
|
||||
, rounding_minutes
|
||||
))
|
||||
conn.commit()
|
||||
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))
|
||||
|
||||
|
||||
@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}")
|
||||
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])
|
||||
async def get_prepaid_stats():
|
||||
"""
|
||||
Get prepaid cards statistics
|
||||
Get prepaid cards statistics (calculated from actual timelogs)
|
||||
"""
|
||||
try:
|
||||
result = execute_query("""
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE status = 'active') as active_count,
|
||||
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
|
||||
# Get all cards
|
||||
cards = execute_query("""
|
||||
SELECT id, status, purchased_hours, total_amount, rounding_minutes
|
||||
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:
|
||||
logger.error(f"❌ Error fetching prepaid stats: {e}", exc_info=True)
|
||||
|
||||
@ -92,24 +92,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transactions -->
|
||||
<!-- Timelogs -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-0 py-3">
|
||||
<h5 class="mb-0">Transaktioner</h5>
|
||||
<h5 class="mb-0">Timelogs</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<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">
|
||||
<tr>
|
||||
<th>Dato</th>
|
||||
<th>Ticket</th>
|
||||
<th>Sag / Ticket</th>
|
||||
<th>Beskrivelse</th>
|
||||
<th class="text-end">Timer</th>
|
||||
<th class="text-end">Beløb</th>
|
||||
<th class="text-end">Faktisk tid</th>
|
||||
<th class="text-end">Afrundet</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="transactionsBody">
|
||||
<tbody id="timelogsBody">
|
||||
<tr>
|
||||
<td colspan="5" class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
@ -118,6 +118,13 @@
|
||||
</td>
|
||||
</tr>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@ -211,6 +218,18 @@ async function loadCardDetails() {
|
||||
<label class="small text-muted">Pris pr. Time</label>
|
||||
<p class="mb-0"><strong>${parseFloat(card.price_per_hour).toFixed(2)} kr</strong></p>
|
||||
</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">
|
||||
<label class="small text-muted">Købt Dato</label>
|
||||
<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('') ||
|
||||
'<p class="text-muted text-center mb-0">Ingen handlinger tilgængelige</p>';
|
||||
|
||||
// Render transactions
|
||||
renderTransactions(card.transactions || []);
|
||||
const roundingSelect = document.getElementById('roundingMinutes');
|
||||
if (roundingSelect) {
|
||||
roundingSelect.value = String(card.rounding_minutes || 0);
|
||||
}
|
||||
|
||||
// Render timelogs
|
||||
renderTimelogs(card.timelogs || []);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading card:', error);
|
||||
@ -262,32 +286,53 @@ async function loadCardDetails() {
|
||||
}
|
||||
}
|
||||
|
||||
function renderTransactions(transactions) {
|
||||
const tbody = document.getElementById('transactionsBody');
|
||||
function renderTimelogs(timelogs) {
|
||||
const tbody = document.getElementById('timelogsBody');
|
||||
|
||||
if (!transactions || transactions.length === 0) {
|
||||
if (!timelogs || timelogs.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr><td colspan="5" class="text-center text-muted py-5">
|
||||
Ingen transaktioner endnu
|
||||
Ingen timelogs endnu
|
||||
</td></tr>
|
||||
`;
|
||||
document.getElementById('totalActualHours').textContent = '0.00 t';
|
||||
document.getElementById('totalRoundedHours').textContent = '0.00 t';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = transactions.map(t => `
|
||||
<tr>
|
||||
<td>${new Date(t.created_at).toLocaleDateString('da-DK')}</td>
|
||||
<td>
|
||||
${t.ticket_id ?
|
||||
`<a href="/ticket/tickets/${t.ticket_id}" class="text-decoration-none">
|
||||
#${t.ticket_id} - ${t.ticket_title || 'Ticket'}
|
||||
</a>` : '-'}
|
||||
</td>
|
||||
<td>${t.description || '-'}</td>
|
||||
<td class="text-end">${parseFloat(t.hours_used).toFixed(2)} t</td>
|
||||
<td class="text-end">${parseFloat(t.amount).toFixed(2)} kr</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
let totalActual = 0;
|
||||
let totalRounded = 0;
|
||||
|
||||
tbody.innerHTML = timelogs.map(t => {
|
||||
const dateValue = t.worked_date || t.created_at;
|
||||
const dateText = dateValue ? new Date(dateValue).toLocaleDateString('da-DK') : '-';
|
||||
let sourceHtml = '-';
|
||||
if (t.source === 'sag' && t.source_id) {
|
||||
sourceHtml = `<a href="/sag/${t.source_id}" class="text-decoration-none">Sag #${t.source_id}${t.source_title ? ' - ' + t.source_title : ''}</a>`;
|
||||
} else if (t.source === 'ticket' && t.source_id) {
|
||||
const ticketLabel = t.ticket_number ? `#${t.ticket_number}` : `#${t.source_id}`;
|
||||
sourceHtml = `<a href="/ticket/tickets/${t.source_id}" class="text-decoration-none">${ticketLabel} - ${t.source_title || 'Ticket'}</a>`;
|
||||
}
|
||||
|
||||
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) {
|
||||
@ -322,6 +367,28 @@ async function cancelCard() {
|
||||
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>
|
||||
|
||||
<style>
|
||||
|
||||
@ -211,6 +211,17 @@
|
||||
</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">
|
||||
<label class="form-label fw-bold">Udløbsdato <small class="text-muted fw-normal">(valgfri)</small></label>
|
||||
<div class="input-group">
|
||||
@ -518,7 +529,8 @@ async function createCard() {
|
||||
purchased_hours: parseFloat(document.getElementById('purchasedHours').value),
|
||||
price_per_hour: parseFloat(document.getElementById('pricePerHour').value),
|
||||
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 {
|
||||
|
||||
@ -179,6 +179,88 @@ class SimplyCRMService:
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Simply-CRM query error: {e}")
|
||||
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]:
|
||||
"""
|
||||
@ -215,6 +297,41 @@ class SimplyCRMService:
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Simply-CRM retrieve error: {e}")
|
||||
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
|
||||
|
||||
@ -238,6 +238,7 @@
|
||||
<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/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="/conversations/my"><i class="bi bi-mic me-2"></i>Mine Samtaler</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
@ -246,6 +247,7 @@
|
||||
<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="/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><a class="dropdown-item py-2" href="#">Knowledge Base</a></li>
|
||||
</ul>
|
||||
|
||||
@ -237,25 +237,29 @@ class KlippekortService:
|
||||
Raises:
|
||||
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
|
||||
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
|
||||
new_used = Decimal(str(card['used_hours'])) + hours
|
||||
new_used = Decimal(str(card['used_hours'])) + rounded_hours
|
||||
execute_update(
|
||||
"UPDATE tticket_prepaid_cards SET used_hours = %s WHERE id = %s",
|
||||
(new_used, card['id'])
|
||||
)
|
||||
|
||||
# Calculate new balance
|
||||
new_balance = Decimal(str(card['remaining_hours'])) - hours
|
||||
new_balance = Decimal(str(card['remaining_hours'])) - rounded_hours
|
||||
|
||||
# Create transaction
|
||||
transaction_id = execute_insert(
|
||||
@ -268,9 +272,9 @@ class KlippekortService:
|
||||
card['id'],
|
||||
worklog_id,
|
||||
'usage',
|
||||
-hours, # Negative for deduction
|
||||
-rounded_hours, # Negative for deduction
|
||||
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
|
||||
)
|
||||
)
|
||||
|
||||
@ -57,6 +57,7 @@ class WorkType(str, Enum):
|
||||
class BillingMethod(str, Enum):
|
||||
"""Afregningsmetode"""
|
||||
PREPAID_CARD = "prepaid_card"
|
||||
FIXED_PRICE = "fixed_price"
|
||||
INVOICE = "invoice"
|
||||
INTERNAL = "internal"
|
||||
WARRANTY = "warranty"
|
||||
|
||||
@ -6,11 +6,16 @@ REST API endpoints for ticket system.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
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.models import (
|
||||
TTicket,
|
||||
@ -48,13 +53,78 @@ from app.ticket.backend.models import (
|
||||
TicketDeadlineUpdateRequest
|
||||
)
|
||||
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__)
|
||||
|
||||
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
|
||||
# ============================================================================
|
||||
@ -563,11 +633,29 @@ async def create_worklog(
|
||||
status_code=400,
|
||||
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(
|
||||
"""
|
||||
INSERT INTO tticket_worklog
|
||||
(ticket_id, work_date, hours, work_type, description, billing_method, status, user_id, prepaid_card_id, is_internal)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
(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, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
@ -580,7 +668,8 @@ async def create_worklog(
|
||||
'draft',
|
||||
user_id or worklog_data.user_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:
|
||||
logger.error(f"❌ Error fetching audit log: {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))
|
||||
|
||||
226
app/ticket/frontend/archived_ticket_detail.html
Normal file
226
app/ticket/frontend/archived_ticket_detail.html
Normal 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 %}
|
||||
341
app/ticket/frontend/archived_ticket_list.html
Normal file
341
app/ticket/frontend/archived_ticket_list.html
Normal 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 %}
|
||||
@ -18,6 +18,63 @@ router = APIRouter()
|
||||
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)
|
||||
# ============================================================================
|
||||
@ -443,6 +500,141 @@ async def ticket_list_page(
|
||||
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)
|
||||
async def ticket_detail_page(request: Request, ticket_id: int):
|
||||
"""
|
||||
|
||||
@ -8,7 +8,8 @@ Isoleret routing uden påvirkning af existing Hub endpoints.
|
||||
|
||||
import logging
|
||||
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.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()
|
||||
user_name = entry.get("user_name", "Hub User")
|
||||
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")
|
||||
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,))
|
||||
if not card:
|
||||
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.
|
||||
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)
|
||||
new_used = float(card['used_hours']) + hours_decimal
|
||||
new_used = float(card['used_hours']) + rounded_hours
|
||||
execute_update(
|
||||
"UPDATE tticket_prepaid_cards SET used_hours = %s WHERE id = %s",
|
||||
(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",
|
||||
(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'
|
||||
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':
|
||||
billable = False
|
||||
|
||||
@ -1860,18 +1967,21 @@ async def create_internal_time_entry(entry: Dict[str, Any] = Body(...)):
|
||||
INSERT INTO tmodule_times (
|
||||
sag_id, solution_id, customer_id, description,
|
||||
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 (
|
||||
%s, %s, %s, %s,
|
||||
%s, %s, %s,
|
||||
%s, %s, %s, %s, %s
|
||||
%s, %s, %s, %s, %s, %s,
|
||||
%s, %s
|
||||
) RETURNING *
|
||||
"""
|
||||
|
||||
params = (
|
||||
sag_id, solution_id, customer_id, description,
|
||||
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)
|
||||
if result:
|
||||
|
||||
11
main.py
11
main.py
@ -37,6 +37,8 @@ from app.dashboard.backend import views as dashboard_views
|
||||
from app.dashboard.backend import router as dashboard_api
|
||||
from app.prepaid.backend import router as prepaid_api
|
||||
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.frontend import views as ticket_views
|
||||
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.nextcloud.backend import router as nextcloud_api
|
||||
from app.modules.search.backend import router as search_api
|
||||
from app.fixed_price.backend import router as fixed_price_api
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
@ -160,6 +163,12 @@ async def auth_middleware(request: Request, call_next):
|
||||
"/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"):
|
||||
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(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(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(vendors_api.router, prefix="/api/v1", tags=["Vendors"])
|
||||
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(customers_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(timetracking_views.router, tags=["Frontend"])
|
||||
app.include_router(billing_views.router, tags=["Frontend"])
|
||||
|
||||
48
migrations/097_archived_tickets_simplycrm.sql
Normal file
48
migrations/097_archived_tickets_simplycrm.sql
Normal 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);
|
||||
17
migrations/098_prepaid_rounding_minutes.sql
Normal file
17
migrations/098_prepaid_rounding_minutes.sql
Normal 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)';
|
||||
13
migrations/099_worklog_rounded_hours.sql
Normal file
13
migrations/099_worklog_rounded_hours.sql
Normal 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;
|
||||
108
migrations/100_fixed_price_agreements.sql
Normal file
108
migrations/100_fixed_price_agreements.sql
Normal 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.';
|
||||
22
migrations/101_add_fixed_price_to_worklog.sql
Normal file
22
migrations/101_add_fixed_price_to_worklog.sql
Normal 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';
|
||||
65
migrations/102_fixed_price_billing_periods.sql
Normal file
65
migrations/102_fixed_price_billing_periods.sql
Normal 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';
|
||||
113
migrations/103_fixed_price_reporting_views.sql
Normal file
113
migrations/103_fixed_price_reporting_views.sql
Normal 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';
|
||||
@ -9,3 +9,10 @@ python-dateutil==2.8.2
|
||||
jinja2==3.1.4
|
||||
aiohttp==3.10.10
|
||||
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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user