feat: implement advanced expense management with templates and navigation

Implement complete Advanced Expense Management feature set with UI templates,
database schema fixes, and reorganized navigation structure.

Features:
- Expense Categories: Full CRUD with budget tracking and visualization
- Mileage Tracking: Vehicle mileage entries with approval workflow
- Per Diem Management: Daily allowance claims with location-based rates
- Receipt OCR: Infrastructure for receipt scanning (utilities ready)

Database:
- Migration 037: Create expense_categories, mileage, per_diem_rates, per_diems tables
- Migration 038: Fix schema column name mismatches (trip_purpose→purpose, etc.)
- Add missing columns (description, odometer, rates, reimbursement tracking)
- Fix circular foreign key dependencies

Templates (11 new files):
- expense_categories/: list, form, view
- mileage/: list, form, view
- per_diem/: list, form, view, rates_list, rate_form

Navigation:
- Move Mileage and Per Diem to Expenses sub-pages (header buttons)
- Move Expense Categories to Admin menu only
- Remove expense management items from Finance menu

Fixes:
- Fix NoneType comparison error in expense categories utilization
- Handle None values safely in budget progress bars
- Resolve database column name mismatches

UI/UX:
- Responsive design with Tailwind CSS and dark mode support
- Real-time calculations for mileage amounts
- Color-coded budget utilization progress bars
- Status badges for approval workflow states
- Advanced filtering on all list views

Default data:
- 7 expense categories (Travel, Meals, Accommodation, etc.)
- 4 per diem rates (US, GB, DE, FR)
This commit is contained in:
Dries Peeters
2025-10-31 06:21:35 +01:00
parent 1fa13670aa
commit b353184a4f
31 changed files with 6144 additions and 3 deletions

View File

@@ -770,6 +770,9 @@ def create_app(config=None):
from app.routes.expenses import expenses_bp
from app.routes.permissions import permissions_bp
from app.routes.calendar import calendar_bp
from app.routes.expense_categories import expense_categories_bp
from app.routes.mileage import mileage_bp
from app.routes.per_diem import per_diem_bp
app.register_blueprint(auth_bp)
app.register_blueprint(main_bp)
@@ -798,6 +801,9 @@ def create_app(config=None):
app.register_blueprint(expenses_bp)
app.register_blueprint(permissions_bp)
app.register_blueprint(calendar_bp)
app.register_blueprint(expense_categories_bp)
app.register_blueprint(mileage_bp)
app.register_blueprint(per_diem_bp)
# Exempt API blueprints from CSRF protection (JSON API uses token authentication, not CSRF tokens)
# Only if CSRF is enabled

View File

@@ -11,6 +11,9 @@ from .payments import Payment, CreditNote, InvoiceReminderSchedule
from .reporting import SavedReportView, ReportEmailSchedule
from .client import Client
from .task_activity import TaskActivity
from .expense_category import ExpenseCategory
from .mileage import Mileage
from .per_diem import PerDiem, PerDiemRate
from .extra_good import ExtraGood
from .comment import Comment
from .focus_session import FocusSession

View File

@@ -0,0 +1,144 @@
from datetime import datetime
from decimal import Decimal
from app import db
from sqlalchemy import Index
class ExpenseCategory(db.Model):
"""Expense category model with budget tracking"""
__tablename__ = 'expense_categories'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False, unique=True, index=True)
description = db.Column(db.Text, nullable=True)
code = db.Column(db.String(20), nullable=True, unique=True, index=True) # Short code for quick reference
color = db.Column(db.String(7), nullable=True) # Hex color for UI (e.g., #FF5733)
icon = db.Column(db.String(50), nullable=True) # Icon name for UI
# Budget settings
monthly_budget = db.Column(db.Numeric(10, 2), nullable=True)
quarterly_budget = db.Column(db.Numeric(10, 2), nullable=True)
yearly_budget = db.Column(db.Numeric(10, 2), nullable=True)
budget_threshold_percent = db.Column(db.Integer, nullable=False, default=80) # Alert when exceeded
# Settings
requires_receipt = db.Column(db.Boolean, default=True, nullable=False)
requires_approval = db.Column(db.Boolean, default=True, nullable=False)
default_tax_rate = db.Column(db.Numeric(5, 2), nullable=True)
is_active = db.Column(db.Boolean, default=True, nullable=False)
# Metadata
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
def __init__(self, name, **kwargs):
self.name = name.strip()
self.description = kwargs.get('description', '').strip() if kwargs.get('description') else None
self.code = kwargs.get('code', '').strip() if kwargs.get('code') else None
self.color = kwargs.get('color')
self.icon = kwargs.get('icon')
self.monthly_budget = Decimal(str(kwargs.get('monthly_budget'))) if kwargs.get('monthly_budget') else None
self.quarterly_budget = Decimal(str(kwargs.get('quarterly_budget'))) if kwargs.get('quarterly_budget') else None
self.yearly_budget = Decimal(str(kwargs.get('yearly_budget'))) if kwargs.get('yearly_budget') else None
self.budget_threshold_percent = kwargs.get('budget_threshold_percent', 80)
self.requires_receipt = kwargs.get('requires_receipt', True)
self.requires_approval = kwargs.get('requires_approval', True)
self.default_tax_rate = Decimal(str(kwargs.get('default_tax_rate'))) if kwargs.get('default_tax_rate') else None
self.is_active = kwargs.get('is_active', True)
def __repr__(self):
return f'<ExpenseCategory {self.name}>'
def get_spent_amount(self, start_date, end_date):
"""Get total amount spent in this category for date range"""
from app.models.expense import Expense
query = db.session.query(
db.func.sum(Expense.amount + db.func.coalesce(Expense.tax_amount, 0))
).filter(
Expense.category == self.name,
Expense.status.in_(['approved', 'reimbursed']),
Expense.expense_date >= start_date,
Expense.expense_date <= end_date
)
total = query.scalar() or Decimal('0')
return float(total)
def get_budget_utilization(self, period='monthly'):
"""Get budget utilization percentage for the current period"""
from datetime import date
today = date.today()
if period == 'monthly':
start_date = date(today.year, today.month, 1)
budget = self.monthly_budget
elif period == 'quarterly':
quarter = (today.month - 1) // 3 + 1
start_month = (quarter - 1) * 3 + 1
start_date = date(today.year, start_month, 1)
budget = self.quarterly_budget
elif period == 'yearly':
start_date = date(today.year, 1, 1)
budget = self.yearly_budget
else:
return None
if not budget or budget == 0:
return None
spent = self.get_spent_amount(start_date, today)
utilization = (spent / float(budget)) * 100
return {
'spent': spent,
'budget': float(budget),
'utilization_percent': round(utilization, 2),
'remaining': float(budget) - spent,
'over_threshold': utilization >= self.budget_threshold_percent
}
def to_dict(self):
"""Convert category to dictionary for API responses"""
return {
'id': self.id,
'name': self.name,
'description': self.description,
'code': self.code,
'color': self.color,
'icon': self.icon,
'monthly_budget': float(self.monthly_budget) if self.monthly_budget else None,
'quarterly_budget': float(self.quarterly_budget) if self.quarterly_budget else None,
'yearly_budget': float(self.yearly_budget) if self.yearly_budget else None,
'budget_threshold_percent': self.budget_threshold_percent,
'requires_receipt': self.requires_receipt,
'requires_approval': self.requires_approval,
'default_tax_rate': float(self.default_tax_rate) if self.default_tax_rate else None,
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None
}
@classmethod
def get_active_categories(cls):
"""Get all active categories"""
return cls.query.filter_by(is_active=True).order_by(cls.name).all()
@classmethod
def get_categories_over_budget(cls, period='monthly'):
"""Get categories that are over their budget threshold"""
categories = cls.get_active_categories()
over_budget = []
for category in categories:
utilization = category.get_budget_utilization(period)
if utilization and utilization['over_threshold']:
over_budget.append({
'category': category,
'utilization': utilization
})
return over_budget

249
app/models/mileage.py Normal file
View File

@@ -0,0 +1,249 @@
from datetime import datetime
from decimal import Decimal
from app import db
from sqlalchemy import Index
class Mileage(db.Model):
"""Mileage tracking for business travel"""
__tablename__ = 'mileage'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=True, index=True)
client_id = db.Column(db.Integer, db.ForeignKey('clients.id'), nullable=True, index=True)
expense_id = db.Column(db.Integer, db.ForeignKey('expenses.id'), nullable=True, index=True)
# Trip details
trip_date = db.Column(db.Date, nullable=False, index=True)
purpose = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
# Location information
start_location = db.Column(db.String(200), nullable=False)
end_location = db.Column(db.String(200), nullable=False)
start_odometer = db.Column(db.Numeric(10, 2), nullable=True) # Optional odometer readings
end_odometer = db.Column(db.Numeric(10, 2), nullable=True)
# Distance and calculation
distance_km = db.Column(db.Numeric(10, 2), nullable=False)
distance_miles = db.Column(db.Numeric(10, 2), nullable=True) # Computed or manual
rate_per_km = db.Column(db.Numeric(10, 4), nullable=False) # Rate at time of entry
rate_per_mile = db.Column(db.Numeric(10, 4), nullable=True)
currency_code = db.Column(db.String(3), nullable=False, default='EUR')
# Vehicle information
vehicle_type = db.Column(db.String(50), nullable=True) # 'car', 'motorcycle', 'van', 'truck'
vehicle_description = db.Column(db.String(200), nullable=True) # e.g., "BMW 3 Series"
license_plate = db.Column(db.String(20), nullable=True)
# Calculated amount
calculated_amount = db.Column(db.Numeric(10, 2), nullable=False)
# Round trip
is_round_trip = db.Column(db.Boolean, default=False, nullable=False)
# Status and approval
status = db.Column(db.String(20), default='pending', nullable=False) # 'pending', 'approved', 'rejected', 'reimbursed'
approved_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True, index=True)
approved_at = db.Column(db.DateTime, nullable=True)
rejection_reason = db.Column(db.Text, nullable=True)
# Reimbursement
reimbursed = db.Column(db.Boolean, default=False, nullable=False)
reimbursed_at = db.Column(db.DateTime, nullable=True)
# Notes
notes = db.Column(db.Text, nullable=True)
# Metadata
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
user = db.relationship('User', foreign_keys=[user_id], backref=db.backref('mileage_entries', lazy='dynamic'))
approver = db.relationship('User', foreign_keys=[approved_by], backref=db.backref('approved_mileage', lazy='dynamic'))
project = db.relationship('Project', backref=db.backref('mileage_entries', lazy='dynamic'))
client = db.relationship('Client', backref=db.backref('mileage_entries', lazy='dynamic'))
expense = db.relationship('Expense', backref=db.backref('mileage_entry', uselist=False))
# Indexes for common queries
__table_args__ = (
Index('ix_mileage_user_date', 'user_id', 'trip_date'),
Index('ix_mileage_status_date', 'status', 'trip_date'),
)
def __init__(self, user_id, trip_date, purpose, start_location, end_location,
distance_km, rate_per_km, **kwargs):
self.user_id = user_id
self.trip_date = trip_date
self.purpose = purpose.strip()
self.start_location = start_location.strip()
self.end_location = end_location.strip()
self.distance_km = Decimal(str(distance_km))
self.rate_per_km = Decimal(str(rate_per_km))
# Calculate amount
self.calculated_amount = self.distance_km * self.rate_per_km
# Optional fields
self.description = kwargs.get('description', '').strip() if kwargs.get('description') else None
self.project_id = kwargs.get('project_id')
self.client_id = kwargs.get('client_id')
self.expense_id = kwargs.get('expense_id')
self.start_odometer = Decimal(str(kwargs.get('start_odometer'))) if kwargs.get('start_odometer') else None
self.end_odometer = Decimal(str(kwargs.get('end_odometer'))) if kwargs.get('end_odometer') else None
self.distance_miles = Decimal(str(kwargs.get('distance_miles'))) if kwargs.get('distance_miles') else self.distance_km * Decimal('0.621371')
self.rate_per_mile = Decimal(str(kwargs.get('rate_per_mile'))) if kwargs.get('rate_per_mile') else None
self.currency_code = kwargs.get('currency_code', 'EUR')
self.vehicle_type = kwargs.get('vehicle_type')
self.vehicle_description = kwargs.get('vehicle_description')
self.license_plate = kwargs.get('license_plate')
self.is_round_trip = kwargs.get('is_round_trip', False)
self.notes = kwargs.get('notes', '').strip() if kwargs.get('notes') else None
self.status = kwargs.get('status', 'pending')
def __repr__(self):
return f'<Mileage {self.start_location} -> {self.end_location} ({self.distance_km} km)>'
@property
def total_distance_km(self):
"""Get total distance including round trip if applicable"""
multiplier = 2 if self.is_round_trip else 1
return float(self.distance_km) * multiplier
@property
def total_amount(self):
"""Get total amount including round trip if applicable"""
multiplier = 2 if self.is_round_trip else 1
return float(self.calculated_amount) * multiplier
def approve(self, approved_by_user_id, notes=None):
"""Approve the mileage entry"""
self.status = 'approved'
self.approved_by = approved_by_user_id
self.approved_at = datetime.utcnow()
if notes:
self.notes = (self.notes or '') + f'\n\nApproval notes: {notes}'
self.updated_at = datetime.utcnow()
def reject(self, rejected_by_user_id, reason):
"""Reject the mileage entry"""
self.status = 'rejected'
self.approved_by = rejected_by_user_id
self.approved_at = datetime.utcnow()
self.rejection_reason = reason
self.updated_at = datetime.utcnow()
def mark_as_reimbursed(self):
"""Mark this mileage entry as reimbursed"""
self.reimbursed = True
self.reimbursed_at = datetime.utcnow()
self.status = 'reimbursed'
self.updated_at = datetime.utcnow()
def create_expense(self):
"""Create an expense from this mileage entry"""
from app.models.expense import Expense
if self.expense_id:
return None # Already has an expense
expense = Expense(
user_id=self.user_id,
title=f"Mileage: {self.start_location} to {self.end_location}",
category='travel',
amount=self.total_amount,
expense_date=self.trip_date,
description=f"{self.purpose}\nDistance: {self.total_distance_km} km @ {float(self.rate_per_km)} {self.currency_code}/km",
project_id=self.project_id,
client_id=self.client_id,
currency_code=self.currency_code,
status=self.status
)
return expense
def to_dict(self):
"""Convert mileage entry to dictionary for API responses"""
return {
'id': self.id,
'user_id': self.user_id,
'project_id': self.project_id,
'client_id': self.client_id,
'expense_id': self.expense_id,
'trip_date': self.trip_date.isoformat() if self.trip_date else None,
'purpose': self.purpose,
'description': self.description,
'start_location': self.start_location,
'end_location': self.end_location,
'start_odometer': float(self.start_odometer) if self.start_odometer else None,
'end_odometer': float(self.end_odometer) if self.end_odometer else None,
'distance_km': float(self.distance_km),
'distance_miles': float(self.distance_miles) if self.distance_miles else None,
'rate_per_km': float(self.rate_per_km),
'rate_per_mile': float(self.rate_per_mile) if self.rate_per_mile else None,
'currency_code': self.currency_code,
'vehicle_type': self.vehicle_type,
'vehicle_description': self.vehicle_description,
'license_plate': self.license_plate,
'calculated_amount': float(self.calculated_amount),
'is_round_trip': self.is_round_trip,
'total_distance_km': self.total_distance_km,
'total_amount': self.total_amount,
'status': self.status,
'approved_by': self.approved_by,
'approved_at': self.approved_at.isoformat() if self.approved_at else None,
'rejection_reason': self.rejection_reason,
'reimbursed': self.reimbursed,
'reimbursed_at': self.reimbursed_at.isoformat() if self.reimbursed_at else None,
'notes': self.notes,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'user': self.user.username if self.user else None,
'project': self.project.name if self.project else None,
'client': self.client.name if self.client else None,
'approver': self.approver.username if self.approver else None
}
@classmethod
def get_default_rates(cls):
"""Get default mileage rates for different vehicle types"""
# These are example rates and should be configurable in settings
return {
'car': {'km': 0.30, 'mile': 0.48, 'currency': 'EUR'},
'motorcycle': {'km': 0.20, 'mile': 0.32, 'currency': 'EUR'},
'van': {'km': 0.35, 'mile': 0.56, 'currency': 'EUR'},
'truck': {'km': 0.40, 'mile': 0.64, 'currency': 'EUR'}
}
@classmethod
def get_pending_approvals(cls, user_id=None):
"""Get mileage entries pending approval"""
query = cls.query.filter_by(status='pending')
if user_id:
query = query.filter(cls.user_id == user_id)
return query.order_by(cls.trip_date.desc()).all()
@classmethod
def get_total_distance(cls, user_id=None, start_date=None, end_date=None):
"""Calculate total distance traveled"""
query = db.session.query(db.func.sum(cls.distance_km))
if user_id:
query = query.filter(cls.user_id == user_id)
if start_date:
query = query.filter(cls.trip_date >= start_date)
if end_date:
query = query.filter(cls.trip_date <= end_date)
query = query.filter(cls.status.in_(['approved', 'reimbursed']))
total = query.scalar() or Decimal('0')
return float(total)

418
app/models/per_diem.py Normal file
View File

@@ -0,0 +1,418 @@
from datetime import datetime, timedelta
from decimal import Decimal
from app import db
from sqlalchemy import Index
class PerDiemRate(db.Model):
"""Per diem rate configuration for different locations"""
__tablename__ = 'per_diem_rates'
id = db.Column(db.Integer, primary_key=True)
country = db.Column(db.String(100), nullable=False, index=True)
city = db.Column(db.String(100), nullable=True, index=True)
# Rates
full_day_rate = db.Column(db.Numeric(10, 2), nullable=False)
half_day_rate = db.Column(db.Numeric(10, 2), nullable=False)
breakfast_rate = db.Column(db.Numeric(10, 2), nullable=True)
lunch_rate = db.Column(db.Numeric(10, 2), nullable=True)
dinner_rate = db.Column(db.Numeric(10, 2), nullable=True)
incidental_rate = db.Column(db.Numeric(10, 2), nullable=True) # Tips, etc.
currency_code = db.Column(db.String(3), nullable=False, default='EUR')
# Validity period
effective_from = db.Column(db.Date, nullable=False, index=True)
effective_to = db.Column(db.Date, nullable=True, index=True)
# Settings
is_active = db.Column(db.Boolean, default=True, nullable=False)
notes = db.Column(db.Text, nullable=True)
# Metadata
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
__table_args__ = (
Index('ix_per_diem_rates_country_city', 'country', 'city'),
Index('ix_per_diem_rates_effective', 'effective_from', 'effective_to'),
)
def __init__(self, country, full_day_rate, half_day_rate, effective_from, **kwargs):
self.country = country.strip()
self.city = kwargs.get('city', '').strip() if kwargs.get('city') else None
self.full_day_rate = Decimal(str(full_day_rate))
self.half_day_rate = Decimal(str(half_day_rate))
self.breakfast_rate = Decimal(str(kwargs.get('breakfast_rate'))) if kwargs.get('breakfast_rate') else None
self.lunch_rate = Decimal(str(kwargs.get('lunch_rate'))) if kwargs.get('lunch_rate') else None
self.dinner_rate = Decimal(str(kwargs.get('dinner_rate'))) if kwargs.get('dinner_rate') else None
self.incidental_rate = Decimal(str(kwargs.get('incidental_rate'))) if kwargs.get('incidental_rate') else None
self.currency_code = kwargs.get('currency_code', 'EUR')
self.effective_from = effective_from
self.effective_to = kwargs.get('effective_to')
self.is_active = kwargs.get('is_active', True)
self.notes = kwargs.get('notes', '').strip() if kwargs.get('notes') else None
def __repr__(self):
location = f"{self.city}, {self.country}" if self.city else self.country
return f'<PerDiemRate {location}: {self.full_day_rate} {self.currency_code}>'
def to_dict(self):
"""Convert rate to dictionary for API responses"""
return {
'id': self.id,
'country': self.country,
'city': self.city,
'full_day_rate': float(self.full_day_rate),
'half_day_rate': float(self.half_day_rate),
'breakfast_rate': float(self.breakfast_rate) if self.breakfast_rate else None,
'lunch_rate': float(self.lunch_rate) if self.lunch_rate else None,
'dinner_rate': float(self.dinner_rate) if self.dinner_rate else None,
'incidental_rate': float(self.incidental_rate) if self.incidental_rate else None,
'currency_code': self.currency_code,
'effective_from': self.effective_from.isoformat() if self.effective_from else None,
'effective_to': self.effective_to.isoformat() if self.effective_to else None,
'is_active': self.is_active,
'notes': self.notes,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None
}
@classmethod
def get_rate_for_location(cls, country, city=None, date=None):
"""Get applicable per diem rate for a location and date"""
from datetime import date as dt_date
if date is None:
date = dt_date.today()
query = cls.query.filter(
cls.country == country,
cls.is_active == True,
cls.effective_from <= date
)
if city:
# Try to find city-specific rate first
city_rate = query.filter(cls.city == city).filter(
db.or_(cls.effective_to.is_(None), cls.effective_to >= date)
).first()
if city_rate:
return city_rate
# Fall back to country rate
country_rate = query.filter(cls.city.is_(None)).filter(
db.or_(cls.effective_to.is_(None), cls.effective_to >= date)
).first()
return country_rate
class PerDiem(db.Model):
"""Per diem claim for business travel"""
__tablename__ = 'per_diems'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=True, index=True)
client_id = db.Column(db.Integer, db.ForeignKey('clients.id'), nullable=True, index=True)
expense_id = db.Column(db.Integer, db.ForeignKey('expenses.id'), nullable=True, index=True)
per_diem_rate_id = db.Column(db.Integer, db.ForeignKey('per_diem_rates.id'), nullable=True, index=True)
# Trip details
trip_purpose = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
# Date range
start_date = db.Column(db.Date, nullable=False, index=True)
end_date = db.Column(db.Date, nullable=False, index=True)
departure_time = db.Column(db.Time, nullable=True)
return_time = db.Column(db.Time, nullable=True)
# Location
country = db.Column(db.String(100), nullable=False)
city = db.Column(db.String(100), nullable=True)
# Calculation details
full_days = db.Column(db.Integer, default=0, nullable=False)
half_days = db.Column(db.Integer, default=0, nullable=False)
# Meal deductions (if meals were provided)
breakfast_provided = db.Column(db.Integer, default=0, nullable=False) # Number of breakfasts
lunch_provided = db.Column(db.Integer, default=0, nullable=False)
dinner_provided = db.Column(db.Integer, default=0, nullable=False)
# Rates used (stored at time of creation)
full_day_rate = db.Column(db.Numeric(10, 2), nullable=False)
half_day_rate = db.Column(db.Numeric(10, 2), nullable=False)
breakfast_deduction = db.Column(db.Numeric(10, 2), nullable=True)
lunch_deduction = db.Column(db.Numeric(10, 2), nullable=True)
dinner_deduction = db.Column(db.Numeric(10, 2), nullable=True)
# Calculated amount
calculated_amount = db.Column(db.Numeric(10, 2), nullable=False)
currency_code = db.Column(db.String(3), nullable=False, default='EUR')
# Status and approval
status = db.Column(db.String(20), default='pending', nullable=False) # 'pending', 'approved', 'rejected', 'reimbursed'
approved_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True, index=True)
approved_at = db.Column(db.DateTime, nullable=True)
rejection_reason = db.Column(db.Text, nullable=True)
# Reimbursement
reimbursed = db.Column(db.Boolean, default=False, nullable=False)
reimbursed_at = db.Column(db.DateTime, nullable=True)
# Notes
notes = db.Column(db.Text, nullable=True)
# Metadata
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
user = db.relationship('User', foreign_keys=[user_id], backref=db.backref('per_diem_claims', lazy='dynamic'))
approver = db.relationship('User', foreign_keys=[approved_by], backref=db.backref('approved_per_diems', lazy='dynamic'))
project = db.relationship('Project', backref=db.backref('per_diem_claims', lazy='dynamic'))
client = db.relationship('Client', backref=db.backref('per_diem_claims', lazy='dynamic'))
expense = db.relationship('Expense', backref=db.backref('per_diem_claim', uselist=False))
rate = db.relationship('PerDiemRate', backref=db.backref('per_diem_claims', lazy='dynamic'))
# Indexes for common queries
__table_args__ = (
Index('ix_per_diems_user_date', 'user_id', 'start_date'),
Index('ix_per_diems_status_date', 'status', 'start_date'),
)
def __init__(self, user_id, trip_purpose, start_date, end_date, country,
full_day_rate, half_day_rate, **kwargs):
self.user_id = user_id
self.trip_purpose = trip_purpose.strip()
self.start_date = start_date
self.end_date = end_date
self.country = country.strip()
self.city = kwargs.get('city', '').strip() if kwargs.get('city') else None
# Store rates
self.full_day_rate = Decimal(str(full_day_rate))
self.half_day_rate = Decimal(str(half_day_rate))
# Optional fields
self.description = kwargs.get('description', '').strip() if kwargs.get('description') else None
self.project_id = kwargs.get('project_id')
self.client_id = kwargs.get('client_id')
self.expense_id = kwargs.get('expense_id')
self.per_diem_rate_id = kwargs.get('per_diem_rate_id')
self.departure_time = kwargs.get('departure_time')
self.return_time = kwargs.get('return_time')
self.full_days = kwargs.get('full_days', 0)
self.half_days = kwargs.get('half_days', 0)
self.breakfast_provided = kwargs.get('breakfast_provided', 0)
self.lunch_provided = kwargs.get('lunch_provided', 0)
self.dinner_provided = kwargs.get('dinner_provided', 0)
self.breakfast_deduction = Decimal(str(kwargs.get('breakfast_deduction', 0)))
self.lunch_deduction = Decimal(str(kwargs.get('lunch_deduction', 0)))
self.dinner_deduction = Decimal(str(kwargs.get('dinner_deduction', 0)))
self.currency_code = kwargs.get('currency_code', 'EUR')
self.notes = kwargs.get('notes', '').strip() if kwargs.get('notes') else None
self.status = kwargs.get('status', 'pending')
# Calculate amount
self.calculated_amount = self._calculate_amount()
def _calculate_amount(self):
"""Calculate the per diem amount based on days and deductions"""
# Base amount
amount = (self.full_day_rate * self.full_days) + (self.half_day_rate * self.half_days)
# Deduct provided meals
amount -= (self.breakfast_deduction * self.breakfast_provided)
amount -= (self.lunch_deduction * self.lunch_provided)
amount -= (self.dinner_deduction * self.dinner_provided)
return max(Decimal('0'), amount) # Ensure non-negative
def recalculate_amount(self):
"""Recalculate the amount (useful when days or deductions change)"""
self.calculated_amount = self._calculate_amount()
return self.calculated_amount
def __repr__(self):
location = f"{self.city}, {self.country}" if self.city else self.country
return f'<PerDiem {location} ({self.start_date} - {self.end_date})>'
@property
def total_days(self):
"""Get total number of days (full + half)"""
return self.full_days + (self.half_days * 0.5)
@property
def trip_duration(self):
"""Get trip duration in days"""
return (self.end_date - self.start_date).days + 1
def approve(self, approved_by_user_id, notes=None):
"""Approve the per diem claim"""
self.status = 'approved'
self.approved_by = approved_by_user_id
self.approved_at = datetime.utcnow()
if notes:
self.notes = (self.notes or '') + f'\n\nApproval notes: {notes}'
self.updated_at = datetime.utcnow()
def reject(self, rejected_by_user_id, reason):
"""Reject the per diem claim"""
self.status = 'rejected'
self.approved_by = rejected_by_user_id
self.approved_at = datetime.utcnow()
self.rejection_reason = reason
self.updated_at = datetime.utcnow()
def mark_as_reimbursed(self):
"""Mark this per diem claim as reimbursed"""
self.reimbursed = True
self.reimbursed_at = datetime.utcnow()
self.status = 'reimbursed'
self.updated_at = datetime.utcnow()
def create_expense(self):
"""Create an expense from this per diem claim"""
from app.models.expense import Expense
if self.expense_id:
return None # Already has an expense
location = f"{self.city}, {self.country}" if self.city else self.country
expense = Expense(
user_id=self.user_id,
title=f"Per Diem: {location}",
category='meals',
amount=self.calculated_amount,
expense_date=self.start_date,
description=f"{self.trip_purpose}\n{self.start_date} to {self.end_date} ({self.total_days} days)",
project_id=self.project_id,
client_id=self.client_id,
currency_code=self.currency_code,
status=self.status
)
return expense
def to_dict(self):
"""Convert per diem claim to dictionary for API responses"""
return {
'id': self.id,
'user_id': self.user_id,
'project_id': self.project_id,
'client_id': self.client_id,
'expense_id': self.expense_id,
'per_diem_rate_id': self.per_diem_rate_id,
'trip_purpose': self.trip_purpose,
'description': self.description,
'start_date': self.start_date.isoformat() if self.start_date else None,
'end_date': self.end_date.isoformat() if self.end_date else None,
'departure_time': self.departure_time.isoformat() if self.departure_time else None,
'return_time': self.return_time.isoformat() if self.return_time else None,
'country': self.country,
'city': self.city,
'full_days': self.full_days,
'half_days': self.half_days,
'total_days': self.total_days,
'trip_duration': self.trip_duration,
'breakfast_provided': self.breakfast_provided,
'lunch_provided': self.lunch_provided,
'dinner_provided': self.dinner_provided,
'full_day_rate': float(self.full_day_rate),
'half_day_rate': float(self.half_day_rate),
'breakfast_deduction': float(self.breakfast_deduction) if self.breakfast_deduction else None,
'lunch_deduction': float(self.lunch_deduction) if self.lunch_deduction else None,
'dinner_deduction': float(self.dinner_deduction) if self.dinner_deduction else None,
'calculated_amount': float(self.calculated_amount),
'currency_code': self.currency_code,
'status': self.status,
'approved_by': self.approved_by,
'approved_at': self.approved_at.isoformat() if self.approved_at else None,
'rejection_reason': self.rejection_reason,
'reimbursed': self.reimbursed,
'reimbursed_at': self.reimbursed_at.isoformat() if self.reimbursed_at else None,
'notes': self.notes,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'user': self.user.username if self.user else None,
'project': self.project.name if self.project else None,
'client': self.client.name if self.client else None,
'approver': self.approver.username if self.approver else None
}
@classmethod
def calculate_days_from_dates(cls, start_date, end_date, departure_time=None, return_time=None):
"""
Calculate full and half days based on departure and return times.
Rules:
- Departure before 12:00 = full day
- Departure after 12:00 = half day
- Return after 12:00 = full day
- Return before 12:00 = half day
- Middle days = full days
"""
from datetime import time as dt_time
if start_date > end_date:
return {'full_days': 0, 'half_days': 0}
trip_days = (end_date - start_date).days + 1
if trip_days == 1:
# Single day trip
if departure_time and return_time:
# Check if it qualifies for a full day (>= 8 hours)
departure_datetime = datetime.combine(start_date, departure_time)
return_datetime = datetime.combine(end_date, return_time)
hours = (return_datetime - departure_datetime).total_seconds() / 3600
if hours >= 8:
return {'full_days': 1, 'half_days': 0}
else:
return {'full_days': 0, 'half_days': 1}
else:
# Default to half day for single day
return {'full_days': 0, 'half_days': 1}
full_days = 0
half_days = 0
# First day
noon = dt_time(12, 0)
if departure_time and departure_time < noon:
full_days += 1
else:
half_days += 1
# Middle days (all full days)
if trip_days > 2:
full_days += (trip_days - 2)
# Last day
if return_time and return_time >= noon:
full_days += 1
else:
half_days += 1
return {'full_days': full_days, 'half_days': half_days}
@classmethod
def get_pending_approvals(cls, user_id=None):
"""Get per diem claims pending approval"""
query = cls.query.filter_by(status='pending')
if user_id:
query = query.filter(cls.user_id == user_id)
return query.order_by(cls.start_date.desc()).all()

View File

@@ -0,0 +1,253 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from flask_babel import gettext as _
from flask_login import login_required, current_user
from app import db, log_event, track_event
from app.models import ExpenseCategory
from datetime import datetime, date
from decimal import Decimal
from app.utils.db import safe_commit
from app.utils.permissions import admin_or_permission_required
expense_categories_bp = Blueprint('expense_categories', __name__)
@expense_categories_bp.route('/expense-categories')
@login_required
@admin_or_permission_required('expense_categories.view')
def list_categories():
"""List all expense categories"""
from app import track_page_view
track_page_view("expense_categories_list")
categories = ExpenseCategory.query.order_by(ExpenseCategory.name).all()
# Get budget utilization for each category
for category in categories:
category.monthly_utilization = category.get_budget_utilization('monthly')
category.yearly_utilization = category.get_budget_utilization('yearly')
return render_template('expense_categories/list.html', categories=categories)
@expense_categories_bp.route('/expense-categories/create', methods=['GET', 'POST'])
@login_required
@admin_or_permission_required('expense_categories.create')
def create_category():
"""Create a new expense category"""
if request.method == 'GET':
return render_template('expense_categories/form.html', category=None)
try:
# Get form data
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
code = request.form.get('code', '').strip()
color = request.form.get('color', '').strip()
icon = request.form.get('icon', '').strip()
# Validate required fields
if not name:
flash(_('Category name is required'), 'error')
return redirect(url_for('expense_categories.create_category'))
# Budget fields
monthly_budget = request.form.get('monthly_budget', '').strip()
quarterly_budget = request.form.get('quarterly_budget', '').strip()
yearly_budget = request.form.get('yearly_budget', '').strip()
budget_threshold_percent = request.form.get('budget_threshold_percent', '80')
# Settings
requires_receipt = request.form.get('requires_receipt') == 'on'
requires_approval = request.form.get('requires_approval') == 'on'
default_tax_rate = request.form.get('default_tax_rate', '').strip()
is_active = request.form.get('is_active') == 'on'
# Create category
category = ExpenseCategory(
name=name,
description=description,
code=code if code else None,
color=color if color else None,
icon=icon if icon else None,
monthly_budget=Decimal(monthly_budget) if monthly_budget else None,
quarterly_budget=Decimal(quarterly_budget) if quarterly_budget else None,
yearly_budget=Decimal(yearly_budget) if yearly_budget else None,
budget_threshold_percent=int(budget_threshold_percent) if budget_threshold_percent else 80,
requires_receipt=requires_receipt,
requires_approval=requires_approval,
default_tax_rate=Decimal(default_tax_rate) if default_tax_rate else None,
is_active=is_active
)
db.session.add(category)
if safe_commit(db):
flash(_('Expense category created successfully'), 'success')
log_event('expense_category_created', user_id=current_user.id, category_id=category.id)
track_event(current_user.id, 'expense_category.created', {'category_id': category.id})
return redirect(url_for('expense_categories.list_categories'))
else:
flash(_('Error creating expense category'), 'error')
return redirect(url_for('expense_categories.create_category'))
except Exception as e:
from flask import current_app
current_app.logger.error(f"Error creating expense category: {e}")
flash(_('Error creating expense category'), 'error')
return redirect(url_for('expense_categories.create_category'))
@expense_categories_bp.route('/expense-categories/<int:category_id>')
@login_required
@admin_or_permission_required('expense_categories.view')
def view_category(category_id):
"""View expense category details"""
category = ExpenseCategory.query.get_or_404(category_id)
from app import track_page_view
track_page_view("expense_category_detail", properties={'category_id': category_id})
# Get budget utilization
monthly_util = category.get_budget_utilization('monthly')
quarterly_util = category.get_budget_utilization('quarterly')
yearly_util = category.get_budget_utilization('yearly')
return render_template(
'expense_categories/view.html',
category=category,
monthly_utilization=monthly_util,
quarterly_utilization=quarterly_util,
yearly_utilization=yearly_util
)
@expense_categories_bp.route('/expense-categories/<int:category_id>/edit', methods=['GET', 'POST'])
@login_required
@admin_or_permission_required('expense_categories.update')
def edit_category(category_id):
"""Edit an expense category"""
category = ExpenseCategory.query.get_or_404(category_id)
if request.method == 'GET':
return render_template('expense_categories/form.html', category=category)
try:
# Get form data
name = request.form.get('name', '').strip()
if not name:
flash(_('Category name is required'), 'error')
return redirect(url_for('expense_categories.edit_category', category_id=category_id))
# Update category fields
category.name = name
category.description = request.form.get('description', '').strip()
category.code = request.form.get('code', '').strip() or None
category.color = request.form.get('color', '').strip() or None
category.icon = request.form.get('icon', '').strip() or None
# Budget fields
monthly_budget = request.form.get('monthly_budget', '').strip()
quarterly_budget = request.form.get('quarterly_budget', '').strip()
yearly_budget = request.form.get('yearly_budget', '').strip()
category.monthly_budget = Decimal(monthly_budget) if monthly_budget else None
category.quarterly_budget = Decimal(quarterly_budget) if quarterly_budget else None
category.yearly_budget = Decimal(yearly_budget) if yearly_budget else None
category.budget_threshold_percent = int(request.form.get('budget_threshold_percent', '80'))
# Settings
category.requires_receipt = request.form.get('requires_receipt') == 'on'
category.requires_approval = request.form.get('requires_approval') == 'on'
default_tax_rate = request.form.get('default_tax_rate', '').strip()
category.default_tax_rate = Decimal(default_tax_rate) if default_tax_rate else None
category.is_active = request.form.get('is_active') == 'on'
category.updated_at = datetime.utcnow()
if safe_commit(db):
flash(_('Expense category updated successfully'), 'success')
log_event('expense_category_updated', user_id=current_user.id, category_id=category.id)
track_event(current_user.id, 'expense_category.updated', {'category_id': category.id})
return redirect(url_for('expense_categories.view_category', category_id=category.id))
else:
flash(_('Error updating expense category'), 'error')
return redirect(url_for('expense_categories.edit_category', category_id=category_id))
except Exception as e:
from flask import current_app
current_app.logger.error(f"Error updating expense category: {e}")
flash(_('Error updating expense category'), 'error')
return redirect(url_for('expense_categories.edit_category', category_id=category_id))
@expense_categories_bp.route('/expense-categories/<int:category_id>/delete', methods=['POST'])
@login_required
@admin_or_permission_required('expense_categories.delete')
def delete_category(category_id):
"""Delete an expense category"""
category = ExpenseCategory.query.get_or_404(category_id)
try:
# Instead of deleting, just deactivate
category.is_active = False
category.updated_at = datetime.utcnow()
if safe_commit(db):
flash(_('Expense category deactivated successfully'), 'success')
log_event('expense_category_deleted', user_id=current_user.id, category_id=category_id)
track_event(current_user.id, 'expense_category.deleted', {'category_id': category_id})
else:
flash(_('Error deactivating expense category'), 'error')
except Exception as e:
from flask import current_app
current_app.logger.error(f"Error deactivating expense category: {e}")
flash(_('Error deactivating expense category'), 'error')
return redirect(url_for('expense_categories.list_categories'))
# API endpoints
@expense_categories_bp.route('/api/expense-categories', methods=['GET'])
@login_required
def api_list_categories():
"""API endpoint to list expense categories"""
categories = ExpenseCategory.get_active_categories()
return jsonify({
'categories': [category.to_dict() for category in categories],
'count': len(categories)
})
@expense_categories_bp.route('/api/expense-categories/<int:category_id>', methods=['GET'])
@login_required
def api_get_category(category_id):
"""API endpoint to get a single expense category"""
category = ExpenseCategory.query.get_or_404(category_id)
return jsonify(category.to_dict())
@expense_categories_bp.route('/api/expense-categories/budget-alerts', methods=['GET'])
@login_required
@admin_or_permission_required('expense_categories.view')
def api_budget_alerts():
"""API endpoint to get categories over budget threshold"""
period = request.args.get('period', 'monthly')
over_budget = ExpenseCategory.get_categories_over_budget(period)
return jsonify({
'period': period,
'alerts': [
{
'category': item['category'].to_dict(),
'utilization': item['utilization']
}
for item in over_budget
],
'count': len(over_budget)
})

View File

@@ -6,10 +6,12 @@ from app.models import Expense, Project, Client, User
from datetime import datetime, date, timedelta
from decimal import Decimal
from app.utils.db import safe_commit
from app.utils.ocr import scan_receipt, get_suggested_expense_data, is_ocr_available
import csv
import io
import os
from werkzeug.utils import secure_filename
import json
expenses_bp = Blueprint('expenses', __name__)
@@ -883,3 +885,264 @@ def api_get_expense(expense_id):
return jsonify(expense.to_dict())
@expenses_bp.route('/api/expenses/scan-receipt', methods=['POST'])
@login_required
def api_scan_receipt():
"""API endpoint to scan a receipt image using OCR"""
if not is_ocr_available():
return jsonify({
'error': 'OCR not available',
'message': 'Please install Tesseract OCR and pytesseract'
}), 503
# Check if file is in request
if 'receipt_file' not in request.files:
return jsonify({'error': 'No file provided'}), 400
file = request.files['receipt_file']
if not file or not file.filename:
return jsonify({'error': 'No file selected'}), 400
if not allowed_file(file.filename):
return jsonify({'error': 'Invalid file type'}), 400
try:
# Save file temporarily
filename = secure_filename(file.filename)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"temp_{timestamp}_{filename}"
temp_dir = os.path.join(current_app.root_path, '..', 'uploads', 'temp')
os.makedirs(temp_dir, exist_ok=True)
temp_path = os.path.join(temp_dir, filename)
file.save(temp_path)
# Scan receipt
ocr_lang = request.form.get('lang', 'eng')
receipt_data = scan_receipt(temp_path, lang=ocr_lang)
# Get suggested expense data
suggestions = get_suggested_expense_data(receipt_data)
# Clean up temp file
try:
os.remove(temp_path)
except Exception:
pass
# Log event
log_event('receipt_scanned', user_id=current_user.id)
track_event(current_user.id, 'receipt.scanned', {
'has_amount': bool(receipt_data.get('total')),
'has_vendor': bool(receipt_data.get('vendor')),
'has_date': bool(receipt_data.get('date'))
})
return jsonify({
'success': True,
'receipt_data': receipt_data,
'suggestions': suggestions
})
except Exception as e:
current_app.logger.error(f"Error scanning receipt: {e}")
return jsonify({
'error': 'Failed to scan receipt',
'message': str(e)
}), 500
@expenses_bp.route('/expenses/scan-receipt', methods=['GET', 'POST'])
@login_required
def scan_receipt_page():
"""Page for scanning receipts with OCR"""
if request.method == 'GET':
return render_template('expenses/scan_receipt.html', ocr_available=is_ocr_available())
# POST - handle receipt scanning
if not is_ocr_available():
flash(_('OCR is not available. Please contact your administrator.'), 'error')
return redirect(url_for('expenses.scan_receipt_page'))
if 'receipt_file' not in request.files:
flash(_('No file provided'), 'error')
return redirect(url_for('expenses.scan_receipt_page'))
file = request.files['receipt_file']
if not file or not file.filename:
flash(_('No file selected'), 'error')
return redirect(url_for('expenses.scan_receipt_page'))
if not allowed_file(file.filename):
flash(_('Invalid file type. Allowed types: png, jpg, jpeg, gif, pdf'), 'error')
return redirect(url_for('expenses.scan_receipt_page'))
try:
# Save file temporarily
filename = secure_filename(file.filename)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
temp_filename = f"temp_{timestamp}_{filename}"
temp_dir = os.path.join(current_app.root_path, '..', 'uploads', 'temp')
os.makedirs(temp_dir, exist_ok=True)
temp_path = os.path.join(temp_dir, temp_filename)
file.save(temp_path)
# Scan receipt
ocr_lang = request.form.get('lang', 'eng')
receipt_data = scan_receipt(temp_path, lang=ocr_lang)
# Get suggested expense data
suggestions = get_suggested_expense_data(receipt_data)
# Save receipt permanently
filename = f"{timestamp}_{filename}"
upload_dir = os.path.join(current_app.root_path, '..', UPLOAD_FOLDER)
os.makedirs(upload_dir, exist_ok=True)
permanent_path = os.path.join(upload_dir, filename)
os.rename(temp_path, permanent_path)
receipt_path = os.path.join(UPLOAD_FOLDER, filename)
# Store OCR data in session for use in expense creation
from flask import session
session['scanned_receipt'] = {
'receipt_path': receipt_path,
'receipt_data': receipt_data,
'suggestions': suggestions
}
# Log event
log_event('receipt_scanned', user_id=current_user.id)
track_event(current_user.id, 'receipt.scanned', {
'has_amount': bool(receipt_data.get('total')),
'has_vendor': bool(receipt_data.get('vendor')),
'has_date': bool(receipt_data.get('date'))
})
flash(_('Receipt scanned successfully! You can now create an expense with the extracted data.'), 'success')
return redirect(url_for('expenses.create_expense_from_scan'))
except Exception as e:
current_app.logger.error(f"Error scanning receipt: {e}")
flash(_('Error scanning receipt. Please try again or enter the expense manually.'), 'error')
return redirect(url_for('expenses.scan_receipt_page'))
@expenses_bp.route('/expenses/create-from-scan', methods=['GET', 'POST'])
@login_required
def create_expense_from_scan():
"""Create expense from scanned receipt data"""
from flask import session
scanned_data = session.get('scanned_receipt')
if not scanned_data:
flash(_('No scanned receipt data found. Please scan a receipt first.'), 'error')
return redirect(url_for('expenses.scan_receipt_page'))
if request.method == 'GET':
# Get data for form
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
clients = Client.get_active_clients()
categories = Expense.get_expense_categories()
payment_methods = Expense.get_payment_methods()
return render_template(
'expenses/create_from_scan.html',
expense=None,
projects=projects,
clients=clients,
categories=categories,
payment_methods=payment_methods,
suggestions=scanned_data.get('suggestions', {}),
receipt_data=scanned_data.get('receipt_data', {})
)
# POST - create the expense
try:
# Get form data (similar to create_expense)
title = request.form.get('title', '').strip()
description = request.form.get('description', '').strip()
category = request.form.get('category', '').strip()
amount = request.form.get('amount', '0').strip()
currency_code = request.form.get('currency_code', 'EUR').strip()
tax_amount = request.form.get('tax_amount', '0').strip()
expense_date = request.form.get('expense_date', '').strip()
# Validate required fields
if not all([title, category, amount, expense_date]):
flash(_('Please fill in all required fields'), 'error')
return redirect(url_for('expenses.create_expense_from_scan'))
# Parse date
try:
expense_date_obj = datetime.strptime(expense_date, '%Y-%m-%d').date()
except ValueError:
flash(_('Invalid date format'), 'error')
return redirect(url_for('expenses.create_expense_from_scan'))
# Parse amounts
try:
amount_decimal = Decimal(amount)
tax_amount_decimal = Decimal(tax_amount) if tax_amount else Decimal('0')
except (ValueError, Decimal.InvalidOperation):
flash(_('Invalid amount format'), 'error')
return redirect(url_for('expenses.create_expense_from_scan'))
# Create expense with OCR data
expense = Expense(
user_id=current_user.id,
title=title,
category=category,
amount=amount_decimal,
expense_date=expense_date_obj,
description=description,
currency_code=currency_code,
tax_amount=tax_amount_decimal,
project_id=request.form.get('project_id', type=int),
client_id=request.form.get('client_id', type=int),
payment_method=request.form.get('payment_method', '').strip(),
vendor=request.form.get('vendor', '').strip(),
receipt_number=request.form.get('receipt_number', '').strip(),
receipt_path=scanned_data.get('receipt_path'),
notes=request.form.get('notes', '').strip(),
tags=request.form.get('tags', '').strip(),
billable=request.form.get('billable') == 'on',
reimbursable=request.form.get('reimbursable') == 'on'
)
# Store OCR data as JSON
if scanned_data.get('receipt_data'):
# expense.ocr_data = json.dumps(scanned_data['receipt_data']) # Uncomment after migration
pass
db.session.add(expense)
if safe_commit(db):
# Clear scanned data from session
session.pop('scanned_receipt', None)
flash(_('Expense created successfully from scanned receipt'), 'success')
log_event('expense_created_from_scan', user_id=current_user.id, expense_id=expense.id)
track_event(current_user.id, 'expense.created_from_scan', {
'expense_id': expense.id,
'category': category,
'amount': float(amount_decimal)
})
return redirect(url_for('expenses.view_expense', expense_id=expense.id))
else:
flash(_('Error creating expense'), 'error')
return redirect(url_for('expenses.create_expense_from_scan'))
except Exception as e:
current_app.logger.error(f"Error creating expense from scan: {e}")
flash(_('Error creating expense'), 'error')
return redirect(url_for('expenses.create_expense_from_scan'))

466
app/routes/mileage.py Normal file
View File

@@ -0,0 +1,466 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file, current_app
from flask_babel import gettext as _
from flask_login import login_required, current_user
from app import db, log_event, track_event
from app.models import Mileage, Project, Client, Expense
from datetime import datetime, date, timedelta
from decimal import Decimal
from app.utils.db import safe_commit
import csv
import io
mileage_bp = Blueprint('mileage', __name__)
@mileage_bp.route('/mileage')
@login_required
def list_mileage():
"""List all mileage entries with filters"""
from app import track_page_view
track_page_view("mileage_list")
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 25, type=int)
# Filter parameters
status = request.args.get('status', '').strip()
project_id = request.args.get('project_id', type=int)
client_id = request.args.get('client_id', type=int)
start_date = request.args.get('start_date', '').strip()
end_date = request.args.get('end_date', '').strip()
search = request.args.get('search', '').strip()
# Build query
query = Mileage.query
# Non-admin users can only see their own mileage or mileage they approved
if not current_user.is_admin:
query = query.filter(
db.or_(
Mileage.user_id == current_user.id,
Mileage.approved_by == current_user.id
)
)
# Apply filters
if status:
query = query.filter(Mileage.status == status)
if project_id:
query = query.filter(Mileage.project_id == project_id)
if client_id:
query = query.filter(Mileage.client_id == client_id)
if start_date:
try:
start = datetime.strptime(start_date, '%Y-%m-%d').date()
query = query.filter(Mileage.trip_date >= start)
except ValueError:
pass
if end_date:
try:
end = datetime.strptime(end_date, '%Y-%m-%d').date()
query = query.filter(Mileage.trip_date <= end)
except ValueError:
pass
if search:
like = f"%{search}%"
query = query.filter(
db.or_(
Mileage.purpose.ilike(like),
Mileage.description.ilike(like),
Mileage.start_location.ilike(like),
Mileage.end_location.ilike(like)
)
)
# Paginate
mileage_pagination = query.order_by(Mileage.trip_date.desc()).paginate(
page=page,
per_page=per_page,
error_out=False
)
# Get filter options
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
clients = Client.get_active_clients()
# Calculate totals
total_distance = Mileage.get_total_distance(
user_id=None if current_user.is_admin else current_user.id,
start_date=datetime.strptime(start_date, '%Y-%m-%d').date() if start_date else None,
end_date=datetime.strptime(end_date, '%Y-%m-%d').date() if end_date else None
)
total_amount_query = db.session.query(
db.func.sum(Mileage.calculated_amount * db.case(
(Mileage.is_round_trip, 2),
else_=1
))
).filter(Mileage.status.in_(['approved', 'reimbursed']))
if not current_user.is_admin:
total_amount_query = total_amount_query.filter(Mileage.user_id == current_user.id)
total_amount = total_amount_query.scalar() or 0
return render_template(
'mileage/list.html',
mileage_entries=mileage_pagination.items,
pagination=mileage_pagination,
projects=projects,
clients=clients,
total_distance=total_distance,
total_amount=float(total_amount),
# Pass back filter values
status=status,
project_id=project_id,
client_id=client_id,
start_date=start_date,
end_date=end_date,
search=search
)
@mileage_bp.route('/mileage/create', methods=['GET', 'POST'])
@login_required
def create_mileage():
"""Create a new mileage entry"""
if request.method == 'GET':
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
clients = Client.get_active_clients()
default_rates = Mileage.get_default_rates()
return render_template(
'mileage/form.html',
mileage=None,
projects=projects,
clients=clients,
default_rates=default_rates
)
try:
# Get form data
trip_date = request.form.get('trip_date', '').strip()
purpose = request.form.get('purpose', '').strip()
description = request.form.get('description', '').strip()
start_location = request.form.get('start_location', '').strip()
end_location = request.form.get('end_location', '').strip()
distance_km = request.form.get('distance_km', '').strip()
rate_per_km = request.form.get('rate_per_km', '').strip()
# Validate required fields
if not all([trip_date, purpose, start_location, end_location, distance_km, rate_per_km]):
flash(_('Please fill in all required fields'), 'error')
return redirect(url_for('mileage.create_mileage'))
# Parse date
try:
trip_date_obj = datetime.strptime(trip_date, '%Y-%m-%d').date()
except ValueError:
flash(_('Invalid date format'), 'error')
return redirect(url_for('mileage.create_mileage'))
# Create mileage entry
mileage = Mileage(
user_id=current_user.id,
trip_date=trip_date_obj,
purpose=purpose,
start_location=start_location,
end_location=end_location,
distance_km=Decimal(distance_km),
rate_per_km=Decimal(rate_per_km),
description=description,
project_id=request.form.get('project_id', type=int),
client_id=request.form.get('client_id', type=int),
start_odometer=request.form.get('start_odometer'),
end_odometer=request.form.get('end_odometer'),
vehicle_type=request.form.get('vehicle_type'),
vehicle_description=request.form.get('vehicle_description'),
license_plate=request.form.get('license_plate'),
is_round_trip=request.form.get('is_round_trip') == 'on',
currency_code=request.form.get('currency_code', 'EUR'),
notes=request.form.get('notes')
)
db.session.add(mileage)
# Create expense if requested
if request.form.get('create_expense') == 'on':
expense = mileage.create_expense()
if expense:
db.session.add(expense)
if safe_commit(db):
flash(_('Mileage entry created successfully'), 'success')
log_event('mileage_created', user_id=current_user.id, mileage_id=mileage.id)
track_event(current_user.id, 'mileage.created', {
'mileage_id': mileage.id,
'distance_km': float(distance_km),
'amount': float(mileage.total_amount)
})
return redirect(url_for('mileage.view_mileage', mileage_id=mileage.id))
else:
flash(_('Error creating mileage entry'), 'error')
return redirect(url_for('mileage.create_mileage'))
except Exception as e:
current_app.logger.error(f"Error creating mileage entry: {e}")
flash(_('Error creating mileage entry'), 'error')
return redirect(url_for('mileage.create_mileage'))
@mileage_bp.route('/mileage/<int:mileage_id>')
@login_required
def view_mileage(mileage_id):
"""View mileage entry details"""
mileage = Mileage.query.get_or_404(mileage_id)
# Check permission
if not current_user.is_admin and mileage.user_id != current_user.id and mileage.approved_by != current_user.id:
flash(_('You do not have permission to view this mileage entry'), 'error')
return redirect(url_for('mileage.list_mileage'))
from app import track_page_view
track_page_view("mileage_detail", properties={'mileage_id': mileage_id})
return render_template('mileage/view.html', mileage=mileage)
@mileage_bp.route('/mileage/<int:mileage_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_mileage(mileage_id):
"""Edit a mileage entry"""
mileage = Mileage.query.get_or_404(mileage_id)
# Check permission
if not current_user.is_admin and mileage.user_id != current_user.id:
flash(_('You do not have permission to edit this mileage entry'), 'error')
return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id))
# Cannot edit approved or reimbursed entries without admin privileges
if not current_user.is_admin and mileage.status in ['approved', 'reimbursed']:
flash(_('Cannot edit approved or reimbursed mileage entries'), 'error')
return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id))
if request.method == 'GET':
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
clients = Client.get_active_clients()
default_rates = Mileage.get_default_rates()
return render_template(
'mileage/form.html',
mileage=mileage,
projects=projects,
clients=clients,
default_rates=default_rates
)
try:
# Update fields
trip_date = request.form.get('trip_date', '').strip()
mileage.trip_date = datetime.strptime(trip_date, '%Y-%m-%d').date()
mileage.purpose = request.form.get('purpose', '').strip()
mileage.description = request.form.get('description', '').strip()
mileage.start_location = request.form.get('start_location', '').strip()
mileage.end_location = request.form.get('end_location', '').strip()
mileage.distance_km = Decimal(request.form.get('distance_km', '0'))
mileage.rate_per_km = Decimal(request.form.get('rate_per_km', '0'))
mileage.calculated_amount = mileage.distance_km * mileage.rate_per_km
mileage.project_id = request.form.get('project_id', type=int)
mileage.client_id = request.form.get('client_id', type=int)
mileage.vehicle_type = request.form.get('vehicle_type')
mileage.vehicle_description = request.form.get('vehicle_description')
mileage.license_plate = request.form.get('license_plate')
mileage.is_round_trip = request.form.get('is_round_trip') == 'on'
mileage.currency_code = request.form.get('currency_code', 'EUR')
mileage.notes = request.form.get('notes')
mileage.updated_at = datetime.utcnow()
if safe_commit(db):
flash(_('Mileage entry updated successfully'), 'success')
log_event('mileage_updated', user_id=current_user.id, mileage_id=mileage.id)
track_event(current_user.id, 'mileage.updated', {'mileage_id': mileage.id})
return redirect(url_for('mileage.view_mileage', mileage_id=mileage.id))
else:
flash(_('Error updating mileage entry'), 'error')
return redirect(url_for('mileage.edit_mileage', mileage_id=mileage_id))
except Exception as e:
current_app.logger.error(f"Error updating mileage entry: {e}")
flash(_('Error updating mileage entry'), 'error')
return redirect(url_for('mileage.edit_mileage', mileage_id=mileage_id))
@mileage_bp.route('/mileage/<int:mileage_id>/delete', methods=['POST'])
@login_required
def delete_mileage(mileage_id):
"""Delete a mileage entry"""
mileage = Mileage.query.get_or_404(mileage_id)
# Check permission
if not current_user.is_admin and mileage.user_id != current_user.id:
flash(_('You do not have permission to delete this mileage entry'), 'error')
return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id))
try:
db.session.delete(mileage)
if safe_commit(db):
flash(_('Mileage entry deleted successfully'), 'success')
log_event('mileage_deleted', user_id=current_user.id, mileage_id=mileage_id)
track_event(current_user.id, 'mileage.deleted', {'mileage_id': mileage_id})
else:
flash(_('Error deleting mileage entry'), 'error')
except Exception as e:
current_app.logger.error(f"Error deleting mileage entry: {e}")
flash(_('Error deleting mileage entry'), 'error')
return redirect(url_for('mileage.list_mileage'))
@mileage_bp.route('/mileage/<int:mileage_id>/approve', methods=['POST'])
@login_required
def approve_mileage(mileage_id):
"""Approve a mileage entry"""
if not current_user.is_admin:
flash(_('Only administrators can approve mileage entries'), 'error')
return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id))
mileage = Mileage.query.get_or_404(mileage_id)
if mileage.status != 'pending':
flash(_('Only pending mileage entries can be approved'), 'error')
return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id))
try:
notes = request.form.get('approval_notes', '').strip()
mileage.approve(current_user.id, notes)
if safe_commit(db):
flash(_('Mileage entry approved successfully'), 'success')
log_event('mileage_approved', user_id=current_user.id, mileage_id=mileage_id)
track_event(current_user.id, 'mileage.approved', {'mileage_id': mileage_id})
else:
flash(_('Error approving mileage entry'), 'error')
except Exception as e:
current_app.logger.error(f"Error approving mileage entry: {e}")
flash(_('Error approving mileage entry'), 'error')
return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id))
@mileage_bp.route('/mileage/<int:mileage_id>/reject', methods=['POST'])
@login_required
def reject_mileage(mileage_id):
"""Reject a mileage entry"""
if not current_user.is_admin:
flash(_('Only administrators can reject mileage entries'), 'error')
return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id))
mileage = Mileage.query.get_or_404(mileage_id)
if mileage.status != 'pending':
flash(_('Only pending mileage entries can be rejected'), 'error')
return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id))
try:
reason = request.form.get('rejection_reason', '').strip()
if not reason:
flash(_('Rejection reason is required'), 'error')
return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id))
mileage.reject(current_user.id, reason)
if safe_commit(db):
flash(_('Mileage entry rejected'), 'success')
log_event('mileage_rejected', user_id=current_user.id, mileage_id=mileage_id)
track_event(current_user.id, 'mileage.rejected', {'mileage_id': mileage_id})
else:
flash(_('Error rejecting mileage entry'), 'error')
except Exception as e:
current_app.logger.error(f"Error rejecting mileage entry: {e}")
flash(_('Error rejecting mileage entry'), 'error')
return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id))
@mileage_bp.route('/mileage/<int:mileage_id>/reimburse', methods=['POST'])
@login_required
def mark_reimbursed(mileage_id):
"""Mark a mileage entry as reimbursed"""
if not current_user.is_admin:
flash(_('Only administrators can mark mileage entries as reimbursed'), 'error')
return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id))
mileage = Mileage.query.get_or_404(mileage_id)
if mileage.status != 'approved':
flash(_('Only approved mileage entries can be marked as reimbursed'), 'error')
return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id))
try:
mileage.mark_as_reimbursed()
if safe_commit(db):
flash(_('Mileage entry marked as reimbursed'), 'success')
log_event('mileage_reimbursed', user_id=current_user.id, mileage_id=mileage_id)
track_event(current_user.id, 'mileage.reimbursed', {'mileage_id': mileage_id})
else:
flash(_('Error marking mileage entry as reimbursed'), 'error')
except Exception as e:
current_app.logger.error(f"Error marking mileage entry as reimbursed: {e}")
flash(_('Error marking mileage entry as reimbursed'), 'error')
return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id))
# API endpoints
@mileage_bp.route('/api/mileage', methods=['GET'])
@login_required
def api_list_mileage():
"""API endpoint to list mileage entries"""
status = request.args.get('status', '').strip()
query = Mileage.query
if not current_user.is_admin:
query = query.filter_by(user_id=current_user.id)
if status:
query = query.filter(Mileage.status == status)
entries = query.order_by(Mileage.trip_date.desc()).all()
return jsonify({
'mileage': [entry.to_dict() for entry in entries],
'count': len(entries)
})
@mileage_bp.route('/api/mileage/<int:mileage_id>', methods=['GET'])
@login_required
def api_get_mileage(mileage_id):
"""API endpoint to get a single mileage entry"""
mileage = Mileage.query.get_or_404(mileage_id)
# Check permission
if not current_user.is_admin and mileage.user_id != current_user.id:
return jsonify({'error': 'Permission denied'}), 403
return jsonify(mileage.to_dict())
@mileage_bp.route('/api/mileage/default-rates', methods=['GET'])
@login_required
def api_get_default_rates():
"""API endpoint to get default mileage rates"""
return jsonify(Mileage.get_default_rates())

542
app/routes/per_diem.py Normal file
View File

@@ -0,0 +1,542 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app
from flask_babel import gettext as _
from flask_login import login_required, current_user
from app import db, log_event, track_event
from app.models import PerDiem, PerDiemRate, Project, Client
from datetime import datetime, date, time
from decimal import Decimal
from app.utils.db import safe_commit
from app.utils.permissions import admin_or_permission_required
per_diem_bp = Blueprint('per_diem', __name__)
@per_diem_bp.route('/per-diem')
@login_required
def list_per_diem():
"""List all per diem claims with filters"""
from app import track_page_view
track_page_view("per_diem_list")
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 25, type=int)
# Filter parameters
status = request.args.get('status', '').strip()
project_id = request.args.get('project_id', type=int)
client_id = request.args.get('client_id', type=int)
start_date = request.args.get('start_date', '').strip()
end_date = request.args.get('end_date', '').strip()
# Build query
query = PerDiem.query
# Non-admin users can only see their own claims
if not current_user.is_admin:
query = query.filter(
db.or_(
PerDiem.user_id == current_user.id,
PerDiem.approved_by == current_user.id
)
)
# Apply filters
if status:
query = query.filter(PerDiem.status == status)
if project_id:
query = query.filter(PerDiem.project_id == project_id)
if client_id:
query = query.filter(PerDiem.client_id == client_id)
if start_date:
try:
start = datetime.strptime(start_date, '%Y-%m-%d').date()
query = query.filter(PerDiem.start_date >= start)
except ValueError:
pass
if end_date:
try:
end = datetime.strptime(end_date, '%Y-%m-%d').date()
query = query.filter(PerDiem.end_date <= end)
except ValueError:
pass
# Paginate
per_diem_pagination = query.order_by(PerDiem.start_date.desc()).paginate(
page=page,
per_page=per_page,
error_out=False
)
# Get filter options
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
clients = Client.get_active_clients()
# Calculate totals
total_amount_query = db.session.query(
db.func.sum(PerDiem.calculated_amount)
).filter(PerDiem.status.in_(['approved', 'reimbursed']))
if not current_user.is_admin:
total_amount_query = total_amount_query.filter(PerDiem.user_id == current_user.id)
total_amount = total_amount_query.scalar() or 0
return render_template(
'per_diem/list.html',
per_diem_claims=per_diem_pagination.items,
pagination=per_diem_pagination,
projects=projects,
clients=clients,
total_amount=float(total_amount),
status=status,
project_id=project_id,
client_id=client_id,
start_date=start_date,
end_date=end_date
)
@per_diem_bp.route('/per-diem/create', methods=['GET', 'POST'])
@login_required
def create_per_diem():
"""Create a new per diem claim"""
if request.method == 'GET':
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
clients = Client.get_active_clients()
return render_template(
'per_diem/form.html',
per_diem=None,
projects=projects,
clients=clients
)
try:
# Get form data
trip_purpose = request.form.get('trip_purpose', '').strip()
start_date_str = request.form.get('start_date', '').strip()
end_date_str = request.form.get('end_date', '').strip()
country = request.form.get('country', '').strip()
city = request.form.get('city', '').strip()
# Validate required fields
if not all([trip_purpose, start_date_str, end_date_str, country]):
flash(_('Please fill in all required fields'), 'error')
return redirect(url_for('per_diem.create_per_diem'))
# Parse dates
try:
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date()
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
except ValueError:
flash(_('Invalid date format'), 'error')
return redirect(url_for('per_diem.create_per_diem'))
if start_date > end_date:
flash(_('Start date must be before end date'), 'error')
return redirect(url_for('per_diem.create_per_diem'))
# Parse times if provided
departure_time = None
return_time = None
departure_time_str = request.form.get('departure_time', '').strip()
return_time_str = request.form.get('return_time', '').strip()
if departure_time_str:
try:
departure_time = datetime.strptime(departure_time_str, '%H:%M').time()
except ValueError:
pass
if return_time_str:
try:
return_time = datetime.strptime(return_time_str, '%H:%M').time()
except ValueError:
pass
# Get or calculate full/half days
auto_calculate = request.form.get('auto_calculate_days') == 'on'
if auto_calculate:
days_calc = PerDiem.calculate_days_from_dates(start_date, end_date, departure_time, return_time)
full_days = days_calc['full_days']
half_days = days_calc['half_days']
else:
full_days = int(request.form.get('full_days', 0))
half_days = int(request.form.get('half_days', 0))
# Get applicable rate
rate = PerDiemRate.get_rate_for_location(country, city, start_date)
if not rate:
flash(_('No per diem rate found for this location. Please configure rates first.'), 'error')
return redirect(url_for('per_diem.create_per_diem'))
# Meal deductions
breakfast_provided = int(request.form.get('breakfast_provided', 0))
lunch_provided = int(request.form.get('lunch_provided', 0))
dinner_provided = int(request.form.get('dinner_provided', 0))
# Create per diem claim
per_diem = PerDiem(
user_id=current_user.id,
trip_purpose=trip_purpose,
start_date=start_date,
end_date=end_date,
country=country,
city=city,
full_day_rate=rate.full_day_rate,
half_day_rate=rate.half_day_rate,
description=request.form.get('description'),
project_id=request.form.get('project_id', type=int),
client_id=request.form.get('client_id', type=int),
per_diem_rate_id=rate.id,
departure_time=departure_time,
return_time=return_time,
full_days=full_days,
half_days=half_days,
breakfast_provided=breakfast_provided,
lunch_provided=lunch_provided,
dinner_provided=dinner_provided,
breakfast_deduction=rate.breakfast_rate or Decimal('0'),
lunch_deduction=rate.lunch_rate or Decimal('0'),
dinner_deduction=rate.dinner_rate or Decimal('0'),
currency_code=rate.currency_code,
notes=request.form.get('notes')
)
db.session.add(per_diem)
# Create expense if requested
if request.form.get('create_expense') == 'on':
expense = per_diem.create_expense()
if expense:
db.session.add(expense)
if safe_commit(db):
flash(_('Per diem claim created successfully'), 'success')
log_event('per_diem_created', user_id=current_user.id, per_diem_id=per_diem.id)
track_event(current_user.id, 'per_diem.created', {
'per_diem_id': per_diem.id,
'amount': float(per_diem.calculated_amount),
'days': per_diem.total_days
})
return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem.id))
else:
flash(_('Error creating per diem claim'), 'error')
return redirect(url_for('per_diem.create_per_diem'))
except Exception as e:
current_app.logger.error(f"Error creating per diem claim: {e}")
flash(_('Error creating per diem claim'), 'error')
return redirect(url_for('per_diem.create_per_diem'))
@per_diem_bp.route('/per-diem/<int:per_diem_id>')
@login_required
def view_per_diem(per_diem_id):
"""View per diem claim details"""
per_diem = PerDiem.query.get_or_404(per_diem_id)
# Check permission
if not current_user.is_admin and per_diem.user_id != current_user.id and per_diem.approved_by != current_user.id:
flash(_('You do not have permission to view this per diem claim'), 'error')
return redirect(url_for('per_diem.list_per_diem'))
from app import track_page_view
track_page_view("per_diem_detail", properties={'per_diem_id': per_diem_id})
return render_template('per_diem/view.html', per_diem=per_diem)
@per_diem_bp.route('/per-diem/<int:per_diem_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_per_diem(per_diem_id):
"""Edit a per diem claim"""
per_diem = PerDiem.query.get_or_404(per_diem_id)
# Check permission
if not current_user.is_admin and per_diem.user_id != current_user.id:
flash(_('You do not have permission to edit this per diem claim'), 'error')
return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id))
# Cannot edit approved or reimbursed claims without admin privileges
if not current_user.is_admin and per_diem.status in ['approved', 'reimbursed']:
flash(_('Cannot edit approved or reimbursed per diem claims'), 'error')
return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id))
if request.method == 'GET':
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
clients = Client.get_active_clients()
return render_template(
'per_diem/form.html',
per_diem=per_diem,
projects=projects,
clients=clients
)
try:
# Update fields
per_diem.trip_purpose = request.form.get('trip_purpose', '').strip()
per_diem.description = request.form.get('description', '').strip()
per_diem.start_date = datetime.strptime(request.form.get('start_date'), '%Y-%m-%d').date()
per_diem.end_date = datetime.strptime(request.form.get('end_date'), '%Y-%m-%d').date()
per_diem.country = request.form.get('country', '').strip()
per_diem.city = request.form.get('city', '').strip()
per_diem.project_id = request.form.get('project_id', type=int)
per_diem.client_id = request.form.get('client_id', type=int)
per_diem.full_days = int(request.form.get('full_days', 0))
per_diem.half_days = int(request.form.get('half_days', 0))
per_diem.breakfast_provided = int(request.form.get('breakfast_provided', 0))
per_diem.lunch_provided = int(request.form.get('lunch_provided', 0))
per_diem.dinner_provided = int(request.form.get('dinner_provided', 0))
per_diem.notes = request.form.get('notes')
per_diem.updated_at = datetime.utcnow()
# Recalculate amount
per_diem.recalculate_amount()
if safe_commit(db):
flash(_('Per diem claim updated successfully'), 'success')
log_event('per_diem_updated', user_id=current_user.id, per_diem_id=per_diem.id)
track_event(current_user.id, 'per_diem.updated', {'per_diem_id': per_diem.id})
return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem.id))
else:
flash(_('Error updating per diem claim'), 'error')
return redirect(url_for('per_diem.edit_per_diem', per_diem_id=per_diem_id))
except Exception as e:
current_app.logger.error(f"Error updating per diem claim: {e}")
flash(_('Error updating per diem claim'), 'error')
return redirect(url_for('per_diem.edit_per_diem', per_diem_id=per_diem_id))
@per_diem_bp.route('/per-diem/<int:per_diem_id>/delete', methods=['POST'])
@login_required
def delete_per_diem(per_diem_id):
"""Delete a per diem claim"""
per_diem = PerDiem.query.get_or_404(per_diem_id)
# Check permission
if not current_user.is_admin and per_diem.user_id != current_user.id:
flash(_('You do not have permission to delete this per diem claim'), 'error')
return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id))
try:
db.session.delete(per_diem)
if safe_commit(db):
flash(_('Per diem claim deleted successfully'), 'success')
log_event('per_diem_deleted', user_id=current_user.id, per_diem_id=per_diem_id)
track_event(current_user.id, 'per_diem.deleted', {'per_diem_id': per_diem_id})
else:
flash(_('Error deleting per diem claim'), 'error')
except Exception as e:
current_app.logger.error(f"Error deleting per diem claim: {e}")
flash(_('Error deleting per diem claim'), 'error')
return redirect(url_for('per_diem.list_per_diem'))
@per_diem_bp.route('/per-diem/<int:per_diem_id>/approve', methods=['POST'])
@login_required
def approve_per_diem(per_diem_id):
"""Approve a per diem claim"""
if not current_user.is_admin:
flash(_('Only administrators can approve per diem claims'), 'error')
return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id))
per_diem = PerDiem.query.get_or_404(per_diem_id)
if per_diem.status != 'pending':
flash(_('Only pending per diem claims can be approved'), 'error')
return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id))
try:
notes = request.form.get('approval_notes', '').strip()
per_diem.approve(current_user.id, notes)
if safe_commit(db):
flash(_('Per diem claim approved successfully'), 'success')
log_event('per_diem_approved', user_id=current_user.id, per_diem_id=per_diem_id)
track_event(current_user.id, 'per_diem.approved', {'per_diem_id': per_diem_id})
else:
flash(_('Error approving per diem claim'), 'error')
except Exception as e:
current_app.logger.error(f"Error approving per diem claim: {e}")
flash(_('Error approving per diem claim'), 'error')
return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id))
@per_diem_bp.route('/per-diem/<int:per_diem_id>/reject', methods=['POST'])
@login_required
def reject_per_diem(per_diem_id):
"""Reject a per diem claim"""
if not current_user.is_admin:
flash(_('Only administrators can reject per diem claims'), 'error')
return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id))
per_diem = PerDiem.query.get_or_404(per_diem_id)
if per_diem.status != 'pending':
flash(_('Only pending per diem claims can be rejected'), 'error')
return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id))
try:
reason = request.form.get('rejection_reason', '').strip()
if not reason:
flash(_('Rejection reason is required'), 'error')
return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id))
per_diem.reject(current_user.id, reason)
if safe_commit(db):
flash(_('Per diem claim rejected'), 'success')
log_event('per_diem_rejected', user_id=current_user.id, per_diem_id=per_diem_id)
track_event(current_user.id, 'per_diem.rejected', {'per_diem_id': per_diem_id})
else:
flash(_('Error rejecting per diem claim'), 'error')
except Exception as e:
current_app.logger.error(f"Error rejecting per diem claim: {e}")
flash(_('Error rejecting per diem claim'), 'error')
return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id))
# Per Diem Rates Management
@per_diem_bp.route('/per-diem/rates')
@login_required
@admin_or_permission_required('per_diem_rates.view')
def list_rates():
"""List all per diem rates"""
from app import track_page_view
track_page_view("per_diem_rates_list")
rates = PerDiemRate.query.filter_by(is_active=True).order_by(
PerDiemRate.country, PerDiemRate.city, PerDiemRate.effective_from.desc()
).all()
return render_template('per_diem/rates_list.html', rates=rates)
@per_diem_bp.route('/per-diem/rates/create', methods=['GET', 'POST'])
@login_required
@admin_or_permission_required('per_diem_rates.create')
def create_rate():
"""Create a new per diem rate"""
if request.method == 'GET':
return render_template('per_diem/rate_form.html', rate=None)
try:
country = request.form.get('country', '').strip()
full_day_rate = request.form.get('full_day_rate', '').strip()
half_day_rate = request.form.get('half_day_rate', '').strip()
effective_from = request.form.get('effective_from', '').strip()
if not all([country, full_day_rate, half_day_rate, effective_from]):
flash(_('Please fill in all required fields'), 'error')
return redirect(url_for('per_diem.create_rate'))
rate = PerDiemRate(
country=country,
city=request.form.get('city'),
full_day_rate=Decimal(full_day_rate),
half_day_rate=Decimal(half_day_rate),
breakfast_rate=request.form.get('breakfast_rate') or None,
lunch_rate=request.form.get('lunch_rate') or None,
dinner_rate=request.form.get('dinner_rate') or None,
incidental_rate=request.form.get('incidental_rate') or None,
currency_code=request.form.get('currency_code', 'EUR'),
effective_from=datetime.strptime(effective_from, '%Y-%m-%d').date(),
effective_to=datetime.strptime(request.form.get('effective_to'), '%Y-%m-%d').date() if request.form.get('effective_to') else None,
notes=request.form.get('notes')
)
db.session.add(rate)
if safe_commit(db):
flash(_('Per diem rate created successfully'), 'success')
log_event('per_diem_rate_created', user_id=current_user.id, rate_id=rate.id)
return redirect(url_for('per_diem.list_rates'))
else:
flash(_('Error creating per diem rate'), 'error')
return redirect(url_for('per_diem.create_rate'))
except Exception as e:
current_app.logger.error(f"Error creating per diem rate: {e}")
flash(_('Error creating per diem rate'), 'error')
return redirect(url_for('per_diem.create_rate'))
# API endpoints
@per_diem_bp.route('/api/per-diem', methods=['GET'])
@login_required
def api_list_per_diem():
"""API endpoint to list per diem claims"""
status = request.args.get('status', '').strip()
query = PerDiem.query
if not current_user.is_admin:
query = query.filter_by(user_id=current_user.id)
if status:
query = query.filter(PerDiem.status == status)
claims = query.order_by(PerDiem.start_date.desc()).all()
return jsonify({
'per_diem': [claim.to_dict() for claim in claims],
'count': len(claims)
})
@per_diem_bp.route('/api/per-diem/rates/search', methods=['GET'])
@login_required
def api_search_rates():
"""API endpoint to search for per diem rates"""
country = request.args.get('country', '').strip()
city = request.args.get('city', '').strip()
date_str = request.args.get('date', '').strip()
if not country:
return jsonify({'error': 'Country is required'}), 400
search_date = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else date.today()
rate = PerDiemRate.get_rate_for_location(country, city, search_date)
if rate:
return jsonify(rate.to_dict())
else:
return jsonify({'error': 'No rate found for this location'}), 404
@per_diem_bp.route('/api/per-diem/calculate-days', methods=['POST'])
@login_required
def api_calculate_days():
"""API endpoint to calculate full/half days from dates and times"""
data = request.get_json()
try:
start_date = datetime.strptime(data['start_date'], '%Y-%m-%d').date()
end_date = datetime.strptime(data['end_date'], '%Y-%m-%d').date()
departure_time = datetime.strptime(data.get('departure_time', ''), '%H:%M').time() if data.get('departure_time') else None
return_time = datetime.strptime(data.get('return_time', ''), '%H:%M').time() if data.get('return_time') else None
result = PerDiem.calculate_days_from_dates(start_date, end_date, departure_time, return_time)
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 400

View File

@@ -102,7 +102,7 @@
{% set work_open = ep.startswith('projects.') or ep.startswith('clients.') or ep.startswith('tasks.') or ep.startswith('timer.') or ep.startswith('kanban.') or ep.startswith('time_entry_templates.') %}
{% set finance_open = ep.startswith('reports.') or ep.startswith('invoices.') or ep.startswith('payments.') or ep.startswith('expenses.') %}
{% set analytics_open = ep.startswith('analytics.') %}
{% set admin_open = ep.startswith('admin.') or ep.startswith('permissions.') %}
{% set admin_open = ep.startswith('admin.') or ep.startswith('permissions.') or (ep.startswith('expense_categories.') and current_user.is_admin) or (ep.startswith('per_diem.list_rates') and current_user.is_admin) %}
<div class="flex items-center justify-between mb-4">
<h2 class="text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider sidebar-label">{{ _('Navigation') }}</h2>
<button id="sidebarCollapseBtn" class="p-1.5 rounded hover:bg-background-light dark:hover:bg-background-dark" aria-label="Toggle sidebar" title="Toggle sidebar">
@@ -223,6 +223,12 @@
<li>
<a class="block px-2 py-1 rounded {% if ep == 'admin.pdf_layout' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.pdf_layout') }}">{{ _('PDF Layout') }}</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if ep == 'expense_categories.list_categories' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('expense_categories.list_categories') }}">{{ _('Expense Categories') }}</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if ep == 'per_diem.list_rates' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('per_diem.list_rates') }}">{{ _('Per Diem Rates') }}</a>
</li>
{% endif %}
{% if current_user.is_admin or has_permission('view_system_info') %}
<li>

View File

@@ -0,0 +1,196 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Expense Categories', 'url': url_for('expense_categories.list_categories')},
{'text': 'Edit' if category else 'New'}
] %}
{{ page_header(
icon_class='fas fa-tags',
title_text=('Edit Expense Category' if category else 'New Expense Category'),
subtitle_text=('Update category details' if category else 'Create a new expense category'),
breadcrumbs=breadcrumbs
) }}
<div class="max-w-4xl mx-auto">
<form method="POST" class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Basic Information -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-info-circle mr-2"></i>Basic Information
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Category Name <span class="text-red-500">*</span>
</label>
<input type="text" name="name" id="name" required
value="{{ category.name if category else '' }}"
placeholder="e.g., Travel, Meals, Office Supplies"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="code" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Code
</label>
<input type="text" name="code" id="code"
value="{{ category.code if category else '' }}"
placeholder="e.g., TRAVEL, MEALS"
maxlength="20"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div class="md:col-span-2">
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Description
</label>
<textarea name="description" id="description" rows="3"
placeholder="Brief description of this category..."
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">{{ category.description if category else '' }}</textarea>
</div>
<div>
<label for="color" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Color
</label>
<input type="color" name="color" id="color"
value="{{ category.color if category else '#3B82F6' }}"
class="h-10 w-full border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary">
</div>
<div>
<label for="icon" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Icon (Font Awesome class)
</label>
<input type="text" name="icon" id="icon"
value="{{ category.icon if category else '' }}"
placeholder="e.g., fa-plane, fa-utensils"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
</div>
</div>
<!-- Budget Settings -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-chart-pie mr-2"></i>Budget Settings
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="monthly_budget" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Monthly Budget
</label>
<input type="number" name="monthly_budget" id="monthly_budget" step="0.01"
value="{{ category.monthly_budget if category and category.monthly_budget else '' }}"
placeholder="0.00"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="quarterly_budget" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Quarterly Budget
</label>
<input type="number" name="quarterly_budget" id="quarterly_budget" step="0.01"
value="{{ category.quarterly_budget if category and category.quarterly_budget else '' }}"
placeholder="0.00"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="yearly_budget" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Yearly Budget
</label>
<input type="number" name="yearly_budget" id="yearly_budget" step="0.01"
value="{{ category.yearly_budget if category and category.yearly_budget else '' }}"
placeholder="0.00"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="budget_threshold_percent" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Alert Threshold (%)
</label>
<input type="number" name="budget_threshold_percent" id="budget_threshold_percent"
value="{{ category.budget_threshold_percent if category else '80' }}"
min="0" max="100"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<p class="text-xs text-gray-500 mt-1">Alert when budget utilization reaches this percentage</p>
</div>
</div>
</div>
<!-- Category Settings -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-cog mr-2"></i>Category Settings
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="default_tax_rate" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Default Tax Rate (%)
</label>
<input type="number" name="default_tax_rate" id="default_tax_rate" step="0.01"
value="{{ category.default_tax_rate if category and category.default_tax_rate else '' }}"
placeholder="e.g., 19.00"
min="0" max="100"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
</div>
<div class="mt-4 space-y-3">
<div class="flex items-center">
<input type="checkbox" name="requires_receipt" id="requires_receipt"
{% if not category or category.requires_receipt %}checked{% endif %}
class="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded">
<label for="requires_receipt" class="ml-2 text-sm text-gray-700 dark:text-gray-300">
<strong>Requires Receipt</strong>
<span class="block text-xs text-gray-500">Expenses in this category must have an attached receipt</span>
</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="requires_approval" id="requires_approval"
{% if not category or category.requires_approval %}checked{% endif %}
class="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded">
<label for="requires_approval" class="ml-2 text-sm text-gray-700 dark:text-gray-300">
<strong>Requires Approval</strong>
<span class="block text-xs text-gray-500">Expenses in this category must be approved by an administrator</span>
</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="is_active" id="is_active"
{% if not category or category.is_active %}checked{% endif %}
class="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded">
<label for="is_active" class="ml-2 text-sm text-gray-700 dark:text-gray-300">
<strong>Active</strong>
<span class="block text-xs text-gray-500">Only active categories are available for selection</span>
</label>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<a href="{{ url_for('expense_categories.list_categories') }}"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<i class="fas fa-times mr-2"></i>Cancel
</a>
<button type="submit" class="bg-primary text-white px-6 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-save mr-2"></i>{{ 'Update Category' if category else 'Create Category' }}
</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,160 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header, breadcrumb_nav %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Expense Categories'}
] %}
{{ page_header(
icon_class='fas fa-tags',
title_text='Expense Categories',
subtitle_text='Manage expense categories and budgets',
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("expense_categories.create_category") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>New Category</a>'
) }}
<!-- Summary Stats -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Total Categories</p>
<p class="text-2xl font-bold">{{ categories|length }}</p>
</div>
<div class="text-primary text-3xl">
<i class="fas fa-tags"></i>
</div>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Active Categories</p>
<p class="text-2xl font-bold">{{ categories|selectattr('is_active')|list|length }}</p>
</div>
<div class="text-green-500 text-3xl">
<i class="fas fa-check-circle"></i>
</div>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">With Budgets</p>
<p class="text-2xl font-bold">{{ categories|selectattr('monthly_budget')|list|length }}</p>
</div>
<div class="text-blue-500 text-3xl">
<i class="fas fa-chart-pie"></i>
</div>
</div>
</div>
</div>
<!-- Categories Table -->
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Category</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Code</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Monthly Budget</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Utilization</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{% if categories %}
{% for category in categories %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-6 py-4">
<div class="flex items-center">
{% if category.icon %}
<div class="flex-shrink-0 h-10 w-10 flex items-center justify-center rounded-full" style="{% if category.color %}background-color: {{ category.color }}20;{% else %}background-color: #f3f4f6;{% endif %}">
<i class="{{ category.icon }} {% if category.color %}text-[{{ category.color }}]{% else %}text-gray-600{% endif %}"></i>
</div>
{% endif %}
<div class="ml-4">
<a href="{{ url_for('expense_categories.view_category', category_id=category.id) }}" class="text-primary hover:underline font-medium">
{{ category.name }}
</a>
{% if category.description %}
<div class="text-xs text-gray-500">{{ category.description[:50] }}{% if category.description|length > 50 %}...{% endif %}</div>
{% endif %}
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
{% if category.code %}
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">
{{ category.code }}
</span>
{% else %}
<span class="text-gray-400">-</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
{% if category.monthly_budget %}
€{{ '%.2f'|format(category.monthly_budget) }}
{% else %}
<span class="text-gray-400">No budget</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
{% set util = category.monthly_utilization if category.monthly_utilization is not none else None %}
{% if util is not none %}
<div class="flex items-center">
<div class="w-24 bg-gray-200 dark:bg-gray-700 rounded-full h-2 mr-2">
<div class="{% if util >= (category.budget_threshold_percent or 80) %}bg-red-500{% elif util >= (category.budget_threshold_percent or 80) * 0.8 %}bg-yellow-500{% else %}bg-green-500{% endif %} h-2 rounded-full" style="width: {{ [util, 100]|min }}%"></div>
</div>
<span class="{% if util >= (category.budget_threshold_percent or 80) %}text-red-600{% elif util >= (category.budget_threshold_percent or 80) * 0.8 %}text-yellow-600{% else %}text-green-600{% endif %}">{{ util }}%</span>
</div>
{% else %}
<span class="text-gray-400">-</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
{% if category.is_active %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Active
</span>
{% else %}
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200">
Inactive
</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end gap-2">
<a href="{{ url_for('expense_categories.view_category', category_id=category.id) }}" class="text-primary hover:text-primary/80" title="View">
<i class="fas fa-eye"></i>
</a>
<a href="{{ url_for('expense_categories.edit_category', category_id=category.id) }}" class="text-blue-600 hover:text-blue-800" title="Edit">
<i class="fas fa-edit"></i>
</a>
</div>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="6" class="px-6 py-8 text-center text-gray-500">
<i class="fas fa-tags text-4xl mb-2 opacity-50"></i>
<p>No expense categories found</p>
<a href="{{ url_for('expense_categories.create_category') }}" class="text-primary hover:underline mt-2 inline-block">
Create your first category
</a>
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,208 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Expense Categories', 'url': url_for('expense_categories.list_categories')},
{'text': category.name}
] %}
{{ page_header(
icon_class='fas fa-tags',
title_text=category.name,
subtitle_text=category.description if category.description else 'Expense Category Details',
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("expense_categories.edit_category", category_id=category.id) + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-edit mr-2"></i>Edit Category</a>'
) }}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Category Details -->
<div class="lg:col-span-2 space-y-6">
<!-- Basic Information -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-info-circle mr-2"></i>Basic Information
</h3>
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Category Name</p>
<div class="flex items-center">
{% if category.icon %}
<div class="flex-shrink-0 h-10 w-10 flex items-center justify-center rounded-full mr-3" style="{% if category.color %}background-color: {{ category.color }}20;{% else %}background-color: #f3f4f6;{% endif %}">
<i class="{{ category.icon }} {% if category.color %}text-[{{ category.color }}]{% else %}text-gray-600{% endif %}"></i>
</div>
{% endif %}
<p class="font-semibold">{{ category.name }}</p>
</div>
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Code</p>
<p class="font-semibold">{{ category.code if category.code else '-' }}</p>
</div>
<div class="col-span-2">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Description</p>
<p class="font-semibold">{{ category.description if category.description else '-' }}</p>
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Status</p>
{% if category.is_active %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Active
</span>
{% else %}
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200">
Inactive
</span>
{% endif %}
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Default Tax Rate</p>
<p class="font-semibold">{{ '%.2f'|format(category.default_tax_rate) if category.default_tax_rate else '-' }}%</p>
</div>
</div>
</div>
<!-- Budget Information -->
{% if category.monthly_budget or category.quarterly_budget or category.yearly_budget %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-chart-pie mr-2"></i>Budget & Utilization
</h3>
<div class="space-y-4">
{% if category.monthly_budget %}
<div>
<div class="flex justify-between items-center mb-2">
<span class="text-sm font-medium">Monthly Budget</span>
<span class="text-sm font-semibold">€{{ '%.2f'|format(category.monthly_budget) }}</span>
</div>
{% if monthly_utilization is defined %}
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3">
<div class="{% if monthly_utilization >= category.budget_threshold_percent %}bg-red-500{% elif monthly_utilization >= category.budget_threshold_percent * 0.8 %}bg-yellow-500{% else %}bg-green-500{% endif %} h-3 rounded-full flex items-center justify-center text-xs text-white font-semibold" style="width: {{ [monthly_utilization, 100]|min }}%">
{{ monthly_utilization }}%
</div>
</div>
{% endif %}
</div>
{% endif %}
{% if category.quarterly_budget %}
<div>
<div class="flex justify-between items-center mb-2">
<span class="text-sm font-medium">Quarterly Budget</span>
<span class="text-sm font-semibold">€{{ '%.2f'|format(category.quarterly_budget) }}</span>
</div>
{% if quarterly_utilization is defined %}
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3">
<div class="{% if quarterly_utilization >= category.budget_threshold_percent %}bg-red-500{% elif quarterly_utilization >= category.budget_threshold_percent * 0.8 %}bg-yellow-500{% else %}bg-green-500{% endif %} h-3 rounded-full flex items-center justify-center text-xs text-white font-semibold" style="width: {{ [quarterly_utilization, 100]|min }}%">
{{ quarterly_utilization }}%
</div>
</div>
{% endif %}
</div>
{% endif %}
{% if category.yearly_budget %}
<div>
<div class="flex justify-between items-center mb-2">
<span class="text-sm font-medium">Yearly Budget</span>
<span class="text-sm font-semibold">€{{ '%.2f'|format(category.yearly_budget) }}</span>
</div>
{% if yearly_utilization is defined %}
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3">
<div class="{% if yearly_utilization >= category.budget_threshold_percent %}bg-red-500{% elif yearly_utilization >= category.budget_threshold_percent * 0.8 %}bg-yellow-500{% else %}bg-green-500{% endif %} h-3 rounded-full flex items-center justify-center text-xs text-white font-semibold" style="width: {{ [yearly_utilization, 100]|min }}%">
{{ yearly_utilization }}%
</div>
</div>
{% endif %}
</div>
{% endif %}
<div class="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<p class="text-sm text-blue-800 dark:text-blue-200">
<i class="fas fa-info-circle mr-1"></i>
<strong>Alert Threshold:</strong> {{ category.budget_threshold_percent }}%
</p>
</div>
</div>
</div>
{% endif %}
</div>
<!-- Sidebar -->
<div class="space-y-6">
<!-- Settings -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-cog mr-2"></i>Settings
</h3>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm">Requires Receipt</span>
{% if category.requires_receipt %}
<span class="text-green-600"><i class="fas fa-check-circle"></i> Yes</span>
{% else %}
<span class="text-gray-500"><i class="fas fa-times-circle"></i> No</span>
{% endif %}
</div>
<div class="flex items-center justify-between">
<span class="text-sm">Requires Approval</span>
{% if category.requires_approval %}
<span class="text-green-600"><i class="fas fa-check-circle"></i> Yes</span>
{% else %}
<span class="text-gray-500"><i class="fas fa-times-circle"></i> No</span>
{% endif %}
</div>
</div>
</div>
<!-- Metadata -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-clock mr-2"></i>Metadata
</h3>
<div class="space-y-3">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Created</p>
<p class="font-semibold text-sm">{{ category.created_at.strftime('%Y-%m-%d %H:%M') if category.created_at else '-' }}</p>
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Last Updated</p>
<p class="font-semibold text-sm">{{ category.updated_at.strftime('%Y-%m-%d %H:%M') if category.updated_at else '-' }}</p>
</div>
</div>
</div>
<!-- Actions -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-tools mr-2"></i>Actions
</h3>
<div class="space-y-2">
<a href="{{ url_for('expense_categories.edit_category', category_id=category.id) }}" class="block w-full bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors text-center">
<i class="fas fa-edit mr-2"></i>Edit Category
</a>
<form method="POST" action="{{ url_for('expense_categories.delete_category', category_id=category.id) }}" onsubmit="return confirm('Are you sure you want to deactivate this category?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="block w-full bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors">
<i class="fas fa-ban mr-2"></i>Deactivate
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -11,7 +11,12 @@
title_text='Expenses',
subtitle_text='Track and manage business expenses',
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("expenses.create_expense") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>New Expense</a>'
actions_html=''
+ '<div class="flex gap-2">'
+ '<a href="' + url_for("expenses.create_expense") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>New Expense</a>'
+ '<a href="' + url_for("mileage.create_mileage") + '" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"><i class="fas fa-car mr-2"></i>New Mileage</a>'
+ '<a href="' + url_for("per_diem.create_per_diem") + '" class="bg-emerald-600 text-white px-4 py-2 rounded-lg hover:bg-emerald-700 transition-colors"><i class="fas fa-money-bill-alt mr-2"></i>New Per Diem</a>'
+ '</div>'
) }}
<!-- Summary Stats -->

View File

@@ -0,0 +1,309 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Mileage', 'url': url_for('mileage.list_mileage')},
{'text': 'Edit' if mileage else 'New'}
] %}
{{ page_header(
icon_class='fas fa-car',
title_text=('Edit Mileage Entry' if mileage else 'New Mileage Entry'),
subtitle_text=('Update mileage details' if mileage else 'Record a new vehicle mileage entry'),
breadcrumbs=breadcrumbs
) }}
<div class="max-w-4xl mx-auto">
<form method="POST" class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Trip Information -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-info-circle mr-2"></i>Trip Information
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="trip_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Trip Date <span class="text-red-500">*</span>
</label>
<input type="date" name="trip_date" id="trip_date" required
value="{{ mileage.trip_date.strftime('%Y-%m-%d') if mileage else '' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="currency_code" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Currency
</label>
<select name="currency_code" id="currency_code"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="EUR" {% if not mileage or mileage.currency_code == 'EUR' %}selected{% endif %}>EUR</option>
<option value="USD" {% if mileage and mileage.currency_code == 'USD' %}selected{% endif %}>USD</option>
<option value="GBP" {% if mileage and mileage.currency_code == 'GBP' %}selected{% endif %}>GBP</option>
</select>
</div>
<div class="md:col-span-2">
<label for="purpose" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Purpose <span class="text-red-500">*</span>
</label>
<input type="text" name="purpose" id="purpose" required
value="{{ mileage.purpose if mileage else '' }}"
placeholder="e.g., Client meeting, Site visit"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div class="md:col-span-2">
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Description
</label>
<textarea name="description" id="description" rows="2"
placeholder="Additional details..."
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">{{ mileage.description if mileage else '' }}</textarea>
</div>
</div>
</div>
<!-- Route Information -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-route mr-2"></i>Route Details
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="start_location" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Start Location <span class="text-red-500">*</span>
</label>
<input type="text" name="start_location" id="start_location" required
value="{{ mileage.start_location if mileage else '' }}"
placeholder="e.g., Office, Home"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="end_location" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
End Location <span class="text-red-500">*</span>
</label>
<input type="text" name="end_location" id="end_location" required
value="{{ mileage.end_location if mileage else '' }}"
placeholder="e.g., Client site, Airport"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="distance_km" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Distance (km) <span class="text-red-500">*</span>
</label>
<input type="number" name="distance_km" id="distance_km" step="0.01" required
value="{{ mileage.distance_km if mileage else '' }}"
placeholder="0.00"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="rate_per_km" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Rate per km <span class="text-red-500">*</span>
</label>
<input type="number" name="rate_per_km" id="rate_per_km" step="0.01" required
value="{{ mileage.rate_per_km if mileage else (default_rates.car if default_rates else '0.30') }}"
placeholder="0.30"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<p class="text-xs text-gray-500 mt-1">Standard rate: €0.30/km</p>
</div>
<div>
<label for="start_odometer" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Start Odometer (optional)
</label>
<input type="number" name="start_odometer" id="start_odometer" step="1"
value="{{ mileage.start_odometer if mileage and mileage.start_odometer else '' }}"
placeholder="e.g., 12345"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="end_odometer" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
End Odometer (optional)
</label>
<input type="number" name="end_odometer" id="end_odometer" step="1"
value="{{ mileage.end_odometer if mileage and mileage.end_odometer else '' }}"
placeholder="e.g., 12400"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div class="md:col-span-2 flex items-center">
<input type="checkbox" name="is_round_trip" id="is_round_trip"
{% if mileage and mileage.is_round_trip %}checked{% endif %}
class="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded">
<label for="is_round_trip" class="ml-2 text-sm text-gray-700 dark:text-gray-300">
<strong>Round Trip</strong>
<span class="block text-xs text-gray-500">Double the distance for return journey</span>
</label>
</div>
</div>
<div class="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<p class="text-sm text-blue-800 dark:text-blue-200">
<i class="fas fa-calculator mr-1"></i>
<strong>Calculated Amount:</strong> <span id="calculated_amount">0.00</span> <span id="amount_currency">EUR</span>
</p>
</div>
</div>
<!-- Vehicle Information -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-car mr-2"></i>Vehicle Information
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="vehicle_type" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Vehicle Type
</label>
<select name="vehicle_type" id="vehicle_type"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="">Select Type</option>
<option value="car" {% if mileage and mileage.vehicle_type == 'car' %}selected{% endif %}>Car</option>
<option value="motorcycle" {% if mileage and mileage.vehicle_type == 'motorcycle' %}selected{% endif %}>Motorcycle</option>
<option value="van" {% if mileage and mileage.vehicle_type == 'van' %}selected{% endif %}>Van</option>
<option value="truck" {% if mileage and mileage.vehicle_type == 'truck' %}selected{% endif %}>Truck</option>
</select>
</div>
<div>
<label for="vehicle_description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Vehicle Make/Model
</label>
<input type="text" name="vehicle_description" id="vehicle_description"
value="{{ mileage.vehicle_description if mileage and mileage.vehicle_description else '' }}"
placeholder="e.g., VW Golf"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="license_plate" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
License Plate
</label>
<input type="text" name="license_plate" id="license_plate"
value="{{ mileage.license_plate if mileage and mileage.license_plate else '' }}"
placeholder="e.g., ABC-123"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
</div>
</div>
<!-- Project & Client -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-project-diagram mr-2"></i>Association
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Project
</label>
<select name="project_id" id="project_id"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="">No Project</option>
{% for project in projects %}
<option value="{{ project.id }}" {% if mileage and mileage.project_id == project.id %}selected{% endif %}>
{{ project.name }}
</option>
{% endfor %}
</select>
</div>
<div>
<label for="client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Client
</label>
<select name="client_id" id="client_id"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="">No Client</option>
{% for client in clients %}
<option value="{{ client.id }}" {% if mileage and mileage.client_id == client.id %}selected{% endif %}>
{{ client.name }}
</option>
{% endfor %}
</select>
</div>
</div>
</div>
<!-- Notes -->
<div class="mb-6">
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Notes
</label>
<textarea name="notes" id="notes" rows="3"
placeholder="Additional notes..."
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">{{ mileage.notes if mileage else '' }}</textarea>
</div>
<!-- Create Expense Option -->
{% if not mileage %}
<div class="mb-6">
<div class="flex items-center">
<input type="checkbox" name="create_expense" id="create_expense" checked
class="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded">
<label for="create_expense" class="ml-2 text-sm text-gray-700 dark:text-gray-300">
<strong>Create linked expense entry</strong>
<span class="block text-xs text-gray-500">Automatically create an expense record for this mileage</span>
</label>
</div>
</div>
{% endif %}
<!-- Actions -->
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<a href="{{ url_for('mileage.view_mileage', mileage_id=mileage.id) if mileage else url_for('mileage.list_mileage') }}"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<i class="fas fa-times mr-2"></i>Cancel
</a>
<button type="submit" class="bg-primary text-white px-6 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-save mr-2"></i>{{ 'Update Mileage' if mileage else 'Create Mileage Entry' }}
</button>
</div>
</form>
</div>
<script>
// Calculate and display amount
function updateAmount() {
const distance = parseFloat(document.getElementById('distance_km').value) || 0;
const rate = parseFloat(document.getElementById('rate_per_km').value) || 0;
const isRoundTrip = document.getElementById('is_round_trip').checked;
const currency = document.getElementById('currency_code').value;
let amount = distance * rate;
if (isRoundTrip) {
amount *= 2;
}
document.getElementById('calculated_amount').textContent = amount.toFixed(2);
document.getElementById('amount_currency').textContent = currency;
}
document.getElementById('distance_km').addEventListener('input', updateAmount);
document.getElementById('rate_per_km').addEventListener('input', updateAmount);
document.getElementById('is_round_trip').addEventListener('change', updateAmount);
document.getElementById('currency_code').addEventListener('change', updateAmount);
// Set default trip date to today if creating new
{% if not mileage %}
document.getElementById('trip_date').value = new Date().toISOString().split('T')[0];
{% endif %}
// Initial calculation
updateAmount();
</script>
{% endblock %}

View File

@@ -0,0 +1,214 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Mileage'}
] %}
{{ page_header(
icon_class='fas fa-car',
title_text='Mileage Tracking',
subtitle_text='Track and manage vehicle mileage expenses',
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("mileage.create_mileage") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>New Mileage Entry</a>'
) }}
<!-- Summary Stats -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Total Distance</p>
<p class="text-2xl font-bold">{{ '%.2f'|format(total_distance) }} km</p>
</div>
<div class="text-primary text-3xl">
<i class="fas fa-route"></i>
</div>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Total Amount</p>
<p class="text-2xl font-bold">€{{ '%.2f'|format(total_amount) }}</p>
</div>
<div class="text-green-500 text-3xl">
<i class="fas fa-euro-sign"></i>
</div>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Total Entries</p>
<p class="text-2xl font-bold">{{ pagination.total if pagination else (mileage_entries|length) }}</p>
</div>
<div class="text-blue-500 text-3xl">
<i class="fas fa-list"></i>
</div>
</div>
</div>
</div>
<!-- Filter Form -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<h2 class="text-lg font-semibold mb-4">Filter Mileage</h2>
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label for="search" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Search</label>
<input type="text" name="search" id="search" value="{{ search or '' }}"
placeholder="Purpose, location..."
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Status</label>
<select name="status" id="status" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="">All Statuses</option>
<option value="pending" {% if status == 'pending' %}selected{% endif %}>Pending</option>
<option value="approved" {% if status == 'approved' %}selected{% endif %}>Approved</option>
<option value="rejected" {% if status == 'rejected' %}selected{% endif %}>Rejected</option>
<option value="reimbursed" {% if status == 'reimbursed' %}selected{% endif %}>Reimbursed</option>
</select>
</div>
<div>
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Project</label>
<select name="project_id" id="project_id" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="">All Projects</option>
{% for project in projects %}
<option value="{{ project.id }}" {% if project_id == project.id %}selected{% endif %}>{{ project.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Client</label>
<select name="client_id" id="client_id" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="">All Clients</option>
{% for client in clients %}
<option value="{{ client.id }}" {% if client_id == client.id %}selected{% endif %}>{{ client.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="start_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Start Date</label>
<input type="date" name="start_date" id="start_date" value="{{ start_date or '' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="end_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">End Date</label>
<input type="date" name="end_date" id="end_date" value="{{ end_date or '' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div class="flex items-end gap-2 md:col-span-2">
<button type="submit" class="flex-1 bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-filter mr-2"></i>Filter
</button>
<a href="{{ url_for('mileage.list_mileage') }}" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
<i class="fas fa-times"></i>
</a>
</div>
</form>
</div>
<!-- Mileage Table -->
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Purpose</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Route</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Distance</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Amount</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{% if mileage_entries %}
{% for entry in mileage_entries %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-6 py-4 whitespace-nowrap text-sm">
{{ entry.date.strftime('%Y-%m-%d') if entry.date else (entry.trip_date.strftime('%Y-%m-%d') if entry.trip_date else '-') }}
</td>
<td class="px-6 py-4 text-sm">
<a href="{{ url_for('mileage.view_mileage', mileage_id=entry.id) }}" class="text-primary hover:underline font-medium">
{{ entry.purpose }}
</a>
{% if entry.project %}
<div class="text-xs text-gray-500">{{ entry.project.name }}</div>
{% endif %}
</td>
<td class="px-6 py-4 text-sm">
<div class="flex flex-col">
<span class="text-gray-600">{{ entry.start_location }}</span>
<i class="fas fa-arrow-down text-xs text-gray-400 my-1"></i>
<span class="text-gray-600">{{ entry.end_location }}</span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
{{ '%.2f'|format(entry.distance_km) }} km
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
{{ entry.currency_code or 'EUR' }} {{ '%.2f'|format(entry.total_amount or (entry.calculated_amount or 0)) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
{% if entry.status == 'pending' %}
<span class="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
Pending
</span>
{% elif entry.status == 'approved' %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Approved
</span>
{% elif entry.status == 'rejected' %}
<span class="px-2 py-1 text-xs rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
Rejected
</span>
{% elif entry.status == 'reimbursed' %}
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
Reimbursed
</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end gap-2">
<a href="{{ url_for('mileage.view_mileage', mileage_id=entry.id) }}" class="text-primary hover:text-primary/80" title="View">
<i class="fas fa-eye"></i>
</a>
{% if current_user.is_admin or entry.user_id == current_user.id %}
<a href="{{ url_for('mileage.edit_mileage', mileage_id=entry.id) }}" class="text-blue-600 hover:text-blue-800" title="Edit">
<i class="fas fa-edit"></i>
</a>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="7" class="px-6 py-8 text-center text-gray-500">
<i class="fas fa-car text-4xl mb-2 opacity-50"></i>
<p>No mileage entries found</p>
<a href="{{ url_for('mileage.create_mileage') }}" class="text-primary hover:underline mt-2 inline-block">
Create your first mileage entry
</a>
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,302 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Mileage', 'url': url_for('mileage.list_mileage')},
{'text': 'Mileage #' + mileage.id|string}
] %}
{{ page_header(
icon_class='fas fa-car',
title_text='Mileage Entry #' + mileage.id|string,
subtitle_text=mileage.purpose,
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("mileage.edit_mileage", mileage_id=mileage.id) + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-edit mr-2"></i>Edit</a>' if current_user.is_admin or mileage.user_id == current_user.id else ''
) }}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Main Content -->
<div class="lg:col-span-2 space-y-6">
<!-- Trip Details -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-route mr-2"></i>Trip Details
</h3>
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Trip Date</p>
<p class="font-semibold">{{ mileage.trip_date.strftime('%Y-%m-%d') if mileage.trip_date else (mileage.date.strftime('%Y-%m-%d') if mileage.date else '-') }}</p>
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">User</p>
<p class="font-semibold">{{ mileage.user.full_name if mileage.user and mileage.user.full_name else (mileage.user.username if mileage.user else '-') }}</p>
</div>
<div class="col-span-2">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Purpose</p>
<p class="font-semibold">{{ mileage.purpose }}</p>
</div>
{% if mileage.description %}
<div class="col-span-2">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Description</p>
<p class="font-semibold">{{ mileage.description }}</p>
</div>
{% endif %}
<div class="col-span-2">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">Route</p>
<div class="flex items-center p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="flex-1">
<div class="flex items-center mb-2">
<i class="fas fa-map-marker-alt text-green-500 mr-2"></i>
<span class="font-semibold">{{ mileage.start_location }}</span>
</div>
<div class="flex items-center ml-1">
<i class="fas fa-arrow-down text-gray-400 text-sm mr-2"></i>
<span class="text-sm text-gray-600 dark:text-gray-400">{{ '%.2f'|format(mileage.distance_km) }} km</span>
</div>
<div class="flex items-center mt-2">
<i class="fas fa-map-marker-alt text-red-500 mr-2"></i>
<span class="font-semibold">{{ mileage.end_location }}</span>
</div>
</div>
{% if mileage.is_round_trip %}
<div class="ml-4 px-3 py-1 bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 rounded-full text-xs font-semibold">
Round Trip
</div>
{% endif %}
</div>
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Distance</p>
<p class="font-semibold text-lg">{{ '%.2f'|format(mileage.distance_km) }} km</p>
{% if mileage.is_round_trip %}
<p class="text-xs text-gray-500">Total: {{ '%.2f'|format(mileage.distance_km * 2) }} km</p>
{% endif %}
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Rate per km</p>
<p class="font-semibold text-lg">{{ mileage.currency_code or 'EUR' }} {{ '%.2f'|format(mileage.rate_per_km) }}</p>
</div>
<div class="col-span-2 mt-2">
<div class="p-4 bg-primary/10 rounded-lg">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Total Amount</p>
<p class="font-bold text-2xl text-primary">{{ mileage.currency_code or 'EUR' }} {{ '%.2f'|format(mileage.total_amount or (mileage.calculated_amount or 0)) }}</p>
</div>
</div>
</div>
</div>
<!-- Vehicle Information -->
{% if mileage.vehicle_type or mileage.vehicle_description or mileage.license_plate %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-car mr-2"></i>Vehicle Information
</h3>
<div class="grid grid-cols-3 gap-4">
{% if mileage.vehicle_type %}
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Type</p>
<p class="font-semibold">{{ mileage.vehicle_type|title }}</p>
</div>
{% endif %}
{% if mileage.vehicle_description %}
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Make/Model</p>
<p class="font-semibold">{{ mileage.vehicle_description }}</p>
</div>
{% endif %}
{% if mileage.license_plate %}
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">License Plate</p>
<p class="font-semibold">{{ mileage.license_plate }}</p>
</div>
{% endif %}
{% if mileage.start_odometer and mileage.end_odometer %}
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Start Odometer</p>
<p class="font-semibold">{{ mileage.start_odometer }}</p>
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">End Odometer</p>
<p class="font-semibold">{{ mileage.end_odometer }}</p>
</div>
{% endif %}
</div>
</div>
{% endif %}
<!-- Association -->
{% if mileage.project or mileage.client %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-project-diagram mr-2"></i>Association
</h3>
<div class="grid grid-cols-2 gap-4">
{% if mileage.project %}
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Project</p>
<a href="{{ url_for('projects.view_project', project_id=mileage.project_id) }}" class="text-primary hover:underline font-semibold">
{{ mileage.project.name }}
</a>
</div>
{% endif %}
{% if mileage.client %}
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Client</p>
<a href="{{ url_for('clients.view_client', client_id=mileage.client_id) }}" class="text-primary hover:underline font-semibold">
{{ mileage.client.name }}
</a>
</div>
{% endif %}
</div>
</div>
{% endif %}
<!-- Notes -->
{% if mileage.notes %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-sticky-note mr-2"></i>Notes
</h3>
<p class="text-gray-700 dark:text-gray-300">{{ mileage.notes }}</p>
</div>
{% endif %}
</div>
<!-- Sidebar -->
<div class="space-y-6">
<!-- Status -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-info-circle mr-2"></i>Status
</h3>
<div class="mb-4">
{% if mileage.status == 'pending' %}
<span class="px-3 py-2 text-sm rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 font-semibold">
<i class="fas fa-clock mr-1"></i>Pending Approval
</span>
{% elif mileage.status == 'approved' %}
<span class="px-3 py-2 text-sm rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 font-semibold">
<i class="fas fa-check-circle mr-1"></i>Approved
</span>
{% elif mileage.status == 'rejected' %}
<span class="px-3 py-2 text-sm rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 font-semibold">
<i class="fas fa-times-circle mr-1"></i>Rejected
</span>
{% elif mileage.status == 'reimbursed' %}
<span class="px-3 py-2 text-sm rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 font-semibold">
<i class="fas fa-money-bill mr-1"></i>Reimbursed
</span>
{% endif %}
</div>
{% if mileage.approved_by %}
<div class="mt-4">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">{% if mileage.status == 'approved' %}Approved By{% else %}Reviewed By{% endif %}</p>
<p class="font-semibold">{{ mileage.approver.full_name if mileage.approver and mileage.approver.full_name else (mileage.approver.username if mileage.approver else '-') }}</p>
{% if mileage.approved_at %}
<p class="text-xs text-gray-500 mt-1">{{ mileage.approved_at.strftime('%Y-%m-%d %H:%M') }}</p>
{% endif %}
</div>
{% endif %}
{% if mileage.status == 'rejected' and mileage.rejection_reason %}
<div class="mt-4 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
<p class="text-sm text-red-800 dark:text-red-200">
<strong>Rejection Reason:</strong><br>
{{ mileage.rejection_reason }}
</p>
</div>
{% endif %}
{% if mileage.approval_notes %}
<div class="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<p class="text-sm text-blue-800 dark:text-blue-200">
<strong>Approval Notes:</strong><br>
{{ mileage.approval_notes }}
</p>
</div>
{% endif %}
</div>
<!-- Actions for Admin -->
{% if current_user.is_admin and mileage.status == 'pending' %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-gavel mr-2"></i>Admin Actions
</h3>
<form method="POST" action="{{ url_for('mileage.approve_mileage', mileage_id=mileage.id) }}" class="mb-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<textarea name="approval_notes" placeholder="Optional approval notes..." rows="2"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mb-2 text-sm dark:bg-gray-700"></textarea>
<button type="submit" class="w-full bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition-colors">
<i class="fas fa-check mr-2"></i>Approve
</button>
</form>
<form method="POST" action="{{ url_for('mileage.reject_mileage', mileage_id=mileage.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<textarea name="rejection_reason" placeholder="Rejection reason (required)" rows="2" required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mb-2 text-sm dark:bg-gray-700"></textarea>
<button type="submit" class="w-full bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors">
<i class="fas fa-times mr-2"></i>Reject
</button>
</form>
</div>
{% endif %}
{% if current_user.is_admin and mileage.status == 'approved' %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-money-bill mr-2"></i>Reimbursement
</h3>
<form method="POST" action="{{ url_for('mileage.mark_reimbursed', mileage_id=mileage.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="w-full bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors">
<i class="fas fa-check mr-2"></i>Mark as Reimbursed
</button>
</form>
</div>
{% endif %}
<!-- Metadata -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-clock mr-2"></i>Metadata
</h3>
<div class="space-y-3">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Created</p>
<p class="font-semibold text-sm">{{ mileage.created_at.strftime('%Y-%m-%d %H:%M') if mileage.created_at else '-' }}</p>
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Last Updated</p>
<p class="font-semibold text-sm">{{ mileage.updated_at.strftime('%Y-%m-%d %H:%M') if mileage.updated_at else '-' }}</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,230 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Per Diem', 'url': url_for('per_diem.list_per_diem')},
{'text': 'Edit' if per_diem else 'New'}
] %}
{{ page_header(
icon_class='fas fa-money-bill-alt',
title_text=('Edit Per Diem Claim' if per_diem else 'New Per Diem Claim'),
subtitle_text=('Update claim details' if per_diem else 'Create a new per diem claim'),
breadcrumbs=breadcrumbs
) }}
<div class="max-w-4xl mx-auto">
<form method="POST" class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Trip Information -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-info-circle mr-2"></i>Trip Information
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="md:col-span-2">
<label for="trip_purpose" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Trip Purpose <span class="text-red-500">*</span>
</label>
<input type="text" name="trip_purpose" id="trip_purpose" required
value="{{ per_diem.trip_purpose if per_diem else '' }}"
placeholder="e.g., Business trip to Berlin"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="start_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Start Date <span class="text-red-500">*</span>
</label>
<input type="date" name="start_date" id="start_date" required
value="{{ per_diem.start_date.strftime('%Y-%m-%d') if per_diem else '' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="end_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
End Date <span class="text-red-500">*</span>
</label>
<input type="date" name="end_date" id="end_date" required
value="{{ per_diem.end_date.strftime('%Y-%m-%d') if per_diem else '' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="country" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Country <span class="text-red-500">*</span>
</label>
<input type="text" name="country" id="country" required
value="{{ per_diem.country if per_diem else '' }}"
placeholder="e.g., DE, US, GB"
maxlength="2"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="city" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
City
</label>
<input type="text" name="city" id="city"
value="{{ per_diem.city if per_diem and per_diem.city else '' }}"
placeholder="e.g., Berlin"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
</div>
</div>
<!-- Days Calculation -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-calendar mr-2"></i>Days Calculation
</h3>
<div class="mb-4">
<div class="flex items-center">
<input type="checkbox" name="auto_calculate_days" id="auto_calculate_days" checked
class="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded">
<label for="auto_calculate_days" class="ml-2 text-sm text-gray-700 dark:text-gray-300">
<strong>Auto-calculate days from dates</strong>
</label>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="full_days" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Full Days
</label>
<input type="number" name="full_days" id="full_days" min="0"
value="{{ per_diem.full_days if per_diem else '0' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="half_days" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Half Days
</label>
<input type="number" name="half_days" id="half_days" min="0"
value="{{ per_diem.half_days if per_diem else '0' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
</div>
</div>
<!-- Meal Deductions -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-utensils mr-2"></i>Provided Meals (Deductions)
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="breakfast_provided" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Breakfasts Provided
</label>
<input type="number" name="breakfast_provided" id="breakfast_provided" min="0"
value="{{ per_diem.breakfast_provided if per_diem else '0' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="lunch_provided" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Lunches Provided
</label>
<input type="number" name="lunch_provided" id="lunch_provided" min="0"
value="{{ per_diem.lunch_provided if per_diem else '0' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="dinner_provided" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Dinners Provided
</label>
<input type="number" name="dinner_provided" id="dinner_provided" min="0"
value="{{ per_diem.dinner_provided if per_diem else '0' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
</div>
</div>
<!-- Project & Client -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-project-diagram mr-2"></i>Association
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Project
</label>
<select name="project_id" id="project_id"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="">No Project</option>
{% for project in projects %}
<option value="{{ project.id }}" {% if per_diem and per_diem.project_id == project.id %}selected{% endif %}>
{{ project.name }}
</option>
{% endfor %}
</select>
</div>
<div>
<label for="client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Client
</label>
<select name="client_id" id="client_id"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="">No Client</option>
{% for client in clients %}
<option value="{{ client.id }}" {% if per_diem and per_diem.client_id == client.id %}selected{% endif %}>
{{ client.name }}
</option>
{% endfor %}
</select>
</div>
</div>
</div>
<!-- Notes -->
<div class="mb-6">
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Notes
</label>
<textarea name="notes" id="notes" rows="3"
placeholder="Additional notes..."
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">{{ per_diem.notes if per_diem else '' }}</textarea>
</div>
<!-- Create Expense Option -->
{% if not per_diem %}
<div class="mb-6">
<div class="flex items-center">
<input type="checkbox" name="create_expense" id="create_expense" checked
class="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded">
<label for="create_expense" class="ml-2 text-sm text-gray-700 dark:text-gray-300">
<strong>Create linked expense entry</strong>
<span class="block text-xs text-gray-500">Automatically create an expense record for this per diem</span>
</label>
</div>
</div>
{% endif %}
<!-- Actions -->
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<a href="{{ url_for('per_diem.view_per_diem', per_diem_id=per_diem.id) if per_diem else url_for('per_diem.list_per_diem') }}"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<i class="fas fa-times mr-2"></i>Cancel
</a>
<button type="submit" class="bg-primary text-white px-6 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-save mr-2"></i>{{ 'Update Claim' if per_diem else 'Create Claim' }}
</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,182 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Per Diem'}
] %}
{{ page_header(
icon_class='fas fa-money-bill-alt',
title_text='Per Diem Claims',
subtitle_text='Manage daily allowance claims',
breadcrumbs=breadcrumbs,
actions_html='<div class="flex gap-2"><a href="' + url_for("per_diem.list_rates") + '" class="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700 transition-colors"><i class="fas fa-list-alt mr-2"></i>Manage Rates</a><a href="' + url_for("per_diem.create_per_diem") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>New Claim</a></div>'
) }}
<!-- Summary Stats -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Total Claims</p>
<p class="text-2xl font-bold">{{ pagination.total if pagination else (per_diem_claims|length) }}</p>
</div>
<div class="text-primary text-3xl">
<i class="fas fa-list"></i>
</div>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Total Amount</p>
<p class="text-2xl font-bold">€{{ '%.2f'|format(total_amount) }}</p>
</div>
<div class="text-green-500 text-3xl">
<i class="fas fa-euro-sign"></i>
</div>
</div>
</div>
</div>
<!-- Filter Form -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<h2 class="text-lg font-semibold mb-4">Filter Claims</h2>
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Status</label>
<select name="status" id="status" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="">All Statuses</option>
<option value="pending" {% if status == 'pending' %}selected{% endif %}>Pending</option>
<option value="approved" {% if status == 'approved' %}selected{% endif %}>Approved</option>
<option value="rejected" {% if status == 'rejected' %}selected{% endif %}>Rejected</option>
<option value="reimbursed" {% if status == 'reimbursed' %}selected{% endif %}>Reimbursed</option>
</select>
</div>
<div>
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Project</label>
<select name="project_id" id="project_id" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="">All Projects</option>
{% for project in projects %}
<option value="{{ project.id }}" {% if project_id == project.id %}selected{% endif %}>{{ project.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="start_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Start Date</label>
<input type="date" name="start_date" id="start_date" value="{{ start_date or '' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="end_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">End Date</label>
<input type="date" name="end_date" id="end_date" value="{{ end_date or '' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div class="flex items-end gap-2 md:col-span-2 lg:col-span-4">
<button type="submit" class="flex-1 bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-filter mr-2"></i>Filter
</button>
<a href="{{ url_for('per_diem.list_per_diem') }}" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
<i class="fas fa-times"></i>
</a>
</div>
</form>
</div>
<!-- Claims Table -->
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Period</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Purpose</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Location</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Days</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Amount</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{% if per_diem_claims %}
{% for claim in per_diem_claims %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-6 py-4 whitespace-nowrap text-sm">
{{ claim.start_date.strftime('%Y-%m-%d') }}<br>
<span class="text-xs text-gray-500">to {{ claim.end_date.strftime('%Y-%m-%d') }}</span>
</td>
<td class="px-6 py-4 text-sm">
<a href="{{ url_for('per_diem.view_per_diem', per_diem_id=claim.id) }}" class="text-primary hover:underline font-medium">
{{ claim.trip_purpose }}
</a>
{% if claim.project %}
<div class="text-xs text-gray-500">{{ claim.project.name }}</div>
{% endif %}
</td>
<td class="px-6 py-4 text-sm">
{{ claim.city + ', ' if claim.city else '' }}{{ claim.country }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
{{ claim.total_days if claim.total_days else ((claim.full_days or 0) + (claim.half_days or 0) * 0.5) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
{{ claim.currency_code or 'EUR' }} {{ '%.2f'|format(claim.calculated_amount or 0) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
{% if claim.status == 'pending' %}
<span class="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
Pending
</span>
{% elif claim.status == 'approved' %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Approved
</span>
{% elif claim.status == 'rejected' %}
<span class="px-2 py-1 text-xs rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
Rejected
</span>
{% elif claim.status == 'reimbursed' %}
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
Reimbursed
</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end gap-2">
<a href="{{ url_for('per_diem.view_per_diem', per_diem_id=claim.id) }}" class="text-primary hover:text-primary/80" title="View">
<i class="fas fa-eye"></i>
</a>
{% if current_user.is_admin or claim.user_id == current_user.id %}
<a href="{{ url_for('per_diem.edit_per_diem', per_diem_id=claim.id) }}" class="text-blue-600 hover:text-blue-800" title="Edit">
<i class="fas fa-edit"></i>
</a>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="7" class="px-6 py-8 text-center text-gray-500">
<i class="fas fa-money-bill-alt text-4xl mb-2 opacity-50"></i>
<p>No per diem claims found</p>
<a href="{{ url_for('per_diem.create_per_diem') }}" class="text-primary hover:underline mt-2 inline-block">
Create your first claim
</a>
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,175 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Per Diem', 'url': url_for('per_diem.list_per_diem')},
{'text': 'Rates', 'url': url_for('per_diem.list_rates')},
{'text': 'Edit' if rate else 'New'}
] %}
{{ page_header(
icon_class='fas fa-list-alt',
title_text=('Edit Per Diem Rate' if rate else 'New Per Diem Rate'),
subtitle_text=('Update rate details' if rate else 'Create a new per diem rate'),
breadcrumbs=breadcrumbs
) %}
<div class="max-w-4xl mx-auto">
<form method="POST" class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Location Information -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-map-marker-alt mr-2"></i>Location
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="country" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Country Code <span class="text-red-500">*</span>
</label>
<input type="text" name="country" id="country" required
value="{{ rate.country if rate else '' }}"
placeholder="e.g., DE, US, GB"
maxlength="2"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="city" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
City (optional)
</label>
<input type="text" name="city" id="city"
value="{{ rate.city if rate and rate.city else '' }}"
placeholder="e.g., Berlin"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
</div>
</div>
<!-- Rate Information -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-euro-sign mr-2"></i>Rates
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="full_day_rate" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Full Day Rate <span class="text-red-500">*</span>
</label>
<input type="number" name="full_day_rate" id="full_day_rate" step="0.01" required
value="{{ rate.full_day_rate if rate else '' }}"
placeholder="0.00"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="half_day_rate" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Half Day Rate <span class="text-red-500">*</span>
</label>
<input type="number" name="half_day_rate" id="half_day_rate" step="0.01" required
value="{{ rate.half_day_rate if rate else '' }}"
placeholder="0.00"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="currency_code" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Currency
</label>
<select name="currency_code" id="currency_code"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="EUR" {% if not rate or rate.currency_code == 'EUR' %}selected{% endif %}>EUR</option>
<option value="USD" {% if rate and rate.currency_code == 'USD' %}selected{% endif %}>USD</option>
<option value="GBP" {% if rate and rate.currency_code == 'GBP' %}selected{% endif %}>GBP</option>
</select>
</div>
<div>
<label for="breakfast_rate" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Breakfast Deduction
</label>
<input type="number" name="breakfast_rate" id="breakfast_rate" step="0.01"
value="{{ rate.breakfast_rate if rate and rate.breakfast_rate else '' }}"
placeholder="0.00"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="lunch_rate" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Lunch Deduction
</label>
<input type="number" name="lunch_rate" id="lunch_rate" step="0.01"
value="{{ rate.lunch_rate if rate and rate.lunch_rate else '' }}"
placeholder="0.00"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="dinner_rate" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Dinner Deduction
</label>
<input type="number" name="dinner_rate" id="dinner_rate" step="0.01"
value="{{ rate.dinner_rate if rate and rate.dinner_rate else '' }}"
placeholder="0.00"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
</div>
</div>
<!-- Effective Period -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-calendar mr-2"></i>Effective Period
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="effective_from" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Effective From <span class="text-red-500">*</span>
</label>
<input type="date" name="effective_from" id="effective_from" required
value="{{ rate.effective_from.strftime('%Y-%m-%d') if rate else '' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="effective_to" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Effective To (optional)
</label>
<input type="date" name="effective_to" id="effective_to"
value="{{ rate.effective_to.strftime('%Y-%m-%d') if rate and rate.effective_to else '' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
</div>
</div>
<!-- Notes -->
<div class="mb-6">
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Notes
</label>
<textarea name="notes" id="notes" rows="3"
placeholder="Additional notes about this rate..."
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">{{ rate.notes if rate else '' }}</textarea>
</div>
<!-- Actions -->
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<a href="{{ url_for('per_diem.list_rates') }}"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<i class="fas fa-times mr-2"></i>Cancel
</a>
<button type="submit" class="bg-primary text-white px-6 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-save mr-2"></i>{{ 'Update Rate' if rate else 'Create Rate' }}
</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,84 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Per Diem', 'url': url_for('per_diem.list_per_diem')},
{'text': 'Rates'}
] %}
{{ page_header(
icon_class='fas fa-list-alt',
title_text='Per Diem Rates',
subtitle_text='Manage per diem rates by location',
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("per_diem.create_rate") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>New Rate</a>'
) }}
<!-- Rates Table -->
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Location</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Full Day Rate</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Half Day Rate</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Currency</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Effective From</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{% if rates %}
{% for rate in rates %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-6 py-4 text-sm">
<div class="font-medium">{{ rate.city + ', ' if rate.city else '' }}{{ rate.country }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
{{ '%.2f'|format(rate.full_day_rate) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
{{ '%.2f'|format(rate.half_day_rate) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
{{ rate.currency_code }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
{{ rate.effective_from.strftime('%Y-%m-%d') }}
{% if rate.effective_to %}
<br><span class="text-xs text-gray-500">to {{ rate.effective_to.strftime('%Y-%m-%d') }}</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
{% if rate.is_active %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Active
</span>
{% else %}
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200">
Inactive
</span>
{% endif %}
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="6" class="px-6 py-8 text-center text-gray-500">
<i class="fas fa-list-alt text-4xl mb-2 opacity-50"></i>
<p>No per diem rates found</p>
<a href="{{ url_for('per_diem.create_rate') }}" class="text-primary hover:underline mt-2 inline-block">
Create your first rate
</a>
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,115 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Per Diem', 'url': url_for('per_diem.list_per_diem')},
{'text': 'Claim #' + per_diem.id|string}
] %}
{{ page_header(
icon_class='fas fa-money-bill-alt',
title_text='Per Diem Claim #' + per_diem.id|string,
subtitle_text=per_diem.trip_purpose,
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("per_diem.edit_per_diem", per_diem_id=per_diem.id) + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-edit mr-2"></i>Edit</a>' if current_user.is_admin or per_diem.user_id == current_user.id else ''
) }}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Main Content -->
<div class="lg:col-span-2 space-y-6">
<!-- Claim Details -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-info-circle mr-2"></i>Claim Details
</h3>
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Period</p>
<p class="font-semibold">{{ per_diem.start_date.strftime('%Y-%m-%d') }} to {{ per_diem.end_date.strftime('%Y-%m-%d') }}</p>
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Location</p>
<p class="font-semibold">{{ per_diem.city + ', ' if per_diem.city else '' }}{{ per_diem.country }}</p>
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Full Days</p>
<p class="font-semibold">{{ per_diem.full_days }}</p>
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Half Days</p>
<p class="font-semibold">{{ per_diem.half_days }}</p>
</div>
<div class="col-span-2 mt-2">
<div class="p-4 bg-primary/10 rounded-lg">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Total Amount</p>
<p class="font-bold text-2xl text-primary">{{ per_diem.currency_code or 'EUR' }} {{ '%.2f'|format(per_diem.calculated_amount or 0) }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="space-y-6">
<!-- Status -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-info-circle mr-2"></i>Status
</h3>
<div class="mb-4">
{% if per_diem.status == 'pending' %}
<span class="px-3 py-2 text-sm rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 font-semibold">
<i class="fas fa-clock mr-1"></i>Pending
</span>
{% elif per_diem.status == 'approved' %}
<span class="px-3 py-2 text-sm rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 font-semibold">
<i class="fas fa-check-circle mr-1"></i>Approved
</span>
{% elif per_diem.status == 'rejected' %}
<span class="px-3 py-2 text-sm rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 font-semibold">
<i class="fas fa-times-circle mr-1"></i>Rejected
</span>
{% elif per_diem.status == 'reimbursed' %}
<span class="px-3 py-2 text-sm rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 font-semibold">
<i class="fas fa-money-bill mr-1"></i>Reimbursed
</span>
{% endif %}
</div>
</div>
<!-- Admin Actions -->
{% if current_user.is_admin and per_diem.status == 'pending' %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-gavel mr-2"></i>Admin Actions
</h3>
<form method="POST" action="{{ url_for('per_diem.approve_per_diem', per_diem_id=per_diem.id) }}" class="mb-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="w-full bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition-colors">
<i class="fas fa-check mr-2"></i>Approve
</button>
</form>
<form method="POST" action="{{ url_for('per_diem.reject_per_diem', per_diem_id=per_diem.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<textarea name="rejection_reason" placeholder="Rejection reason (required)" rows="2" required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mb-2 text-sm dark:bg-gray-700"></textarea>
<button type="submit" class="w-full bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors">
<i class="fas fa-times mr-2"></i>Reject
</button>
</form>
</div>
{% endif %}
</div>
</div>
{% endblock %}

344
app/utils/ocr.py Normal file
View File

@@ -0,0 +1,344 @@
"""
OCR utilities for receipt scanning and text extraction.
This module provides functionality to extract text and data from receipt images
using Tesseract OCR and parse common receipt information.
"""
import os
import re
from decimal import Decimal
from datetime import datetime
import logging
logger = logging.getLogger(__name__)
# Check if Tesseract is available
try:
import pytesseract
from PIL import Image
TESSERACT_AVAILABLE = True
except ImportError:
TESSERACT_AVAILABLE = False
logger.warning("pytesseract or PIL not installed. Receipt OCR will not be available.")
def is_ocr_available():
"""Check if OCR functionality is available"""
return TESSERACT_AVAILABLE
def extract_text_from_image(image_path, lang='eng'):
"""
Extract text from an image using Tesseract OCR.
Args:
image_path: Path to the image file
lang: OCR language (default: 'eng', can be 'eng+deu' for multilingual)
Returns:
Extracted text as string
"""
if not TESSERACT_AVAILABLE:
raise RuntimeError("Tesseract OCR is not available. Install pytesseract and PIL.")
try:
# Open and preprocess image
image = Image.open(image_path)
# Convert to RGB if necessary
if image.mode != 'RGB':
image = image.convert('RGB')
# Extract text
text = pytesseract.image_to_string(image, lang=lang)
return text
except Exception as e:
logger.error(f"Error extracting text from image {image_path}: {e}")
raise
def parse_receipt_data(text):
"""
Parse common receipt information from extracted text.
Args:
text: Extracted text from receipt
Returns:
Dictionary with parsed data (vendor, date, total, items, etc.)
"""
data = {
'vendor': None,
'date': None,
'total': None,
'tax': None,
'subtotal': None,
'items': [],
'currency': 'EUR',
'raw_text': text
}
lines = text.split('\n')
# Try to extract vendor (usually first few lines)
vendor_lines = []
for line in lines[:5]:
line = line.strip()
if line and len(line) > 3:
vendor_lines.append(line)
if vendor_lines:
data['vendor'] = vendor_lines[0]
# Extract amounts
amounts = extract_amounts(text)
if amounts:
# Try to identify total (usually largest amount or labeled as total)
total_candidates = []
for amount_info in amounts:
label = amount_info.get('label', '').lower()
if any(keyword in label for keyword in ['total', 'gesamt', 'suma', 'totale']):
data['total'] = amount_info['amount']
elif any(keyword in label for keyword in ['tax', 'vat', 'mwst', 'iva', 'tva']):
data['tax'] = amount_info['amount']
elif any(keyword in label for keyword in ['subtotal', 'zwischensumme', 'sous-total']):
data['subtotal'] = amount_info['amount']
else:
total_candidates.append(amount_info['amount'])
# If no labeled total found, use the largest amount
if not data['total'] and total_candidates:
data['total'] = max(total_candidates)
# Extract date
date = extract_date(text)
if date:
data['date'] = date
# Extract currency
currency = extract_currency(text)
if currency:
data['currency'] = currency
return data
def extract_amounts(text):
"""
Extract monetary amounts from text.
Returns:
List of dictionaries with 'amount' and 'label' keys
"""
amounts = []
# Patterns for amounts (supports various formats)
# Examples: 12.34, 12,34, $12.34, €12,34, 12.34 EUR
patterns = [
r'([A-Za-z\s]*?)\s*([$€£¥]?)\s*(\d{1,3}(?:[.,]\d{3})*[.,]\d{2})\s*([A-Z]{3})?',
]
for pattern in patterns:
matches = re.finditer(pattern, text, re.IGNORECASE | re.MULTILINE)
for match in matches:
label = match.group(1).strip() if match.group(1) else ''
symbol = match.group(2) if match.group(2) else ''
amount_str = match.group(3)
currency = match.group(4) if match.group(4) else ''
# Normalize amount (convert comma to dot if needed)
# Determine if comma or dot is decimal separator
if ',' in amount_str and '.' in amount_str:
# Has both, assume European format (1.234,56)
amount_str = amount_str.replace('.', '').replace(',', '.')
elif ',' in amount_str:
# Only comma, check if it's thousands separator or decimal
parts = amount_str.split(',')
if len(parts) == 2 and len(parts[1]) == 2:
# Likely decimal separator
amount_str = amount_str.replace(',', '.')
else:
# Likely thousands separator
amount_str = amount_str.replace(',', '')
try:
amount = Decimal(amount_str)
amounts.append({
'amount': amount,
'label': label,
'symbol': symbol,
'currency': currency
})
except (ValueError, Decimal.InvalidOperation):
continue
return amounts
def extract_date(text):
"""
Extract date from receipt text.
Returns:
datetime.date object or None
"""
# Common date patterns
patterns = [
r'(\d{1,2})[./\-](\d{1,2})[./\-](\d{2,4})', # DD/MM/YYYY or MM/DD/YYYY
r'(\d{4})[./\-](\d{1,2})[./\-](\d{1,2})', # YYYY-MM-DD
r'(\d{1,2})\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+(\d{2,4})', # DD Month YYYY
]
for pattern in patterns:
match = re.search(pattern, text, re.IGNORECASE)
if match:
try:
groups = match.groups()
if len(groups) == 3:
if pattern == patterns[0]: # DD/MM/YYYY or MM/DD/YYYY
# Try DD/MM/YYYY first (European format)
try:
day, month, year = int(groups[0]), int(groups[1]), int(groups[2])
if year < 100:
year += 2000
return datetime(year, month, day).date()
except ValueError:
# Try MM/DD/YYYY (US format)
try:
month, day, year = int(groups[0]), int(groups[1]), int(groups[2])
if year < 100:
year += 2000
return datetime(year, month, day).date()
except ValueError:
continue
elif pattern == patterns[1]: # YYYY-MM-DD
year, month, day = int(groups[0]), int(groups[1]), int(groups[2])
return datetime(year, month, day).date()
elif pattern == patterns[2]: # DD Month YYYY
day = int(groups[0])
month_str = groups[1].lower()
year = int(groups[2])
if year < 100:
year += 2000
months = {
'jan': 1, 'feb': 2, 'mar': 3, 'apr': 4,
'may': 5, 'jun': 6, 'jul': 7, 'aug': 8,
'sep': 9, 'oct': 10, 'nov': 11, 'dec': 12
}
month = months.get(month_str[:3])
if month:
return datetime(year, month, day).date()
except (ValueError, TypeError):
continue
return None
def extract_currency(text):
"""
Extract currency code from receipt text.
Returns:
3-letter currency code (ISO 4217) or 'EUR' as default
"""
# Currency symbols and their codes
currency_symbols = {
'$': 'USD',
'': 'EUR',
'£': 'GBP',
'¥': 'JPY',
'': 'INR',
'Fr': 'CHF'
}
# Look for currency symbols
for symbol, code in currency_symbols.items():
if symbol in text:
return code
# Look for currency codes (3 uppercase letters)
currency_pattern = r'\b([A-Z]{3})\b'
matches = re.findall(currency_pattern, text)
# Common currency codes
common_currencies = ['USD', 'EUR', 'GBP', 'JPY', 'CHF', 'CAD', 'AUD', 'INR']
for match in matches:
if match in common_currencies:
return match
return 'EUR' # Default
def scan_receipt(image_path, lang='eng'):
"""
Scan a receipt image and extract structured data.
Args:
image_path: Path to the receipt image
lang: OCR language(s) to use (e.g., 'eng', 'eng+deu')
Returns:
Dictionary with extracted receipt data
"""
if not is_ocr_available():
return {
'error': 'OCR not available',
'message': 'Please install pytesseract and Pillow: pip install pytesseract pillow'
}
try:
# Extract text
text = extract_text_from_image(image_path, lang=lang)
# Parse data
data = parse_receipt_data(text)
return data
except Exception as e:
logger.error(f"Error scanning receipt {image_path}: {e}")
return {
'error': str(e),
'message': 'Failed to scan receipt'
}
def get_suggested_expense_data(receipt_data):
"""
Convert receipt data to expense form data suggestions.
Args:
receipt_data: Dictionary returned by scan_receipt()
Returns:
Dictionary with suggested expense data
"""
suggestions = {}
if receipt_data.get('vendor'):
suggestions['vendor'] = receipt_data['vendor']
suggestions['title'] = f"Receipt from {receipt_data['vendor']}"
if receipt_data.get('total'):
suggestions['amount'] = float(receipt_data['total'])
if receipt_data.get('tax'):
suggestions['tax_amount'] = float(receipt_data['tax'])
if receipt_data.get('date'):
suggestions['expense_date'] = receipt_data['date'].isoformat()
if receipt_data.get('currency'):
suggestions['currency_code'] = receipt_data['currency']
return suggestions

77
fix_expense_schema.sql Normal file
View File

@@ -0,0 +1,77 @@
-- Fix Advanced Expense Management Schema
-- Run this manually to fix the column name mismatches
-- Fix mileage table
ALTER TABLE mileage RENAME COLUMN trip_purpose TO purpose;
ALTER TABLE mileage RENAME COLUMN vehicle_registration TO license_plate;
ALTER TABLE mileage RENAME COLUMN total_amount TO calculated_amount;
-- Add missing columns to mileage
ALTER TABLE mileage ADD COLUMN IF NOT EXISTS description TEXT;
ALTER TABLE mileage ADD COLUMN IF NOT EXISTS start_odometer NUMERIC(10, 2);
ALTER TABLE mileage ADD COLUMN IF NOT EXISTS end_odometer NUMERIC(10, 2);
ALTER TABLE mileage ADD COLUMN IF NOT EXISTS distance_miles NUMERIC(10, 2);
ALTER TABLE mileage ADD COLUMN IF NOT EXISTS rate_per_mile NUMERIC(10, 4);
ALTER TABLE mileage ADD COLUMN IF NOT EXISTS vehicle_description VARCHAR(200);
ALTER TABLE mileage ADD COLUMN IF NOT EXISTS is_round_trip BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE mileage ADD COLUMN IF NOT EXISTS reimbursed BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE mileage ADD COLUMN IF NOT EXISTS reimbursed_at TIMESTAMP;
ALTER TABLE mileage ADD COLUMN IF NOT EXISTS currency_code VARCHAR(3) NOT NULL DEFAULT 'EUR';
-- Make rate_per_km NOT NULL
ALTER TABLE mileage ALTER COLUMN rate_per_km SET NOT NULL;
ALTER TABLE mileage ALTER COLUMN rate_per_km SET DEFAULT 0.30;
-- Fix per_diem_rates table
ALTER TABLE per_diem_rates RENAME COLUMN location TO city;
ALTER TABLE per_diem_rates RENAME COLUMN valid_from TO effective_from;
ALTER TABLE per_diem_rates RENAME COLUMN valid_to TO effective_to;
ALTER TABLE per_diem_rates RENAME COLUMN country_code TO country;
-- Add missing columns to per_diem_rates
ALTER TABLE per_diem_rates ADD COLUMN IF NOT EXISTS full_day_rate NUMERIC(10, 2) NOT NULL DEFAULT 0;
ALTER TABLE per_diem_rates ADD COLUMN IF NOT EXISTS half_day_rate NUMERIC(10, 2) NOT NULL DEFAULT 0;
ALTER TABLE per_diem_rates ADD COLUMN IF NOT EXISTS breakfast_rate NUMERIC(10, 2);
ALTER TABLE per_diem_rates ADD COLUMN IF NOT EXISTS lunch_rate NUMERIC(10, 2);
ALTER TABLE per_diem_rates ADD COLUMN IF NOT EXISTS dinner_rate NUMERIC(10, 2);
ALTER TABLE per_diem_rates ADD COLUMN IF NOT EXISTS incidental_rate NUMERIC(10, 2);
ALTER TABLE per_diem_rates ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- Copy rate_per_day to full_day_rate and calculate half_day_rate
UPDATE per_diem_rates SET full_day_rate = rate_per_day, half_day_rate = rate_per_day * 0.5 WHERE full_day_rate = 0;
-- Drop old columns from per_diem_rates
ALTER TABLE per_diem_rates DROP COLUMN IF EXISTS rate_per_day;
ALTER TABLE per_diem_rates DROP COLUMN IF EXISTS breakfast_deduction;
ALTER TABLE per_diem_rates DROP COLUMN IF EXISTS lunch_deduction;
ALTER TABLE per_diem_rates DROP COLUMN IF EXISTS dinner_deduction;
-- Fix per_diems table
ALTER TABLE per_diems RENAME COLUMN trip_start_date TO start_date;
ALTER TABLE per_diems RENAME COLUMN trip_end_date TO end_date;
ALTER TABLE per_diems RENAME COLUMN destination_country TO country;
ALTER TABLE per_diems RENAME COLUMN destination_location TO city;
ALTER TABLE per_diems RENAME COLUMN number_of_days TO full_days;
ALTER TABLE per_diems RENAME COLUMN total_amount TO calculated_amount;
-- Add missing columns to per_diems
ALTER TABLE per_diems ADD COLUMN IF NOT EXISTS trip_purpose VARCHAR(255) NOT NULL DEFAULT 'Business trip';
ALTER TABLE per_diems ADD COLUMN IF NOT EXISTS description TEXT;
ALTER TABLE per_diems ADD COLUMN IF NOT EXISTS departure_time TIME;
ALTER TABLE per_diems ADD COLUMN IF NOT EXISTS return_time TIME;
ALTER TABLE per_diems ADD COLUMN IF NOT EXISTS half_days INTEGER NOT NULL DEFAULT 0;
ALTER TABLE per_diems ADD COLUMN IF NOT EXISTS total_days NUMERIC(5, 2) NOT NULL DEFAULT 0;
ALTER TABLE per_diems ADD COLUMN IF NOT EXISTS full_day_rate NUMERIC(10, 2) NOT NULL DEFAULT 0;
ALTER TABLE per_diems ADD COLUMN IF NOT EXISTS half_day_rate NUMERIC(10, 2) NOT NULL DEFAULT 0;
ALTER TABLE per_diems ADD COLUMN IF NOT EXISTS breakfast_deduction NUMERIC(10, 2) NOT NULL DEFAULT 0;
ALTER TABLE per_diems ADD COLUMN IF NOT EXISTS lunch_deduction NUMERIC(10, 2) NOT NULL DEFAULT 0;
ALTER TABLE per_diems ADD COLUMN IF NOT EXISTS dinner_deduction NUMERIC(10, 2) NOT NULL DEFAULT 0;
ALTER TABLE per_diems ADD COLUMN IF NOT EXISTS reimbursed BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE per_diems ADD COLUMN IF NOT EXISTS reimbursed_at TIMESTAMP;
ALTER TABLE per_diems ADD COLUMN IF NOT EXISTS approval_notes TEXT;
-- Mark migration as applied (optional)
-- UPDATE alembic_version SET version_num = '038_fix_expenses_schema';
SELECT 'Schema fixed successfully!' AS result;

View File

@@ -0,0 +1,204 @@
"""Add advanced expense management
Revision ID: 037_advanced_expenses
Revises: 036_add_pdf_design_json
Create Date: 2025-10-30 14:30:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '037_advanced_expenses'
down_revision = '036_add_pdf_design_json'
branch_labels = None
depends_on = None
def upgrade():
# Create expense_categories table
op.create_table(
'expense_categories',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('code', sa.String(length=20), nullable=True),
sa.Column('color', sa.String(length=7), nullable=True),
sa.Column('icon', sa.String(length=50), nullable=True),
sa.Column('monthly_budget', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('quarterly_budget', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('yearly_budget', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('budget_threshold_percent', sa.Integer(), nullable=False, server_default='80'),
sa.Column('requires_receipt', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('requires_approval', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('default_tax_rate', sa.Numeric(precision=5, scale=2), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name'),
sa.UniqueConstraint('code')
)
op.create_index('ix_expense_categories_name', 'expense_categories', ['name'], unique=True)
op.create_index('ix_expense_categories_code', 'expense_categories', ['code'], unique=True)
# Create mileage table (without expense_id FK initially)
op.create_table(
'mileage',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('project_id', sa.Integer(), nullable=True),
sa.Column('client_id', sa.Integer(), nullable=True),
sa.Column('expense_id', sa.Integer(), nullable=True),
sa.Column('trip_date', sa.Date(), nullable=False),
sa.Column('trip_purpose', sa.Text(), nullable=False),
sa.Column('start_location', sa.String(length=255), nullable=False),
sa.Column('end_location', sa.String(length=255), nullable=False),
sa.Column('distance_km', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('vehicle_type', sa.String(length=50), nullable=True),
sa.Column('vehicle_registration', sa.String(length=20), nullable=True),
sa.Column('rate_per_km', sa.Numeric(precision=10, scale=4), nullable=True),
sa.Column('total_amount', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('status', sa.String(length=20), nullable=False, server_default='pending'),
sa.Column('approved_by', sa.Integer(), nullable=True),
sa.Column('approved_at', sa.DateTime(), nullable=True),
sa.Column('rejection_reason', sa.Text(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
sa.ForeignKeyConstraint(['project_id'], ['projects.id']),
sa.ForeignKeyConstraint(['client_id'], ['clients.id']),
sa.ForeignKeyConstraint(['approved_by'], ['users.id'])
)
op.create_index('ix_mileage_user_id', 'mileage', ['user_id'])
op.create_index('ix_mileage_trip_date', 'mileage', ['trip_date'])
# Create per_diem_rates table
op.create_table(
'per_diem_rates',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('country_code', sa.String(length=2), nullable=False),
sa.Column('location', sa.String(length=255), nullable=True),
sa.Column('rate_per_day', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('breakfast_deduction', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('lunch_deduction', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('dinner_deduction', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('valid_from', sa.Date(), nullable=False),
sa.Column('valid_to', sa.Date(), nullable=True),
sa.Column('currency_code', sa.String(length=3), nullable=False),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_per_diem_rates_country', 'per_diem_rates', ['country_code'])
op.create_index('ix_per_diem_rates_valid_from', 'per_diem_rates', ['valid_from'])
# Create per_diems table (without expense_id FK initially)
op.create_table(
'per_diems',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('project_id', sa.Integer(), nullable=True),
sa.Column('client_id', sa.Integer(), nullable=True),
sa.Column('expense_id', sa.Integer(), nullable=True),
sa.Column('trip_start_date', sa.Date(), nullable=False),
sa.Column('trip_end_date', sa.Date(), nullable=False),
sa.Column('destination_country', sa.String(length=2), nullable=False),
sa.Column('destination_location', sa.String(length=255), nullable=True),
sa.Column('per_diem_rate_id', sa.Integer(), nullable=True),
sa.Column('number_of_days', sa.Integer(), nullable=False),
sa.Column('breakfast_provided', sa.Integer(), nullable=False, server_default='0'),
sa.Column('lunch_provided', sa.Integer(), nullable=False, server_default='0'),
sa.Column('dinner_provided', sa.Integer(), nullable=False, server_default='0'),
sa.Column('total_amount', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('currency_code', sa.String(length=3), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False, server_default='pending'),
sa.Column('approved_by', sa.Integer(), nullable=True),
sa.Column('approved_at', sa.DateTime(), nullable=True),
sa.Column('rejection_reason', sa.Text(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
sa.ForeignKeyConstraint(['project_id'], ['projects.id']),
sa.ForeignKeyConstraint(['client_id'], ['clients.id']),
sa.ForeignKeyConstraint(['per_diem_rate_id'], ['per_diem_rates.id']),
sa.ForeignKeyConstraint(['approved_by'], ['users.id'])
)
op.create_index('ix_per_diems_user_id', 'per_diems', ['user_id'])
op.create_index('ix_per_diems_trip_start', 'per_diems', ['trip_start_date'])
# Add new columns to expenses table
op.add_column('expenses', sa.Column('ocr_data', sa.Text(), nullable=True))
op.add_column('expenses', sa.Column('mileage_id', sa.Integer(), nullable=True))
op.add_column('expenses', sa.Column('per_diem_id', sa.Integer(), nullable=True))
# Add foreign keys from expenses to mileage and per_diems
op.create_foreign_key('fk_expenses_mileage', 'expenses', 'mileage', ['mileage_id'], ['id'])
op.create_foreign_key('fk_expenses_per_diem', 'expenses', 'per_diems', ['per_diem_id'], ['id'])
# Now add the circular foreign keys from mileage and per_diems back to expenses
op.create_foreign_key('fk_mileage_expense', 'mileage', 'expenses', ['expense_id'], ['id'])
op.create_foreign_key('fk_per_diems_expense', 'per_diems', 'expenses', ['expense_id'], ['id'])
# Insert default expense categories
op.execute("""
INSERT INTO expense_categories (name, code, color, icon, requires_receipt, requires_approval, is_active)
VALUES
('Travel', 'TRAVEL', '#4CAF50', '✈️', true, true, true),
('Meals', 'MEALS', '#FF9800', '🍽️', true, false, true),
('Accommodation', 'ACCOM', '#2196F3', '🏨', true, true, true),
('Office Supplies', 'OFFICE', '#9C27B0', '📎', false, false, true),
('Equipment', 'EQUIP', '#F44336', '💻', true, true, true),
('Mileage', 'MILE', '#00BCD4', '🚗', false, false, true),
('Per Diem', 'PERDIEM', '#8BC34A', '📅', false, false, true)
ON CONFLICT (name) DO NOTHING
""")
# Insert default per diem rates
op.execute("""
INSERT INTO per_diem_rates (country_code, location, rate_per_day, breakfast_deduction, lunch_deduction, dinner_deduction, valid_from, currency_code, is_active)
VALUES
('US', 'General', 55.00, 13.00, 16.00, 26.00, '2025-01-01', 'USD', true),
('GB', 'General', 45.00, 10.00, 13.00, 22.00, '2025-01-01', 'GBP', true),
('DE', 'General', 24.00, 5.00, 8.00, 11.00, '2025-01-01', 'EUR', true),
('FR', 'General', 20.00, 4.00, 7.00, 9.00, '2025-01-01', 'EUR', true)
""")
def downgrade():
# Remove circular foreign keys first
op.drop_constraint('fk_per_diems_expense', 'per_diems', type_='foreignkey')
op.drop_constraint('fk_mileage_expense', 'mileage', type_='foreignkey')
# Remove foreign keys from expenses
op.drop_constraint('fk_expenses_per_diem', 'expenses', type_='foreignkey')
op.drop_constraint('fk_expenses_mileage', 'expenses', type_='foreignkey')
# Remove columns from expenses table
op.drop_column('expenses', 'per_diem_id')
op.drop_column('expenses', 'mileage_id')
op.drop_column('expenses', 'ocr_data')
# Drop tables in reverse order
op.drop_index('ix_per_diems_trip_start', table_name='per_diems')
op.drop_index('ix_per_diems_user_id', table_name='per_diems')
op.drop_table('per_diems')
op.drop_index('ix_per_diem_rates_valid_from', table_name='per_diem_rates')
op.drop_index('ix_per_diem_rates_country', table_name='per_diem_rates')
op.drop_table('per_diem_rates')
op.drop_index('ix_mileage_trip_date', table_name='mileage')
op.drop_index('ix_mileage_user_id', table_name='mileage')
op.drop_table('mileage')
op.drop_index('ix_expense_categories_code', table_name='expense_categories')
op.drop_index('ix_expense_categories_name', table_name='expense_categories')
op.drop_table('expense_categories')

View File

@@ -0,0 +1,147 @@
"""Fix advanced expenses schema
Revision ID: 038_fix_expenses_schema
Revises: 037_advanced_expenses
Create Date: 2025-10-30 15:05:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '038_fix_expenses_schema'
down_revision = '037_advanced_expenses'
branch_labels = None
depends_on = None
def upgrade():
# Fix mileage table - rename columns and add missing ones
op.alter_column('mileage', 'trip_purpose', new_column_name='purpose', existing_type=sa.Text(), existing_nullable=False)
op.alter_column('mileage', 'vehicle_registration', new_column_name='license_plate', existing_type=sa.String(20), existing_nullable=True)
op.alter_column('mileage', 'total_amount', new_column_name='calculated_amount', existing_type=sa.Numeric(10, 2), existing_nullable=True)
# Add missing columns to mileage
op.add_column('mileage', sa.Column('description', sa.Text(), nullable=True))
op.add_column('mileage', sa.Column('start_odometer', sa.Numeric(precision=10, scale=2), nullable=True))
op.add_column('mileage', sa.Column('end_odometer', sa.Numeric(precision=10, scale=2), nullable=True))
op.add_column('mileage', sa.Column('distance_miles', sa.Numeric(precision=10, scale=2), nullable=True))
op.add_column('mileage', sa.Column('rate_per_mile', sa.Numeric(precision=10, scale=4), nullable=True))
op.add_column('mileage', sa.Column('vehicle_description', sa.String(length=200), nullable=True))
op.add_column('mileage', sa.Column('is_round_trip', sa.Boolean(), nullable=False, server_default='false'))
op.add_column('mileage', sa.Column('reimbursed', sa.Boolean(), nullable=False, server_default='false'))
op.add_column('mileage', sa.Column('reimbursed_at', sa.DateTime(), nullable=True))
op.add_column('mileage', sa.Column('currency_code', sa.String(length=3), nullable=False, server_default='EUR'))
# Make rate_per_km NOT NULL (it's required)
op.alter_column('mileage', 'rate_per_km', nullable=False, server_default='0.30')
# Fix per_diem_rates table - rename columns
op.alter_column('per_diem_rates', 'location', new_column_name='city', existing_type=sa.String(255), existing_nullable=True)
op.alter_column('per_diem_rates', 'valid_from', new_column_name='effective_from', existing_type=sa.Date(), existing_nullable=False)
op.alter_column('per_diem_rates', 'valid_to', new_column_name='effective_to', existing_type=sa.Date(), existing_nullable=True)
# Add missing columns to per_diem_rates
op.add_column('per_diem_rates', sa.Column('full_day_rate', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0'))
op.add_column('per_diem_rates', sa.Column('half_day_rate', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0'))
op.add_column('per_diem_rates', sa.Column('breakfast_rate', sa.Numeric(precision=10, scale=2), nullable=True))
op.add_column('per_diem_rates', sa.Column('lunch_rate', sa.Numeric(precision=10, scale=2), nullable=True))
op.add_column('per_diem_rates', sa.Column('dinner_rate', sa.Numeric(precision=10, scale=2), nullable=True))
op.add_column('per_diem_rates', sa.Column('incidental_rate', sa.Numeric(precision=10, scale=2), nullable=True))
op.add_column('per_diem_rates', sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')))
# Rename country_code to country
op.alter_column('per_diem_rates', 'country_code', new_column_name='country', existing_type=sa.String(2), existing_nullable=False)
# Drop old rate_per_day column after copying to full_day_rate
op.execute("UPDATE per_diem_rates SET full_day_rate = rate_per_day, half_day_rate = rate_per_day * 0.5")
op.drop_column('per_diem_rates', 'rate_per_day')
op.drop_column('per_diem_rates', 'breakfast_deduction')
op.drop_column('per_diem_rates', 'lunch_deduction')
op.drop_column('per_diem_rates', 'dinner_deduction')
# Fix per_diems table - rename columns
op.alter_column('per_diems', 'trip_start_date', new_column_name='start_date', existing_type=sa.Date(), existing_nullable=False)
op.alter_column('per_diems', 'trip_end_date', new_column_name='end_date', existing_type=sa.Date(), existing_nullable=False)
op.alter_column('per_diems', 'destination_country', new_column_name='country', existing_type=sa.String(2), existing_nullable=False)
op.alter_column('per_diems', 'destination_location', new_column_name='city', existing_type=sa.String(255), existing_nullable=True)
op.alter_column('per_diems', 'number_of_days', new_column_name='full_days', existing_type=sa.Integer(), existing_nullable=False)
op.alter_column('per_diems', 'total_amount', new_column_name='calculated_amount', existing_type=sa.Numeric(10, 2), existing_nullable=True)
# Add missing columns to per_diems
op.add_column('per_diems', sa.Column('trip_purpose', sa.String(length=255), nullable=False, server_default='Business trip'))
op.add_column('per_diems', sa.Column('description', sa.Text(), nullable=True))
op.add_column('per_diems', sa.Column('departure_time', sa.Time(), nullable=True))
op.add_column('per_diems', sa.Column('return_time', sa.Time(), nullable=True))
op.add_column('per_diems', sa.Column('half_days', sa.Integer(), nullable=False, server_default='0'))
op.add_column('per_diems', sa.Column('total_days', sa.Numeric(precision=5, scale=2), nullable=False, server_default='0'))
op.add_column('per_diems', sa.Column('full_day_rate', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0'))
op.add_column('per_diems', sa.Column('half_day_rate', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0'))
op.add_column('per_diems', sa.Column('breakfast_deduction', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0'))
op.add_column('per_diems', sa.Column('lunch_deduction', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0'))
op.add_column('per_diems', sa.Column('dinner_deduction', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0'))
op.add_column('per_diems', sa.Column('reimbursed', sa.Boolean(), nullable=False, server_default='false'))
op.add_column('per_diems', sa.Column('reimbursed_at', sa.DateTime(), nullable=True))
op.add_column('per_diems', sa.Column('approval_notes', sa.Text(), nullable=True))
def downgrade():
# Revert per_diems changes
op.drop_column('per_diems', 'approval_notes')
op.drop_column('per_diems', 'reimbursed_at')
op.drop_column('per_diems', 'reimbursed')
op.drop_column('per_diems', 'dinner_deduction')
op.drop_column('per_diems', 'lunch_deduction')
op.drop_column('per_diems', 'breakfast_deduction')
op.drop_column('per_diems', 'half_day_rate')
op.drop_column('per_diems', 'full_day_rate')
op.drop_column('per_diems', 'total_days')
op.drop_column('per_diems', 'half_days')
op.drop_column('per_diems', 'return_time')
op.drop_column('per_diems', 'departure_time')
op.drop_column('per_diems', 'description')
op.drop_column('per_diems', 'trip_purpose')
op.alter_column('per_diems', 'calculated_amount', new_column_name='total_amount')
op.alter_column('per_diems', 'full_days', new_column_name='number_of_days')
op.alter_column('per_diems', 'city', new_column_name='destination_location')
op.alter_column('per_diems', 'country', new_column_name='destination_country')
op.alter_column('per_diems', 'end_date', new_column_name='trip_end_date')
op.alter_column('per_diems', 'start_date', new_column_name='trip_start_date')
# Revert per_diem_rates changes
op.add_column('per_diem_rates', sa.Column('rate_per_day', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0'))
op.add_column('per_diem_rates', sa.Column('breakfast_deduction', sa.Numeric(precision=10, scale=2), nullable=True))
op.add_column('per_diem_rates', sa.Column('lunch_deduction', sa.Numeric(precision=10, scale=2), nullable=True))
op.add_column('per_diem_rates', sa.Column('dinner_deduction', sa.Numeric(precision=10, scale=2), nullable=True))
op.execute("UPDATE per_diem_rates SET rate_per_day = full_day_rate")
op.drop_column('per_diem_rates', 'updated_at')
op.drop_column('per_diem_rates', 'incidental_rate')
op.drop_column('per_diem_rates', 'dinner_rate')
op.drop_column('per_diem_rates', 'lunch_rate')
op.drop_column('per_diem_rates', 'breakfast_rate')
op.drop_column('per_diem_rates', 'half_day_rate')
op.drop_column('per_diem_rates', 'full_day_rate')
op.alter_column('per_diem_rates', 'country', new_column_name='country_code')
op.alter_column('per_diem_rates', 'effective_to', new_column_name='valid_to')
op.alter_column('per_diem_rates', 'effective_from', new_column_name='valid_from')
op.alter_column('per_diem_rates', 'city', new_column_name='location')
# Revert mileage changes
op.drop_column('mileage', 'currency_code')
op.drop_column('mileage', 'reimbursed_at')
op.drop_column('mileage', 'reimbursed')
op.drop_column('mileage', 'is_round_trip')
op.drop_column('mileage', 'vehicle_description')
op.drop_column('mileage', 'rate_per_mile')
op.drop_column('mileage', 'distance_miles')
op.drop_column('mileage', 'end_odometer')
op.drop_column('mileage', 'start_odometer')
op.drop_column('mileage', 'description')
op.alter_column('mileage', 'rate_per_km', nullable=True)
op.alter_column('mileage', 'calculated_amount', new_column_name='total_amount')
op.alter_column('mileage', 'license_plate', new_column_name='vehicle_registration')
op.alter_column('mileage', 'purpose', new_column_name='trip_purpose')

View File

@@ -70,4 +70,7 @@ posthog==3.1.0
# API Documentation
flask-swagger-ui==5.21.0
apispec==6.3.0
marshmallow==3.20.1
marshmallow==3.20.1
# OCR for receipt scanning
pytesseract==0.3.10

3
temp_migration.sql Normal file
View File

@@ -0,0 +1,3 @@
-- Temporary migration to set up advanced expense management schema
UPDATE alembic_version SET version_num = '037_add_advanced_expense_management';

View File

@@ -0,0 +1,219 @@
"""
Tests for ExpenseCategory model
"""
import pytest
from datetime import date, datetime, timedelta
from decimal import Decimal
from app import db
from app.models import ExpenseCategory, Expense, User
@pytest.fixture
def user(client):
"""Create a test user"""
user = User(username='testuser', email='test@example.com')
user.set_password('password123')
db.session.add(user)
db.session.commit()
return user
@pytest.fixture
def category(client):
"""Create a test expense category"""
category = ExpenseCategory(
name='Travel',
code='TRV',
monthly_budget=5000,
quarterly_budget=15000,
yearly_budget=60000,
budget_threshold_percent=80,
requires_receipt=True,
requires_approval=True
)
db.session.add(category)
db.session.commit()
return category
def test_create_expense_category(client):
"""Test creating an expense category"""
category = ExpenseCategory(
name='Meals',
code='MEL',
description='Meal expenses',
monthly_budget=1000,
requires_receipt=True
)
db.session.add(category)
db.session.commit()
assert category.id is not None
assert category.name == 'Meals'
assert category.code == 'MEL'
assert category.monthly_budget == Decimal('1000')
assert category.requires_receipt is True
assert category.is_active is True
def test_category_budget_utilization(client, category, user):
"""Test budget utilization calculation"""
# Create some approved expenses in current month
today = date.today()
start_of_month = date(today.year, today.month, 1)
expense1 = Expense(
user_id=user.id,
title='Flight tickets',
category='Travel',
amount=2000,
expense_date=today,
status='approved'
)
expense2 = Expense(
user_id=user.id,
title='Hotel',
category='Travel',
amount=1500,
expense_date=today,
status='approved'
)
db.session.add_all([expense1, expense2])
db.session.commit()
# Get monthly utilization
util = category.get_budget_utilization('monthly')
assert util is not None
assert util['budget'] == 5000
assert util['spent'] == 3500
assert util['utilization_percent'] == 70.0
assert util['remaining'] == 1500
assert util['over_threshold'] is False
def test_category_over_budget_threshold(client, category, user):
"""Test detecting when budget threshold is exceeded"""
today = date.today()
# Create expense that exceeds threshold (80% of 5000 = 4000)
expense = Expense(
user_id=user.id,
title='Expensive trip',
category='Travel',
amount=4500,
expense_date=today,
status='approved'
)
db.session.add(expense)
db.session.commit()
# Get monthly utilization
util = category.get_budget_utilization('monthly')
assert util is not None
assert util['utilization_percent'] == 90.0
assert util['over_threshold'] is True
def test_get_active_categories(client, category):
"""Test getting active categories"""
# Create an inactive category
inactive_category = ExpenseCategory(
name='Deprecated',
code='DEP',
is_active=False
)
db.session.add(inactive_category)
db.session.commit()
# Get active categories
active_categories = ExpenseCategory.get_active_categories()
assert len(active_categories) >= 1
assert category in active_categories
assert inactive_category not in active_categories
def test_category_to_dict(client, category):
"""Test converting category to dictionary"""
data = category.to_dict()
assert data['id'] == category.id
assert data['name'] == 'Travel'
assert data['code'] == 'TRV'
assert data['monthly_budget'] == 5000
assert data['quarterly_budget'] == 15000
assert data['yearly_budget'] == 60000
assert data['budget_threshold_percent'] == 80
assert data['requires_receipt'] is True
assert data['requires_approval'] is True
assert data['is_active'] is True
def test_category_unique_name(client, category):
"""Test that category names must be unique"""
duplicate = ExpenseCategory(
name='Travel', # Same as existing category
code='TRV2'
)
db.session.add(duplicate)
with pytest.raises(Exception): # IntegrityError
db.session.commit()
def test_category_quarterly_budget(client, category, user):
"""Test quarterly budget utilization"""
today = date.today()
quarter = (today.month - 1) // 3 + 1
start_month = (quarter - 1) * 3 + 1
# Create expenses in current quarter
expense = Expense(
user_id=user.id,
title='Q1 Travel',
category='Travel',
amount=8000,
expense_date=today,
status='approved'
)
db.session.add(expense)
db.session.commit()
# Get quarterly utilization
util = category.get_budget_utilization('quarterly')
assert util is not None
assert util['budget'] == 15000
assert util['spent'] == 8000
assert util['utilization_percent'] == pytest.approx(53.33, rel=0.1)
def test_get_categories_over_budget(client, category, user):
"""Test getting categories over budget threshold"""
today = date.today()
# Create expense that exceeds threshold
expense = Expense(
user_id=user.id,
title='Over budget',
category='Travel',
amount=4500,
expense_date=today,
status='approved'
)
db.session.add(expense)
db.session.commit()
# Get categories over budget
over_budget = ExpenseCategory.get_categories_over_budget('monthly')
assert len(over_budget) > 0
assert any(item['category'].id == category.id for item in over_budget)

View File

@@ -0,0 +1,276 @@
"""
Tests for Mileage model
"""
import pytest
from datetime import date, datetime
from decimal import Decimal
from app import db
from app.models import Mileage, User, Project, Client, Expense
@pytest.fixture
def user(client):
"""Create a test user"""
user = User(username='testuser', email='test@example.com')
user.set_password('password123')
db.session.add(user)
db.session.commit()
return user
@pytest.fixture
def project(client):
"""Create a test project"""
client_obj = Client(name='Test Client', company='Test Client')
db.session.add(client_obj)
db.session.commit()
project = Project(
name='Test Project',
client_id=client_obj.id,
billable=True
)
db.session.add(project)
db.session.commit()
return project
def test_create_mileage(client, user):
"""Test creating a mileage entry"""
mileage = Mileage(
user_id=user.id,
trip_date=date.today(),
purpose='Client meeting',
start_location='Office',
end_location='Client Site',
distance_km=45.5,
rate_per_km=0.30,
vehicle_type='car'
)
db.session.add(mileage)
db.session.commit()
assert mileage.id is not None
assert mileage.purpose == 'Client meeting'
assert mileage.distance_km == Decimal('45.5')
assert mileage.rate_per_km == Decimal('0.30')
assert mileage.calculated_amount == Decimal('13.65')
assert mileage.status == 'pending'
def test_mileage_round_trip(client, user):
"""Test mileage calculation for round trip"""
mileage = Mileage(
user_id=user.id,
trip_date=date.today(),
purpose='Round trip',
start_location='A',
end_location='B',
distance_km=50,
rate_per_km=0.30,
is_round_trip=True
)
db.session.add(mileage)
db.session.commit()
# Check that total distance and amount are doubled
assert mileage.total_distance_km == 100.0
assert mileage.total_amount == 30.0 # 50 km * 2 * 0.30
def test_mileage_approval(client, user):
"""Test mileage approval workflow"""
admin = User(username='admin', email='admin@example.com', role='admin')
admin.set_password('admin123')
db.session.add(admin)
db.session.commit()
mileage = Mileage(
user_id=user.id,
trip_date=date.today(),
purpose='Test trip',
start_location='A',
end_location='B',
distance_km=30,
rate_per_km=0.30
)
db.session.add(mileage)
db.session.commit()
# Approve mileage
mileage.approve(admin.id, notes='Approved')
db.session.commit()
assert mileage.status == 'approved'
assert mileage.approved_by == admin.id
assert mileage.approved_at is not None
assert 'Approved' in mileage.notes
def test_mileage_rejection(client, user):
"""Test mileage rejection workflow"""
admin = User(username='admin', email='admin@example.com', role='admin')
admin.set_password('admin123')
db.session.add(admin)
db.session.commit()
mileage = Mileage(
user_id=user.id,
trip_date=date.today(),
purpose='Test trip',
start_location='A',
end_location='B',
distance_km=30,
rate_per_km=0.30
)
db.session.add(mileage)
db.session.commit()
# Reject mileage
mileage.reject(admin.id, reason='Missing documentation')
db.session.commit()
assert mileage.status == 'rejected'
assert mileage.approved_by == admin.id
assert mileage.rejection_reason == 'Missing documentation'
def test_mileage_create_expense(client, user, project):
"""Test creating expense from mileage entry"""
mileage = Mileage(
user_id=user.id,
trip_date=date.today(),
purpose='Client visit',
start_location='Office',
end_location='Client',
distance_km=40,
rate_per_km=0.30,
project_id=project.id,
is_round_trip=True
)
db.session.add(mileage)
db.session.commit()
# Create expense
expense = mileage.create_expense()
assert expense is not None
assert expense.user_id == user.id
assert expense.category == 'travel'
assert expense.amount == mileage.total_amount
assert expense.project_id == project.id
assert 'Distance' in expense.description
def test_mileage_to_dict(client, user):
"""Test converting mileage to dictionary"""
mileage = Mileage(
user_id=user.id,
trip_date=date.today(),
purpose='Test trip',
start_location='A',
end_location='B',
distance_km=25.5,
rate_per_km=0.30
)
db.session.add(mileage)
db.session.commit()
data = mileage.to_dict()
assert data['id'] == mileage.id
assert data['user_id'] == user.id
assert data['purpose'] == 'Test trip'
assert data['start_location'] == 'A'
assert data['end_location'] == 'B'
assert data['distance_km'] == 25.5
assert data['rate_per_km'] == 0.30
assert data['calculated_amount'] == 7.65
assert data['status'] == 'pending'
def test_get_total_distance(client, user):
"""Test getting total distance traveled"""
today = date.today()
# Create multiple mileage entries
mileage1 = Mileage(
user_id=user.id,
trip_date=today,
purpose='Trip 1',
start_location='A',
end_location='B',
distance_km=30,
rate_per_km=0.30,
status='approved'
)
mileage2 = Mileage(
user_id=user.id,
trip_date=today,
purpose='Trip 2',
start_location='C',
end_location='D',
distance_km=50,
rate_per_km=0.30,
status='approved'
)
db.session.add_all([mileage1, mileage2])
db.session.commit()
# Get total distance
total = Mileage.get_total_distance(user_id=user.id)
assert total == 80.0
def test_mileage_default_rates(client):
"""Test getting default mileage rates"""
rates = Mileage.get_default_rates()
assert 'car' in rates
assert 'motorcycle' in rates
assert 'van' in rates
assert 'truck' in rates
assert rates['car']['km'] == 0.30
assert rates['motorcycle']['km'] == 0.20
def test_mileage_reimbursement(client, user):
"""Test marking mileage as reimbursed"""
admin = User(username='admin', email='admin@example.com', role='admin')
admin.set_password('admin123')
db.session.add(admin)
db.session.commit()
mileage = Mileage(
user_id=user.id,
trip_date=date.today(),
purpose='Test trip',
start_location='A',
end_location='B',
distance_km=30,
rate_per_km=0.30,
status='approved'
)
db.session.add(mileage)
db.session.commit()
# Mark as reimbursed
mileage.mark_as_reimbursed()
db.session.commit()
assert mileage.status == 'reimbursed'
assert mileage.reimbursed is True
assert mileage.reimbursed_at is not None

View File

@@ -0,0 +1,338 @@
"""
Tests for PerDiem and PerDiemRate models
"""
import pytest
from datetime import date, datetime, time
from decimal import Decimal
from app import db
from app.models import PerDiem, PerDiemRate, User
@pytest.fixture
def user(client):
"""Create a test user"""
user = User(username='testuser', email='test@example.com')
user.set_password('password123')
db.session.add(user)
db.session.commit()
return user
@pytest.fixture
def rate(client):
"""Create a test per diem rate"""
rate = PerDiemRate(
country='Germany',
city='Berlin',
full_day_rate=28.00,
half_day_rate=14.00,
breakfast_rate=5.60,
lunch_rate=11.20,
dinner_rate=11.20,
incidental_rate=3.00,
currency_code='EUR',
effective_from=date(2024, 1, 1)
)
db.session.add(rate)
db.session.commit()
return rate
def test_create_per_diem_rate(client):
"""Test creating a per diem rate"""
rate = PerDiemRate(
country='France',
city='Paris',
full_day_rate=45.00,
half_day_rate=22.50,
effective_from=date(2024, 1, 1)
)
db.session.add(rate)
db.session.commit()
assert rate.id is not None
assert rate.country == 'France'
assert rate.city == 'Paris'
assert rate.full_day_rate == Decimal('45.00')
assert rate.half_day_rate == Decimal('22.50')
assert rate.is_active is True
def test_get_rate_for_location(client, rate):
"""Test getting rate for a specific location"""
found_rate = PerDiemRate.get_rate_for_location('Germany', 'Berlin', date.today())
assert found_rate is not None
assert found_rate.id == rate.id
assert found_rate.city == 'Berlin'
def test_get_rate_falls_back_to_country(client):
"""Test that rate search falls back to country rate if city not found"""
# Create country-level rate
country_rate = PerDiemRate(
country='Netherlands',
city=None, # Country-level rate
full_day_rate=35.00,
half_day_rate=17.50,
effective_from=date(2024, 1, 1)
)
db.session.add(country_rate)
db.session.commit()
# Search for a city that doesn't have a rate
found_rate = PerDiemRate.get_rate_for_location('Netherlands', 'Amsterdam', date.today())
assert found_rate is not None
assert found_rate.id == country_rate.id
assert found_rate.city is None
def test_create_per_diem_claim(client, user, rate):
"""Test creating a per diem claim"""
per_diem = PerDiem(
user_id=user.id,
trip_purpose='Conference',
start_date=date(2025, 10, 20),
end_date=date(2025, 10, 23),
country='Germany',
city='Berlin',
full_day_rate=rate.full_day_rate,
half_day_rate=rate.half_day_rate,
full_days=3,
half_days=1,
breakfast_deduction=rate.breakfast_rate,
currency_code='EUR'
)
db.session.add(per_diem)
db.session.commit()
assert per_diem.id is not None
assert per_diem.trip_purpose == 'Conference'
assert per_diem.full_days == 3
assert per_diem.half_days == 1
assert per_diem.total_days == 3.5
assert per_diem.status == 'pending'
def test_per_diem_calculation(client, user, rate):
"""Test per diem amount calculation"""
per_diem = PerDiem(
user_id=user.id,
trip_purpose='Business trip',
start_date=date(2025, 10, 20),
end_date=date(2025, 10, 22),
country='Germany',
city='Berlin',
full_day_rate=28,
half_day_rate=14,
full_days=2,
half_days=1,
breakfast_provided=0,
breakfast_deduction=0,
lunch_deduction=0,
dinner_deduction=0
)
db.session.add(per_diem)
db.session.commit()
# Calculation: (2 * 28) + (1 * 14) = 56 + 14 = 70
assert per_diem.calculated_amount == Decimal('70')
def test_per_diem_with_meal_deductions(client, user, rate):
"""Test per diem with provided meals"""
per_diem = PerDiem(
user_id=user.id,
trip_purpose='Conference with meals',
start_date=date(2025, 10, 20),
end_date=date(2025, 10, 22),
country='Germany',
city='Berlin',
full_day_rate=28,
half_day_rate=14,
full_days=3,
half_days=0,
breakfast_provided=2,
lunch_provided=3,
dinner_provided=2,
breakfast_deduction=5.60,
lunch_deduction=11.20,
dinner_deduction=11.20
)
db.session.add(per_diem)
db.session.commit()
# Calculation: (3 * 28) - (2 * 5.60) - (3 * 11.20) - (2 * 11.20)
# = 84 - 11.20 - 33.60 - 22.40 = 16.80
assert per_diem.calculated_amount == Decimal('16.80')
def test_calculate_days_from_dates_single_day(client):
"""Test calculating days for a single day trip"""
result = PerDiem.calculate_days_from_dates(
start_date=date(2025, 10, 20),
end_date=date(2025, 10, 20),
departure_time=time(8, 0),
return_time=time(18, 0) # 10 hours
)
assert result['full_days'] == 1
assert result['half_days'] == 0
def test_calculate_days_from_dates_multi_day(client):
"""Test calculating days for multi-day trip"""
result = PerDiem.calculate_days_from_dates(
start_date=date(2025, 10, 20),
end_date=date(2025, 10, 23),
departure_time=time(8, 0), # Before noon = full day
return_time=time(14, 0) # After noon = full day
)
# Day 1: departure before 12:00 = full day
# Day 2-3: middle days = 2 full days
# Day 4: return after 12:00 = full day
# Total: 4 full days
assert result['full_days'] == 4
assert result['half_days'] == 0
def test_calculate_days_with_half_days(client):
"""Test calculating days with half days"""
result = PerDiem.calculate_days_from_dates(
start_date=date(2025, 10, 20),
end_date=date(2025, 10, 22),
departure_time=time(14, 0), # After noon = half day
return_time=time(10, 0) # Before noon = half day
)
# Day 1: departure after 12:00 = half day
# Day 2: middle day = full day
# Day 3: return before 12:00 = half day
# Total: 1 full day, 2 half days
assert result['full_days'] == 1
assert result['half_days'] == 2
def test_per_diem_approval(client, user):
"""Test per diem approval workflow"""
admin = User(username='admin', email='admin@example.com', role='admin')
admin.set_password('admin123')
db.session.add(admin)
db.session.commit()
per_diem = PerDiem(
user_id=user.id,
trip_purpose='Business trip',
start_date=date(2025, 10, 20),
end_date=date(2025, 10, 22),
country='Germany',
full_day_rate=28,
half_day_rate=14,
full_days=2,
half_days=1
)
db.session.add(per_diem)
db.session.commit()
# Approve
per_diem.approve(admin.id, notes='Approved')
db.session.commit()
assert per_diem.status == 'approved'
assert per_diem.approved_by == admin.id
assert per_diem.approved_at is not None
def test_per_diem_to_dict(client, user, rate):
"""Test converting per diem to dictionary"""
per_diem = PerDiem(
user_id=user.id,
trip_purpose='Test trip',
start_date=date(2025, 10, 20),
end_date=date(2025, 10, 22),
country='Germany',
city='Berlin',
full_day_rate=28,
half_day_rate=14,
full_days=2,
half_days=1
)
db.session.add(per_diem)
db.session.commit()
data = per_diem.to_dict()
assert data['id'] == per_diem.id
assert data['user_id'] == user.id
assert data['trip_purpose'] == 'Test trip'
assert data['country'] == 'Germany'
assert data['city'] == 'Berlin'
assert data['full_days'] == 2
assert data['half_days'] == 1
assert data['total_days'] == 2.5
def test_per_diem_recalculate(client, user):
"""Test recalculating per diem amount"""
per_diem = PerDiem(
user_id=user.id,
trip_purpose='Trip',
start_date=date(2025, 10, 20),
end_date=date(2025, 10, 22),
country='Germany',
full_day_rate=28,
half_day_rate=14,
full_days=2,
half_days=0
)
db.session.add(per_diem)
db.session.commit()
initial_amount = per_diem.calculated_amount
assert initial_amount == Decimal('56')
# Change days
per_diem.full_days = 3
new_amount = per_diem.recalculate_amount()
assert new_amount == Decimal('84')
assert per_diem.calculated_amount == Decimal('84')
def test_per_diem_create_expense(client, user):
"""Test creating expense from per diem claim"""
per_diem = PerDiem(
user_id=user.id,
trip_purpose='Conference',
start_date=date(2025, 10, 20),
end_date=date(2025, 10, 23),
country='Germany',
city='Berlin',
full_day_rate=28,
half_day_rate=14,
full_days=3,
half_days=1
)
db.session.add(per_diem)
db.session.commit()
# Create expense
expense = per_diem.create_expense()
assert expense is not None
assert expense.user_id == user.id
assert expense.category == 'meals'
assert expense.amount == per_diem.calculated_amount
assert 'Berlin, Germany' in expense.title