mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-23 06:40:53 -05:00
463704f054
Unify buttons, cards, headers, toasts, and form treatments across the app so screens feel consistent and are easier to scan on desktop and mobile. Update the broader template set to use the shared UI primitives and responsive spacing patterns introduced in this refresh.
228 lines
14 KiB
HTML
228 lines
14 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-xl border border-border-light dark:border-border-dark shadow-sm">
|
|
<form method="POST" action="{{ url_for('projects.edit_project', project_id=project.id) }}" novalidate data-validate-form>
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<div class="md:col-span-2">
|
|
<label for="name" class="form-label">{{ _('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="form-label">{{ _('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="form-label">{{ _('Client') }} *</label>
|
|
{% if only_one_client and single_client %}
|
|
<input type="hidden" name="client_id" id="client_id" value="{{ single_client.id }}" data-default-rate="{{ single_client.default_hourly_rate or '' }}">
|
|
<input type="text" class="form-input bg-gray-100 dark:bg-gray-700 cursor-not-allowed opacity-75" value="{{ single_client.name }}" disabled readonly aria-label="{{ _('Client (auto-selected)') }}">
|
|
{% else %}
|
|
<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>
|
|
{% endif %}
|
|
<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="form-label">{{ _('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="form-label">{{ _('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="form-label">{{ _('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="form-label">{{ _('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="form-label">{{ _('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>
|
|
<label for="color" class="form-label">{{ _('Gantt color') }}</label>
|
|
<div class="gantt-color-picker flex items-center gap-3 mt-1">
|
|
<div class="gantt-color-picker-swatch rounded border border-gray-300 dark:border-gray-600 overflow-hidden flex-shrink-0" style="width:2.5rem;height:2.5rem;min-width:2.5rem;min-height:2.5rem;"></div>
|
|
<input type="text" name="color" id="color" value="{{ request.form.get('color', project.color or '#3b82f6') }}" class="form-input w-28 font-mono text-sm" maxlength="7" placeholder="#3b82f6" data-color-input>
|
|
</div>
|
|
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Optional: Color for this project on the Gantt chart. Pick or enter a hex code (e.g. #3b82f6).') }}</p>
|
|
</div>
|
|
|
|
{% if custom_field_definitions %}
|
|
<div class="mt-6 border-t border-border-light dark:border-border-dark pt-6">
|
|
<h3 class="text-lg font-semibold mb-4">{{ _('Custom Fields') }}</h3>
|
|
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4">
|
|
{{ _('These custom fields are defined globally and available for all projects.') }}
|
|
</p>
|
|
<div class="space-y-4">
|
|
{% for definition in custom_field_definitions %}
|
|
<div>
|
|
<label for="custom_field_{{ definition.field_key }}" class="form-label">
|
|
{{ definition.label }}
|
|
{% if definition.is_mandatory %}<span class="text-red-600 dark:text-red-400">*</span>{% endif %}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
id="custom_field_{{ definition.field_key }}"
|
|
name="custom_field_{{ definition.field_key }}"
|
|
value="{{ request.form.get('custom_field_' + definition.field_key, project.get_custom_field(definition.field_key, '') if project.custom_fields else '') }}"
|
|
placeholder="{{ definition.description or _('Enter value') }}"
|
|
class="form-input w-full"
|
|
{% if definition.is_mandatory %}required{% endif %}
|
|
>
|
|
{% if definition.description %}
|
|
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ definition.description }}</p>
|
|
{% endif %}
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<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">
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@simonwep/pickr/dist/themes/classic.min.css">
|
|
{% endblock %}
|
|
|
|
{% endblock %}
|
|
|
|
{% block scripts_extra %}
|
|
<script src="https://cdn.jsdelivr.net/npm/@simonwep/pickr/dist/pickr.min.js"></script>
|
|
<script src="{{ url_for('static', filename='js/gantt-color-picker.js') }}"></script>
|
|
<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');
|
|
function applyClientDefaultRate() {
|
|
let defaultRate = null;
|
|
if (clientSelect.tagName === 'INPUT' && clientSelect.type === 'hidden') {
|
|
defaultRate = clientSelect.getAttribute('data-default-rate');
|
|
} else if (clientSelect.options && clientSelect.selectedIndex >= 0) {
|
|
const selectedOption = clientSelect.options[clientSelect.selectedIndex];
|
|
defaultRate = selectedOption ? selectedOption.getAttribute('data-default-rate') : null;
|
|
}
|
|
if (defaultRate && !hourlyRateInput.value) {
|
|
hourlyRateInput.value = defaultRate;
|
|
}
|
|
}
|
|
clientSelect.addEventListener('change', applyClientDefaultRate);
|
|
applyClientDefaultRate();
|
|
|
|
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'] });
|
|
|
|
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); }
|
|
});
|
|
}
|
|
|
|
const form = document.querySelector('form[action*="/edit"]') || document.querySelector('form');
|
|
if (form) {
|
|
form.addEventListener('submit', function(){
|
|
if (mdEditor && descriptionInput) {
|
|
try {
|
|
descriptionInput.value = mdEditor.getMarkdown();
|
|
} catch (err) {
|
|
console.error('Failed to sync markdown editor:', err);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
</script>
|
|
{% endblock %}
|
|
|
|
|