mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-02-11 22:59:17 -06:00
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:
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
73
migrations/versions/083_add_paid_status_to_time_entries.py
Normal file
73
migrations/versions/083_add_paid_status_to_time_entries.py
Normal 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')
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user