Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 0 additions & 36 deletions .env.local-test

This file was deleted.

1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ desktop/*.pvk

# Environment backups
.env.backup
.env.local-test

# Node.js / Frontend build
node_modules/
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [5.8.2] - 2026-06-15

### Fixed

- **Invoice expenses** — Expense records from the Expenses module now link to the invoice Expenses section instead of being misrouted into invoice items or lost on save. Generate-from-time no longer wipes existing line items when only expenses are selected; the Add Expense flow focuses the expenses picker; edit-time expense sync is hardened (#662).

### Documentation

- **Version** — Documented release **5.8.2** to match `setup.py` (single source of truth for the application version).

## [5.8.1] - 2026-06-10

### Fixed
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ TimeTracker has been continuously enhanced with powerful new features! Here's wh

**Current version** is defined in `setup.py` (single source of truth). See [CHANGELOG.md](CHANGELOG.md) for versioned release history.

### ✨ Highlights of v5.8.2

**Patch (5.8.2):** **Invoice expenses** — billable expenses from the Expenses module link to the invoice Expenses section (not invoice items), stay separate with their descriptions, and are no longer wiped when adding expenses to an existing invoice. See [CHANGELOG.md](CHANGELOG.md#582---2026-06-15).

### ✨ Highlights of v5.8.1

**Patch (5.8.1):** **Quote email** — sending a quote no longer flashes a false error after the email is delivered; fixes the follow-up regression to #652. See [CHANGELOG.md](CHANGELOG.md#581---2026-06-10).
Expand Down
46 changes: 34 additions & 12 deletions app/routes/invoices.py
Original file line number Diff line number Diff line change
Expand Up @@ -451,20 +451,24 @@ def edit_invoice(invoice_id):
flash(f"Invalid quantity or price for item {i+1}", "error")
continue

# Update expenses
expense_ids = request.form.getlist("expense_id[]")
# Update expenses (only when the expenses section was submitted)
if request.form.get("expenses_sync"):
expense_ids = request.form.getlist("expense_id[]")

# Unlink expenses not in the list
for expense in invoice.expenses.all():
if str(expense.id) not in expense_ids:
expense.unmark_as_invoiced()
# Unlink expenses removed from this invoice
for expense in invoice.expenses.all():
if expense.invoice_id == invoice.id and str(expense.id) not in expense_ids:
expense.unmark_as_invoiced()

# Link expenses in the list
if expense_ids:
# Link new expenses from the list
for expense_id in expense_ids:
try:
expense = Expense.query.get(int(expense_id))
if expense and not expense.invoiced:
if not expense:
continue
if expense.invoice_id == invoice.id:
continue
if not expense.invoiced:
expense.mark_as_invoiced(invoice.id)
except (ValueError, AttributeError):
continue
Expand Down Expand Up @@ -893,8 +897,9 @@ def generate_from_time(invoice_id):
flash(_("No time entries, costs, expenses, or extra goods selected"), "error")
return redirect(url_for("invoices.generate_from_time", invoice_id=invoice.id))

# Clear existing items
invoice.items.delete()
# Only regenerate invoice items when time entries or project costs are selected
if selected_entries or selected_costs:
invoice.items.delete()

total_prepaid_allocated = Decimal("0")
prepaid_allocator = None
Expand Down Expand Up @@ -997,6 +1002,8 @@ 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))

db.session.refresh(invoice)

# If invoice is already sent (not draft), mark time entries as paid
if invoice.status != "draft":
from app.services import InvoiceService
Expand All @@ -1006,7 +1013,20 @@ def generate_from_time(invoice_id):
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")
added_parts = []
if selected_entries or selected_costs:
added_parts.append(_("time entries and costs"))
if selected_expenses:
added_parts.append(_("expenses"))
if selected_goods:
added_parts.append(_("extra goods"))
if added_parts:
flash(
_("Successfully added %(parts)s to the invoice.", parts=", ".join(added_parts)),
"success",
)
else:
flash(_("Invoice updated successfully"), "success")
if total_prepaid_allocated and total_prepaid_allocated > 0:
flash(
_(
Expand All @@ -1022,6 +1042,7 @@ def generate_from_time(invoice_id):
from app.services import InvoiceService

data = InvoiceService().get_unbilled_data_for_invoice(invoice)
focus = request.args.get("focus", "").strip()
return render_template(
"invoices/generate_from_time.html",
invoice=invoice,
Expand All @@ -1038,6 +1059,7 @@ def generate_from_time(invoice_id):
prepaid_summary=data["prepaid_summary"],
prepaid_plan_hours=data["prepaid_plan_hours"],
prepaid_reset_day=data.get("prepaid_reset_day"),
focus=focus,
)


Expand Down
5 changes: 3 additions & 2 deletions app/templates/invoices/edit.html
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,8 @@ <h2 class="text-lg font-semibold flex items-center">
</div>

<div id="invoice-expenses" class="space-y-2">
{% for expense in invoice.expenses %}
<input type="hidden" name="expenses_sync" value="1">
{% for expense in invoice.expenses.all() %}
<div class="grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-amber-50/50 dark:bg-amber-950/20 border border-amber-200/50 dark:border-amber-800/50 invoice-expense-row min-w-0 hover:shadow-sm transition">
<input type="hidden" name="expense_id[]" value="{{ expense.id }}">
<div class="md:col-span-3 min-w-0 flex flex-col">
Expand Down Expand Up @@ -654,7 +655,7 @@ <h3 class="text-lg font-semibold mb-3">{{ _('Quick Actions') }}</h3>
// Add expense button - redirect to generate from time page which includes expenses
const addExpenseBtn = document.getElementById('add-expense');
addExpenseBtn && addExpenseBtn.addEventListener('click', function() {
window.location.href = '{{ url_for("invoices.generate_from_time", invoice_id=invoice.id) }}';
window.location.href = '{{ url_for("invoices.generate_from_time", invoice_id=invoice.id, focus="expenses") }}';
});

// Remove expense handler (unlink from invoice)
Expand Down
64 changes: 60 additions & 4 deletions app/templates/invoices/generate_from_time.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,20 @@
{% block content %}
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6 gap-4">
<div>
<h1 class="text-2xl font-bold">{{ _('Generate from Time, Costs & Goods') }}</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Select unbilled time entries, project costs, and extra goods to add to this invoice') }}</p>
<h1 class="text-2xl font-bold">
{% if focus == 'expenses' %}
{{ _('Add Expenses to Invoice') }}
{% else %}
{{ _('Generate from Time, Costs & Goods') }}
{% endif %}
</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">
{% if focus == 'expenses' %}
{{ _('Select billable expenses to link to this invoice. They will appear in the Expenses section, not as invoice items.') }}
{% else %}
{{ _('Select unbilled time entries, project costs, and extra goods to add to this invoice') }}
{% endif %}
</p>
</div>
<a href="{{ url_for('invoices.edit_invoice', invoice_id=invoice.id) }}" class="btn btn-secondary shrink-0">{{ _('Back to Edit') }}</a>
</div>
Expand All @@ -15,6 +27,16 @@ <h1 class="text-2xl font-bold">{{ _('Generate from Time, Costs & Goods') }}</h1>
<form method="POST" id="generateFromTimeForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">

{% if focus == 'expenses' %}
<div class="mb-4">
<button type="button" id="toggle-time-costs" class="text-sm text-primary hover:underline">
<i class="fas fa-chevron-down mr-1" id="toggle-time-costs-icon"></i>
<span id="toggle-time-costs-label">{{ _('Show time entries and project costs') }}</span>
</button>
</div>
<div id="time-costs-sections" class="hidden mb-6 space-y-6">
{% endif %}

<div class="mb-6">
<h2 class="text-lg font-semibold mb-3">{{ _('Unbilled Time Entries') }}</h2>
{% if grouped_time_entries %}
Expand Down Expand Up @@ -87,8 +109,15 @@ <h2 class="text-lg font-semibold mb-3">{{ _('Uninvoiced Project Costs') }}</h2>
{% endif %}
</div>

<div class="mb-6">
<h2 class="text-lg font-semibold mb-3">{{ _('Uninvoiced Billable Expenses') }}</h2>
{% if focus == 'expenses' %}
</div>
{% endif %}

<div id="expenses-section" class="mb-6{% if focus == 'expenses' %} rounded-xl border-2 border-amber-400 dark:border-amber-500 bg-amber-50/50 dark:bg-amber-950/20 p-4 ring-2 ring-amber-200/50 dark:ring-amber-800/50{% endif %}">
<h2 class="text-lg font-semibold mb-3 flex items-center">
{% if focus == 'expenses' %}<i class="fas fa-receipt mr-2 text-amber-600"></i>{% endif %}
{{ _('Uninvoiced Billable Expenses') }}
</h2>
{% if expenses %}
<div class="space-y-2">
{% for expense in expenses %}
Expand Down Expand Up @@ -187,6 +216,33 @@ <h3 class="text-lg font-semibold mb-3">{{ _('Tips') }}</h3>
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('generateFromTimeForm');
if (!form) return;

{% if focus == 'expenses' %}
const expensesSection = document.getElementById('expenses-section');
if (expensesSection) {
expensesSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
const toggleBtn = document.getElementById('toggle-time-costs');
const timeCostsSections = document.getElementById('time-costs-sections');
const toggleIcon = document.getElementById('toggle-time-costs-icon');
const toggleLabel = document.getElementById('toggle-time-costs-label');
if (toggleBtn && timeCostsSections) {
toggleBtn.addEventListener('click', function() {
const isHidden = timeCostsSections.classList.contains('hidden');
timeCostsSections.classList.toggle('hidden', !isHidden);
if (toggleIcon) {
toggleIcon.classList.toggle('fa-chevron-down', !isHidden);
toggleIcon.classList.toggle('fa-chevron-up', isHidden);
}
if (toggleLabel) {
toggleLabel.textContent = isHidden
? '{{ _('Hide time entries and project costs') }}'
: '{{ _('Show time entries and project costs') }}';
}
});
}
{% endif %}

form.addEventListener('submit', function(e) {
const anyChecked = form.querySelector('input[name="time_entries[]"]:checked, input[name="project_costs[]"]:checked, input[name="expenses[]"]:checked, input[name="extra_goods[]"]:checked');
if (!anyChecked) {
Expand Down
4 changes: 2 additions & 2 deletions desktop/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "timetracker-desktop",
"version": "5.8.1",
"version": "5.8.2",
"description": "TimeTracker desktop app for Windows, Linux, and macOS",
"main": "src/main/main.js",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion docs/BUILD_CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ To update the version for all applications:
```python
setup(
name='timetracker',
version='5.8.1', # Update here
version='5.8.2', # Update here
...
)
```
Expand Down
1 change: 1 addition & 0 deletions docs/Untitled
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.env.local-test
4 changes: 2 additions & 2 deletions mobile/android/local.properties
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
flutter.versionCode=50801
flutter.versionName=5.8.1
flutter.versionCode=50802
flutter.versionName=5.8.2
flutter.sdk=C:\\Flutter\\flutter
sdk.dir=C:\\Users\\dries\\AppData\\Local\\Android\\Sdk
flutter.buildMode=release
2 changes: 1 addition & 1 deletion mobile/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: timetracker_mobile
description: TimeTracker mobile app for Android and iOS
publish_to: 'none'
version: 5.8.1+1
version: 5.8.2+1

environment:
sdk: '>=3.0.0 <4.0.0'
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

setup(
name='timetracker',
version='5.8.1',
version='5.8.2',
packages=find_packages(),
include_package_data=True,
package_data={
Expand Down
Loading
Loading