Build and Publish / Build and Publish Docker Image (push) Successful in 1m44s
457 lines
19 KiB
HTML
457 lines
19 KiB
HTML
{% extends "admin/layout.html" %}
|
|
{% let active_page = "schedule" %}
|
|
|
|
{% block title %}{{ t.schedule_edit_title }}{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="page-head">
|
|
<h1>{{ t.schedule_edit_title }}</h1>
|
|
</div>
|
|
|
|
<div class="form-card">
|
|
<form method="post" action="/admin/schedule/{{ visit.id }}/save">
|
|
<!-- Client -->
|
|
<div class="field">
|
|
<label class="label">{{ t.schedule_client }}</label>
|
|
<div class="control">
|
|
<input class="input" type="text" value="{{ client.name }}{% if let Some(p) = client.phone.as_deref() %} ({{ p }}){% endif %}" readonly>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Admin -->
|
|
<div class="field">
|
|
<label class="label">{{ t.schedule_admin }}</label>
|
|
<div class="control">
|
|
<div class="select is-fullwidth">
|
|
<select name="user_id">
|
|
{% for u in &users %}
|
|
<option value="{{ u.id }}" {% if u.id.unwrap() == visit.user_id.primary_key().unwrap() %}selected{% endif %}>
|
|
{{ u.display_name.as_deref().unwrap_or(&u.login) }}
|
|
</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Date -->
|
|
<div class="field">
|
|
<label class="label">{{ t.schedule_date }}</label>
|
|
<div class="control">
|
|
<input class="input" type="date" name="visit_date" value="{{ visit.visit_date }}" required>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Time -->
|
|
<div class="field">
|
|
<label class="label">{{ t.schedule_default_time }}</label>
|
|
<div class="columns is-mobile" style="margin-bottom:0;">
|
|
<div class="column">
|
|
<div class="control">
|
|
<input class="input" type="time" name="time_start" value="{{ visit.time_start }}" required>
|
|
</div>
|
|
</div>
|
|
<div class="column">
|
|
<div class="control">
|
|
<input class="input" type="time" name="time_end" value="{{ visit.time_end }}" required>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Status -->
|
|
<div class="field">
|
|
<label class="label">{{ t.schedule_status }}</label>
|
|
<input type="hidden" name="status" id="statusInput" value="{{ visit.status }}">
|
|
<div class="status-picker">
|
|
<button type="button" class="status-btn status-btn-scheduled {% if visit.status == "scheduled" %}is-active{% endif %}" data-value="scheduled">
|
|
<span class="status-btn-icon">📅</span>{{ t.visit_status_scheduled }}
|
|
</button>
|
|
<button type="button" class="status-btn status-btn-completed {% if visit.status == "completed" %}is-active{% endif %}" data-value="completed">
|
|
<span class="status-btn-icon">✅</span>{{ t.visit_status_completed }}
|
|
</button>
|
|
<button type="button" class="status-btn status-btn-cancelled {% if visit.status == "cancelled" %}is-active{% endif %}" data-value="cancelled">
|
|
<span class="status-btn-icon">✕</span>{{ t.visit_status_cancelled }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Private Notes -->
|
|
<div class="field">
|
|
<label class="label">{{ t.schedule_notes }}</label>
|
|
<div class="control">
|
|
<textarea class="textarea" name="notes" rows="2">{{ visit.notes.as_deref().unwrap_or("") }}</textarea>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Public Notes (visible to client) -->
|
|
<div class="field">
|
|
<label class="label">{{ t.schedule_public_notes }}</label>
|
|
<div class="control">
|
|
<textarea class="textarea" name="public_notes" rows="2">{{ visit.public_notes.as_deref().unwrap_or("") }}</textarea>
|
|
</div>
|
|
</div>
|
|
|
|
{% if let Some(fb) = visit.client_feedback.as_deref() %}
|
|
<div style="margin-bottom:1rem;">
|
|
<label class="label">{{ t.schedule_client_feedback }}</label>
|
|
<div style="background:#f0f0ff;border-radius:8px;padding:0.6rem 0.85rem;font-size:0.9rem;color:#4a4570;">{{ fb }}</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<hr style="margin:1rem 0;">
|
|
|
|
<!-- Media -->
|
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem;">
|
|
<label class="label" style="margin:0;">{{ t.nav_media }}</label>
|
|
<button type="button" class="button is-info is-small is-outlined" onclick="document.getElementById('uploadModal').classList.add('is-open')">+ {{ t.media_upload }}</button>
|
|
</div>
|
|
{% if media.is_empty() %}
|
|
<p class="has-text-grey is-size-7" style="margin-bottom:1rem;">{{ t.media_empty }}</p>
|
|
{% else %}
|
|
<div class="visit-media-grid">
|
|
{% for m in &media %}
|
|
<div class="visit-media-item">
|
|
{% if m.file_type == "photo" %}
|
|
<a href="{{ m.url }}" data-lightbox="photo">
|
|
<span class="photo-thumb media-loading-frame is-loading">
|
|
<img src="{{ m.thumbnail_url }}" alt="" loading="lazy" data-media-load>
|
|
</span>
|
|
</a>
|
|
{% else %}
|
|
<a href="{{ m.url }}" data-lightbox="video">
|
|
<div class="video-thumb-sm media-loading-frame{% if !m.thumbnail_url.is_empty() %} is-loading{% endif %}" data-video-preview>
|
|
{% if !m.thumbnail_url.is_empty() %}
|
|
<img class="video-preview-sprite" src="{{ m.thumbnail_url }}" alt="" loading="lazy" data-media-load data-preview-frames="4">
|
|
{% endif %}
|
|
<span class="video-play">▶</span>
|
|
</div>
|
|
</a>
|
|
{% endif %}
|
|
{% if let Some(cap) = m.caption.as_deref() %}
|
|
<div class="media-cap">{{ cap }}</div>
|
|
{% endif %}
|
|
<div class="visit-media-delete">
|
|
<button type="submit" form="visit-media-delete-{{ m.id.unwrap() }}" class="button is-small is-danger is-outlined">{{ t.media_delete }}</button>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
<hr style="margin:1rem 0;">
|
|
|
|
<button type="submit" class="button is-primary is-fullwidth">{{ t.schedule_save }}</button>
|
|
</form>
|
|
{% for m in &media %}
|
|
<form id="visit-media-delete-{{ m.id.unwrap() }}" method="post" action="/admin/media/{{ m.id.unwrap() }}/delete" onsubmit="return confirm('{{ t.media_delete_confirm }}');"></form>
|
|
{% endfor %}
|
|
|
|
<hr style="margin:1rem 0;">
|
|
<form method="post" action="/admin/schedule/{{ visit.id }}/delete" onsubmit="return confirm('{{ t.schedule_delete_confirm }}');">
|
|
<button type="submit" class="button is-danger is-outlined is-fullwidth is-small">{{ t.schedule_delete }}</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Upload Modal -->
|
|
<div class="upload-modal-bg" id="uploadModal">
|
|
<div class="upload-modal">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
|
|
<h3 style="font-size:1.1rem;font-weight:700;margin:0;">{{ t.media_upload_title }}</h3>
|
|
<button type="button" id="uploadModalClose" style="background:none;border:none;font-size:1.2rem;cursor:pointer;color:#888;">✕</button>
|
|
</div>
|
|
<form id="uploadForm" action="/admin/media/{{ visit.id }}/upload/submit" enctype="multipart/form-data">
|
|
<div class="field">
|
|
<label class="label">{{ t.media_choose_files }}</label>
|
|
<div class="control">
|
|
<input class="input" type="file" id="uploadFiles" name="files" multiple accept="image/*,video/*" required>
|
|
</div>
|
|
<p id="fileCount" style="font-size:0.8rem;color:#888;margin-top:0.3rem;"></p>
|
|
<div id="uploadQueue" class="upload-queue"></div>
|
|
</div>
|
|
<div class="field">
|
|
<label class="label">{{ t.media_caption }}</label>
|
|
<div class="control">
|
|
<input class="input" type="text" name="caption" placeholder="{{ t.media_caption }}">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Progress -->
|
|
<div id="uploadProgress" style="display:none;margin-bottom:1rem;">
|
|
<div style="display:flex;justify-content:space-between;font-size:0.82rem;color:#555;margin-bottom:0.3rem;">
|
|
<span id="uploadStatusText">{{ t.media_upload }}...</span>
|
|
<span id="uploadPercent">0%</span>
|
|
</div>
|
|
<div style="background:#e8e8e8;border-radius:99px;height:8px;overflow:hidden;">
|
|
<div id="uploadBar" style="height:100%;width:0%;background:linear-gradient(90deg,#6c63ff,#b06cff);border-radius:99px;transition:width 0.2s;"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<button type="submit" id="uploadSubmit" class="button is-primary is-fullwidth">{{ t.media_upload }}</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.status-picker {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
.status-btn {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
padding: 0.6rem 0.4rem;
|
|
border-radius: 10px;
|
|
border: 2px solid transparent;
|
|
background: #f5f5f5;
|
|
font-size: 0.82rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
color: #777;
|
|
transition: all 0.15s;
|
|
line-height: 1.2;
|
|
}
|
|
.status-btn-icon { font-size: 1.3rem; line-height: 1; }
|
|
.status-btn:hover { filter: brightness(0.95); }
|
|
|
|
.status-btn-scheduled.is-active { background: #dbeafe; border-color: #3b82f6; color: #1e40af; }
|
|
.status-btn-completed.is-active { background: #d1fae5; border-color: #22c55e; color: #15803d; }
|
|
.status-btn-cancelled.is-active { background: #fee2e2; border-color: #ef4444; color: #b91c1c; }
|
|
|
|
.status-btn-scheduled:not(.is-active):hover { background: #eff6ff; color: #3b82f6; }
|
|
.status-btn-completed:not(.is-active):hover { background: #f0fdf4; color: #22c55e; }
|
|
.status-btn-cancelled:not(.is-active):hover { background: #fff5f5; color: #ef4444; }
|
|
|
|
.visit-media-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
|
gap: 0.5rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.visit-media-item {
|
|
background: #fafafa;
|
|
border-radius: 8px;
|
|
border: 1px solid #eee;
|
|
overflow: hidden;
|
|
}
|
|
.visit-media-item img {
|
|
width: 100%;
|
|
height: 80px;
|
|
object-fit: cover;
|
|
display: block;
|
|
}
|
|
.visit-media-item .photo-thumb {
|
|
width: 100%;
|
|
height: 80px;
|
|
}
|
|
.visit-media-item .video-thumb-sm {
|
|
position: relative;
|
|
width: 100%;
|
|
height: 80px;
|
|
background: #111;
|
|
}
|
|
.visit-media-item .video-play {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
color: white;
|
|
font-size: 1.6rem;
|
|
line-height: 1;
|
|
text-shadow: 0 1px 4px #000;
|
|
pointer-events: none;
|
|
}
|
|
.visit-media-item .media-cap {
|
|
font-size: 0.7rem;
|
|
color: #888;
|
|
padding: 0.2rem 0.4rem;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.visit-media-delete {
|
|
padding: 0.25rem 0.4rem 0.4rem;
|
|
}
|
|
.visit-media-delete .button {
|
|
width: 100%;
|
|
font-size: 0.68rem;
|
|
min-height: 1.65rem;
|
|
}
|
|
.upload-modal-bg {
|
|
display: none;
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0,0,0,0.35);
|
|
z-index: 100;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.upload-modal-bg.is-open {
|
|
display: flex;
|
|
}
|
|
.upload-modal {
|
|
background: #fff;
|
|
border-radius: 12px;
|
|
padding: 1.5rem;
|
|
width: 90%;
|
|
max-width: 420px;
|
|
box-shadow: 0 4px 24px rgba(0,0,0,0.15);
|
|
}
|
|
.upload-queue { display:flex; flex-direction:column; gap:.4rem; margin-top:.65rem; }
|
|
.upload-queue:empty { display:none; }
|
|
.upload-queue-item { display:grid; grid-template-columns:34px minmax(0,1fr); gap:.55rem; align-items:center; padding:.45rem .55rem; background:#f7f6ff; border-radius:8px; }
|
|
.upload-queue-icon { width:34px; height:34px; display:flex; align-items:center; justify-content:center; border-radius:7px; background:#ebe8ff; font-size:1.05rem; }
|
|
.upload-queue-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:.78rem; color:#494467; }
|
|
.upload-queue-state { font-size:.68rem; color:#8a84a5; }
|
|
.upload-queue-track { height:3px; overflow:hidden; border-radius:99px; background:#dedbea; margin-top:.25rem; }
|
|
.upload-queue-bar { height:100%; width:0; background:#7567e8; transition:width .2s; }
|
|
#uploadBar.is-processing, .upload-queue-bar.is-processing { width:35% !important; animation:upload-processing 1.15s ease-in-out infinite; }
|
|
@keyframes upload-processing { from { transform:translateX(-110%); } to { transform:translateX(300%); } }
|
|
</style>
|
|
|
|
<script>
|
|
// Status picker
|
|
document.querySelectorAll('.status-btn').forEach(function(btn) {
|
|
btn.addEventListener('click', function() {
|
|
document.querySelectorAll('.status-btn').forEach(function(b) { b.classList.remove('is-active'); });
|
|
btn.classList.add('is-active');
|
|
document.getElementById('statusInput').value = btn.dataset.value;
|
|
});
|
|
});
|
|
</script>
|
|
|
|
<script>
|
|
(function() {
|
|
var modal = document.getElementById('uploadModal');
|
|
var form = document.getElementById('uploadForm');
|
|
var filesInput = document.getElementById('uploadFiles');
|
|
var fileCount = document.getElementById('fileCount');
|
|
var queue = document.getElementById('uploadQueue');
|
|
var progress = document.getElementById('uploadProgress');
|
|
var bar = document.getElementById('uploadBar');
|
|
var percent = document.getElementById('uploadPercent');
|
|
var statusText = document.getElementById('uploadStatusText');
|
|
var submitBtn = document.getElementById('uploadSubmit');
|
|
|
|
// Close modal on backdrop click
|
|
modal.addEventListener('click', function(e) {
|
|
if (e.target === this) closeModal();
|
|
});
|
|
document.getElementById('uploadModalClose').addEventListener('click', closeModal);
|
|
|
|
function closeModal() {
|
|
if (submitBtn.disabled) return; // prevent close during upload
|
|
modal.classList.remove('is-open');
|
|
}
|
|
|
|
// Show selected file count
|
|
filesInput.addEventListener('change', function() {
|
|
var n = this.files.length;
|
|
fileCount.textContent = n > 0 ? ('{{ t.media_files_selected }}: ' + n) : '';
|
|
queue.replaceChildren();
|
|
Array.from(this.files).forEach(function(file) {
|
|
var item = document.createElement('div');
|
|
item.className = 'upload-queue-item';
|
|
var icon = document.createElement('span');
|
|
icon.className = 'upload-queue-icon';
|
|
icon.textContent = file.type.indexOf('video/') === 0 ? '🎬' : '🖼️';
|
|
var details = document.createElement('div');
|
|
var name = document.createElement('div');
|
|
name.className = 'upload-queue-name';
|
|
name.textContent = file.name;
|
|
var state = document.createElement('div');
|
|
state.className = 'upload-queue-state';
|
|
state.textContent = '0%';
|
|
var track = document.createElement('div');
|
|
track.className = 'upload-queue-track';
|
|
var itemBar = document.createElement('div');
|
|
itemBar.className = 'upload-queue-bar';
|
|
track.appendChild(itemBar);
|
|
details.append(name, state, track);
|
|
item.append(icon, details);
|
|
queue.appendChild(item);
|
|
});
|
|
});
|
|
|
|
function updateQueue(state, progressValue, processing) {
|
|
queue.querySelectorAll('.upload-queue-item').forEach(function(item) {
|
|
item.querySelector('.upload-queue-state').textContent = state;
|
|
var itemBar = item.querySelector('.upload-queue-bar');
|
|
itemBar.classList.toggle('is-processing', processing);
|
|
if (!processing) itemBar.style.width = progressValue + '%';
|
|
});
|
|
}
|
|
|
|
// Submit via XHR for progress tracking
|
|
form.addEventListener('submit', function(e) {
|
|
e.preventDefault();
|
|
if (!filesInput.files.length) return;
|
|
|
|
var data = new FormData(form);
|
|
var xhr = new XMLHttpRequest();
|
|
|
|
// Show progress bar, disable submit
|
|
progress.style.display = 'block';
|
|
submitBtn.disabled = true;
|
|
submitBtn.textContent = '{{ t.media_upload_sending }}';
|
|
statusText.textContent = '{{ t.media_upload_sending }}';
|
|
bar.classList.remove('is-processing');
|
|
bar.style.width = '0%';
|
|
percent.textContent = '0%';
|
|
|
|
xhr.upload.addEventListener('progress', function(ev) {
|
|
if (!ev.lengthComputable) return;
|
|
var pct = Math.round(ev.loaded / ev.total * 100);
|
|
bar.style.width = pct + '%';
|
|
percent.textContent = pct + '%';
|
|
updateQueue(pct + '%', pct, false);
|
|
if (pct === 100) {
|
|
statusText.textContent = '{{ t.media_upload_processing }}';
|
|
percent.textContent = '•••';
|
|
bar.classList.add('is-processing');
|
|
updateQueue('{{ t.media_upload_processing }}', 100, true);
|
|
}
|
|
});
|
|
xhr.upload.addEventListener('load', function() {
|
|
statusText.textContent = '{{ t.media_upload_processing }}';
|
|
percent.textContent = '•••';
|
|
bar.classList.add('is-processing');
|
|
updateQueue('{{ t.media_upload_processing }}', 100, true);
|
|
});
|
|
|
|
xhr.addEventListener('load', function() {
|
|
if (xhr.status >= 200 && xhr.status < 400) {
|
|
bar.style.width = '100%';
|
|
bar.classList.remove('is-processing');
|
|
percent.textContent = '100%';
|
|
statusText.textContent = '{{ t.media_upload_done }}';
|
|
updateQueue('{{ t.media_upload_done }}', 100, false);
|
|
// Reload page to show uploaded media
|
|
setTimeout(function() { window.location.reload(); }, 300);
|
|
} else {
|
|
bar.classList.remove('is-processing');
|
|
statusText.textContent = 'Ошибка загрузки (' + xhr.status + ')';
|
|
updateQueue('Ошибка загрузки', 0, false);
|
|
submitBtn.disabled = false;
|
|
submitBtn.textContent = '{{ t.media_upload }}';
|
|
}
|
|
});
|
|
|
|
xhr.addEventListener('error', function() {
|
|
bar.classList.remove('is-processing');
|
|
statusText.textContent = '{{ t.media_upload_connection_error }}';
|
|
updateQueue('{{ t.media_upload_connection_error }}', 0, false);
|
|
submitBtn.disabled = false;
|
|
submitBtn.textContent = '{{ t.media_upload }}';
|
|
});
|
|
|
|
xhr.open('POST', form.action);
|
|
xhr.send(data);
|
|
});
|
|
})();
|
|
</script>
|
|
{% endblock %}
|