feat(billing): add paid status tracking for time entries with invoice reference

Add ability to mark time entries as paid and link them to internal invoice
numbers. Automatically mark time entries as paid when invoices are sent.

Database Changes:
- Add migration 083 to add `paid` boolean and `invoice_number` string columns
  to time_entries table
- Add index on `paid` field for faster queries

Model Updates:
- Add `paid` (default: False) and `invoice_number` (nullable) fields to TimeEntry
- Add `set_paid()` helper method to TimeEntry model
- Update `to_dict()` to include paid status and invoice number

API & Service Layer:
- Update TimeEntrySchema (all variants) to include paid/invoice_number fields
- Update API endpoints (/api/entry, /api/v1/time-entries) to accept these fields
- Update TimeTrackingService and TimeEntryRepository to handle paid status
- Add InvoiceService.mark_time_entries_as_paid() to automatically mark entries
- Update InvoiceService.mark_as_sent() to auto-mark time entries as paid

UI Updates:
- Add "Paid" checkbox and "Invoice Number" input field to time entry edit forms
- Update both admin and regular user edit forms
- Fields appear in timer edit page after tags section

Invoice Integration:
- Automatically mark time entries as paid when invoice status changes to "sent"
- Mark entries when time is added to already-sent invoices
- Store invoice number reference on time entries for tracking
- Enhanced create_invoice_from_time_entries() to properly link time entries

This enables proper tracking of which hours have been invoiced and paid
through the internal invoicing system, separate from the external ERP system.
This commit is contained in:
Dries Peeters
2025-11-30 11:31:42 +01:00
parent bf4b36e701
commit 90d8407bda
12 changed files with 288 additions and 60 deletions

View File

@@ -30,6 +30,8 @@ class TimeEntry(db.Model):
tags = db.Column(db.String(500), nullable=True) # Comma-separated tags
source = db.Column(db.String(20), default="manual", nullable=False) # 'manual' or 'auto'
billable = db.Column(db.Boolean, default=True, nullable=False)
paid = db.Column(db.Boolean, default=False, nullable=False, index=True)
invoice_number = db.Column(db.String(100), nullable=True)
created_at = db.Column(db.DateTime, default=local_now, nullable=False)
updated_at = db.Column(db.DateTime, default=local_now, onupdate=local_now, nullable=False)
@@ -50,6 +52,8 @@ class TimeEntry(db.Model):
tags=None,
source="manual",
billable=True,
paid=False,
invoice_number=None,
duration_seconds=None,
**kwargs,
):
@@ -66,6 +70,8 @@ class TimeEntry(db.Model):
tags: Optional comma-separated tags
source: Source of the entry ('manual' or 'auto')
billable: Whether this entry is billable
paid: Whether this entry has been paid
invoice_number: Optional internal invoice number reference
duration_seconds: Optional duration override (usually calculated automatically)
**kwargs: Additional keyword arguments (for SQLAlchemy compatibility)
"""
@@ -94,6 +100,8 @@ class TimeEntry(db.Model):
self.tags = tags.strip() if tags else None
self.source = source
self.billable = billable
self.paid = paid
self.invoice_number = invoice_number.strip() if invoice_number else None
# Allow manual duration override
if duration_seconds is not None:
@@ -224,6 +232,17 @@ class TimeEntry(db.Model):
self.updated_at = local_now()
db.session.commit()
def set_paid(self, paid, invoice_number=None):
"""Set paid status and optional invoice number"""
self.paid = paid
if invoice_number:
self.invoice_number = invoice_number.strip() if invoice_number else None
elif not paid:
# Clear invoice number when marking as unpaid
self.invoice_number = None
self.updated_at = local_now()
db.session.commit()
def to_dict(self):
"""Convert time entry to dictionary for API responses"""
return {
@@ -242,6 +261,8 @@ class TimeEntry(db.Model):
"tag_list": self.tag_list,
"source": self.source,
"billable": self.billable,
"paid": self.paid,
"invoice_number": self.invoice_number,
"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,

View File

@@ -167,6 +167,8 @@ class TimeEntryRepository(BaseRepository[TimeEntry]):
notes: Optional[str] = None,
tags: Optional[str] = None,
billable: bool = True,
paid: bool = False,
invoice_number: Optional[str] = None,
) -> TimeEntry:
"""Create a manual time entry"""
entry = self.model(
@@ -179,6 +181,8 @@ class TimeEntryRepository(BaseRepository[TimeEntry]):
notes=notes,
tags=tags,
billable=billable,
paid=paid,
invoice_number=invoice_number,
source=TimeEntrySource.MANUAL.value,
)
entry.calculate_duration()

View File

@@ -1337,6 +1337,16 @@ def update_entry(entry_id):
if "billable" in data:
entry.billable = bool(data["billable"])
if "paid" in data:
entry.paid = bool(data["paid"])
# Clear invoice number if marking as unpaid
if not entry.paid:
entry.invoice_number = None
if "invoice_number" in data:
invoice_number = data["invoice_number"]
entry.invoice_number = invoice_number.strip() if invoice_number else None
# Prefer local time for updated_at per project preference
entry.updated_at = local_now()

View File

@@ -643,6 +643,8 @@ def create_time_entry():
notes=data.get("notes"),
tags=data.get("tags"),
billable=data.get("billable", True),
paid=data.get("paid", False),
invoice_number=data.get("invoice_number"),
)
if not result.get("success"):
@@ -704,6 +706,8 @@ def update_time_entry(entry_id):
notes=data.get("notes"),
tags=data.get("tags"),
billable=data.get("billable"),
paid=data.get("paid"),
invoice_number=data.get("invoice_number"),
)
if not result.get("success"):

View File

@@ -1,4 +1,4 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file, make_response
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file, make_response, current_app
from flask_babel import gettext as _
from flask_login import login_required, current_user
from app import db, log_event, track_event
@@ -461,6 +461,16 @@ def update_invoice_status(invoice_id):
if not invoice.payment_date:
invoice.payment_date = datetime.utcnow().date()
# Mark time entries as paid when invoice is sent (non-external invoices)
if new_status == "sent":
from app.services import InvoiceService
invoice_service = InvoiceService()
marked_count = invoice_service.mark_time_entries_as_paid(invoice)
if marked_count > 0:
current_app.logger.info(
f"Marked {marked_count} time entr{'y' if marked_count == 1 else 'ies'} as paid for invoice {invoice.invoice_number}"
)
# Reduce stock when invoice is sent or paid (if configured)
from app.models import StockMovement, StockReservation
import os
@@ -775,6 +785,14 @@ def generate_from_time(invoice_id):
flash(_("Could not generate items due to a database error. Please check server logs."), "error")
return redirect(url_for("invoices.edit_invoice", invoice_id=invoice.id))
# If invoice is already sent (not draft), mark time entries as paid
if invoice.status != "draft":
from app.services import InvoiceService
invoice_service = InvoiceService()
marked_count = invoice_service.mark_time_entries_as_paid(invoice)
if marked_count > 0:
safe_commit("mark_time_entries_paid_from_invoice", {"invoice_id": invoice.id})
flash(_("Invoice items generated successfully from time entries and costs"), "success")
if total_prepaid_allocated and total_prepaid_allocated > 0:
flash(

View File

@@ -531,6 +531,14 @@ def edit_timer(timer_id):
timer.notes = request.form.get("notes", "").strip()
timer.tags = request.form.get("tags", "").strip()
timer.billable = request.form.get("billable") == "on"
timer.paid = request.form.get("paid") == "on"
# Update invoice number
invoice_number = request.form.get("invoice_number", "").strip()
timer.invoice_number = invoice_number if invoice_number else None
# Clear invoice number if marking as unpaid
if not timer.paid:
timer.invoice_number = None
# Admin users can edit additional fields
if current_user.is_admin:

View File

@@ -22,6 +22,8 @@ class TimeEntrySchema(Schema):
tags = fields.Str(allow_none=True)
source = fields.Str(validate=validate.OneOf([s.value for s in TimeEntrySource]))
billable = fields.Bool(missing=True)
paid = fields.Bool(missing=False)
invoice_number = fields.Str(allow_none=True, validate=validate.Length(max=100))
created_at = fields.DateTime(dump_only=True)
updated_at = fields.DateTime(dump_only=True)
@@ -43,6 +45,8 @@ class TimeEntryCreateSchema(Schema):
notes = fields.Str(allow_none=True, validate=validate.Length(max=5000))
tags = fields.Str(allow_none=True, validate=validate.Length(max=500))
billable = fields.Bool(missing=True)
paid = fields.Bool(missing=False)
invoice_number = fields.Str(allow_none=True, validate=validate.Length(max=100))
@validates("end_time")
def validate_end_time(self, value, **kwargs):
@@ -88,6 +92,8 @@ class TimeEntryUpdateSchema(Schema):
notes = fields.Str(allow_none=True, validate=validate.Length(max=5000))
tags = fields.Str(allow_none=True, validate=validate.Length(max=500))
billable = fields.Bool(allow_none=True)
paid = fields.Bool(allow_none=True)
invoice_number = fields.Str(allow_none=True, validate=validate.Length(max=100))
class TimerStartSchema(Schema):

View File

@@ -83,20 +83,47 @@ class InvoiceService:
)
# Create invoice items from time entries
# Group entries by task for better organization
grouped_entries = {}
for entry in entries:
if entry.duration_seconds:
hours = Decimal(str(entry.duration_seconds / 3600))
rate = project.hourly_rate or Decimal("0.00")
amount = hours * rate
item = InvoiceItem(
invoice_id=invoice.id,
description=f"Time entry: {entry.notes or 'No description'}",
quantity=hours,
unit_price=rate,
amount=amount,
)
db.session.add(item)
if hours <= 0:
continue
# Group by task if available, otherwise by project
if entry.task_id:
key = f"task_{entry.task_id}"
description = f"Task: {entry.task.name if entry.task else 'Unknown Task'}"
else:
key = f"project_{entry.project_id}"
description = f"Project: {project.name}"
if key not in grouped_entries:
grouped_entries[key] = {
"description": description,
"entries": [],
"total_hours": Decimal("0"),
}
grouped_entries[key]["entries"].append(entry)
grouped_entries[key]["total_hours"] += hours
# Create invoice items from grouped entries
for group in grouped_entries.values():
rate = project.hourly_rate or Decimal("0.00")
# Store all time entry IDs as comma-separated string
time_entry_ids = ",".join(str(entry.id) for entry in group["entries"])
item = InvoiceItem(
invoice_id=invoice.id,
description=group["description"],
quantity=group["total_hours"],
unit_price=rate,
time_entry_ids=time_entry_ids,
)
db.session.add(item)
if not safe_commit("create_invoice", {"project_id": project_id, "created_by": created_by}):
return {
@@ -181,12 +208,15 @@ class InvoiceService:
return {"success": True, "message": "Invoice created successfully", "invoice": invoice}
def mark_as_sent(self, invoice_id: int) -> Dict[str, Any]:
"""Mark an invoice as sent"""
"""Mark an invoice as sent and mark associated time entries as paid"""
invoice = self.invoice_repo.mark_as_sent(invoice_id)
if not invoice:
return {"success": False, "message": "Invoice not found", "error": "not_found"}
# Mark associated time entries as paid
marked_count = self.mark_time_entries_as_paid(invoice)
if not safe_commit("mark_invoice_sent", {"invoice_id": invoice_id}):
return {
"success": False,
@@ -194,7 +224,11 @@ class InvoiceService:
"error": "database_error",
}
return {"success": True, "message": "Invoice marked as sent", "invoice": invoice}
message = "Invoice marked as sent"
if marked_count > 0:
message += f" ({marked_count} time entr{'y' if marked_count == 1 else 'ies'} marked as paid)"
return {"success": True, "message": message, "invoice": invoice}
def mark_as_paid(
self,
@@ -223,6 +257,40 @@ class InvoiceService:
return {"success": True, "message": "Invoice marked as paid", "invoice": invoice}
def mark_time_entries_as_paid(self, invoice: Invoice) -> int:
"""
Mark all time entries associated with an invoice as paid.
Args:
invoice: The Invoice object
Returns:
Number of time entries marked as paid
"""
time_entry_ids = set()
# Collect all time entry IDs from invoice items
for item in invoice.items:
if item.time_entry_ids:
# Parse comma-separated IDs
ids = [int(id_str.strip()) for id_str in item.time_entry_ids.split(",") if id_str.strip().isdigit()]
time_entry_ids.update(ids)
if not time_entry_ids:
return 0
# Mark all time entries as paid
entries = TimeEntry.query.filter(TimeEntry.id.in_(time_entry_ids)).all()
marked_count = 0
for entry in entries:
if not entry.paid:
entry.paid = True
entry.invoice_number = invoice.invoice_number
marked_count += 1
return marked_count
def update_invoice(self, invoice_id: int, user_id: int, **kwargs) -> Dict[str, Any]:
"""
Update an invoice.

View File

@@ -175,6 +175,8 @@ class TimeTrackingService:
notes: Optional[str] = None,
tags: Optional[str] = None,
billable: bool = True,
paid: bool = False,
invoice_number: Optional[str] = None,
) -> Dict[str, Any]:
"""
Create a manual time entry.
@@ -234,6 +236,8 @@ class TimeTrackingService:
notes=notes,
tags=tags,
billable=billable,
paid=paid,
invoice_number=invoice_number,
)
commit_data = {"user_id": user_id}
@@ -295,6 +299,8 @@ class TimeTrackingService:
notes: Optional[str] = None,
tags: Optional[str] = None,
billable: Optional[bool] = None,
paid: Optional[bool] = None,
invoice_number: Optional[str] = None,
) -> Dict[str, Any]:
"""
Update a time entry.
@@ -361,6 +367,13 @@ class TimeTrackingService:
entry.tags = tags
if billable is not None:
entry.billable = billable
if paid is not None:
entry.paid = paid
# Clear invoice number if marking as unpaid
if not entry.paid:
entry.invoice_number = None
if invoice_number is not None:
entry.invoice_number = invoice_number.strip() if invoice_number else None
entry.updated_at = local_now()

View File

@@ -376,27 +376,8 @@
{{ _('Configure OAuth client credentials for integrations. These settings take precedence over environment variables. Leave client secret empty to keep the current value.') }}
</p>
<!-- Integration Selector -->
<div class="mb-4">
<label for="oauth_integration_select" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ _('Select Integration') }}</label>
<select name="oauth_integration_select" id="oauth_integration_select" class="form-input" onchange="showOAuthIntegration(this.value)">
<option value="">{{ _('-- Select an integration --') }}</option>
<option value="jira">Jira</option>
<option value="slack">Slack</option>
<option value="github">GitHub</option>
<option value="google_calendar">Google Calendar</option>
<option value="outlook_calendar">Outlook Calendar</option>
<option value="microsoft_teams">Microsoft Teams</option>
<option value="asana">Asana</option>
<option value="trello">Trello</option>
<option value="gitlab">GitLab</option>
<option value="quickbooks">QuickBooks Online</option>
<option value="xero">Xero</option>
</select>
</div>
<!-- Jira -->
<div id="oauth_jira" class="oauth-integration-section hidden mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<h3 class="text-md font-semibold mb-3 flex items-center">
<i class="fab fa-jira text-blue-600 mr-2"></i>Jira
</h3>
@@ -416,7 +397,7 @@
</div>
<!-- Slack -->
<div id="oauth_slack" class="oauth-integration-section hidden mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<h3 class="text-md font-semibold mb-3 flex items-center">
<i class="fab fa-slack text-purple-600 mr-2"></i>Slack
</h3>
@@ -436,7 +417,7 @@
</div>
<!-- GitHub -->
<div id="oauth_github" class="oauth-integration-section hidden mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<h3 class="text-md font-semibold mb-3 flex items-center">
<i class="fab fa-github text-gray-800 dark:text-gray-200 mr-2"></i>GitHub
</h3>
@@ -456,7 +437,7 @@
</div>
<!-- Google Calendar -->
<div id="oauth_google_calendar" class="oauth-integration-section hidden mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<h3 class="text-md font-semibold mb-3 flex items-center">
<i class="fab fa-google text-red-600 mr-2"></i>Google Calendar
</h3>
@@ -476,7 +457,7 @@
</div>
<!-- Outlook Calendar -->
<div id="oauth_outlook_calendar" class="oauth-integration-section hidden mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<h3 class="text-md font-semibold mb-3 flex items-center">
<i class="fab fa-microsoft text-blue-600 mr-2"></i>Outlook Calendar
</h3>
@@ -500,7 +481,7 @@
</div>
<!-- Microsoft Teams -->
<div id="oauth_microsoft_teams" class="oauth-integration-section hidden mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<h3 class="text-md font-semibold mb-3 flex items-center">
<i class="fab fa-microsoft-teams text-blue-600 mr-2"></i>Microsoft Teams
</h3>
@@ -524,7 +505,7 @@
</div>
<!-- Asana -->
<div id="oauth_asana" class="oauth-integration-section hidden mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<h3 class="text-md font-semibold mb-3 flex items-center">
<i class="fab fa-asana text-fuchsia-600 mr-2"></i>Asana
</h3>
@@ -544,7 +525,7 @@
</div>
<!-- Trello -->
<div id="oauth_trello" class="oauth-integration-section hidden mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<h3 class="text-md font-semibold mb-3 flex items-center">
<i class="fab fa-trello text-blue-500 mr-2"></i>Trello
</h3>
@@ -564,7 +545,7 @@
</div>
<!-- GitLab -->
<div id="oauth_gitlab" class="oauth-integration-section hidden mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<h3 class="text-md font-semibold mb-3 flex items-center">
<i class="fab fa-gitlab text-orange-600 mr-2"></i>GitLab
</h3>
@@ -588,7 +569,7 @@
</div>
<!-- QuickBooks -->
<div id="oauth_quickbooks" class="oauth-integration-section hidden mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<h3 class="text-md font-semibold mb-3 flex items-center">
<i class="fas fa-dollar-sign text-green-600 mr-2"></i>QuickBooks Online
</h3>
@@ -608,7 +589,7 @@
</div>
<!-- Xero -->
<div id="oauth_xero" class="oauth-integration-section hidden mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<h3 class="text-md font-semibold mb-3 flex items-center">
<i class="fas fa-chart-line text-blue-600 mr-2"></i>Xero
</h3>
@@ -769,21 +750,5 @@ async function confirmRemoveLogo() {
document.getElementById('removeLogoForm').submit();
}
}
function showOAuthIntegration(value) {
// Hide all integration sections
const sections = document.querySelectorAll('.oauth-integration-section');
sections.forEach(section => {
section.classList.add('hidden');
});
// Show the selected integration section
if (value) {
const selectedSection = document.getElementById('oauth_' + value);
if (selectedSection) {
selectedSection.classList.remove('hidden');
}
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,73 @@
"""Add paid status and invoice number to time entries
Revision ID: 083_add_paid_status_time_entries
Revises: 082_add_global_integrations
Create Date: 2025-01-27 12:00:00.000000
This migration adds:
- paid column (Boolean) to mark hours as paid
- invoice_number column (String) to store internal invoice number reference
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '083_add_paid_status_time_entries'
down_revision = '082_add_global_integrations'
branch_labels = None
depends_on = None
def _has_column(inspector, table_name: str, column_name: str) -> bool:
"""Check if a column exists in a table"""
try:
return column_name in [col['name'] for col in inspector.get_columns(table_name)]
except Exception:
return False
def upgrade():
"""Add paid and invoice_number columns to time_entries"""
bind = op.get_bind()
inspector = sa.inspect(bind)
if 'time_entries' not in inspector.get_table_names():
return
# Add paid column if it doesn't exist
if not _has_column(inspector, 'time_entries', 'paid'):
op.add_column('time_entries', sa.Column('paid', sa.Boolean(), nullable=False, server_default='false'))
# Add invoice_number column if it doesn't exist
if not _has_column(inspector, 'time_entries', 'invoice_number'):
op.add_column('time_entries', sa.Column('invoice_number', sa.String(100), nullable=True))
# Add index on paid status for faster queries
try:
op.create_index('idx_time_entries_paid', 'time_entries', ['paid'], unique=False)
except Exception:
pass # Index might already exist
def downgrade():
"""Remove paid and invoice_number columns from time_entries"""
bind = op.get_bind()
inspector = sa.inspect(bind)
if 'time_entries' not in inspector.get_table_names():
return
# Remove index
try:
op.drop_index('idx_time_entries_paid', table_name='time_entries')
except Exception:
pass
# Remove invoice_number column
if _has_column(inspector, 'time_entries', 'invoice_number'):
op.drop_column('time_entries', 'invoice_number')
# Remove paid column
if _has_column(inspector, 'time_entries', 'paid'):
op.drop_column('time_entries', 'paid')

View File

@@ -313,6 +313,25 @@ document.addEventListener('DOMContentLoaded', function() {
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Separate tags with commas') }}</p>
</div>
<!-- Paid Status and Invoice Number -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div class="flex items-center">
<div class="flex items-center gap-3">
<input type="checkbox" id="paid" name="paid" class="h-5 w-5 rounded border-gray-300 text-primary focus:ring-0" {% if timer.paid %}checked{% endif %}>
<label for="paid" class="text-sm font-medium text-gray-700 dark:text-gray-300">
<i class="fas fa-check-circle mr-1"></i>{{ _('Paid') }}
</label>
</div>
</div>
<div>
<label for="invoice_number" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<i class="fas fa-file-invoice mr-1"></i>{{ _('Invoice Number') }}
</label>
<input type="text" class="form-input" id="invoice_number" name="invoice_number" placeholder="{{ _('Internal invoice reference') }}" value="{{ timer.invoice_number or '' }}" maxlength="100">
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Reference to internal invoice number') }}</p>
</div>
</div>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row justify-between gap-3 mt-8 pt-6 border-t border-border-light dark:border-border-dark">
<div class="flex gap-2">
@@ -403,6 +422,25 @@ document.addEventListener('DOMContentLoaded', function() {
</div>
</div>
<!-- Paid Status and Invoice Number -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div class="flex items-center">
<div class="flex items-center gap-3">
<input type="checkbox" id="paid" name="paid" class="h-5 w-5 rounded border-gray-300 text-primary focus:ring-0" {% if timer.paid %}checked{% endif %}>
<label for="paid" class="text-sm font-medium text-gray-700 dark:text-gray-300">
<i class="fas fa-check-circle mr-1"></i>{{ _('Paid') }}
</label>
</div>
</div>
<div>
<label for="invoice_number" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<i class="fas fa-file-invoice mr-1"></i>{{ _('Invoice Number') }}
</label>
<input type="text" class="form-input" id="invoice_number" name="invoice_number" placeholder="{{ _('Internal invoice reference') }}" value="{{ timer.invoice_number or '' }}" maxlength="100">
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Reference to internal invoice number') }}</p>
</div>
</div>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row justify-between gap-3 mt-8 pt-6 border-t border-border-light dark:border-border-dark">
<div class="flex gap-2">