feat: enhance invoice management UI and add generate-from-time feature

- Enhanced invoice creation form with auto-fill client data from project selection
- Redesigned invoice edit page with improved layout and quick actions sidebar
- Added new generate-from-time template for adding unbilled time entries and costs
- Improved form styling and added responsive design enhancements
- Added internationalization (i18n) support throughout invoice templates
- Added notes and terms fields to invoice forms
- Implemented item removal functionality in invoice editor
- Added comprehensive tests for new invoice features
- Updated .gitignore to exclude logs directory
- Bumped version from 3.0.0 to 3.2.0

The invoice UI now provides:
- Quick actions panel with export, duplicate, and payment recording links
- Invoice summary sidebar showing totals and status
- Tips and guidance sidebars for better UX
- Client data auto-population when selecting projects
- Improved visual hierarchy and mobile responsiveness
This commit is contained in:
Dries Peeters
2025-10-20 21:01:09 +02:00
parent f390a13474
commit 4c67b25f9d
6 changed files with 460 additions and 93 deletions
+2 -1
View File
@@ -191,4 +191,5 @@ node_modules/
package-lock.json
# Tailwind CSS build output (keep source in git)
app/static/dist/
app/static/dist/
/logs
+91 -39
View File
@@ -1,47 +1,99 @@
{% extends "base.html" %}
{% block content %}
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">Create Invoice</h1>
</div>
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
<div>
<h1 class="text-2xl font-bold">{{ _('Create Invoice') }}</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Generate a new invoice for a project and client') }}</p>
</div>
<a href="{{ url_for('invoices.list_invoices') }}" class="bg-gray-200 dark:bg-gray-700 px-4 py-2 rounded-lg mt-4 md:mt-0">{{ _('Back to Invoices') }}</a>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Project</label>
<select name="project_id" id="project_id" required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
<option value="">Select a project</option>
{% for project in projects %}
<option value="{{ project.id }}">{{ project.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="client_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client Name</label>
<input type="text" name="client_name" id="client_name" required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
</div>
<div>
<label for="client_email" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client Email</label>
<input type="email" name="client_email" id="client_email" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
</div>
<div>
<label for="due_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Due Date</label>
<input type="date" name="due_date" id="due_date" value="{{ default_due_date }}" required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
</div>
<div class="md:col-span-2">
<label for="client_address" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client Address</label>
<textarea name="client_address" id="client_address" rows="3" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600"></textarea>
</div>
<div>
<label for="tax_rate" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Tax Rate (%)</label>
<input type="number" name="tax_rate" id="tax_rate" value="{{ settings.tax_rate or 0 }}" step="0.01" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<form method="POST" id="createInvoiceForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Project') }} *</label>
<select name="project_id" id="project_id" required class="form-input">
<option value="">{{ _('Select a project') }}</option>
{% for project in projects %}
<option value="{{ project.id }}" data-client-name="{{ project.client_obj.name if project.client_obj else '' }}" data-client-email="{{ project.client_obj.email if project.client_obj else '' }}" data-client-address="{{ project.client_obj.address if project.client_obj else '' }}">{{ project.name }} ({{ project.client }})</option>
{% endfor %}
</select>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Selecting a project will auto-fill client details') }}</p>
</div>
<div>
<label for="client_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Client Name') }} *</label>
<input type="text" name="client_name" id="client_name" value="{{ request.form.get('client_name','') }}" required class="form-input">
</div>
<div>
<label for="client_email" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Client Email') }}</label>
<input type="email" name="client_email" id="client_email" value="{{ request.form.get('client_email','') }}" class="form-input">
</div>
<div>
<label for="due_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Due Date') }} *</label>
<input type="date" name="due_date" id="due_date" value="{{ default_due_date }}" required class="form-input">
</div>
<div class="md:col-span-2">
<label for="client_address" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Client Address') }}</label>
<textarea name="client_address" id="client_address" rows="3" class="form-input">{{ request.form.get('client_address','') }}</textarea>
</div>
<div>
<label for="tax_rate" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Tax Rate (%)') }}</label>
<input type="number" name="tax_rate" id="tax_rate" value="{{ settings.tax_rate or 0 }}" step="0.01" class="form-input">
</div>
<div class="md:col-span-2">
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Notes') }}</label>
<textarea name="notes" id="notes" rows="3" class="form-input">{{ request.form.get('notes','') }}</textarea>
</div>
<div class="md:col-span-2">
<label for="terms" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Terms') }}</label>
<textarea name="terms" id="terms" rows="3" class="form-input">{{ request.form.get('terms','') }}</textarea>
</div>
</div>
<div class="mt-6">
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">{{ _('Create Invoice') }}</button>
</div>
</form>
</div>
<div class="mt-6">
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">Create Invoice</button>
</div>
<div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">{{ _('Tips') }}</h2>
<ul class="list-disc pl-5 text-sm text-text-muted-light dark:text-text-muted-dark space-y-2">
<li>{{ _('Choose the correct project to auto-fill client details.') }}</li>
<li>{{ _('You can customize notes and terms before sending the invoice.') }}</li>
</ul>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts_extra %}
<script>
document.addEventListener('DOMContentLoaded', function() {
var projectSelect = document.getElementById('project_id');
var clientName = document.getElementById('client_name');
var clientEmail = document.getElementById('client_email');
var clientAddress = document.getElementById('client_address');
function applyFromSelected() {
var opt = projectSelect.options[projectSelect.selectedIndex];
if (!opt) return;
var name = opt.getAttribute('data-client-name') || '';
var email = opt.getAttribute('data-client-email') || '';
var address = opt.getAttribute('data-client-address') || '';
clientName.value = name || clientName.value;
clientEmail.value = email || '';
clientAddress.value = address || '';
}
projectSelect.addEventListener('change', applyFromSelected);
// Apply on first load if query param preselects a project
applyFromSelected();
});
</script>
{% endblock %}
+139 -51
View File
@@ -1,69 +1,157 @@
{% extends "base.html" %}
{% block content %}
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">Edit Invoice {{ invoice.invoice_number }}</h1>
</div>
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
<div>
<h1 class="text-2xl font-bold">{{ _('Edit Invoice') }} {{ invoice.invoice_number }}</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Update invoice details, items, and terms') }}</p>
</div>
<a href="{{ url_for('invoices.view_invoice', invoice_id=invoice.id) }}" class="bg-gray-200 dark:bg-gray-700 px-4 py-2 rounded-lg mt-4 md:mt-0">{{ _('Back to Invoice') }}</a>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="client_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client Name</label>
<input type="text" name="client_name" id="client_name" value="{{ invoice.client_name }}" required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
</div>
<div>
<label for="client_email" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client Email</label>
<input type="email" name="client_email" id="client_email" value="{{ invoice.client_email }}" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
</div>
<div>
<label for="due_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Due Date</label>
<input type="date" name="due_date" id="due_date" value="{{ invoice.due_date.strftime('%Y-%m-%d') }}" required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
</div>
<div class="md:col-span-2">
<label for="client_address" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client Address</label>
<textarea name="client_address" id="client_address" rows="3" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">{{ invoice.client_address }}</textarea>
</div>
<div>
<label for="tax_rate" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Tax Rate (%)</label>
<input type="number" name="tax_rate" id="tax_rate" value="{{ invoice.tax_rate }}" step="0.01" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
</div>
</div>
<div class="mt-6">
<h2 class="text-lg font-semibold mb-4">Items</h2>
<div id="invoice-items">
{% for item in invoice.items %}
<div class="grid grid-cols-4 gap-4 mb-4">
<input type="text" name="description[]" placeholder="Description" value="{{ item.description }}" class="col-span-2 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
<input type="number" name="quantity[]" placeholder="Quantity" value="{{ item.quantity }}" step="0.01" class="rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
<input type="number" name="unit_price[]" placeholder="Unit Price" value="{{ item.unit_price }}" step="0.01" class="rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<form method="POST" id="editInvoiceForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="client_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Client Name') }} *</label>
<input type="text" name="client_name" id="client_name" value="{{ invoice.client_name }}" required class="form-input">
</div>
<div>
<label for="client_email" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Client Email') }}</label>
<input type="email" name="client_email" id="client_email" value="{{ invoice.client_email }}" class="form-input">
</div>
<div>
<label for="due_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Due Date') }} *</label>
<input type="date" name="due_date" id="due_date" value="{{ invoice.due_date.strftime('%Y-%m-%d') }}" required class="form-input">
</div>
<div>
<label for="tax_rate" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Tax Rate (%)') }}</label>
<input type="number" name="tax_rate" id="tax_rate" value="{{ invoice.tax_rate }}" step="0.01" class="form-input">
</div>
<div class="md:col-span-2">
<label for="client_address" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Client Address') }}</label>
<textarea name="client_address" id="client_address" rows="3" class="form-input">{{ invoice.client_address }}</textarea>
</div>
<div class="md:col-span-2">
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Notes') }}</label>
<textarea name="notes" id="notes" rows="3" class="form-input">{{ invoice.notes or '' }}</textarea>
</div>
<div class="md:col-span-2">
<label for="terms" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Terms') }}</label>
<textarea name="terms" id="terms" rows="3" class="form-input">{{ invoice.terms or '' }}</textarea>
</div>
</div>
<div class="mt-8">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold">{{ _('Items') }}</h2>
<button type="button" id="add-item" class="bg-gray-200 dark:bg-gray-700 px-3 py-1.5 rounded-lg text-sm"><i class="fas fa-plus mr-2"></i>{{ _('Add Item') }}</button>
</div>
<div id="invoice-items">
{% for item in invoice.items %}
<div class="grid grid-cols-1 md:grid-cols-12 gap-3 mb-3 invoice-item-row">
<input type="text" name="description[]" placeholder="{{ _('Description') }}" value="{{ item.description }}" class="md:col-span-6 form-input">
<input type="number" name="quantity[]" placeholder="{{ _('Quantity') }}" value="{{ item.quantity }}" step="0.01" class="md:col-span-2 form-input">
<input type="number" name="unit_price[]" placeholder="{{ _('Unit Price') }}" value="{{ item.unit_price }}" step="0.01" class="md:col-span-3 form-input">
<button type="button" class="remove-item md:col-span-1 bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 px-3 py-2 rounded"><i class="fas fa-trash"></i></button>
</div>
{% endfor %}
</div>
</div>
<div class="mt-6 flex justify-end gap-3 border-t border-border-light dark:border-border-dark pt-4">
<a href="{{ url_for('invoices.view_invoice', invoice_id=invoice.id) }}" class="bg-gray-200 dark:bg-gray-700 px-4 py-2 rounded-lg">{{ _('Cancel') }}</a>
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">{{ _('Save Changes') }}</button>
</div>
</form>
</div>
</div>
<div class="space-y-4">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-3">{{ _('Invoice Summary') }}</h3>
<div class="grid grid-cols-2 gap-3 text-sm">
<div>
<div class="text-text-muted-light dark:text-text-muted-dark">{{ _('Issue Date') }}</div>
<div class="font-medium">{{ invoice.issue_date.strftime('%Y-%m-%d') if invoice.issue_date else '-' }}</div>
</div>
<div>
<div class="text-text-muted-light dark:text-text-muted-dark">{{ _('Status') }}</div>
<div class="font-medium">{{ invoice.status|capitalize }}</div>
</div>
<div>
<div class="text-text-muted-light dark:text-text-muted-dark">{{ _('Payment Status') }}</div>
<div class="font-medium">{{ invoice.payment_status.replace('_',' ')|capitalize }}</div>
</div>
<div>
<div class="text-text-muted-light dark:text-text-muted-dark">{{ _('Currency') }}</div>
<div class="font-medium">{{ invoice.currency_code }}</div>
</div>
</div>
<div class="mt-4 grid grid-cols-2 gap-3 text-sm">
<div>
<div class="text-text-muted-light dark:text-text-muted-dark">{{ _('Subtotal') }}</div>
<div class="font-semibold">{{ '%.2f'|format(invoice.subtotal) }}</div>
</div>
<div>
<div class="text-text-muted-light dark:text-text-muted-dark">{{ _('Tax') }}</div>
<div class="font-semibold">{{ '%.2f'|format(invoice.tax_amount) }} ({{ '%.2f'|format(invoice.tax_rate) }}%)</div>
</div>
<div class="col-span-2">
<div class="text-text-muted-light dark:text-text-muted-dark">{{ _('Total Amount') }}</div>
<div class="text-xl font-bold">{{ '%.2f'|format(invoice.total_amount) }}</div>
</div>
<div>
<div class="text-text-muted-light dark:text-text-muted-dark">{{ _('Amount Paid') }}</div>
<div class="font-semibold">{{ '%.2f'|format(invoice.amount_paid or 0) }}</div>
</div>
<div>
<div class="text-text-muted-light dark:text-text-muted-dark">{{ _('Outstanding') }}</div>
<div class="font-semibold">{{ '%.2f'|format(invoice.outstanding_amount) }}</div>
</div>
{% endfor %}
</div>
<button type="button" id="add-item" class="bg-gray-200 dark:bg-gray-700 px-4 py-2 rounded-lg">Add Item</button>
</div>
<div class="mt-6">
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">Save Changes</button>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-3">{{ _('Quick Actions') }}</h3>
<div class="grid grid-cols-1 gap-2">
<a href="{{ url_for('invoices.generate_from_time', invoice_id=invoice.id) }}" class="inline-flex items-center justify-center px-3 py-2 text-sm rounded-md border border-primary text-primary hover:bg-primary/10"><i class="fas fa-clock mr-2"></i>{{ _('Generate from Time/Costs') }}</a>
<a href="{{ url_for('invoices.record_payment', invoice_id=invoice.id) }}" class="inline-flex items-center justify-center px-3 py-2 text-sm rounded-md bg-emerald-600 text-white hover:bg-emerald-700"><i class="fas fa-cash-register mr-2"></i>{{ _('Record Payment') }}</a>
<a href="{{ url_for('invoices.export_invoice_pdf', invoice_id=invoice.id) }}" class="inline-flex items-center justify-center px-3 py-2 text-sm rounded-md border border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark"><i class="fas fa-file-pdf mr-2"></i>{{ _('Export PDF') }}</a>
<a href="{{ url_for('invoices.export_invoice_csv', invoice_id=invoice.id) }}" class="inline-flex items-center justify-center px-3 py-2 text-sm rounded-md border border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark"><i class="fas fa-file-csv mr-2"></i>{{ _('Export CSV') }}</a>
<a href="{{ url_for('invoices.duplicate_invoice', invoice_id=invoice.id) }}" class="inline-flex items-center justify-center px-3 py-2 text-sm rounded-md border border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark"><i class="fas fa-clone mr-2"></i>{{ _('Duplicate Invoice') }}</a>
</div>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts_extra %}
<script>
document.getElementById('add-item').addEventListener('click', function() {
var itemsContainer = document.getElementById('invoice-items');
var newItem = document.createElement('div');
newItem.className = 'grid grid-cols-4 gap-4 mb-4';
newItem.innerHTML = `
<input type="text" name="description[]" placeholder="Description" class="col-span-2 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
<input type="number" name="quantity[]" placeholder="Quantity" step="0.01" class="rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
<input type="number" name="unit_price[]" placeholder="Unit Price" step="0.01" class="rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
document.addEventListener('DOMContentLoaded', function() {
const itemsContainer = document.getElementById('invoice-items');
const addBtn = document.getElementById('add-item');
addBtn && addBtn.addEventListener('click', function() {
const row = document.createElement('div');
row.className = 'grid grid-cols-1 md:grid-cols-12 gap-3 mb-3 invoice-item-row';
row.innerHTML = `
<input type="text" name="description[]" placeholder="{{ _('Description') }}" class="md:col-span-6 form-input">
<input type="number" name="quantity[]" placeholder="{{ _('Quantity') }}" step="0.01" class="md:col-span-2 form-input">
<input type="number" name="unit_price[]" placeholder="{{ _('Unit Price') }}" step="0.01" class="md:col-span-3 form-input">
<button type="button" class="remove-item md:col-span-1 bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 px-3 py-2 rounded"><i class="fas fa-trash"></i></button>
`;
itemsContainer.appendChild(newItem);
itemsContainer.appendChild(row);
});
itemsContainer && itemsContainer.addEventListener('click', function(e) {
if (e.target.closest('.remove-item')) {
const row = e.target.closest('.invoice-item-row');
row && row.remove();
}
});
});
</script>
{% endblock %}
@@ -0,0 +1,102 @@
{% extends "base.html" %}
{% block content %}
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
<div>
<h1 class="text-2xl font-bold">{{ _('Generate from Time & Costs') }}</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Select unbilled time entries and project costs to add as invoice items') }}</p>
</div>
<a href="{{ url_for('invoices.edit_invoice', invoice_id=invoice.id) }}" class="bg-gray-200 dark:bg-gray-700 px-4 py-2 rounded-lg mt-4 md:mt-0">{{ _('Back to Edit') }}</a>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<form method="POST" id="generateFromTimeForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-6">
<h2 class="text-lg font-semibold mb-3">{{ _('Unbilled Time Entries') }}</h2>
{% if time_entries %}
<div class="space-y-2">
{% for entry in time_entries %}
<label class="flex items-start gap-3 p-3 rounded border border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark">
<input type="checkbox" class="mt-1" name="time_entries[]" value="{{ entry.id }}">
<div class="flex-1 text-sm">
<div class="font-medium">{{ entry.task.name if entry.task else _('Time Entry') }} · {{ '%.2f'|format(entry.duration_hours) }} {{ _('h') }}</div>
<div class="text-text-muted-light dark:text-text-muted-dark">
{{ entry.start_time.strftime('%Y-%m-%d %H:%M') }} → {{ entry.end_time.strftime('%Y-%m-%d %H:%M') if entry.end_time else '-' }}
{% if entry.notes %} · {{ entry.notes[:80] }}{% if entry.notes|length > 80 %}…{% endif %}{% endif %}
</div>
</div>
</label>
{% endfor %}
</div>
{% else %}
<div class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('No unbilled time entries found for this project.') }}</div>
{% endif %}
</div>
<div class="mb-6">
<h2 class="text-lg font-semibold mb-3">{{ _('Uninvoiced Project Costs') }}</h2>
{% if project_costs %}
<div class="space-y-2">
{% for cost in project_costs %}
<label class="flex items-start gap-3 p-3 rounded border border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark">
<input type="checkbox" class="mt-1" name="project_costs[]" value="{{ cost.id }}">
<div class="flex-1 text-sm">
<div class="font-medium">{{ cost.description }} ({{ cost.category|title }}) · {{ '%.2f'|format(cost.amount) }} {{ cost.currency_code }}</div>
<div class="text-text-muted-light dark:text-text-muted-dark">{{ cost.cost_date.strftime('%Y-%m-%d') }}</div>
</div>
</label>
{% endfor %}
</div>
{% else %}
<div class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('No uninvoiced costs found for this project.') }}</div>
{% endif %}
</div>
<div class="mt-6 flex justify-end gap-3 border-t border-border-light dark:border-border-dark pt-4">
<a href="{{ url_for('invoices.edit_invoice', invoice_id=invoice.id) }}" class="bg-gray-200 dark:bg-gray-700 px-4 py-2 rounded-lg">{{ _('Cancel') }}</a>
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">{{ _('Add Selected to Invoice') }}</button>
</div>
</form>
</div>
</div>
<div class="space-y-4">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-3">{{ _('Selection Summary') }}</h3>
<div class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Total available hours') }}: <span class="font-semibold text-text-light dark:text-text-dark">{{ '%.2f'|format(total_available_hours) }}</span></div>
<div class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Total available costs') }}: <span class="font-semibold text-text-light dark:text-text-dark">{{ '%.2f'|format(total_available_costs) }} {{ currency }}</span></div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-3">{{ _('Tips') }}</h3>
<ul class="list-disc pl-5 text-sm text-text-muted-light dark:text-text-muted-dark space-y-2">
<li>{{ _('You can select multiple time entry groups and costs.') }}</li>
<li>{{ _('Time entries are grouped by task or project at item creation.') }}</li>
<li>{{ _('Costs are added as individual invoice items.') }}</li>
</ul>
</div>
</div>
</div>
{% endblock %}
{% block scripts_extra %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('generateFromTimeForm');
if (!form) return;
form.addEventListener('submit', function(e) {
const anyChecked = form.querySelector('input[name="time_entries[]"]:checked, input[name="project_costs[]"]:checked');
if (!anyChecked) {
e.preventDefault();
try { window.toastManager && window.toastManager.warning('{{ _('Please select at least one time entry or cost') }}'); } catch(_) {}
}
});
});
</script>
{% endblock %}
+1 -1
View File
@@ -7,7 +7,7 @@ from setuptools import setup, find_packages
setup(
name='timetracker',
version='3.0.0',
version='3.2.0',
packages=find_packages(),
include_package_data=True,
install_requires=[
+125 -1
View File
@@ -2,7 +2,7 @@ import pytest
from datetime import datetime, date, timedelta
from decimal import Decimal
from app import db
from app.models import User, Project, Invoice, InvoiceItem, Settings
from app.models import User, Project, Invoice, InvoiceItem, Settings, Client
@pytest.fixture
def sample_user(app):
@@ -225,6 +225,32 @@ def test_invoice_overdue_status(app, sample_user, sample_project):
if hasattr(invoice, 'days_overdue'):
assert invoice.days_overdue >= 0 # Should be non-negative
@pytest.mark.routes
def test_create_invoice_template_has_client_data_attributes(app, client, user, project):
"""Ensure the create invoice page renders project options with client data attributes."""
# Authenticate
with client.session_transaction() as sess:
sess['_user_id'] = str(user.id)
sess['_fresh'] = True
# Ensure project has a client with email/address
proj = Project.query.get(project.id)
cl = Client.query.get(proj.client_id)
cl.email = 'client@example.com'
cl.address = '123 Test St\nCity'
from app import db
db.session.commit()
resp = client.get('/invoices/create')
assert resp.status_code == 200
html = resp.get_data(as_text=True)
# The option should include data-client-name/email/address
assert f'data-client-name="{cl.name}"' in html
assert 'data-client-email="client@example.com"' in html
assert 'data-client-address="123 Test St' in html
def test_invoice_to_dict(app, sample_invoice):
"""Test that invoice can be converted to dictionary."""
invoice_dict = sample_invoice.to_dict()
@@ -256,6 +282,104 @@ def test_invoice_item_to_dict(app, sample_invoice):
assert 'unit_price' in item_dict
assert 'total_amount' in item_dict
@pytest.mark.routes
def test_edit_invoice_template_has_expected_fields(app, client, user, project):
"""Ensure the edit invoice page renders key fields and existing items."""
# Authenticate
with client.session_transaction() as sess:
sess['_user_id'] = str(user.id)
sess['_fresh'] = True
# Create client and invoice with an item
from app.models import Client, InvoiceItem
cl = Client(name='Edit Test Client', email='edit@test.com', address='Street 1')
db.session.add(cl)
db.session.commit()
inv = Invoice(
invoice_number='INV-TEST-EDIT-001',
project_id=project.id,
client_name=cl.name,
client_id=cl.id,
due_date=date.today() + timedelta(days=14),
created_by=user.id,
tax_rate=Decimal('10.00'),
notes='Note',
terms='Terms'
)
db.session.add(inv)
db.session.commit()
it = InvoiceItem(invoice_id=inv.id, description='Line A', quantity=Decimal('2.00'), unit_price=Decimal('50.00'))
db.session.add(it)
db.session.commit()
resp = client.get(f'/invoices/{inv.id}/edit')
assert resp.status_code == 200
html = resp.get_data(as_text=True)
# Fields
assert 'name="client_name"' in html
assert 'name="client_email"' in html
assert 'name="client_address"' in html
assert 'name="due_date"' in html
assert 'name="tax_rate"' in html
assert 'name="notes"' in html
assert 'name="terms"' in html
# Item row present with existing description
assert 'Line A' in html
@pytest.mark.routes
def test_generate_from_time_page_renders_lists(app, client, user, project):
"""Ensure the generate-from-time page renders unbilled entries and costs with checkboxes."""
# Authenticate
with client.session_transaction() as sess:
sess['_user_id'] = str(user.id)
sess['_fresh'] = True
# Create client and invoice
cl = Client(name='GenFromTime Client', email='gft@test.com')
db.session.add(cl)
db.session.commit()
inv = Invoice(
invoice_number='INV-TEST-GFT-001',
project_id=project.id,
client_name=cl.name,
client_id=cl.id,
due_date=date.today() + timedelta(days=7),
created_by=user.id
)
db.session.add(inv)
db.session.commit()
# Add an unbilled time entry and a project cost
from app.models import TimeEntry, ProjectCost
start = datetime.utcnow() - timedelta(hours=2)
end = datetime.utcnow()
te = TimeEntry(user_id=user.id, project_id=project.id, start_time=start, end_time=end, notes='Work A', billable=True)
db.session.add(te)
db.session.commit()
pc = ProjectCost(project_id=project.id, user_id=user.id, description='Expense A', category='materials', amount=Decimal('12.50'), cost_date=date.today(), billable=True)
db.session.add(pc)
db.session.commit()
# Visit page
resp = client.get(f'/invoices/{inv.id}/generate-from-time')
assert resp.status_code == 200
html = resp.get_data(as_text=True)
# Check checkboxes render
assert 'name="time_entries[]"' in html
assert 'name="project_costs[]"' in html
# Check summary numbers render
assert 'Total available hours' in html
assert 'Total available costs' in html
# Payment Status Tracking Tests
def test_invoice_payment_status_initialization(app, sample_user, sample_project):