Files
TimeTracker/app/templates/projects/create.html
Dries Peeters 18d9808d5e feat: add user favorite projects functionality with CSV export enhancements
Features:
Add favorite projects feature allowing users to star/bookmark frequently used projects
New UserFavoriteProject association model with user-project relationships
Star icons in project list for one-click favorite toggling via AJAX
Filter to display only favorite projects
Per-user favorites with proper isolation and cascade delete behavior
Activity logging for favorite/unfavorite actions
Database:
Add user_favorite_projects table with migration (023_add_user_favorite_projects.py)
Foreign keys to users and projects with CASCADE delete
Unique constraint preventing duplicate favorites
Indexes on user_id and project_id for query optimization
Models:
User model: Add favorite_projects relationship with helper methods
add_favorite_project() - add project to favorites
remove_favorite_project() - remove from favorites
is_project_favorite() - check favorite status
get_favorite_projects() - retrieve favorites with status filter
Project model: Add is_favorited_by() method and include favorite status in to_dict()
Export UserFavoriteProject model in app/models/__init__.py
Routes:
Add /projects/<id>/favorite POST endpoint to favorite a project
Add /projects/<id>/unfavorite POST endpoint to unfavorite a project
Update /projects GET route to support favorites=true query parameter
Fix status filtering to work correctly with favorites JOIN query
Add /reports/export/form GET endpoint for enhanced CSV export form
Templates:
Update projects/list.html:
Add favorites filter dropdown to filter form (5-column grid)
Add star icon column with Font Awesome icons (filled/unfilled)
Add JavaScript toggleFavorite() function for AJAX favorite toggling
Improve hover states and transitions for better UX
Pass favorite_project_ids and favorites_only to template
Update reports/index.html:
Update CSV export link to point to new export form
Add icon and improve hover styling
Reports:
Enhance CSV export functionality with dedicated form page
Add filter options for users, projects, clients, and date ranges
Set default date range to last 30 days
Import Client model and or_ operator for advanced filtering
Testing:
Comprehensive test suite in tests/test_favorite_projects.py (550+ lines)
Model tests for UserFavoriteProject creation and validation
User/Project method tests for favorite operations
Route tests for favorite/unfavorite endpoints
Filtering tests for favorites-only view
Relationship tests for cascade delete behavior
Smoke tests for complete workflows
Coverage for edge cases and error handling
Documentation:
Add comprehensive feature documentation in docs/FAVORITE_PROJECTS_FEATURE.md
User guide with step-by-step instructions
Technical implementation details
API documentation for new endpoints
Migration guide and troubleshooting
Performance and security considerations
Template Cleanup:
Remove duplicate templates from root templates/ directory
Admin templates (dashboard, users, settings, OIDC debug, etc.)
Client CRUD templates
Error page templates
Invoice templates
Project templates
Report templates
Timer templates
All templates now properly located in app/templates/
Breaking Changes:
None - fully backward compatible
Migration Required:
Run alembic upgrade head to create user_favorite_projects table
2025-10-23 21:15:16 +02:00

513 lines
25 KiB
HTML

{% extends "base.html" %}
{% block title %}{{ _('Create Project') }} - {{ app_name }}{% endblock %}
{% 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">{{ _('Create Project') }}</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Set up a new project to organize your work and track time effectively') }}</p>
</div>
<a href="{{ url_for('projects.list_projects') }}" class="bg-gray-200 dark:bg-gray-700 px-4 py-2 rounded-lg mt-4 md:mt-0">{{ _('Back to Projects') }}</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" action="{{ url_for('projects.create_project') }}" novalidate id="createProjectForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="md:col-span-2">
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Project Name') }} *</label>
<input type="text" id="name" name="name" required value="{{ request.form.get('name','') }}" placeholder="{{ _('Enter a descriptive project name') }}" class="form-input">
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Choose a clear, descriptive name that explains the project scope') }}</p>
</div>
<div>
<label for="code" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Project Code') }}</label>
<input type="text" id="code" name="code" value="{{ request.form.get('code','') }}" placeholder="{{ _('Short code, e.g., ABC') }}" class="form-input" maxlength="20">
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Optional: Short tag shown on Kanban cards') }}</p>
</div>
<div>
<label for="client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Client') }} *</label>
<select id="client_id" name="client_id" required class="form-input">
<option value="">{{ _('Select a client...') }}</option>
{% for client in clients %}
<option value="{{ client.id }}" {% if request.form.get('client_id') == client.id|string %}selected{% endif %} data-default-rate="{{ client.default_hourly_rate or '' }}">{{ client.name }}</option>
{% endfor %}
</select>
<p class="text-xs mt-1">
<button type="button" id="openCreateClientModal" class="text-primary hover:underline">{{ _('Create new client') }}</button>
</p>
</div>
</div>
<div>
<div class="flex items-center justify-between">
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Description') }}</label>
<small class="text-text-muted-light dark:text-text-muted-dark">{{ _('Supports Markdown') }}</small>
</div>
<div class="markdown-editor-wrapper">
<textarea class="form-input hidden" id="description" name="description" rows="10" placeholder="{{ _('Provide detailed information about the project, objectives, and deliverables...') }}">{{ request.form.get('description','') }}</textarea>
<div id="description_editor"></div>
</div>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Optional: Add context, objectives, or specific requirements for the project') }}</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="inline-flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
<input type="checkbox" id="billable" name="billable" {% if request.form.get('billable') %}checked{% endif %} class="rounded border-gray-300 text-primary shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
{{ _('Billable Project') }}
</label>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Enable billing for this project') }}</p>
</div>
<div>
<label for="hourly_rate" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Hourly Rate') }}</label>
<input type="number" step="0.01" min="0" id="hourly_rate" name="hourly_rate" value="{{ request.form.get('hourly_rate','') }}" placeholder="{{ _('e.g. 75.00') }}" class="form-input">
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Leave empty for non-billable projects') }}</p>
</div>
</div>
<div>
<label for="billing_ref" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Billing Reference') }}</label>
<input type="text" id="billing_ref" name="billing_ref" value="{{ request.form.get('billing_ref','') }}" placeholder="{{ _('PO number, contract reference, etc.') }}" class="form-input">
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Optional: Add a reference number or identifier for billing purposes') }}</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="budget_amount" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Budget Amount') }}</label>
<input type="number" step="0.01" min="0" id="budget_amount" name="budget_amount" value="{{ request.form.get('budget_amount','') }}" placeholder="{{ _('e.g. 10000.00') }}" class="form-input">
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Optional: Set a total project budget to monitor spend') }}</p>
</div>
<div>
<label for="budget_threshold_percent" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Alert Threshold (%)') }}</label>
<input type="number" step="1" min="0" max="100" id="budget_threshold_percent" name="budget_threshold_percent" value="{{ request.form.get('budget_threshold_percent','80') }}" placeholder="80" class="form-input">
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Notify when consumed budget exceeds this threshold') }}</p>
</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('projects.list_projects') }}" 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">{{ _('Create Project') }}</button>
</div>
</form>
</div>
</div>
<div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-3">{{ _('Project Creation Tips') }}</h3>
<ul class="space-y-3 text-sm">
<li><strong>{{ _('Clear Naming') }}</strong><p class="text-text-muted-light dark:text-text-muted-dark">{{ _("Use descriptive names that clearly indicate the project's purpose") }}</p></li>
<li><strong>{{ _('Billing Setup') }}</strong><p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Set appropriate hourly rates based on project complexity and client budget') }}</p></li>
<li><strong>{{ _('Detailed Description') }}</strong><p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Include project objectives, deliverables, and key requirements') }}</p></li>
<li><strong>{{ _('Client Selection') }}</strong><p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Choose the right client to ensure proper project organization') }}</p></li>
</ul>
</div>
</div>
</div>
<style>
/* Billing Badges */
.billing-badge {
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.billing-billable {
background-color: #dcfce7;
color: #166534;
}
.billing-non-billable {
background-color: #e2e8f0;
color: #475569;
}
.billing-rate {
background-color: #fef3c7;
color: #92400e;
}
/* Tip Items */
.tip-item {
padding: 0.75rem;
border-radius: 8px;
background-color: #f8fafc;
transition: all 0.2s ease;
}
.tip-item:hover {
background-color: #f1f5f9;
transform: translateX(4px);
}
/* Billing and Management Guide Items */
.billing-guide-item,
.management-item {
padding: 0.5rem;
border-radius: 6px;
transition: all 0.2s ease;
}
.billing-guide-item:hover,
.management-item:hover {
background-color: #f8fafc;
}
/* Form Styling */
.form-control:focus,
.form-select:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 0.2rem rgba(59, 130, 246, 0.25);
}
.form-control-lg {
min-height: 56px;
}
.form-select-lg {
min-height: 56px;
}
/* Mobile Optimizations */
@media (max-width: 768px) {
.card-header {
padding: 1rem 1rem 0.75rem 1rem;
}
.card-body {
padding: 0.75rem 1rem;
}
.tip-item:hover {
transform: none;
}
.billing-guide-item:hover,
.management-item:hover {
background-color: transparent;
}
}
/* Hover Effects */
.btn:hover {
transform: none;
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
}
.tip-item:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
</style>
{% block extra_css %}
<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/toastui-editor.min.css">
<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/theme/toastui-editor-dark.css">
{% endblock %}
<script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const clientSelect = document.getElementById('client_id');
const hourlyRateInput = document.getElementById('hourly_rate');
const billableCheckbox = document.getElementById('billable');
const form = document.getElementById('createProjectForm');
const nameInput = document.getElementById('name');
const descriptionInput = document.getElementById('description');
// Auto-fill hourly rate from client default
clientSelect.addEventListener('change', function() {
const selectedOption = this.options[this.selectedIndex];
const defaultRate = selectedOption.getAttribute('data-default-rate');
if (defaultRate && !hourlyRateInput.value) {
hourlyRateInput.value = defaultRate;
}
});
// Toggle hourly rate field based on billable checkbox
billableCheckbox.addEventListener('change', function() {
if (this.checked) {
hourlyRateInput.removeAttribute('disabled');
hourlyRateInput.classList.remove('text-muted');
} else {
hourlyRateInput.setAttribute('disabled', 'disabled');
hourlyRateInput.classList.add('text-muted');
hourlyRateInput.value = '';
}
});
// Initialize Markdown editor
let mdEditor = null;
if (descriptionInput && window.toastui && window.toastui.Editor) {
const theme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
mdEditor = new toastui.Editor({
el: document.getElementById('description_editor'),
height: '340px',
initialEditType: 'wysiwyg',
previewStyle: 'vertical',
usageStatistics: false,
theme: theme,
toolbarItems: [
['heading', 'bold', 'italic', 'strike'],
['hr', 'quote'],
['ul', 'ol', 'task'],
['link', 'code', 'codeblock', 'table'],
['image'],
['scrollSync']
],
initialValue: descriptionInput.value || ''
});
// Dynamic theme updates
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === 'attributes' && mutation.attributeName === 'class' && mdEditor) {
const nextTheme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
try { if (typeof mdEditor.setTheme === 'function') mdEditor.setTheme(nextTheme); } catch (e) {}
}
});
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
// Image upload
mdEditor.removeHook && mdEditor.removeHook('addImageBlobHook');
mdEditor.addHook && mdEditor.addHook('addImageBlobHook', async (blob, callback) => {
try {
const formData = new FormData();
formData.append('image', blob, blob.name || 'upload.png');
const res = await fetch('{{ url_for('api.upload_editor_image') }}', { method: 'POST', body: formData });
const data = await res.json();
if (data && data.url) { callback(data.url, blob.name || 'image'); }
} catch (e) { console.error('Image upload error', e); }
});
// Autosave
const autosaveKey = 'tt-project-create-description';
if (!descriptionInput.value) {
const cached = localStorage.getItem(autosaveKey);
if (cached) { try { mdEditor.setMarkdown(cached); } catch(e) {} }
}
mdEditor.on && mdEditor.on('change', () => {
try { localStorage.setItem(autosaveKey, mdEditor.getMarkdown()); } catch (e) {}
});
}
// Form validation and enhancement
form.addEventListener('submit', function(e) {
const name = nameInput.value.trim();
const clientId = clientSelect.value;
if (!name || !clientId) {
e.preventDefault();
if (!name) {
nameInput.focus();
nameInput.classList.add('is-invalid');
}
if (!clientId) {
clientSelect.focus();
clientSelect.classList.add('is-invalid');
}
return false;
}
// Sync markdown content back to hidden textarea
if (mdEditor && descriptionInput) {
try { descriptionInput.value = mdEditor.getMarkdown(); } catch (err) {}
}
// Show loading state
const submitBtn = form.querySelector('button[type="submit"]');
const originalText = submitBtn.innerHTML;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>{{ _('Creating...') }}';
submitBtn.disabled = true;
// Re-enable after a delay (in case of validation errors)
setTimeout(() => {
submitBtn.innerHTML = originalText;
submitBtn.disabled = false;
}, 5000);
});
// Real-time validation feedback
nameInput.addEventListener('input', function() {
if (this.value.trim()) {
this.classList.remove('is-invalid');
this.classList.add('is-valid');
} else {
this.classList.remove('is-valid');
}
});
clientSelect.addEventListener('change', function() {
if (this.value) {
this.classList.remove('is-invalid');
this.classList.add('is-valid');
} else {
this.classList.remove('is-valid');
}
});
// Initialize form state
if (billableCheckbox.checked) {
hourlyRateInput.removeAttribute('disabled');
} else {
hourlyRateInput.setAttribute('disabled', 'disabled');
hourlyRateInput.classList.add('text-muted');
}
});
</script>
<!-- Create Client Modal (Tailwind, no external framework) -->
<div id="createClientModal" class="fixed inset-0 z-50 hidden" aria-hidden="true">
<div class="absolute inset-0 bg-black/50"></div>
<div class="relative max-w-2xl mx-auto mt-10 bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark rounded-lg shadow-xl">
<div class="p-4 border-b border-border-light dark:border-border-dark flex items-center justify-between">
<h3 class="text-lg font-semibold">{{ _('Create Client') }}</h3>
<button type="button" id="closeCreateClientModal" class="px-2 py-1 text-sm hover:bg-background-light dark:hover:bg-background-dark rounded" aria-label="Close">{{ _('Close') }}</button>
</div>
<div class="p-4">
<form id="inlineCreateClientForm" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="client_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Client Name') }} *</label>
<input type="text" id="client_name" name="name" required class="form-input" placeholder="{{ _('Enter client name') }}">
</div>
<div>
<label for="client_default_hourly_rate" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Default Hourly Rate') }}</label>
<input type="number" step="0.01" min="0" id="client_default_hourly_rate" name="default_hourly_rate" class="form-input" placeholder="{{ _('e.g. 75.00') }}">
</div>
</div>
<div class="mt-4">
<label for="client_description" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Description') }}</label>
<textarea id="client_description" name="description" rows="4" class="form-input" placeholder="{{ _('Brief description of the client or project scope') }}"></textarea>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div>
<label for="client_contact_person" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Contact Person') }}</label>
<input type="text" id="client_contact_person" name="contact_person" class="form-input" placeholder="{{ _('Primary contact name') }}">
</div>
<div>
<label for="client_email" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Email') }}</label>
<input type="email" id="client_email" name="email" class="form-input" placeholder="{{ _('contact@client.com') }}">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div>
<label for="client_phone" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Phone') }}</label>
<input type="text" id="client_phone" name="phone" class="form-input" placeholder="+1 (555) 123-4567">
</div>
<div>
<label for="client_address" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Address') }}</label>
<input type="text" id="client_address" name="address" class="form-input" placeholder="123 Business St, City">
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<button type="button" class="bg-gray-200 dark:bg-gray-700 px-4 py-2 rounded-lg" id="cancelCreateClient">{{ _('Cancel') }}</button>
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg" id="submitCreateClient">{{ _('Create Client') }}</button>
</div>
<p id="createClientError" class="text-red-600 dark:text-red-400 text-sm mt-3 hidden"></p>
</form>
</div>
</div>
<style>
#createClientModal .loading { opacity: .6; pointer-events: none; }
</style>
<script>
(function(){
const modal = document.getElementById('createClientModal');
const openBtn = document.getElementById('openCreateClientModal');
const closeBtn = document.getElementById('closeCreateClientModal');
const cancelBtn = document.getElementById('cancelCreateClient');
const form = document.getElementById('inlineCreateClientForm');
const errorEl = document.getElementById('createClientError');
const clientSelect = document.getElementById('client_id');
const hourlyRateInput = document.getElementById('hourly_rate');
function showModal(){ modal.classList.remove('hidden'); setTimeout(()=>{ document.getElementById('client_name')?.focus(); }, 0); }
function hideModal(){ modal.classList.add('hidden'); errorEl.classList.add('hidden'); errorEl.textContent=''; form.reset(); }
openBtn?.addEventListener('click', (e)=>{ e.preventDefault(); showModal(); });
closeBtn?.addEventListener('click', hideModal);
cancelBtn?.addEventListener('click', hideModal);
modal.addEventListener('click', (e)=>{ if (e.target === modal) hideModal(); });
document.addEventListener('keydown', (e)=>{ if (e.key === 'Escape' && !modal.classList.contains('hidden')) hideModal(); });
form?.addEventListener('submit', async (e) => {
e.preventDefault();
errorEl.classList.add('hidden');
errorEl.textContent = '';
const submitBtn = document.getElementById('submitCreateClient');
const originalText = submitBtn.innerHTML;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>{{ _('Creating...') }}';
submitBtn.disabled = true;
form.classList.add('loading');
try {
const formData = new FormData(form);
const tokenMeta = document.querySelector('meta[name="csrf-token"]');
const csrfToken = tokenMeta ? tokenMeta.getAttribute('content') : '';
const resp = await fetch('{{ url_for('clients.create_client') }}', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-Token': csrfToken
},
body: formData,
credentials: 'same-origin'
});
if (!resp.ok) {
let msg = '{{ _('Could not create client. Please try again.') }}';
try { const data = await resp.json(); if (data && (data.message || (data.messages && data.messages[0]))) { msg = data.message || data.messages[0]; } } catch(_){ }
errorEl.textContent = msg;
errorEl.classList.remove('hidden');
} else {
const data = await resp.json();
// Append new client to select, select it
const opt = document.createElement('option');
opt.value = String(data.id);
opt.textContent = data.name;
if (typeof data.default_hourly_rate !== 'undefined' && data.default_hourly_rate !== null) {
opt.setAttribute('data-default-rate', String(data.default_hourly_rate));
}
clientSelect.appendChild(opt);
clientSelect.value = String(data.id);
// If no hourly rate typed yet, prefill from client's default
if ((!hourlyRateInput.value || hourlyRateInput.value === '') && data.default_hourly_rate !== null && typeof data.default_hourly_rate !== 'undefined') {
try { hourlyRateInput.value = String(data.default_hourly_rate); } catch(_){ }
}
// Close modal
hideModal();
// Toast (if available)
if (window.toastManager) {
window.toastManager.success('{{ _('Client created') }}');
}
}
} catch (err) {
errorEl.textContent = '{{ _('Network error while creating client') }}';
errorEl.classList.remove('hidden');
} finally {
submitBtn.innerHTML = originalText;
submitBtn.disabled = false;
form.classList.remove('loading');
}
});
})();
</script>
</div>
{% endblock %}