430 lines
15 KiB
HTML
430 lines
15 KiB
HTML
{% extends "admin/layout.html" %}
|
|
{% let active_page = "schedule" %}
|
|
|
|
{% block title %}{{ t.schedule_new_title }}{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="page-head">
|
|
<h1>{{ t.schedule_new_title }}</h1>
|
|
</div>
|
|
|
|
<div class="form-card">
|
|
<form method="post" action="/admin/schedule/create" id="visitForm">
|
|
|
|
<!-- Client -->
|
|
<div class="field">
|
|
<label class="label">{{ t.schedule_client }}</label>
|
|
<div class="control">
|
|
<div class="select is-fullwidth">
|
|
<select name="client_id" required>
|
|
<option value="">—</option>
|
|
{% for c in &clients %}
|
|
<option value="{{ c.id }}">{{ c.name }}{% if let Some(p) = c.phone.as_deref() %} ({{ p }}){% endif %}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
</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() == current_user_id %}selected{% endif %}>
|
|
{{ u.display_name.as_deref().unwrap_or(&u.login) }}
|
|
</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Default time -->
|
|
<div class="field">
|
|
<label class="label">{{ t.schedule_default_time }}</label>
|
|
<div class="time-row">
|
|
<div class="time-block">
|
|
<span class="time-lbl">{{ t.schedule_time_start }}</span>
|
|
<input class="input" type="time" id="defaultStart" value="18:00">
|
|
</div>
|
|
<div class="time-sep">—</div>
|
|
<div class="time-block">
|
|
<span class="time-lbl">{{ t.schedule_time_end }}</span>
|
|
<input class="input" type="time" id="defaultEnd" value="19:00">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Calendar -->
|
|
<div class="field">
|
|
<div class="cal-toolbar">
|
|
<button type="button" id="rangeModeBtn" class="button is-small is-outlined is-info">⇔ Выбрать диапазон</button>
|
|
<button type="button" id="resetBtn" class="button is-small is-outlined is-danger" style="display:none;">✕ Сбросить</button>
|
|
</div>
|
|
|
|
<div class="sched-cal">
|
|
<div class="cal-nav">
|
|
<button type="button" id="calPrev">◀</button>
|
|
<span id="calTitle"></span>
|
|
<button type="button" id="calNext">▶</button>
|
|
</div>
|
|
<div class="cal-grid" id="calGrid"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Selected days -->
|
|
<div class="field" id="selectedSection" style="display:none;">
|
|
<label class="label">{{ t.schedule_selected_days }} <span id="selectedCount" class="tag is-info is-light" style="margin-left:0.4rem;"></span></label>
|
|
<div id="daysList"></div>
|
|
</div>
|
|
|
|
<!-- Notes -->
|
|
<div class="field">
|
|
<label class="label">{{ t.schedule_notes }}</label>
|
|
<div class="control">
|
|
<textarea class="textarea" name="notes" rows="2"></textarea>
|
|
</div>
|
|
</div>
|
|
|
|
<input type="hidden" name="days_json" id="daysJson" value="[]">
|
|
<button type="submit" class="button is-primary is-fullwidth" id="submitBtn" disabled>{{ t.schedule_create }}</button>
|
|
|
|
</form>
|
|
</div>
|
|
|
|
<style>
|
|
/* Time row */
|
|
.time-row { display:flex; align-items:center; gap:0.5rem; }
|
|
.time-block { display:flex; flex-direction:column; flex:1; }
|
|
.time-lbl { font-size:0.75rem; color:#888; margin-bottom:0.2rem; }
|
|
.time-sep { font-size:1.2rem; color:#aaa; padding-top:1.2rem; }
|
|
|
|
/* Calendar toolbar */
|
|
.cal-toolbar { display:flex; align-items:center; justify-content:space-between; margin-bottom:0.5rem; }
|
|
|
|
|
|
/* Calendar container */
|
|
.sched-cal { background:#fafafa; border:1px solid #eee; border-radius:10px; overflow:hidden; }
|
|
|
|
.cal-nav { display:flex; align-items:center; justify-content:space-between; padding:0.6rem 0.75rem; background:#fff; border-bottom:1px solid #eee; }
|
|
.cal-nav button { background:none; border:none; font-size:1.1rem; cursor:pointer; color:#6c63ff; padding:0.2rem 0.5rem; border-radius:4px; }
|
|
.cal-nav button:hover { background:#f0eeff; }
|
|
.cal-nav span { font-weight:700; font-size:1rem; color:#333; }
|
|
|
|
.cal-grid { display:grid; grid-template-columns:repeat(7,1fr); }
|
|
|
|
.cal-wday { text-align:center; font-size:0.72rem; font-weight:700; color:#aaa; padding:0.4rem 0; background:#fafafa; }
|
|
.cal-wday.is-weekend { color:#f0a0a0; }
|
|
|
|
.cal-day { text-align:center; padding:0.55rem 0.2rem; font-size:0.9rem; cursor:pointer; color:#333; border-radius:0; transition:background 0.1s; position:relative; user-select:none; -webkit-user-select:none; }
|
|
.cal-day:hover { background:#f0eeff; }
|
|
.cal-day.is-empty { cursor:default; }
|
|
.cal-day.is-empty:hover { background:none; }
|
|
.cal-day.is-today { font-weight:700; color:#6c63ff; }
|
|
.cal-day.is-selected { background:#6c63ff !important; color:#fff !important; border-radius:0; }
|
|
.cal-day.is-range-start { background:#a89cff !important; color:#fff !important; }
|
|
.cal-day.is-past { color:#ccc; }
|
|
|
|
/* Selected days list */
|
|
.day-row { display:flex; align-items:center; gap:0.4rem; padding:0.45rem 0; border-bottom:1px solid #f5f5f5; }
|
|
.day-row:last-child { border-bottom:none; }
|
|
.day-date { font-weight:600; font-size:0.85rem; flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
.day-times { display:flex; align-items:center; gap:0.25rem; flex-shrink:0; }
|
|
.day-times .time-sep { color:#bbb; font-size:0.8rem; }
|
|
.day-rm { background:none; border:none; color:#ccc; cursor:pointer; font-size:1rem; padding:0.15rem 0.25rem; flex-shrink:0; line-height:1; }
|
|
.day-rm:hover { color:#e55; }
|
|
|
|
/* Time badge */
|
|
.time-badge-wrap {
|
|
position: relative;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: #ede9ff;
|
|
color: #5b52d6;
|
|
border: 1.5px solid #c4beff;
|
|
border-radius: 20px;
|
|
padding: 0.15rem 0.5rem;
|
|
font-size: 0.78rem;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
min-width: 3.6rem;
|
|
white-space: nowrap;
|
|
}
|
|
.time-badge-wrap:focus-within {
|
|
border-color: #6c63ff;
|
|
background: #f0eeff;
|
|
}
|
|
.time-badge-label { pointer-events: none; z-index: 1; }
|
|
.time-badge-input {
|
|
position: absolute;
|
|
inset: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
opacity: 0;
|
|
cursor: pointer;
|
|
border: none;
|
|
background: transparent;
|
|
padding: 0;
|
|
margin: 0;
|
|
font-size: 16px; /* prevent iOS zoom */
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
(function() {
|
|
|
|
const IS_RU = '{{ lang.code() }}' === 'ru';
|
|
const TZ = '{{ timezone }}';
|
|
const WDAYS = IS_RU ? ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'] : ['Mo','Tu','We','Th','Fr','Sa','Su'];
|
|
const MONTHS = IS_RU
|
|
? ['Январь','Февраль','Март','Апрель','Май','Июнь','Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь']
|
|
: ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
|
|
|
const days = new Map(); // dateStr -> {start, end}
|
|
let viewYear, viewMonth;
|
|
let rangeMode = false;
|
|
let rangeStart = null; // dateStr of first tap in range mode
|
|
|
|
const now = new Date();
|
|
const todayStr_ = tzDateStr(now);
|
|
viewYear = parseInt(todayStr_.slice(0, 4));
|
|
viewMonth = parseInt(todayStr_.slice(5, 7)) - 1; // 0-based
|
|
|
|
function isoDate(y, m, d) {
|
|
return y + '-' + String(m+1).padStart(2,'0') + '-' + String(d).padStart(2,'0');
|
|
}
|
|
|
|
function getDefaults() {
|
|
return {
|
|
start: document.getElementById('defaultStart').value || '18:00',
|
|
end: document.getElementById('defaultEnd').value || '19:00'
|
|
};
|
|
}
|
|
|
|
function addDay(ds) {
|
|
if (!ds || days.has(ds)) return;
|
|
const def = getDefaults();
|
|
days.set(ds, { start: def.start, end: def.end });
|
|
}
|
|
|
|
function toggleDay(ds) {
|
|
if (days.has(ds)) { days.delete(ds); } else { addDay(ds); }
|
|
}
|
|
|
|
// Получить текущую дату в нужном TZ как строку YYYY-MM-DD
|
|
function tzDateStr(d) {
|
|
const parts = new Intl.DateTimeFormat('en-CA', {
|
|
timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit'
|
|
}).formatToParts(d);
|
|
const p = {};
|
|
parts.forEach(function(x) { p[x.type] = x.value; });
|
|
return p.year + '-' + p.month + '-' + p.day;
|
|
}
|
|
|
|
function fillRange(from, to) {
|
|
if (from > to) { let t = from; from = to; to = t; }
|
|
// Используем полдень чтобы избежать проблем с переходом суток при смене DST
|
|
let cur = new Date(from + 'T12:00:00');
|
|
const end = new Date(to + 'T12:00:00');
|
|
while (cur <= end) {
|
|
addDay(tzDateStr(cur));
|
|
cur.setDate(cur.getDate() + 1);
|
|
}
|
|
}
|
|
|
|
// ── Render calendar ──────────────────────────────────────
|
|
function renderCal() {
|
|
document.getElementById('calTitle').textContent = MONTHS[viewMonth] + ' ' + viewYear;
|
|
|
|
const grid = document.getElementById('calGrid');
|
|
grid.innerHTML = '';
|
|
|
|
// Weekday headers
|
|
WDAYS.forEach(function(wd, i) {
|
|
const cell = document.createElement('div');
|
|
cell.className = 'cal-wday' + (i >= 5 ? ' is-weekend' : '');
|
|
cell.textContent = wd;
|
|
grid.appendChild(cell);
|
|
});
|
|
|
|
// First day of month (Mon=0 for our grid)
|
|
const first = new Date(viewYear, viewMonth, 1);
|
|
let startDow = first.getDay(); // 0=Sun
|
|
startDow = (startDow === 0) ? 6 : startDow - 1; // shift to Mon=0
|
|
|
|
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
|
const todayStr = todayStr_;
|
|
|
|
// Empty cells before first day
|
|
for (let i = 0; i < startDow; i++) {
|
|
const empty = document.createElement('div');
|
|
empty.className = 'cal-day is-empty';
|
|
grid.appendChild(empty);
|
|
}
|
|
|
|
for (let d = 1; d <= daysInMonth; d++) {
|
|
const ds = isoDate(viewYear, viewMonth, d);
|
|
const cell = document.createElement('div');
|
|
let cls = 'cal-day';
|
|
if (ds === todayStr) cls += ' is-today';
|
|
if (days.has(ds)) cls += ' is-selected';
|
|
if (ds === rangeStart) cls += ' is-range-start';
|
|
cell.className = cls;
|
|
cell.textContent = d;
|
|
cell.dataset.date = ds;
|
|
cell.addEventListener('click', onDayClick);
|
|
grid.appendChild(cell);
|
|
}
|
|
}
|
|
|
|
function onDayClick(e) {
|
|
const ds = e.currentTarget.dataset.date;
|
|
if (!ds) return;
|
|
if (rangeMode) {
|
|
if (days.has(ds)) {
|
|
days.delete(ds);
|
|
if (rangeStart === ds) rangeStart = null;
|
|
} else if (!rangeStart) {
|
|
rangeStart = ds;
|
|
} else {
|
|
fillRange(rangeStart, ds);
|
|
rangeStart = null;
|
|
}
|
|
} else {
|
|
toggleDay(ds);
|
|
}
|
|
|
|
renderCal();
|
|
renderList();
|
|
}
|
|
|
|
// ── Render selected days list ────────────────────────────
|
|
function renderList() {
|
|
const list = document.getElementById('daysList');
|
|
const section = document.getElementById('selectedSection');
|
|
const count = document.getElementById('selectedCount');
|
|
const btn = document.getElementById('submitBtn');
|
|
|
|
list.innerHTML = '';
|
|
|
|
const resetBtn = document.getElementById('resetBtn');
|
|
if (days.size === 0) {
|
|
section.style.display = 'none';
|
|
btn.disabled = true;
|
|
document.getElementById('daysJson').value = '[]';
|
|
resetBtn.style.display = 'none';
|
|
return;
|
|
}
|
|
resetBtn.style.display = '';
|
|
|
|
section.style.display = '';
|
|
btn.disabled = false;
|
|
count.textContent = days.size;
|
|
|
|
const sorted = [...days.entries()].sort((a,b) => a[0].localeCompare(b[0]));
|
|
|
|
sorted.forEach(function([ds, times]) {
|
|
const d = new Date(ds + 'T00:00:00');
|
|
const dow = IS_RU
|
|
? ['Вс','Пн','Вт','Ср','Чт','Пт','Сб'][d.getDay()]
|
|
: ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d.getDay()];
|
|
const parts = ds.split('-');
|
|
const label = parts[2] + '.' + parts[1] + ' ' + dow; // DD.MM Вт
|
|
|
|
const row = document.createElement('div');
|
|
row.className = 'day-row';
|
|
row.innerHTML =
|
|
'<span class="day-date">' + label + '</span>' +
|
|
'<div class="day-times">' +
|
|
'<div class="time-badge-wrap">' +
|
|
'<span class="time-badge-label">' + times.start + '</span>' +
|
|
'<input type="time" class="time-badge-input" value="' + times.start + '" data-date="' + ds + '" data-field="start">' +
|
|
'</div>' +
|
|
'<span class="time-sep">—</span>' +
|
|
'<div class="time-badge-wrap">' +
|
|
'<span class="time-badge-label">' + times.end + '</span>' +
|
|
'<input type="time" class="time-badge-input" value="' + times.end + '" data-date="' + ds + '" data-field="end">' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'<button type="button" class="day-rm" data-date="' + ds + '" title="Убрать">✕</button>';
|
|
list.appendChild(row);
|
|
});
|
|
|
|
list.querySelectorAll('.time-badge-input').forEach(function(inp) {
|
|
inp.addEventListener('change', function() {
|
|
const d = days.get(this.dataset.date);
|
|
if (d) {
|
|
d[this.dataset.field] = this.value;
|
|
this.previousElementSibling.textContent = this.value;
|
|
updateJson();
|
|
}
|
|
});
|
|
});
|
|
list.querySelectorAll('.day-rm').forEach(function(b) {
|
|
b.addEventListener('click', function() {
|
|
days.delete(this.dataset.date);
|
|
renderCal();
|
|
renderList();
|
|
});
|
|
});
|
|
|
|
updateJson();
|
|
}
|
|
|
|
function updateJson() {
|
|
const arr = [...days.entries()].map(function([date, t]) {
|
|
return { date: date, time_start: t.start, time_end: t.end };
|
|
});
|
|
document.getElementById('daysJson').value = JSON.stringify(arr);
|
|
}
|
|
|
|
// ── Navigation ───────────────────────────────────────────
|
|
document.getElementById('calPrev').addEventListener('click', function() {
|
|
viewMonth--;
|
|
if (viewMonth < 0) { viewMonth = 11; viewYear--; }
|
|
renderCal();
|
|
});
|
|
document.getElementById('calNext').addEventListener('click', function() {
|
|
viewMonth++;
|
|
if (viewMonth > 11) { viewMonth = 0; viewYear++; }
|
|
renderCal();
|
|
});
|
|
|
|
// ── Range mode toggle ────────────────────────────────────
|
|
document.getElementById('rangeModeBtn').addEventListener('click', function() {
|
|
rangeMode = !rangeMode;
|
|
rangeStart = null;
|
|
if (rangeMode) {
|
|
this.classList.remove('is-outlined', 'is-info');
|
|
this.classList.add('is-warning');
|
|
this.textContent = '✕ Выбрать отдельные дни';
|
|
} else {
|
|
this.classList.remove('is-warning');
|
|
this.classList.add('is-outlined', 'is-info');
|
|
this.textContent = '⇔ Выбрать диапазон';
|
|
}
|
|
renderCal();
|
|
});
|
|
|
|
document.getElementById('resetBtn').addEventListener('click', function() {
|
|
days.clear();
|
|
rangeStart = null;
|
|
renderCal();
|
|
renderList();
|
|
});
|
|
|
|
// ── Default time change → update existing days ───────────
|
|
// (only updates days that still have the old default)
|
|
// kept simple: doesn't retroactively update already-added days
|
|
|
|
renderCal();
|
|
renderList();
|
|
|
|
})();
|
|
</script>
|
|
{% endblock %}
|