mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2025-12-30 15:49:44 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
144
app/models/expense_category.py
Normal file
144
app/models/expense_category.py
Normal 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
249
app/models/mileage.py
Normal 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
418
app/models/per_diem.py
Normal 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()
|
||||
|
||||
253
app/routes/expense_categories.py
Normal file
253
app/routes/expense_categories.py
Normal 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)
|
||||
})
|
||||
|
||||
@@ -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
466
app/routes/mileage.py
Normal 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
542
app/routes/per_diem.py
Normal 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
|
||||
|
||||
@@ -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>
|
||||
|
||||
196
app/templates/expense_categories/form.html
Normal file
196
app/templates/expense_categories/form.html
Normal 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 %}
|
||||
|
||||
160
app/templates/expense_categories/list.html
Normal file
160
app/templates/expense_categories/list.html
Normal 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 %}
|
||||
|
||||
208
app/templates/expense_categories/view.html
Normal file
208
app/templates/expense_categories/view.html
Normal 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 %}
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
309
app/templates/mileage/form.html
Normal file
309
app/templates/mileage/form.html
Normal 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 %}
|
||||
|
||||
214
app/templates/mileage/list.html
Normal file
214
app/templates/mileage/list.html
Normal 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 %}
|
||||
|
||||
302
app/templates/mileage/view.html
Normal file
302
app/templates/mileage/view.html
Normal 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 %}
|
||||
|
||||
230
app/templates/per_diem/form.html
Normal file
230
app/templates/per_diem/form.html
Normal 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 %}
|
||||
|
||||
182
app/templates/per_diem/list.html
Normal file
182
app/templates/per_diem/list.html
Normal 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 %}
|
||||
|
||||
175
app/templates/per_diem/rate_form.html
Normal file
175
app/templates/per_diem/rate_form.html
Normal 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 %}
|
||||
|
||||
84
app/templates/per_diem/rates_list.html
Normal file
84
app/templates/per_diem/rates_list.html
Normal 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 %}
|
||||
|
||||
115
app/templates/per_diem/view.html
Normal file
115
app/templates/per_diem/view.html
Normal 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
344
app/utils/ocr.py
Normal 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
77
fix_expense_schema.sql
Normal 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;
|
||||
|
||||
204
migrations/versions/037_advanced_expenses.py
Normal file
204
migrations/versions/037_advanced_expenses.py
Normal 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')
|
||||
|
||||
147
migrations/versions/038_fix_advanced_expenses_schema.py
Normal file
147
migrations/versions/038_fix_advanced_expenses_schema.py
Normal 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')
|
||||
|
||||
@@ -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
3
temp_migration.sql
Normal 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';
|
||||
|
||||
219
tests/test_models/test_expense_category.py
Normal file
219
tests/test_models/test_expense_category.py
Normal 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)
|
||||
|
||||
276
tests/test_models/test_mileage.py
Normal file
276
tests/test_models/test_mileage.py
Normal 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
|
||||
|
||||
338
tests/test_models/test_per_diem.py
Normal file
338
tests/test_models/test_per_diem.py
Normal 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
|
||||
|
||||
Reference in New Issue
Block a user