mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-05 19:20:21 -06:00
Major improvements: - Add bulk operations functionality across clients, projects, and tasks - Implement deletion and status management enhancements - Add project code field with database migration (022) - Improve inactive status handling for projects Backend changes: - Update project model with new code field and status logic - Enhance routes for clients, projects, and tasks with bulk actions - Add migration for project_code field (022_add_project_code_field.py) Frontend updates: - Refactor bulk actions widget component - Update clients list and detail views with bulk operations - Enhance project list, view, and kanban templates - Improve task list, edit, view, and kanban displays - Update base template with UI improvements - Refine saved filters and time entry templates lists Testing: - Add test_project_inactive_status.py for status handling - Update test_tasks_templates.py with new functionality Documentation: - Add BULK_OPERATIONS_IMPROVEMENTS.md - Add DELETION_AND_STATUS_IMPROVEMENTS.md - Add docs/QUICK_WINS_IMPLEMENTATION.md - Update ALL_BUGFIXES_SUMMARY.md and IMPLEMENTATION_COMPLETE.md
167 lines
9.7 KiB
HTML
167 lines
9.7 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}{{ _('Edit 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">{{ _('Edit Project') }}</h1>
|
|
<p class="text-text-muted-light dark:text-text-muted-dark">{{ project.name }}</p>
|
|
</div>
|
|
<a href="{{ url_for('projects.view_project', project_id=project.id) }}" class="bg-gray-200 dark:bg-gray-700 px-4 py-2 rounded-lg mt-4 md:mt-0">{{ _('Back to Project') }}</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.edit_project', project_id=project.id) }}">
|
|
<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', project.name) }}" class="form-input">
|
|
</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', project.code or '') }}" placeholder="{{ _('Short code, e.g., ABC') }}" class="form-input" maxlength="20">
|
|
</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', project.client_id) == client.id %}selected{% endif %} data-default-rate="{{ client.default_hourly_rate or '' }}">{{ client.name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
<p class="text-xs mt-1"><a href="{{ url_for('clients.create_client') }}" class="text-primary hover:underline">{{ _('Create new client') }}</a></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">{{ request.form.get('description', project.description or '') }}</textarea>
|
|
<div id="description_editor"></div>
|
|
</div>
|
|
</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 and request.form.get('billable')) or (not request.form and project.billable) %}checked{% endif %} class="rounded border-gray-300 text-primary shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
|
{{ _('Billable') }}
|
|
</label>
|
|
</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', project.hourly_rate or '') }}" 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', project.billing_ref or '') }}" placeholder="Optional" class="form-input">
|
|
</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', project.budget_amount or '') }}" placeholder="{{ _('e.g. 10000.00') }}" class="form-input">
|
|
</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', project.budget_threshold_percent or 80) }}" placeholder="80" class="form-input">
|
|
</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.view_project', project_id=project.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>
|
|
|
|
{% 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 descriptionInput = document.getElementById('description');
|
|
clientSelect.addEventListener('change', function() {
|
|
const selectedOption = this.options[this.selectedIndex];
|
|
const defaultRate = selectedOption.getAttribute('data-default-rate');
|
|
if (defaultRate && !hourlyRateInput.value) {
|
|
hourlyRateInput.value = defaultRate;
|
|
}
|
|
});
|
|
|
|
// 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 || ''
|
|
});
|
|
|
|
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); }
|
|
});
|
|
}
|
|
|
|
// Sync markdown on form submit
|
|
const form = document.querySelector('form[action*="projects/edit"]') || document.querySelector('form');
|
|
form && form.addEventListener('submit', function(){
|
|
if (mdEditor && descriptionInput) {
|
|
try { descriptionInput.value = mdEditor.getMarkdown(); } catch (err) {}
|
|
}
|
|
});
|
|
});
|
|
</script>
|
|
{% endblock %}
|
|
|
|
|