mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-19 21:00:15 -05:00
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:
+2
-1
@@ -191,4 +191,5 @@ node_modules/
|
||||
package-lock.json
|
||||
|
||||
# Tailwind CSS build output (keep source in git)
|
||||
app/static/dist/
|
||||
app/static/dist/
|
||||
/logs
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user