mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-02-13 23:59:00 -06:00
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
513 lines
25 KiB
HTML
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 %}
|
|
|
|
|