feat(datetime): timezone/time_format в настройках + единый хелпер дат
Часовой пояс и формат времени (24h/12h) перенесены из браузера в настройки приложения: валидация IANA-зоны, сид в db/init.sql + db/migration.sql, отдача в GET /api/public-settings, раздел #sec-datetime в настройках с живым превью. Границы суток в SQL переведены на tzDayStart/tzDayEnd + bindTz ($TZ$) — раньше $n::date по TIMESTAMPTZ считал дни в UTC (у контейнера TimeZone=UTC) и молча сдвигал выборку на день; хардкод Europe/Moscow вычищен, now() заменён на tzWall(). Новый public/js/datetime.js: три семейства хелперов (instant / чистая DATE-строка без Date() / чистая TIME-строка), подключён на всех страницах включая публичные share/report/index, initDateTime() встроен в checkAuth(). Прямые toLocale*/getFullYear/toISOString().slice(0,10) в public/ выпилены. Попутно: bindTz добавляет параметр только при наличии $TZ$ в SQL (иначе запрос виснет вечно без global error handler) — регрессия закрыта в api.smoketest.js; починены off-by-one месяца в report.js и группировка по дате в share.js.
This commit is contained in:
@@ -122,6 +122,20 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
|
|||||||
- **История версий**: `GET /api/lesson-reports/:id/versions`, восстановление — `POST /api/lesson-reports/:id/versions/:versionId/restore`, откат к тексту тьютора — `POST /api/lesson-reports/:id/ai/revert`. Хранится последние `LESSON_AI_VERSION_LIMIT` версий на отчёт
|
- **История версий**: `GET /api/lesson-reports/:id/versions`, восстановление — `POST /api/lesson-reports/:id/versions/:versionId/restore`, откат к тексту тьютора — `POST /api/lesson-reports/:id/ai/revert`. Хранится последние `LESSON_AI_VERSION_LIMIT` версий на отчёт
|
||||||
- **Хуки фронтенда**: `openLessonVersions(id)` и `restoreLessonVersion(...)` живут в `public/admin.js` (модалка доступна с журнала, отчётов и дашборда), список и бейджи статусов — в `public/js/lessons.js`
|
- **Хуки фронтенда**: `openLessonVersions(id)` и `restoreLessonVersion(...)` живут в `public/admin.js` (модалка доступна с журнала, отчётов и дашборда), список и бейджи статусов — в `public/js/lessons.js`
|
||||||
|
|
||||||
|
### 3e. Дата, время и часовой пояс (`server.js`, `public/js/datetime.js`)
|
||||||
|
- **Настройки**: `timezone` (IANA, валидируется через `validTimezone`) и `time_format` (`'24h'` | `'12h'`). Сид — в `db/init.sql` + `db/migration.sql`. `DEFAULT_TIMEZONE` берётся из `process.env.TZ`, иначе `Europe/Moscow`
|
||||||
|
- **Единая точка**: `public/js/datetime.js` подключается на **каждой** странице (`public/*.html`) перед `admin.js`/`js/*.js`, включая публичные `share.html`, `report.html`, `index.html`. Инициализация — `await initDateTime()` (грузят `timezone`/`time_format` из `GET /api/public-settings`). На админ-страницах вызов встроен в `checkAuth()` в `admin.js`
|
||||||
|
- **Запрещено** в `public/`: прямые `toLocaleString`/`toLocaleDateString`/`toLocaleTimeString`, `new Date().getFullYear()` и `new Date().toISOString().slice(0,10)` для показа/вычисления дат. Только хелперы `datetime.js`
|
||||||
|
- **Три семейства данных — не путать**:
|
||||||
|
- *instant* (`TIMESTAMPTZ`, ISO c `Z`) → `fmtFull` / `fmtDateTime` / `fmtDateFull` / `fmtDayMonth` / `fmtDateShort` / `fmtDateLong` / `fmtTimeOnly`. Зона применяется
|
||||||
|
- *чистая DATE-строка* `'YYYY-MM-DD'` (`lesson_date`, `taken_at`, `date_from`) → `fmtDateOnlyIso` / `fmtShortIso` / `fmtDayMonthIso` / `fmtDateOnlyLongIso`. **Без `Date()`** — иначе `new Date('YYYY-MM-DD')` (UTC-полночь) сдвинет дату на день назад
|
||||||
|
- *чистая TIME-строка* `'HH:MM(:SS)'` (`lesson_time`, `time_start`/`time_end`) → `fmtHmStr`. Учитывает 12h/24h, зону не применяет
|
||||||
|
- **Текущие значения**: `todayIso()`, `nowHm()` (всегда 24h — для `<input type="time">`), `nowYear()`, `todayDow()`, `isoAddDays(iso, n)` (арифметика по ISO без зоны)
|
||||||
|
- **Границы дней в SQL**: `TIMESTAMPTZ`-колонки фильтруются **только** через `tzDayStart`/`tzDayEnd` + `bindTz` (плейсхолдер `$TZ$` → `$N`, зона добавляется в `params` последней). Прямой `$n::date` по `created_at` считает дни в UTC (у контейнера `TimeZone=UTC`) и молча ломает границы — такого кода быть не должно. `DATE`-колонки (`lr.lesson_date`) сравниваются напрямую, зона не нужна
|
||||||
|
- **`now()` по зоне**: `tzWall()` для «сейчас» и для `day_of_week`/расписания. Хардкод `'Europe/Moscow'` в SQL запрещён
|
||||||
|
- **`bindTz` добавляет параметр только если в SQL есть `$TZ$`**: иначе Postgres отвечает `bind message supplies 1 parameters, but prepared statement requires 0`, а без global error handler запрос **висит вечно** (страница остаётся «Загрузка...»). Поэтому запрос с фильтрами дат работает, а без них — падает: проверяй оба варианта. Регрессия закрыта в `api.smoketest.js`
|
||||||
|
- **`TZ` в compose** (`docker-compose.yml`, 5 мест) — только фолбэк для `DEFAULT_TIMEZONE`; фактическая зона берётся из настройки
|
||||||
|
|
||||||
### 4. API Patterns
|
### 4. API Patterns
|
||||||
- **Middleware**: `requireAuth` — читает `X-Auth-Token`, 401 без валидной активной сессии. `requireAdmin` — самодостаточный (внутри вызывает `requireAuth`, если `req.user` ещё нет), 403 при `role !== 'admin'`. `optionalAuth` — для публичных страниц с персонализацией
|
- **Middleware**: `requireAuth` — читает `X-Auth-Token`, 401 без валидной активной сессии. `requireAdmin` — самодостаточный (внутри вызывает `requireAuth`, если `req.user` ещё нет), 403 при `role !== 'admin'`. `optionalAuth` — для публичных страниц с персонализацией
|
||||||
- **Филиалы**: `branchScope(user)` / `branchWhere(user, alias)` — для не-admin `user.branch_ids` (из `user_branches`) ограничивают выборку; у `admin` `ids = null` и фильтр не добавляется
|
- **Филиалы**: `branchScope(user)` / `branchWhere(user, alias)` — для не-admin `user.branch_ids` (из `user_branches`) ограничивают выборку; у `admin` `ids = null` и фильтр не добавляется
|
||||||
@@ -310,6 +324,7 @@ guards against.
|
|||||||
| `Dockerfile` | App image build |
|
| `Dockerfile` | App image build |
|
||||||
| `public/*.html` | Frontend pages |
|
| `public/*.html` | Frontend pages |
|
||||||
| `public/admin.js` | Shared frontend logic, модалка отчёта о занятии (`openLessonModal`) |
|
| `public/admin.js` | Shared frontend logic, модалка отчёта о занятии (`openLessonModal`) |
|
||||||
|
| `public/js/datetime.js` | Единая точка форматирования дат/времени: часовой пояс + 24h/12h. Подключается на всех страницах |
|
||||||
| `public/lessons.html` | Отчёты о занятиях: список, фильтры, редактирование |
|
| `public/lessons.html` | Отчёты о занятиях: список, фильтры, редактирование |
|
||||||
| `scripts/backup.sh` | Host-level backup script (DB dump + storage export) |
|
| `scripts/backup.sh` | Host-level backup script (DB dump + storage export) |
|
||||||
| `scripts/restore.sh` | Host-level restore script (DB dump + storage import) |
|
| `scripts/restore.sh` | Host-level restore script (DB dump + storage import) |
|
||||||
|
|||||||
@@ -123,6 +123,27 @@ async function main() {
|
|||||||
const restoredCheck = await api('/api/public-settings');
|
const restoredCheck = await api('/api/public-settings');
|
||||||
ok('cache invalidation: restore visible', restoredCheck.data.system_name === before.data.system_name, restoredCheck.data.system_name);
|
ok('cache invalidation: restore visible', restoredCheck.data.system_name === before.data.system_name, restoredCheck.data.system_name);
|
||||||
|
|
||||||
|
const tzBad = await api('/api/settings', { token, method: 'PUT', body: { settings: { timezone: 'Nope/Nope' } } });
|
||||||
|
ok('timezone: невалидная зона отклонена', tzBad.status === 400, tzBad.status);
|
||||||
|
const fmtBad = await api('/api/settings', { token, method: 'PUT', body: { settings: { time_format: '36h' } } });
|
||||||
|
ok('time_format: невалидный формат отклонён', fmtBad.status === 400, fmtBad.status);
|
||||||
|
const pubTz = await api('/api/public-settings');
|
||||||
|
ok('public-settings отдаёт timezone/time_format', !!pubTz.data.timezone && ['24h', '12h'].includes(pubTz.data.time_format), {
|
||||||
|
timezone: pubTz.data.timezone,
|
||||||
|
time_format: pubTz.data.time_format,
|
||||||
|
});
|
||||||
|
|
||||||
|
const noFilter = await api('/api/files?limit=5', { token });
|
||||||
|
ok('files без фильтров: 200 (без висящих bind-параметров)', noFilter.status === 200, noFilter.status);
|
||||||
|
const withFilter = await api('/api/files?limit=5&date_from=2026-01-01&date_to=2026-12-31', { token });
|
||||||
|
ok('files с фильтром дат: 200', withFilter.status === 200, withFilter.status);
|
||||||
|
const entriesNoFilter = await api('/api/entries?limit=5', { token });
|
||||||
|
ok('entries без фильтров: 200', entriesNoFilter.status === 200, entriesNoFilter.status);
|
||||||
|
const entriesWithFilter = await api('/api/entries?limit=5&date_from=2026-01-01&date_to=2026-12-31', { token });
|
||||||
|
ok('entries с фильтром дат: 200', entriesWithFilter.status === 200, entriesWithFilter.status);
|
||||||
|
const photosFilter = await api('/api/photos?limit=5&date_from=2026-01-01&date_to=2026-12-31', { token });
|
||||||
|
ok('photos с фильтром дат: 200', photosFilter.status === 200, photosFilter.status);
|
||||||
|
|
||||||
const groupCountBefore = Array.isArray(groups.data) ? groups.data.length : null;
|
const groupCountBefore = Array.isArray(groups.data) ? groups.data.length : null;
|
||||||
const bypass = await api('/api/groups', { token, headers: {} });
|
const bypass = await api('/api/groups', { token, headers: {} });
|
||||||
ok('groups scoped by role differ or equal', Array.isArray(bypass.data));
|
ok('groups scoped by role differ or equal', Array.isArray(bypass.data));
|
||||||
|
|||||||
@@ -131,6 +131,10 @@ INSERT INTO settings (key, value) VALUES ('camera_enabled', 'true')
|
|||||||
ON CONFLICT (key) DO NOTHING;
|
ON CONFLICT (key) DO NOTHING;
|
||||||
INSERT INTO settings (key, value) VALUES ('trash_purge_days', '30')
|
INSERT INTO settings (key, value) VALUES ('trash_purge_days', '30')
|
||||||
ON CONFLICT (key) DO NOTHING;
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('timezone', 'Europe/Moscow')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('time_format', '24h')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS share_links (
|
CREATE TABLE IF NOT EXISTS share_links (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
|
|||||||
@@ -429,3 +429,5 @@ WHERE key = 'lesson_ai_prompt' AND value IN (
|
|||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO settings (key, value) VALUES ('notify_lesson_ai_formatted', 'false') ON CONFLICT (key) DO NOTHING;
|
INSERT INTO settings (key, value) VALUES ('notify_lesson_ai_formatted', 'false') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('timezone', 'Europe/Moscow') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('time_format', '24h') ON CONFLICT (key) DO NOTHING;
|
||||||
|
|||||||
+4
-5
@@ -18,6 +18,7 @@ async function checkAuth() {
|
|||||||
const res = await fetch(`${API}/api/auth/me`, { headers: hdr() });
|
const res = await fetch(`${API}/api/auth/me`, { headers: hdr() });
|
||||||
if (!res.ok) { cleanupSession(); return false; }
|
if (!res.ok) { cleanupSession(); return false; }
|
||||||
currentUser = await res.json();
|
currentUser = await res.json();
|
||||||
|
await initDateTime();
|
||||||
return true;
|
return true;
|
||||||
} catch { cleanupSession(); return false; }
|
} catch { cleanupSession(); return false; }
|
||||||
}
|
}
|
||||||
@@ -199,7 +200,7 @@ function notifyTime(ts) {
|
|||||||
if (diff < 60000) return 'только что';
|
if (diff < 60000) return 'только что';
|
||||||
if (diff < 3600000) return `${Math.floor(diff / 60000)} мин назад`;
|
if (diff < 3600000) return `${Math.floor(diff / 60000)} мин назад`;
|
||||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)} ч назад`;
|
if (diff < 86400000) return `${Math.floor(diff / 3600000)} ч назад`;
|
||||||
return d.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
return fmtDateTime(d);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setNotifyUnread(n) {
|
function setNotifyUnread(n) {
|
||||||
@@ -490,13 +491,11 @@ let lessonModalGroups = null;
|
|||||||
let lessonModalGroupsPromise = null;
|
let lessonModalGroupsPromise = null;
|
||||||
|
|
||||||
function lessonTodayIso() {
|
function lessonTodayIso() {
|
||||||
const d = new Date();
|
return todayIso();
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function lessonNowTime() {
|
function lessonNowTime() {
|
||||||
const d = new Date();
|
return nowHm();
|
||||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadLessonGroups() {
|
async function loadLessonGroups() {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="vendor/lucide.min.js"></script>
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="js/datetime.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script src="js/audit.js"></script>
|
<script src="js/audit.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -66,6 +66,7 @@
|
|||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="vendor/lucide.min.js"></script>
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="js/datetime.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script src="js/bans.js"></script>
|
<script src="js/bans.js"></script>
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -64,6 +64,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="vendor/lucide.min.js"></script>
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="js/datetime.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script src="js/branches.js"></script>
|
<script src="js/branches.js"></script>
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -74,6 +74,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="vendor/lucide.min.js"></script>
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="js/datetime.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script src="js/dashboard.js"></script>
|
<script src="js/dashboard.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="/js/datetime.js"></script>
|
||||||
<script src="/js/error.js"></script>
|
<script src="/js/error.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -41,6 +41,7 @@
|
|||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="vendor/lucide.min.js"></script>
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="js/datetime.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script src="js/files.js"></script>
|
<script src="js/files.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -136,6 +136,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="vendor/lucide.min.js"></script>
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="js/datetime.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script src="vendor/jszip.min.js"></script>
|
<script src="vendor/jszip.min.js"></script>
|
||||||
<script src="js/groups.js"></script>
|
<script src="js/groups.js"></script>
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ nav a:hover{color:var(--text)}
|
|||||||
|
|
||||||
<div class="toast" id="toast">Готово!</div>
|
<div class="toast" id="toast">Готово!</div>
|
||||||
|
|
||||||
|
<script src="js/datetime.js"></script>
|
||||||
<script src="js/index.js"></script>
|
<script src="js/index.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -265,6 +265,7 @@
|
|||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="vendor/lucide.min.js"></script>
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="js/datetime.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script src="js/journal.js"></script>
|
<script src="js/journal.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+2
-1
@@ -111,7 +111,8 @@ const FIELD_LABELS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function fmtTime(t) {
|
function fmtTime(t) {
|
||||||
try { return new Date(t).toLocaleString('ru'); } catch { return t || ''; }
|
const f = fmtFull(t);
|
||||||
|
return f || t || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseTarget(t) {
|
function parseTarget(t) {
|
||||||
|
|||||||
+2
-2
@@ -41,8 +41,8 @@ function renderPage() {
|
|||||||
<tr>
|
<tr>
|
||||||
<td><b>${esc(b.ip)}</b></td>
|
<td><b>${esc(b.ip)}</b></td>
|
||||||
<td><span class="reason-badge ${rcls}">${esc(reason)}</span></td>
|
<td><span class="reason-badge ${rcls}">${esc(reason)}</span></td>
|
||||||
<td>${new Date(b.created_at).toLocaleString('ru')}</td>
|
<td>${fmtFull(b.created_at)}</td>
|
||||||
<td>${new Date(b.banned_until).toLocaleString('ru')}</td>
|
<td>${fmtFull(b.banned_until)}</td>
|
||||||
<td><button class="row-btn" data-unban="${esc(b.ip)}" title="Разблокировать">✕</button></td>
|
<td><button class="row-btn" data-unban="${esc(b.ip)}" title="Разблокировать">✕</button></td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|||||||
@@ -30,22 +30,22 @@ function renderStats(s, disk) {
|
|||||||
|
|
||||||
function last14days() {
|
function last14days() {
|
||||||
const out = [];
|
const out = [];
|
||||||
for (let i = 13; i >= 0; i--) { const d = new Date(); d.setDate(d.getDate() - i); out.push(d); }
|
const base = todayIso();
|
||||||
|
for (let i = 13; i >= 0; i--) out.push(isoAddDays(base, -i));
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderActivity(activity) {
|
function renderActivity(activity) {
|
||||||
const map = {};
|
const map = {};
|
||||||
activity.forEach(a => { map[a.d] = a.n; });
|
activity.forEach(a => { map[a.d] = a.n; });
|
||||||
const days = last14days().map(d => {
|
const days = last14days().map(iso => {
|
||||||
const iso = d.toISOString().slice(0, 10);
|
return { iso, label: fmtDayMonthIso(iso), n: map[iso] || 0 };
|
||||||
return { iso, label: d.toLocaleDateString('ru', { day: '2-digit', month: '2-digit' }), n: map[iso] || 0 };
|
|
||||||
});
|
});
|
||||||
const max = Math.max(1, ...days.map(x => x.n));
|
const max = Math.max(1, ...days.map(x => x.n));
|
||||||
const todayIso = new Date().toISOString().slice(0, 10);
|
const todayKey = todayIso();
|
||||||
document.getElementById('activityChart').innerHTML = days.map(x => `
|
document.getElementById('activityChart').innerHTML = days.map(x => `
|
||||||
<div class="bar-col" title="${x.iso} — ${x.n} ${plural(x.n, 'запись', 'записи', 'записей')}">
|
<div class="bar-col" title="${x.iso} — ${x.n} ${plural(x.n, 'запись', 'записи', 'записей')}">
|
||||||
<div class="bar ${x.iso === todayIso ? 'today' : ''}" style="height:${Math.round(x.n / max * 100)}%"></div>
|
<div class="bar ${x.iso === todayKey ? 'today' : ''}" style="height:${Math.round(x.n / max * 100)}%"></div>
|
||||||
<div class="bar-day">${x.label}</div>
|
<div class="bar-day">${x.label}</div>
|
||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,7 @@ function renderRecent(rows) {
|
|||||||
: `<div class="thumb"><i data-lucide="graduation-cap"></i></div>`}
|
: `<div class="thumb"><i data-lucide="graduation-cap"></i></div>`}
|
||||||
<div class="r-info">
|
<div class="r-info">
|
||||||
<div class="r-name">${esc(e.student_name)}</div>
|
<div class="r-name">${esc(e.student_name)}</div>
|
||||||
<div class="r-meta">${esc(e.group_name)} · ${new Date(e.created_at).toLocaleString('ru', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}</div>
|
<div class="r-meta">${esc(e.group_name)} · ${fmtDateTime(e.created_at)}</div>
|
||||||
</div>
|
</div>
|
||||||
<a class="r-link" href="journal.html" title="В журнал">→</a>
|
<a class="r-link" href="journal.html" title="В журнал">→</a>
|
||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
const DateTime = { tz: '', hour12: false, ready: false, listeners: [] };
|
||||||
|
|
||||||
|
const DT_FALLBACK_TZ = 'Europe/Moscow';
|
||||||
|
const DT_CACHE = new Map();
|
||||||
|
|
||||||
|
function dtValidTz(tz) {
|
||||||
|
if (!tz || typeof tz !== 'string') return false;
|
||||||
|
try { new Intl.DateTimeFormat('ru-RU', { timeZone: tz }); return true; } catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function dtBrowserTz() {
|
||||||
|
try { return Intl.DateTimeFormat().resolvedOptions().timeZone || DT_FALLBACK_TZ; } catch { return DT_FALLBACK_TZ; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function dtFmt(opts) {
|
||||||
|
const key = DateTime.tz + '|' + (DateTime.hour12 ? '12' : '24') + '|' + JSON.stringify(opts);
|
||||||
|
let f = DT_CACHE.get(key);
|
||||||
|
if (!f) {
|
||||||
|
f = new Intl.DateTimeFormat('ru-RU', Object.assign({ timeZone: DateTime.tz, hour12: DateTime.hour12 }, opts));
|
||||||
|
DT_CACHE.set(key, f);
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dtDate(v) {
|
||||||
|
if (!v) return null;
|
||||||
|
const d = v instanceof Date ? v : new Date(v);
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDt(v, opts) {
|
||||||
|
const d = dtDate(v);
|
||||||
|
return d ? dtFmt(opts).format(d) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function dtFmtParts() {
|
||||||
|
return { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtFull(v) { return fmtDt(v, dtFmtParts()); }
|
||||||
|
function fmtDateTime(v) { return fmtDt(v, { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); }
|
||||||
|
function fmtDateTimeY(v) { return fmtDt(v, { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }); }
|
||||||
|
function fmtDateFull(v) { return fmtDt(v, { day: '2-digit', month: '2-digit', year: 'numeric' }); }
|
||||||
|
function fmtDayMonth(v) { return fmtDt(v, { day: '2-digit', month: '2-digit' }); }
|
||||||
|
function fmtDateShort(v) { return fmtDt(v, { day: '2-digit', month: '2-digit', year: '2-digit' }); }
|
||||||
|
function fmtDateLong(v) { return fmtDt(v, { day: 'numeric', month: 'long', year: 'numeric' }); }
|
||||||
|
function fmtTimeOnly(v) { return fmtDt(v, { hour: '2-digit', minute: '2-digit' }); }
|
||||||
|
|
||||||
|
function dtIsoMatch(v) {
|
||||||
|
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(v == null ? '' : v));
|
||||||
|
return m ? { y: m[1], m: m[2], d: m[3] } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dtPad(n) { return String(n).padStart(2, '0'); }
|
||||||
|
|
||||||
|
function fmtDateOnlyIso(v) {
|
||||||
|
const p = dtIsoMatch(v);
|
||||||
|
return p ? `${p.d}.${p.m}.${p.y}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDayMonthIso(v) {
|
||||||
|
const p = dtIsoMatch(v);
|
||||||
|
return p ? `${p.d}.${p.m}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtShortIso(v) {
|
||||||
|
const p = dtIsoMatch(v);
|
||||||
|
return p ? `${p.d}.${p.m}.${p.y.slice(2)}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtHmStr(v) {
|
||||||
|
const m = /^(\d{1,2}):(\d{2})/.exec(String(v == null ? '' : v));
|
||||||
|
if (!m) return '';
|
||||||
|
const h = Number(m[1]);
|
||||||
|
const mm = m[2];
|
||||||
|
if (!DateTime.hour12) return `${dtPad(h)}:${mm}`;
|
||||||
|
const ap = h < 12 ? 'AM' : 'PM';
|
||||||
|
const h12 = h % 12 === 0 ? 12 : h % 12;
|
||||||
|
return `${h12}:${mm} ${ap}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDateOnlyLongIso(v) {
|
||||||
|
const p = dtIsoMatch(v);
|
||||||
|
if (!p) return '';
|
||||||
|
const months = ['января','февраля','марта','апреля','мая','июня','июля','августа','сентября','октября','ноября','декабря'];
|
||||||
|
return `${Number(p.d)} ${months[Number(p.m) - 1] || ''} ${p.y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dtZoneDate(d) {
|
||||||
|
try { return new Intl.DateTimeFormat('en-CA', { timeZone: DateTime.tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(d); } catch { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayIso() { return dtZoneDate(new Date()); }
|
||||||
|
|
||||||
|
function todayDow() {
|
||||||
|
const names = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
try { return names.indexOf(new Intl.DateTimeFormat('en-US', { timeZone: DateTime.tz, weekday: 'short' }).format(new Date())); } catch { return new Date().getDay(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowHm() {
|
||||||
|
try { return new Intl.DateTimeFormat('en-GB', { timeZone: DateTime.tz, hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date()); } catch { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowYear() {
|
||||||
|
try { return new Intl.DateTimeFormat('en-CA', { timeZone: DateTime.tz, year: 'numeric' }).format(new Date()); } catch { return String(new Date().getFullYear()); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoAddDays(iso, n) {
|
||||||
|
const p = dtIsoMatch(iso);
|
||||||
|
if (!p) return '';
|
||||||
|
const d = new Date(Date.UTC(Number(p.y), Number(p.m) - 1, Number(p.d)));
|
||||||
|
d.setUTCDate(d.getUTCDate() + n);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dtApply(s) {
|
||||||
|
if (!s || typeof s !== 'object') return;
|
||||||
|
if (dtValidTz(s.timezone)) DateTime.tz = s.timezone;
|
||||||
|
DateTime.hour12 = s.time_format === '12h';
|
||||||
|
DateTime.listeners.forEach(fn => { try { fn(DateTime); } catch {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initDateTime() {
|
||||||
|
DateTime.tz = dtBrowserTz();
|
||||||
|
DateTime.hour12 = false;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/public-settings');
|
||||||
|
if (res.ok) dtApply(await res.json());
|
||||||
|
} catch {}
|
||||||
|
DateTime.ready = true;
|
||||||
|
return DateTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDateTimeChange(fn) {
|
||||||
|
if (typeof fn === 'function') DateTime.listeners.push(fn);
|
||||||
|
}
|
||||||
+1
-1
@@ -51,7 +51,7 @@ function fileCardHTML(f) {
|
|||||||
<div class="info">
|
<div class="info">
|
||||||
<div class="file-name"><a ${target}>${esc(f.name)}</a></div>
|
<div class="file-name"><a ${target}>${esc(f.name)}</a></div>
|
||||||
${meta}
|
${meta}
|
||||||
<div class="time">${new Date(f.created_at).toLocaleString('ru')}${f.size ? ' · ' + humanSize(f.size) : ''}</div>
|
<div class="time">${fmtFull(f.created_at)}${f.size ? ' · ' + humanSize(f.size) : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="f-actions">${action}</div>
|
<div class="f-actions">${action}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+5
-6
@@ -67,7 +67,7 @@ async function loadGroups() {
|
|||||||
<button class="btn-link" data-export-urls="${g.id}" data-group-name="${esc(g.name)}" title="Скачать .url файлы учеников группы">URL</button>
|
<button class="btn-link" data-export-urls="${g.id}" data-group-name="${esc(g.name)}" title="Скачать .url файлы учеников группы">URL</button>
|
||||||
<button class="btn-link" data-export-files="${g.id}" data-group-name="${esc(g.name)}" title="Скачать ZIP-архив файлов группы за период">ZIP</button>
|
<button class="btn-link" data-export-files="${g.id}" data-group-name="${esc(g.name)}" title="Скачать ZIP-архив файлов группы за период">ZIP</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="sched-status">${schedSet ? `${DAYS[g.day_of_week]} ${g.time_start.slice(0,5)}–${g.time_end.slice(0,5)}` : 'Не настроено'}</div>
|
<div class="sched-status">${schedSet ? `${DAYS[g.day_of_week]} ${fmtHmStr(g.time_start)}–${fmtHmStr(g.time_end)}` : 'Не настроено'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('') + `</div>`;
|
}).join('') + `</div>`;
|
||||||
@@ -162,11 +162,11 @@ async function loadGroupPhotos() {
|
|||||||
|
|
||||||
function goPhotoPage(p) { photoPage = p; loadGroupPhotos(); }
|
function goPhotoPage(p) { photoPage = p; loadGroupPhotos(); }
|
||||||
|
|
||||||
function fmtPhotoDate(d) { return d ? d.split('-').reverse().join('.') : ''; }
|
function fmtPhotoDate(d) { return fmtDateOnlyIso(d); }
|
||||||
|
|
||||||
function photoHTML(p) {
|
function photoHTML(p) {
|
||||||
const taken = p.taken_at ? `Дата: ${fmtPhotoDate(p.taken_at)}` : '';
|
const taken = p.taken_at ? `Дата: ${fmtPhotoDate(p.taken_at)}` : '';
|
||||||
const uploaded = `Загружено: ${new Date(p.created_at).toLocaleDateString('ru')}`;
|
const uploaded = `Загружено: ${fmtDateFull(p.created_at)}`;
|
||||||
const isCover = currentCover && p.photo_path === currentCover;
|
const isCover = currentCover && p.photo_path === currentCover;
|
||||||
return `
|
return `
|
||||||
<div class="photo-item" data-photo-id="${p.id}" data-photo-path="${esc(p.photo_path)}" draggable="true">
|
<div class="photo-item" data-photo-id="${p.id}" data-photo-path="${esc(p.photo_path)}" draggable="true">
|
||||||
@@ -274,7 +274,7 @@ async function exportGroup(id, name) {
|
|||||||
e.student_name,
|
e.student_name,
|
||||||
e.group_name,
|
e.group_name,
|
||||||
e.description.replace(/\n/g, ' '),
|
e.description.replace(/\n/g, ' '),
|
||||||
new Date(e.created_at).toLocaleString('ru'),
|
fmtFull(e.created_at),
|
||||||
`${location.origin}/?nameInput=${encodeURIComponent(e.student_name)}`
|
`${location.origin}/?nameInput=${encodeURIComponent(e.student_name)}`
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -318,8 +318,7 @@ function closeGroupFilesModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function todayISOLocal() {
|
function todayISOLocal() {
|
||||||
const d = new Date();
|
return todayIso();
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const GROUP_FILES_PHOTO_BOXES = ['groupFilesMainPhoto', 'groupFilesGallery', 'groupFilesOriginals'];
|
const GROUP_FILES_PHOTO_BOXES = ['groupFilesMainPhoto', 'groupFilesGallery', 'groupFilesOriginals'];
|
||||||
|
|||||||
+2
-1
@@ -166,7 +166,7 @@ function pasteFileName(file) {
|
|||||||
if (base && !/^(image|photo|picture|blob|file|screenshot|снимок|скриншот|изображение|файл)$/i.test(stem)) return base;
|
if (base && !/^(image|photo|picture|blob|file|screenshot|снимок|скриншот|изображение|файл)$/i.test(stem)) return base;
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
const pad = n => String(n).padStart(2, '0');
|
const pad = n => String(n).padStart(2, '0');
|
||||||
const stamp = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`;
|
const stamp = `${todayIso()} ${nowHm().replace(':', '-')}-${pad(d.getSeconds())}`;
|
||||||
return `Вставка ${stamp}${extFromFile(file)}`;
|
return `Вставка ${stamp}${extFromFile(file)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +281,7 @@ async function resolveModuleId(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
|
await initDateTime();
|
||||||
try {
|
try {
|
||||||
const list = await fetchModules('', 50);
|
const list = await fetchModules('', 50);
|
||||||
if (list.length) {
|
if (list.length) {
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ function gridEntryHTML(e) {
|
|||||||
<div class="group">${esc(e.group_name)}${e.module_name ? ' · ' + esc(e.module_name) : ''}</div>
|
<div class="group">${esc(e.group_name)}${e.module_name ? ' · ' + esc(e.module_name) : ''}</div>
|
||||||
<div class="desc">${esc(e.description)}${aiBadge(e)}</div>
|
<div class="desc">${esc(e.description)}${aiBadge(e)}</div>
|
||||||
${filesHTML(e.files)}
|
${filesHTML(e.files)}
|
||||||
<div class="time">${new Date(e.created_at).toLocaleString('ru')}</div>
|
<div class="time">${fmtFull(e.created_at)}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="edit" data-edit="${e.id}" title="Редактировать">✎</button>
|
<button class="edit" data-edit="${e.id}" title="Редактировать">✎</button>
|
||||||
${mp ? `<button class="edit" data-enhance="${e.id}" title="Улучшить фото">✦</button>` : ''}
|
${mp ? `<button class="edit" data-enhance="${e.id}" title="Улучшить фото">✦</button>` : ''}
|
||||||
@@ -148,7 +148,7 @@ function cardEntryHTML(e) {
|
|||||||
${filesHTML(e.files)}
|
${filesHTML(e.files)}
|
||||||
</div>
|
</div>
|
||||||
<div class="foot">
|
<div class="foot">
|
||||||
<span>${new Date(e.created_at).toLocaleString('ru')}</span>
|
<span>${fmtFull(e.created_at)}</span>
|
||||||
<span>
|
<span>
|
||||||
<button class="edit" data-edit="${e.id}" title="Редактировать">✎</button>
|
<button class="edit" data-edit="${e.id}" title="Редактировать">✎</button>
|
||||||
${mp ? `<button class="edit" data-enhance="${e.id}" title="Улучшить фото">✦</button>` : ''}
|
${mp ? `<button class="edit" data-enhance="${e.id}" title="Улучшить фото">✦</button>` : ''}
|
||||||
@@ -220,7 +220,7 @@ function setView(v) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setDefaultDates() {
|
function setDefaultDates() {
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = todayIso();
|
||||||
document.getElementById('dateFrom').value = today;
|
document.getElementById('dateFrom').value = today;
|
||||||
document.getElementById('dateTo').value = today;
|
document.getElementById('dateTo').value = today;
|
||||||
}
|
}
|
||||||
@@ -895,7 +895,7 @@ async function loadPhotoHistory() {
|
|||||||
</div>
|
</div>
|
||||||
<div style="flex:1;min-width:0">
|
<div style="flex:1;min-width:0">
|
||||||
<div style="font-size:.85rem">${esc(PHOTO_JOB_ACTION_LABELS[r.action] || r.action)}
|
<div style="font-size:.85rem">${esc(PHOTO_JOB_ACTION_LABELS[r.action] || r.action)}
|
||||||
<span style="color:var(--muted);font-size:.75rem;margin-left:6px">${esc(PHOTO_JOB_STATUS_LABELS[r.status] || r.status)} · ${esc(new Date(time).toLocaleString('ru'))}</span>
|
<span style="color:var(--muted);font-size:.75rem;margin-left:6px">${esc(PHOTO_JOB_STATUS_LABELS[r.status] || r.status)} · ${esc(fmtFull(time))}</span>
|
||||||
</div>
|
</div>
|
||||||
${err}
|
${err}
|
||||||
</div>
|
</div>
|
||||||
@@ -1229,7 +1229,7 @@ function renderEditAiStatus(e) {
|
|||||||
el.style.display = 'block';
|
el.style.display = 'block';
|
||||||
const changed = e.description_ai && e.description_original && e.description_ai !== e.description_original;
|
const changed = e.description_ai && e.description_original && e.description_ai !== e.description_original;
|
||||||
const parts = [AI_STATUS_LABELS[e.ai_status] || `ИИ: ${e.ai_status}`];
|
const parts = [AI_STATUS_LABELS[e.ai_status] || `ИИ: ${e.ai_status}`];
|
||||||
if (e.ai_checked_at) parts.push(new Date(e.ai_checked_at).toLocaleString('ru'));
|
if (e.ai_checked_at) parts.push(fmtFull(e.ai_checked_at));
|
||||||
let html = `<span>${esc(parts.join(' · '))}</span>`;
|
let html = `<span>${esc(parts.join(' · '))}</span>`;
|
||||||
if (changed) html += ` <button type="button" id="aiRevertBtn" class="ai-btn" style="font-size:.75rem" title="Вернуть исходный текст (до ИИ)">↩ Оригинал</button>`;
|
if (changed) html += ` <button type="button" id="aiRevertBtn" class="ai-btn" style="font-size:.75rem" title="Вернуть исходный текст (до ИИ)">↩ Оригинал</button>`;
|
||||||
html += ` <button type="button" id="aiRecheckBtn" class="ai-btn" style="font-size:.75rem" title="Проверить заново">⟳ Проверить</button>`;
|
html += ` <button type="button" id="aiRecheckBtn" class="ai-btn" style="font-size:.75rem" title="Проверить заново">⟳ Проверить</button>`;
|
||||||
@@ -1416,7 +1416,7 @@ async function exportCSV() {
|
|||||||
e.group_name,
|
e.group_name,
|
||||||
e.module_name || '',
|
e.module_name || '',
|
||||||
e.description.replace(/\n/g, ' '),
|
e.description.replace(/\n/g, ' '),
|
||||||
new Date(e.created_at).toLocaleString('ru'),
|
fmtFull(e.created_at),
|
||||||
e.photo_path ? `${API}${e.photo_path}` : ''
|
e.photo_path ? `${API}${e.photo_path}` : ''
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,14 +7,12 @@ let groupsLoaded = false;
|
|||||||
function lessonDateText(iso) {
|
function lessonDateText(iso) {
|
||||||
const s = String(iso || '').slice(0, 10);
|
const s = String(iso || '').slice(0, 10);
|
||||||
if (!s) return '';
|
if (!s) return '';
|
||||||
const d = new Date(s + 'T00:00:00');
|
return fmtShortIso(s) || s;
|
||||||
if (Number.isNaN(d.getTime())) return s;
|
|
||||||
return d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: '2-digit' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function lessonWhenText(r) {
|
function lessonWhenText(r) {
|
||||||
const date = lessonDateText(r.lesson_date);
|
const date = lessonDateText(r.lesson_date);
|
||||||
const time = r.lesson_time ? String(r.lesson_time).slice(0, 5) : '';
|
const time = r.lesson_time ? fmtHmStr(r.lesson_time) : '';
|
||||||
return time ? `${date}, ${time}` : date;
|
return time ? `${date}, ${time}` : date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -41,10 +41,10 @@ async function loadLinks() {
|
|||||||
${l.group_name ? `Группа: <b>${esc(l.group_name)}</b>` : ''}
|
${l.group_name ? `Группа: <b>${esc(l.group_name)}</b>` : ''}
|
||||||
${l.student_name ? `${l.group_name ? ' · ' : ''}Ученик: <b>${esc(l.student_name)}</b>` : ''}
|
${l.student_name ? `${l.group_name ? ' · ' : ''}Ученик: <b>${esc(l.student_name)}</b>` : ''}
|
||||||
${l.date_from || l.date_to ? ` · период: <b>${l.date_from ? l.date_from.split('-').reverse().join('.') : '…'}</b> — <b>${l.date_to ? l.date_to.split('-').reverse().join('.') : '…'}</b>` : ' · весь период'}
|
${l.date_from || l.date_to ? ` · период: <b>${l.date_from ? l.date_from.split('-').reverse().join('.') : '…'}</b> — <b>${l.date_to ? l.date_to.split('-').reverse().join('.') : '…'}</b>` : ' · весь период'}
|
||||||
${expired ? ` · <span style="color:#ef4444;font-weight:600">срок действия истёк ${new Date(l.expires_at).toLocaleDateString('ru')}</span>` : l.expires_at ? ` · истекает ${new Date(l.expires_at).toLocaleDateString('ru')}` : ''}
|
${expired ? ` · <span style="color:#ef4444;font-weight:600">срок действия истёк ${fmtDateFull(l.expires_at)}</span>` : l.expires_at ? ` · истекает ${fmtDateFull(l.expires_at)}` : ''}
|
||||||
${namesHidden ? ` · <span style="color:#f59e0b">имена скрыты</span>` : ''}
|
${namesHidden ? ` · <span style="color:#f59e0b">имена скрыты</span>` : ''}
|
||||||
${l.access_password_hash ? ` · <span style="color:#2563eb">🔒 пароль</span>` : ''}
|
${l.access_password_hash ? ` · <span style="color:#2563eb">🔒 пароль</span>` : ''}
|
||||||
· создана ${new Date(l.created_at).toLocaleString('ru')}
|
· создана ${fmtFull(l.created_at)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:8px;flex-shrink:0;align-items:center">
|
<div style="display:flex;gap:8px;flex-shrink:0;align-items:center">
|
||||||
|
|||||||
+1
-1
@@ -36,7 +36,7 @@ function photoCardHTML(p) {
|
|||||||
<span class="ph-badge">${esc(p.source_label)}</span>
|
<span class="ph-badge">${esc(p.source_label)}</span>
|
||||||
<div class="ph-title">${esc(p.title || 'Без описания')}</div>
|
<div class="ph-title">${esc(p.title || 'Без описания')}</div>
|
||||||
${meta.length ? `<div class="ph-meta">${meta.join(' · ')}</div>` : ''}
|
${meta.length ? `<div class="ph-meta">${meta.join(' · ')}</div>` : ''}
|
||||||
<div class="ph-time">${new Date(p.created_at).toLocaleString('ru')}</div>
|
<div class="ph-time">${fmtFull(p.created_at)}</div>
|
||||||
${link ? `<a class="ph-link" href="${link}">Открыть источник →</a>` : ''}
|
${link ? `<a class="ph-link" href="${link}">Открыть источник →</a>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+21
-12
@@ -24,9 +24,10 @@ function loadBrand(){
|
|||||||
function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''')}
|
function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''')}
|
||||||
function pad(n){return String(n).padStart(2,'0')}
|
function pad(n){return String(n).padStart(2,'0')}
|
||||||
function toDate(v){if(!v)return null;const d=new Date(v);return isNaN(d.getTime())?null:d}
|
function toDate(v){if(!v)return null;const d=new Date(v);return isNaN(d.getTime())?null:d}
|
||||||
function fmtDate(v){const d=toDate(v);return d?pad(d.getDate())+'.'+pad(d.getMonth()+1)+'.'+d.getFullYear():''}
|
function dtIsDateOnly(v){return typeof v==='string'&&/^\d{4}-\d{2}-\d{2}/.test(v)}
|
||||||
function fmtShort(v){const d=toDate(v);return d?pad(d.getDate())+'.'+pad(d.getMonth()+1)+'.'+String(d.getFullYear()).slice(2):''}
|
function fmtDate(v){return dtIsDateOnly(v)?fmtDateOnlyIso(v):(v?fmtDateFull(v):'')}
|
||||||
function fmtLong(v){const d=toDate(v);return d?d.toLocaleDateString('ru-RU',{day:'numeric',month:'long',year:'numeric'}):''}
|
function fmtShort(v){return dtIsDateOnly(v)?fmtShortIso(v):(v?fmtDateShort(v):'')}
|
||||||
|
function fmtLong(v){return dtIsDateOnly(v)?fmtDateOnlyLongIso(v):(v?fmtDateLong(v):'')}
|
||||||
function plural(n,one,few,many){const m10=n%10,m100=n%100;if(m100>=11&&m100<=14)return many;if(m10===1)return one;if(m10>=2&&m10<=4)return few;return many}
|
function plural(n,one,few,many){const m10=n%10,m100=n%100;if(m100>=11&&m100<=14)return many;if(m10===1)return one;if(m10>=2&&m10<=4)return few;return many}
|
||||||
function words(n,one,few,many){return n+' '+plural(n,one,few,many)}
|
function words(n,one,few,many){return n+' '+plural(n,one,few,many)}
|
||||||
function isImageFile(name){return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name||'')}
|
function isImageFile(name){return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name||'')}
|
||||||
@@ -41,14 +42,19 @@ function hideEl(el,on){if(el)el.classList.toggle('rep-hidden',!!on)}
|
|||||||
function node(html){const t=document.createElement('template');t.innerHTML=html.trim();return t.content.firstElementChild}
|
function node(html){const t=document.createElement('template');t.innerHTML=html.trim();return t.content.firstElementChild}
|
||||||
function periodLabel(from,to){
|
function periodLabel(from,to){
|
||||||
if(!from&&!to)return '';
|
if(!from&&!to)return '';
|
||||||
const a=toDate(from),b=toDate(to);
|
if(from&&to)return fmtDate(from)+' — '+fmtDate(to);
|
||||||
if(a&&b)return fmtDate(a)+' — '+fmtDate(b);
|
return fmtDate(from)||fmtDate(to);
|
||||||
return fmtDate(a)||fmtDate(b);
|
}
|
||||||
|
function dtIsoYear(v){
|
||||||
|
if(!v)return null;
|
||||||
|
if(dtIsDateOnly(v))return String(v).slice(0,4);
|
||||||
|
const d=toDate(v);
|
||||||
|
if(!d)return null;
|
||||||
|
try{return new Intl.DateTimeFormat('en-CA',{timeZone:DateTime.tz,year:'numeric'}).format(d).slice(0,4)}catch{return String(d.getFullYear())}
|
||||||
}
|
}
|
||||||
function yearLabel(from,to){
|
function yearLabel(from,to){
|
||||||
const a=toDate(from),b=toDate(to);
|
if(!from&&!to)return '';
|
||||||
if(!a&&!b)return '';
|
const ay=dtIsoYear(from),by=dtIsoYear(to);
|
||||||
const ay=a?a.getFullYear():null,by=b?b.getFullYear():null;
|
|
||||||
if(ay&&by)return ay===by?String(ay):ay+' — '+String(by).slice(2);
|
if(ay&&by)return ay===by?String(ay):ay+' — '+String(by).slice(2);
|
||||||
return String(ay||by);
|
return String(ay||by);
|
||||||
}
|
}
|
||||||
@@ -268,12 +274,15 @@ function statSep(variant){
|
|||||||
const extra=variant===1?' list__item--u-ip5wdmggw':(variant===2?' list__item--u-ixebr6v8q':'');
|
const extra=variant===1?' list__item--u-ip5wdmggw':(variant===2?' list__item--u-ixebr6v8q':'');
|
||||||
return '<div class="list__item list__item--u-isd3lg3iz'+extra+'"></div>';
|
return '<div class="list__item list__item--u-isd3lg3iz'+extra+'"></div>';
|
||||||
}
|
}
|
||||||
|
function dtYearMonth(d){
|
||||||
|
try{return new Intl.DateTimeFormat('en-CA',{timeZone:DateTime.tz,year:'numeric',month:'2-digit'}).format(d).slice(0,7)}catch{return d.getFullYear()+'-'+d.getMonth()}
|
||||||
|
}
|
||||||
function renderAbout(data,pool){
|
function renderAbout(data,pool){
|
||||||
const entries=data.entries||[];
|
const entries=data.entries||[];
|
||||||
const photos=(data.photos||[]).length;
|
const photos=(data.photos||[]).length;
|
||||||
const files=entries.reduce(function(n,e){return n+((e.files||[]).length)},0);
|
const files=entries.reduce(function(n,e){return n+((e.files||[]).length)},0);
|
||||||
const months={};
|
const months={};
|
||||||
entries.forEach(function(e){const d=toDate(e.created_at);if(d)months[d.getFullYear()+'-'+d.getMonth()]=1});
|
entries.forEach(function(e){const d=toDate(e.created_at);if(d)months[dtYearMonth(d)]=1});
|
||||||
const photosTotal=entries.filter(function(e){return e.photo_path}).length+photos;
|
const photosTotal=entries.filter(function(e){return e.photo_path}).length+photos;
|
||||||
setText('aboutTitle',(data.name&&String(data.name).trim())||'Отчёт');
|
setText('aboutTitle',(data.name&&String(data.name).trim())||'Отчёт');
|
||||||
setText('aboutLead',(data.message&&String(data.message).trim())||('Мы собрали '+words(entries.length,'запись','записи','записей')+' о работе за этот период — с фотографиями, файлами и описанием каждого занятия.'));
|
setText('aboutLead',(data.message&&String(data.message).trim())||('Мы собрали '+words(entries.length,'запись','записи','записей')+' о работе за этот период — с фотографиями, файлами и описанием каждого занятия.'));
|
||||||
@@ -461,7 +470,7 @@ function renderFooter(data,heading,pool){
|
|||||||
const label=btn.querySelector('.text-block-wrap-div');
|
const label=btn.querySelector('.text-block-wrap-div');
|
||||||
if(label)label.textContent='Перейти по ссылке';
|
if(label)label.textContent='Перейти по ссылке';
|
||||||
}
|
}
|
||||||
setText('footCopy',state.systemName+' © '+new Date().getFullYear());
|
setText('footCopy',state.systemName+' © '+nowYear());
|
||||||
setText('footBrand',heading);
|
setText('footBrand',heading);
|
||||||
const fbox=document.getElementById('footerPhotoBox');
|
const fbox=document.getElementById('footerPhotoBox');
|
||||||
const fimg=document.getElementById('footerPhoto');
|
const fimg=document.getElementById('footerPhoto');
|
||||||
@@ -563,5 +572,5 @@ function bindEvents(){
|
|||||||
|
|
||||||
bindEvents();
|
bindEvents();
|
||||||
makeNavLinks();
|
makeNavLinks();
|
||||||
loadBrand().then(function(){loadReport()});
|
initDateTime().then(function(){return loadBrand()}).then(function(){loadReport()});
|
||||||
function closeLightbox(){const lb=document.getElementById('lightbox');if(lb)lb.classList.remove('is-open')}
|
function closeLightbox(){const lb=document.getElementById('lightbox');if(lb)lb.classList.remove('is-open')}
|
||||||
+86
-4
@@ -1,6 +1,6 @@
|
|||||||
const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'lessonAiPrompt', 'lessonAiEnabled', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'photoAiFaceMode', 'photoAiFaceModel', 'photoAiDevicePref', 'trashPurgeDays', 'notifyEnabled', 'notifyRetentionDays'];
|
const DIRTY_FIELDS = ['systemName', 'timezone', 'timeFormat', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'lessonAiPrompt', 'lessonAiEnabled', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'photoAiFaceMode', 'photoAiFaceModel', 'photoAiDevicePref', 'trashPurgeDays', 'notifyEnabled', 'notifyRetentionDays'];
|
||||||
const NOTIFY_IDS = [];
|
const NOTIFY_IDS = [];
|
||||||
const SECTION_IDS = ['sec-system', 'sec-stack', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-lesson-ai', 'sec-backup', 'sec-trash', 'sec-form', 'sec-notify', 'sec-photo', 'sec-photo-ai', 'sec-bans'];
|
const SECTION_IDS = ['sec-system', 'sec-stack', 'sec-brand', 'sec-datetime', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-lesson-ai', 'sec-backup', 'sec-trash', 'sec-form', 'sec-notify', 'sec-photo', 'sec-photo-ai', 'sec-bans'];
|
||||||
const SPAM_MAX = 10080;
|
const SPAM_MAX = 10080;
|
||||||
const BAN_REASONS = {
|
const BAN_REASONS = {
|
||||||
'honeypot': 'Антиспам-поле',
|
'honeypot': 'Антиспам-поле',
|
||||||
@@ -14,6 +14,67 @@ let logoOriginal = '';
|
|||||||
let currentSystemName = '';
|
let currentSystemName = '';
|
||||||
let photoAiService = null;
|
let photoAiService = null;
|
||||||
|
|
||||||
|
function fillTimezoneOptions() {
|
||||||
|
const sel = document.getElementById('timezone');
|
||||||
|
if (!sel || sel.options.length) return;
|
||||||
|
let zones = [];
|
||||||
|
try { zones = Intl.supportedValuesOf('timeZone'); } catch { zones = []; }
|
||||||
|
if (!zones.length) zones = [DateTime.tz || 'Europe/Moscow'];
|
||||||
|
const browserTz = (() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { return ''; } })();
|
||||||
|
const current = sel.dataset.value || DateTime.tz;
|
||||||
|
sel.innerHTML = zones.map(z => {
|
||||||
|
const off = tzOffsetLabel(z);
|
||||||
|
const label = off ? `${z} (UTC${off})` : z;
|
||||||
|
return `<option value="${esc(z)}">${esc(label)}</option>`;
|
||||||
|
}).join('');
|
||||||
|
const extra = browserTz && !zones.includes(browserTz) ? [browserTz] : [];
|
||||||
|
extra.forEach(z => { sel.insertAdjacentHTML('afterbegin', `<option value="${esc(z)}">${esc(z)}</option>`); });
|
||||||
|
if (current) sel.value = current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tzOffsetLabel(tz) {
|
||||||
|
try {
|
||||||
|
const parts = new Intl.DateTimeFormat('ru-RU', { timeZone: tz, timeZoneName: 'longOffset' }).formatToParts(new Date());
|
||||||
|
const name = parts.find(p => p.type === 'timeZoneName');
|
||||||
|
return name ? String(name.value).replace('GMT', '') : '';
|
||||||
|
} catch { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewTz() {
|
||||||
|
const sel = document.getElementById('timezone');
|
||||||
|
return sel && dtValidTz(sel.value) ? sel.value : DateTime.tz;
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewHour12() {
|
||||||
|
const el = document.getElementById('timeFormat');
|
||||||
|
return !!el && el.value === '12h';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTimePreview() {
|
||||||
|
const box = document.getElementById('timePreview');
|
||||||
|
if (!box) return;
|
||||||
|
const tz = previewTz();
|
||||||
|
const h12 = previewHour12();
|
||||||
|
const now = new Date();
|
||||||
|
const sample = new Date(now.getTime() + 3661 * 1000);
|
||||||
|
box.innerHTML = `<span class="chip">Сейчас: ${esc(fmtIn(sample, tz, h12))}</span>`
|
||||||
|
+ `<span class="chip-sub">${esc(fmtIn(now, tz, h12))}</span>`
|
||||||
|
+ `<span class="chip-sub">Расписание 18:30 → ${esc(fmtHmIn('18:30:00', h12))}</span>`
|
||||||
|
+ `<span class="chip-sub">Отчёт за занятие 09:05 → ${esc(fmtHmIn('09:05:00', h12))}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtIn(date, tz, h12) {
|
||||||
|
return new Intl.DateTimeFormat('ru-RU', { timeZone: tz, day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', hour12: h12 }).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtHmIn(v, h12) {
|
||||||
|
const saved = DateTime.hour12;
|
||||||
|
DateTime.hour12 = h12;
|
||||||
|
const out = fmtHmStr(v);
|
||||||
|
DateTime.hour12 = saved;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function renderLogoPreview() {
|
function renderLogoPreview() {
|
||||||
const img = document.getElementById('logoPreviewImg');
|
const img = document.getElementById('logoPreviewImg');
|
||||||
const fb = document.getElementById('logoPreviewFallback');
|
const fb = document.getElementById('logoPreviewFallback');
|
||||||
@@ -89,6 +150,15 @@ function humanizeMinutes(min) {
|
|||||||
return parts.join(' ');
|
return parts.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bindTimePreview() {
|
||||||
|
['timezone', 'timeFormat'].forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
el.addEventListener('change', renderTimePreview);
|
||||||
|
el.addEventListener('input', renderTimePreview);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function updateDirty() {
|
function updateDirty() {
|
||||||
const dirtyCount = DIRTY_FIELDS.filter(id => {
|
const dirtyCount = DIRTY_FIELDS.filter(id => {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
@@ -123,6 +193,7 @@ function revertChanges() {
|
|||||||
renderLogoPreview();
|
renderLogoPreview();
|
||||||
clearFieldError('spamInterval');
|
clearFieldError('spamInterval');
|
||||||
updateSpamUi();
|
updateSpamUi();
|
||||||
|
renderTimePreview();
|
||||||
updateDirty();
|
updateDirty();
|
||||||
showToast('Изменения отменены');
|
showToast('Изменения отменены');
|
||||||
}
|
}
|
||||||
@@ -189,6 +260,7 @@ async function renderNotifyTypes(s) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
|
fillTimezoneOptions();
|
||||||
const [sRes, stRes] = await Promise.all([
|
const [sRes, stRes] = await Promise.all([
|
||||||
fetch(`${API}/api/settings`, { headers: hdr() }),
|
fetch(`${API}/api/settings`, { headers: hdr() }),
|
||||||
fetch(`${API}/api/stats`, { headers: hdr() }),
|
fetch(`${API}/api/stats`, { headers: hdr() }),
|
||||||
@@ -202,6 +274,11 @@ async function loadSettings() {
|
|||||||
renderLogoPreview();
|
renderLogoPreview();
|
||||||
if (s.spam_interval_min !== undefined) document.getElementById('spamInterval').value = s.spam_interval_min;
|
if (s.spam_interval_min !== undefined) document.getElementById('spamInterval').value = s.spam_interval_min;
|
||||||
document.getElementById('trashPurgeDays').value = s.trash_purge_days !== undefined ? s.trash_purge_days : '30';
|
document.getElementById('trashPurgeDays').value = s.trash_purge_days !== undefined ? s.trash_purge_days : '30';
|
||||||
|
const tzSel = document.getElementById('timezone');
|
||||||
|
const storedTz = String(s.timezone || '');
|
||||||
|
tzSel.value = Array.from(tzSel.options).some(o => o.value === storedTz) ? storedTz : DateTime.tz;
|
||||||
|
document.getElementById('timeFormat').value = ['24h', '12h'].includes(s.time_format) ? s.time_format : '24h';
|
||||||
|
renderTimePreview();
|
||||||
document.getElementById('footerLeft').value = s.footer_left || '';
|
document.getElementById('footerLeft').value = s.footer_left || '';
|
||||||
document.getElementById('footerRight').value = s.footer_right || '';
|
document.getElementById('footerRight').value = s.footer_right || '';
|
||||||
document.getElementById('aiPrompt').value = s.ai_prompt || '';
|
document.getElementById('aiPrompt').value = s.ai_prompt || '';
|
||||||
@@ -272,7 +349,7 @@ async function loadSystemInfo(btn) {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
renderSystemInfo(data);
|
renderSystemInfo(data);
|
||||||
renderStackInfo(data.stack);
|
renderStackInfo(data.stack);
|
||||||
const stamp = 'обновлено ' + new Date().toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' });
|
const stamp = 'обновлено ' + fmtTimeOnly(new Date());
|
||||||
const live = document.getElementById('systemLive');
|
const live = document.getElementById('systemLive');
|
||||||
if (live) live.textContent = stamp;
|
if (live) live.textContent = stamp;
|
||||||
const stackLive = document.getElementById('stackLive');
|
const stackLive = document.getElementById('stackLive');
|
||||||
@@ -804,6 +881,8 @@ async function saveSettings() {
|
|||||||
headers: hdrJson(),
|
headers: hdrJson(),
|
||||||
body: JSON.stringify({ settings: {
|
body: JSON.stringify({ settings: {
|
||||||
system_name: document.getElementById('systemName').value.trim(),
|
system_name: document.getElementById('systemName').value.trim(),
|
||||||
|
timezone: document.getElementById('timezone').value,
|
||||||
|
time_format: document.getElementById('timeFormat').value,
|
||||||
spam_interval_min: String(Math.round(Number(v))),
|
spam_interval_min: String(Math.round(Number(v))),
|
||||||
footer_left: document.getElementById('footerLeft').value.trim(),
|
footer_left: document.getElementById('footerLeft').value.trim(),
|
||||||
footer_right: document.getElementById('footerRight').value.trim(),
|
footer_right: document.getElementById('footerRight').value.trim(),
|
||||||
@@ -828,6 +907,8 @@ async function saveSettings() {
|
|||||||
} })
|
} })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
dtApply({ timezone: document.getElementById('timezone').value, time_format: document.getElementById('timeFormat').value });
|
||||||
|
renderTimePreview();
|
||||||
showToast('Настройки сохранены');
|
showToast('Настройки сохранены');
|
||||||
logoOriginal = currentLogo;
|
logoOriginal = currentLogo;
|
||||||
snapshotDirty();
|
snapshotDirty();
|
||||||
@@ -939,7 +1020,7 @@ async function loadBans() {
|
|||||||
el.innerHTML = bans.map(b => `
|
el.innerHTML = bans.map(b => `
|
||||||
<span class="chip ban-chip" data-chip="${esc(b.ip)}">
|
<span class="chip ban-chip" data-chip="${esc(b.ip)}">
|
||||||
<b>${esc(b.ip)}</b>
|
<b>${esc(b.ip)}</b>
|
||||||
<span class="chip-sub">${esc(BAN_REASONS[b.reason] || b.reason || '—')} · до ${new Date(b.banned_until).toLocaleString('ru')}</span>
|
<span class="chip-sub">${esc(BAN_REASONS[b.reason] || b.reason || '—')} · до ${fmtFull(b.banned_until)}</span>
|
||||||
<button type="button" class="unban-btn" data-unban="${esc(b.ip)}" title="Разблокировать" aria-label="Разблокировать ${esc(b.ip)}">✕</button>
|
<button type="button" class="unban-btn" data-unban="${esc(b.ip)}" title="Разблокировать" aria-label="Разблокировать ${esc(b.ip)}">✕</button>
|
||||||
</span>`).join('');
|
</span>`).join('');
|
||||||
}
|
}
|
||||||
@@ -1091,6 +1172,7 @@ window.addEventListener('beforeunload', e => {
|
|||||||
try {
|
try {
|
||||||
await loadSettings();
|
await loadSettings();
|
||||||
bindDirty();
|
bindDirty();
|
||||||
|
bindTimePreview();
|
||||||
if (authStatus) authStatus.classList.add('loaded');
|
if (authStatus) authStatus.classList.add('loaded');
|
||||||
} catch {
|
} catch {
|
||||||
if (authStatus) { authStatus.classList.remove('loaded'); authStatus.classList.add('err'); authStatus.textContent = 'Ошибка загрузки настроек'; }
|
if (authStatus) { authStatus.classList.remove('loaded'); authStatus.classList.add('err'); authStatus.textContent = 'Ошибка загрузки настроек'; }
|
||||||
|
|||||||
+6
-7
@@ -240,13 +240,11 @@ function renderShare(data, password) {
|
|||||||
const showStudentMessage = !!data.show_student_message;
|
const showStudentMessage = !!data.show_student_message;
|
||||||
const showEntryDate = !!data.show_entry_date;
|
const showEntryDate = !!data.show_entry_date;
|
||||||
const dayKey = e => {
|
const dayKey = e => {
|
||||||
const d = new Date(e.created_at);
|
try {
|
||||||
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
return new Intl.DateTimeFormat('en-CA', { timeZone: DateTime.tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(e.created_at));
|
||||||
};
|
} catch { return ''; }
|
||||||
const dayLabel = k => {
|
|
||||||
const [y,m,d] = k.split('-').map(Number);
|
|
||||||
return new Date(y, m-1, d).toLocaleDateString('ru-RU', { day:'numeric', month:'long', year:'numeric' });
|
|
||||||
};
|
};
|
||||||
|
const dayLabel = k => fmtDateOnlyLongIso(k);
|
||||||
const cardHTML = e => {
|
const cardHTML = e => {
|
||||||
const body = [
|
const body = [
|
||||||
!isAnonymized ? `<div class="name">${esc(e.student_name)}</div>` : '',
|
!isAnonymized ? `<div class="name">${esc(e.student_name)}</div>` : '',
|
||||||
@@ -262,7 +260,7 @@ function renderShare(data, password) {
|
|||||||
: `<div class="no-photo"><svg viewBox="0 0 240 160" preserveAspectRatio="xMidYMid meet" aria-hidden="true"><circle cx="105" cy="48" r="15"/><path d="M74 140 C74 106 88 92 105 92 C122 92 136 106 136 140 Z"/><circle cx="145" cy="58" r="18"/><path d="M108 144 C108 104 124 88 145 88 C166 88 182 104 182 144 Z"/></svg></div>`}
|
: `<div class="no-photo"><svg viewBox="0 0 240 160" preserveAspectRatio="xMidYMid meet" aria-hidden="true"><circle cx="105" cy="48" r="15"/><path d="M74 140 C74 106 88 92 105 92 C122 92 136 106 136 140 Z"/><circle cx="145" cy="58" r="18"/><path d="M108 144 C108 104 124 88 145 88 C166 88 182 104 182 144 Z"/></svg></div>`}
|
||||||
</div>
|
</div>
|
||||||
${body ? `<div class="body">${body}</div>` : ''}
|
${body ? `<div class="body">${body}</div>` : ''}
|
||||||
${showEntryDate ? `<div class="foot">${new Date(e.created_at).toLocaleString('ru',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})}</div>` : ''}
|
${showEntryDate ? `<div class="foot">${fmtDateTimeY(e.created_at)}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
};
|
};
|
||||||
@@ -288,6 +286,7 @@ document.addEventListener('click', e => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
(async()=>{
|
(async()=>{
|
||||||
|
await initDateTime();
|
||||||
await loadShare();
|
await loadShare();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ async function exportStudentProfile(id) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
downloadBlob(blob, `profile_${safeWindowsName(s.name)}_${new Date().toISOString().slice(0, 10)}.zip`);
|
downloadBlob(blob, `profile_${safeWindowsName(s.name)}_${todayIso()}.zip`);
|
||||||
showToast(`Готово: ${s.name}`);
|
showToast(`Готово: ${s.name}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast('Ошибка при формировании профиля');
|
showToast('Ошибка при формировании профиля');
|
||||||
|
|||||||
+4
-4
@@ -32,7 +32,7 @@ function entryHTML(e) {
|
|||||||
<div class="group">${esc(e.group_name)}</div>
|
<div class="group">${esc(e.group_name)}</div>
|
||||||
<div class="desc">${esc(e.description)}</div>
|
<div class="desc">${esc(e.description)}</div>
|
||||||
${filesHTML(e.files)}
|
${filesHTML(e.files)}
|
||||||
<div class="trash-time">Удалена: ${new Date(e.deleted_at).toLocaleString('ru')}</div>
|
<div class="trash-time">Удалена: ${fmtFull(e.deleted_at)}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="restore-btn" data-restore="${e.id}" title="Восстановить">Восстановить</button>
|
<button class="restore-btn" data-restore="${e.id}" title="Восстановить">Восстановить</button>
|
||||||
<button class="del-btn" data-hard="${e.id}" title="Пометить на удаление (через ${purgeDays} дней будет удалена безвозвратно)">×</button>
|
<button class="del-btn" data-hard="${e.id}" title="Пометить на удаление (через ${purgeDays} дней будет удалена безвозвратно)">×</button>
|
||||||
@@ -47,7 +47,7 @@ function groupHTML(g) {
|
|||||||
<div class="info">
|
<div class="info">
|
||||||
<div class="name">${esc(g.name)}</div>
|
<div class="name">${esc(g.name)}</div>
|
||||||
${g.branch_name ? `<div class="group">${esc(g.branch_name)}</div>` : ''}
|
${g.branch_name ? `<div class="group">${esc(g.branch_name)}</div>` : ''}
|
||||||
<div class="trash-time">Удалена: ${new Date(g.deleted_at).toLocaleString('ru')}</div>
|
<div class="trash-time">Удалена: ${fmtFull(g.deleted_at)}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="restore-btn" data-restore-group="${g.id}" title="Восстановить">Восстановить</button>
|
<button class="restore-btn" data-restore-group="${g.id}" title="Восстановить">Восстановить</button>
|
||||||
<button class="del-btn" data-hard-group="${g.id}" title="Пометить на удаление (через ${purgeDays} дней будет удалена безвозвратно вместе с записями и фото)">×</button>
|
<button class="del-btn" data-hard-group="${g.id}" title="Пометить на удаление (через ${purgeDays} дней будет удалена безвозвратно вместе с записями и фото)">×</button>
|
||||||
@@ -64,7 +64,7 @@ function pendingEntryHTML(e) {
|
|||||||
<div class="group">${esc(e.group_name)}</div>
|
<div class="group">${esc(e.group_name)}</div>
|
||||||
<div class="desc">${esc(e.description)}</div>
|
<div class="desc">${esc(e.description)}</div>
|
||||||
${filesHTML(e.files)}
|
${filesHTML(e.files)}
|
||||||
<div class="trash-time warn">Удаление через ${purgeDays} дн.: ${new Date(e.purge_at).toLocaleString('ru')}</div>
|
<div class="trash-time warn">Удаление через ${purgeDays} дн.: ${fmtFull(e.purge_at)}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="restore-btn" data-unschedule="${e.id}" title="Вернуть в корзину">В корзину</button>
|
<button class="restore-btn" data-unschedule="${e.id}" title="Вернуть в корзину">В корзину</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,7 +78,7 @@ function pendingGroupHTML(g) {
|
|||||||
<div class="info">
|
<div class="info">
|
||||||
<div class="name">${esc(g.name)}</div>
|
<div class="name">${esc(g.name)}</div>
|
||||||
${g.branch_name ? `<div class="group">${esc(g.branch_name)}</div>` : ''}
|
${g.branch_name ? `<div class="group">${esc(g.branch_name)}</div>` : ''}
|
||||||
<div class="trash-time warn">Удаление через ${purgeDays} дн.: ${new Date(g.purge_at).toLocaleString('ru')}</div>
|
<div class="trash-time warn">Удаление через ${purgeDays} дн.: ${fmtFull(g.purge_at)}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="restore-btn" data-unschedule-group="${g.id}" title="Вернуть в корзину">В корзину</button>
|
<button class="restore-btn" data-unschedule-group="${g.id}" title="Вернуть в корзину">В корзину</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -70,7 +70,7 @@ function fmtDur(ms) {
|
|||||||
|
|
||||||
function fmtTime(t) {
|
function fmtTime(t) {
|
||||||
if (!t) return '—';
|
if (!t) return '—';
|
||||||
try { return new Date(t).toLocaleString('ru'); } catch { return String(t); }
|
return fmtFull(t) || String(t);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ago(t) {
|
function ago(t) {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="vendor/lucide.min.js"></script>
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="js/datetime.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script src="js/lessons.js"></script>
|
<script src="js/lessons.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -91,6 +91,7 @@
|
|||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="vendor/lucide.min.js"></script>
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="js/datetime.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script src="js/links.js"></script>
|
<script src="js/links.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -83,6 +83,7 @@
|
|||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="vendor/lucide.min.js"></script>
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="js/datetime.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script src="js/modules.js"></script>
|
<script src="js/modules.js"></script>
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="vendor/lucide.min.js"></script>
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="js/datetime.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script src="js/notifications.js"></script>
|
<script src="js/notifications.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="vendor/lucide.min.js"></script>
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="js/datetime.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script src="js/photos.js"></script>
|
<script src="js/photos.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -280,6 +280,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="/js/datetime.js"></script>
|
||||||
<script src="/js/report.js"></script>
|
<script src="/js/report.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
<a href="#sec-system" data-nav="sec-system" class="active"><i data-lucide="bar-chart-3"></i> Система</a>
|
<a href="#sec-system" data-nav="sec-system" class="active"><i data-lucide="bar-chart-3"></i> Система</a>
|
||||||
<a href="#sec-stack" data-nav="sec-stack"><i data-lucide="server"></i> Стек</a>
|
<a href="#sec-stack" data-nav="sec-stack"><i data-lucide="server"></i> Стек</a>
|
||||||
<a href="#sec-brand" data-nav="sec-brand"><i data-lucide="palette"></i> Брендинг</a>
|
<a href="#sec-brand" data-nav="sec-brand"><i data-lucide="palette"></i> Брендинг</a>
|
||||||
|
<a href="#sec-datetime" data-nav="sec-datetime"><i data-lucide="clock"></i> Дата и время</a>
|
||||||
<a href="#sec-antispam" data-nav="sec-antispam"><i data-lucide="shield"></i> Антиспам</a>
|
<a href="#sec-antispam" data-nav="sec-antispam"><i data-lucide="shield"></i> Антиспам</a>
|
||||||
<a href="#sec-share" data-nav="sec-share"><i data-lucide="link"></i> Публичные ссылки</a>
|
<a href="#sec-share" data-nav="sec-share"><i data-lucide="link"></i> Публичные ссылки</a>
|
||||||
<a href="#sec-form" data-nav="sec-form"><i data-lucide="file-text"></i> Форма ответа</a>
|
<a href="#sec-form" data-nav="sec-form"><i data-lucide="file-text"></i> Форма ответа</a>
|
||||||
@@ -91,6 +92,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card" id="sec-datetime">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="ic"><i data-lucide="clock"></i></div>
|
||||||
|
<h3>Дата и время</h3>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Часовой пояс, в котором показываются даты и время во всех разделах, и формат времени. На сервере дата в базе остаётся той же — меняется только отображение.</p>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="timezone">Часовой пояс</label>
|
||||||
|
<select id="timezone" class="settings-input"></select>
|
||||||
|
<span class="hint">Определяет, какое время «сейчас» в расписании групп и границы дней в фильтрах</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="timeFormat">Формат времени</label>
|
||||||
|
<select id="timeFormat" class="settings-input">
|
||||||
|
<option value="24h">24 часа (00:30)</option>
|
||||||
|
<option value="12h">12 часов AM/PM (12:30 PM)</option>
|
||||||
|
</select>
|
||||||
|
<span class="hint">Поля ввода времени остаются в формате 24 часов</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="data-stats" id="timePreview"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-card" id="sec-antispam">
|
<div class="settings-card" id="sec-antispam">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div class="ic"><i data-lucide="shield"></i></div>
|
<div class="ic"><i data-lucide="shield"></i></div>
|
||||||
@@ -412,6 +437,7 @@
|
|||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="vendor/lucide.min.js"></script>
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="js/datetime.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script src="js/settings.js"></script>
|
<script src="js/settings.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ body{font-family:'Segoe UI',system-ui,-apple-system,Roboto,sans-serif;background
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="/js/datetime.js"></script>
|
||||||
<script src="/js/share.js"></script>
|
<script src="/js/share.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -215,6 +215,7 @@
|
|||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="vendor/lucide.min.js"></script>
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="js/datetime.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script src="vendor/jszip.min.js"></script>
|
<script src="vendor/jszip.min.js"></script>
|
||||||
<script src="js/students.js"></script>
|
<script src="js/students.js"></script>
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="vendor/lucide.min.js"></script>
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="js/datetime.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script src="js/trash.js"></script>
|
<script src="js/trash.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -83,6 +83,7 @@
|
|||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="vendor/lucide.min.js"></script>
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="js/datetime.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script src="js/users.js"></script>
|
<script src="js/users.js"></script>
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -187,6 +187,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="vendor/lucide.min.js"></script>
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="js/datetime.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script src="js/worker.js"></script>
|
<script src="js/worker.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1088,6 +1088,35 @@ async function trashPurgeDays() {
|
|||||||
return Number.isFinite(v) && v >= 1 && v <= 3650 ? v : 30;
|
return Number.isFinite(v) && v >= 1 && v <= 3650 ? v : 30;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TIMEZONE = (() => {
|
||||||
|
const fromEnv = String(process.env.TZ || '').trim();
|
||||||
|
return validTimezone(fromEnv) ? fromEnv : 'Europe/Moscow';
|
||||||
|
})();
|
||||||
|
|
||||||
|
function validTimezone(tz) {
|
||||||
|
if (!tz || typeof tz !== 'string') return false;
|
||||||
|
try { new Intl.DateTimeFormat('ru-RU', { timeZone: tz }); return true; } catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function appTimezone() {
|
||||||
|
const v = String(await getSetting('timezone', DEFAULT_TIMEZONE) || '').trim();
|
||||||
|
return validTimezone(v) ? v : DEFAULT_TIMEZONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function appHour12() {
|
||||||
|
return (await getSetting('time_format', '24h')) === '12h';
|
||||||
|
}
|
||||||
|
|
||||||
|
function tzDayStart(idx) { return `($${idx}::date::timestamp AT TIME ZONE $TZ$)`; }
|
||||||
|
function tzDayEnd(idx) { return `($${idx}::date::timestamp + interval '1 day') AT TIME ZONE $TZ$`; }
|
||||||
|
function tzWall() { return `(now() AT TIME ZONE $TZ$)`; }
|
||||||
|
|
||||||
|
function bindTz(sql, params, tz) {
|
||||||
|
if (!sql.includes('$TZ$')) return { sql, params };
|
||||||
|
params.push(tz);
|
||||||
|
return { sql: sql.replace(/\$TZ\$/g, '$' + params.length), params };
|
||||||
|
}
|
||||||
|
|
||||||
async function purgeScheduledDeletions() {
|
async function purgeScheduledDeletions() {
|
||||||
const erefs = await pool.query('SELECT id FROM entries WHERE purge_at IS NOT NULL AND purge_at <= now()');
|
const erefs = await pool.query('SELECT id FROM entries WHERE purge_at IS NOT NULL AND purge_at <= now()');
|
||||||
for (const r of erefs.rows) {
|
for (const r of erefs.rows) {
|
||||||
@@ -1780,8 +1809,8 @@ app.get('/api/settings', requireAdmin, async (_, res) => {
|
|||||||
|
|
||||||
app.get('/api/public-settings', apiLimiter, async (_, res) => {
|
app.get('/api/public-settings', apiLimiter, async (_, res) => {
|
||||||
const out = await cacheWrap('public-settings', PUBLIC_TTL_MS, async () => {
|
const out = await cacheWrap('public-settings', PUBLIC_TTL_MS, async () => {
|
||||||
const keys = ['system_name', 'system_logo', 'footer_left', 'footer_right', 'share_show_student_message', 'share_show_entry_date', 'share_show_student_names', 'share_show_group_photos', 'cookie_notice_text', 'spam_interval_min', 'photo_capture_resolution', 'photo_capture_quality', 'photo_enhance_engine', 'camera_enabled', 'photo_ai_face_mode', 'photo_ai_face_model', 'photo_ai_device_pref'];
|
const keys = ['system_name', 'system_logo', 'footer_left', 'footer_right', 'share_show_student_message', 'share_show_entry_date', 'share_show_student_names', 'share_show_group_photos', 'cookie_notice_text', 'spam_interval_min', 'photo_capture_resolution', 'photo_capture_quality', 'photo_enhance_engine', 'camera_enabled', 'photo_ai_face_mode', 'photo_ai_face_model', 'photo_ai_device_pref', 'timezone', 'time_format'];
|
||||||
const defaults = { system_name: 'WhatIDo', system_logo: '', spam_interval_min: '30', photo_capture_resolution: '640x480', photo_capture_quality: '0.92', photo_enhance_engine: 'auto', camera_enabled: 'true', photo_ai_face_mode: PHOTO_AI_DEFAULT_FACE_MODE, photo_ai_face_model: PHOTO_AI_FACE_MODEL, photo_ai_device_pref: 'auto' };
|
const defaults = { system_name: 'WhatIDo', system_logo: '', spam_interval_min: '30', photo_capture_resolution: '640x480', photo_capture_quality: '0.92', photo_enhance_engine: 'auto', camera_enabled: 'true', photo_ai_face_mode: PHOTO_AI_DEFAULT_FACE_MODE, photo_ai_face_model: PHOTO_AI_FACE_MODEL, photo_ai_device_pref: 'auto', timezone: DEFAULT_TIMEZONE, time_format: '24h' };
|
||||||
const result = {};
|
const result = {};
|
||||||
for (const k of keys) result[k] = await getSetting(k, defaults[k] || '');
|
for (const k of keys) result[k] = await getSetting(k, defaults[k] || '');
|
||||||
const rm = /^(\d{2,5})x(\d{2,5})$/.exec(result.photo_capture_resolution);
|
const rm = /^(\d{2,5})x(\d{2,5})$/.exec(result.photo_capture_resolution);
|
||||||
@@ -1859,6 +1888,12 @@ app.put('/api/settings', requireAdmin, async (req, res) => {
|
|||||||
if (key === 'lesson_ai_prompt' && String(value).length > 8000) {
|
if (key === 'lesson_ai_prompt' && String(value).length > 8000) {
|
||||||
return res.status(400).json({ error: 'lesson_ai_prompt длиннее 8000 символов' });
|
return res.status(400).json({ error: 'lesson_ai_prompt длиннее 8000 символов' });
|
||||||
}
|
}
|
||||||
|
if (key === 'timezone' && !validTimezone(String(value).trim())) {
|
||||||
|
return res.status(400).json({ error: 'timezone должен быть корректным часовым поясом IANA, например Europe/Moscow' });
|
||||||
|
}
|
||||||
|
if (key === 'time_format' && !['24h', '12h'].includes(String(value))) {
|
||||||
|
return res.status(400).json({ error: 'time_format должен быть 24h или 12h' });
|
||||||
|
}
|
||||||
if (key.startsWith('notify_') && key !== 'notify_retention_days' && !['true', 'false'].includes(String(value))) {
|
if (key.startsWith('notify_') && key !== 'notify_retention_days' && !['true', 'false'].includes(String(value))) {
|
||||||
return res.status(400).json({ error: `${key} должен быть true или false` });
|
return res.status(400).json({ error: `${key} должен быть true или false` });
|
||||||
}
|
}
|
||||||
@@ -2771,10 +2806,10 @@ app.get('/api/share/:token', fileLimiter, async (req, res) => {
|
|||||||
const params = [];
|
const params = [];
|
||||||
if (l.group_id) { params.push(l.group_id); conditions.push(`e.group_id = $${params.length}`); }
|
if (l.group_id) { params.push(l.group_id); conditions.push(`e.group_id = $${params.length}`); }
|
||||||
if (l.student_name) { params.push(l.student_name); conditions.push(`e.student_name = $${params.length}`); }
|
if (l.student_name) { params.push(l.student_name); conditions.push(`e.student_name = $${params.length}`); }
|
||||||
if (l.date_from) { params.push(l.date_from); conditions.push(`e.created_at >= $${params.length}::date`); }
|
if (l.date_from) { params.push(l.date_from); conditions.push(`e.created_at >= ${tzDayStart(params.length)}`); }
|
||||||
if (l.date_to) { params.push(l.date_to); conditions.push(`e.created_at < ($${params.length}::date + interval '1 day')`); }
|
if (l.date_to) { params.push(l.date_to); conditions.push(`e.created_at < ${tzDayEnd(params.length)}`); }
|
||||||
conditions.push('e.deleted_at IS NULL');
|
conditions.push('e.deleted_at IS NULL');
|
||||||
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
|
const where = conditions.length ? ' WHERE ' + bindTz(conditions.join(' AND '), params, await appTimezone()).sql : '';
|
||||||
const { rows: entries } = await pool.query(
|
const { rows: entries } = await pool.query(
|
||||||
`SELECT e.*, g.name AS group_name FROM entries e
|
`SELECT e.*, g.name AS group_name FROM entries e
|
||||||
JOIN groups g ON g.id = e.group_id${where} ORDER BY e.created_at DESC`,
|
JOIN groups g ON g.id = e.group_id${where} ORDER BY e.created_at DESC`,
|
||||||
@@ -2857,14 +2892,15 @@ app.get('/api/share/:shareToken/files/:fileToken', fileLimiter, async (req, res)
|
|||||||
const params = [];
|
const params = [];
|
||||||
if (l.group_id) { params.push(l.group_id); conditions.push(`e.group_id = $${params.length}`); }
|
if (l.group_id) { params.push(l.group_id); conditions.push(`e.group_id = $${params.length}`); }
|
||||||
if (l.student_name) { params.push(l.student_name); conditions.push(`e.student_name = $${params.length}`); }
|
if (l.student_name) { params.push(l.student_name); conditions.push(`e.student_name = $${params.length}`); }
|
||||||
if (l.date_from) { params.push(l.date_from); conditions.push(`e.created_at >= $${params.length}::date`); }
|
if (l.date_from) { params.push(l.date_from); conditions.push(`e.created_at >= ${tzDayStart(params.length)}`); }
|
||||||
if (l.date_to) { params.push(l.date_to); conditions.push(`e.created_at < ($${params.length}::date + interval '1 day')`); }
|
if (l.date_to) { params.push(l.date_to); conditions.push(`e.created_at < ${tzDayEnd(params.length)}`); }
|
||||||
params.push(fileToken);
|
params.push(fileToken);
|
||||||
|
const bound = bindTz(conditions.join(' AND '), params, await appTimezone());
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT pf.path, pf.name FROM project_files pf
|
`SELECT pf.path, pf.name FROM project_files pf
|
||||||
JOIN entries e ON e.id = pf.entry_id
|
JOIN entries e ON e.id = pf.entry_id
|
||||||
WHERE pf.token = $${params.length} AND ${conditions.join(' AND ')}`,
|
WHERE pf.token = $${params.length - 1} AND ${bound.sql}`,
|
||||||
params
|
bound.params
|
||||||
);
|
);
|
||||||
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
||||||
const r = rows[0];
|
const r = rows[0];
|
||||||
@@ -2920,10 +2956,10 @@ app.get('/api/groups/active', apiLimiter, async (_, res) => {
|
|||||||
AND day_of_week IS NOT NULL
|
AND day_of_week IS NOT NULL
|
||||||
AND time_start IS NOT NULL
|
AND time_start IS NOT NULL
|
||||||
AND time_end IS NOT NULL
|
AND time_end IS NOT NULL
|
||||||
AND day_of_week = EXTRACT(DOW FROM (now() AT TIME ZONE 'Europe/Moscow'))::int
|
AND day_of_week = EXTRACT(DOW FROM (now() AT TIME ZONE $1))::int
|
||||||
AND (now() AT TIME ZONE 'Europe/Moscow')::time BETWEEN time_start AND time_end
|
AND (now() AT TIME ZONE $1)::time BETWEEN time_start AND time_end
|
||||||
ORDER BY id
|
ORDER BY id
|
||||||
`);
|
`, [await appTimezone()]);
|
||||||
return rows;
|
return rows;
|
||||||
});
|
});
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
@@ -4101,9 +4137,9 @@ app.put('/api/students/:id/photos/:pid/main', requireAuth, async (req, res) => {
|
|||||||
res.json({ ok: true, photo_path: rows[0].photo_path });
|
res.json({ ok: true, photo_path: rows[0].photo_path });
|
||||||
});
|
});
|
||||||
|
|
||||||
function fmtLongDate(iso) {
|
function fmtLongDate(iso, tz) {
|
||||||
if (!iso) return '';
|
if (!iso) return '';
|
||||||
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' });
|
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric', timeZone: validTimezone(tz) ? tz : DEFAULT_TIMEZONE });
|
||||||
}
|
}
|
||||||
|
|
||||||
app.get('/api/export/student', requireAuth, async (req, res) => {
|
app.get('/api/export/student', requireAuth, async (req, res) => {
|
||||||
@@ -4141,18 +4177,18 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
|
|||||||
const bw = branchWhere(req.user, 'g');
|
const bw = branchWhere(req.user, 'g');
|
||||||
if (dateFrom) {
|
if (dateFrom) {
|
||||||
params.push(dateFrom);
|
params.push(dateFrom);
|
||||||
conds.push(`e.created_at >= $${params.length}::date`);
|
conds.push(`e.created_at >= ${tzDayStart(params.length)}`);
|
||||||
}
|
}
|
||||||
if (dateTo) {
|
if (dateTo) {
|
||||||
params.push(dateTo);
|
params.push(dateTo);
|
||||||
conds.push(`e.created_at < ($${params.length}::date + interval '1 day')`);
|
conds.push(`e.created_at < ${tzDayEnd(params.length)}`);
|
||||||
}
|
}
|
||||||
if (bw.params.length) {
|
if (bw.params.length) {
|
||||||
const start = params.length + 1;
|
const start = params.length + 1;
|
||||||
conds.push(`g.branch_id IN (${bw.params.map((_, i) => '$' + (start + i)).join(',')})`);
|
conds.push(`g.branch_id IN (${bw.params.map((_, i) => '$' + (start + i)).join(',')})`);
|
||||||
params.push(...bw.params);
|
params.push(...bw.params);
|
||||||
}
|
}
|
||||||
const condStr = conds.join(' AND ');
|
const condStr = bindTz(conds.join(' AND '), params, await appTimezone()).sql;
|
||||||
const whereStr = ' WHERE ' + condStr;
|
const whereStr = ' WHERE ' + condStr;
|
||||||
const [studRes, entriesRes, photosRes, mainsRes, filesRes, modulesRes, groupPhotosRes] = await Promise.all([
|
const [studRes, entriesRes, photosRes, mainsRes, filesRes, modulesRes, groupPhotosRes] = await Promise.all([
|
||||||
pool.query(
|
pool.query(
|
||||||
@@ -4343,7 +4379,8 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
|
|||||||
|
|
||||||
let period = null;
|
let period = null;
|
||||||
if (hasPeriod) {
|
if (hasPeriod) {
|
||||||
period = `Период: ${dateFrom ? fmtLongDate(dateFrom + 'T00:00:00') : 'начало'} — ${dateTo ? fmtLongDate(dateTo + 'T00:00:00') : 'сегодня'}`;
|
const exportTz = await appTimezone();
|
||||||
|
period = `Период: ${dateFrom ? fmtLongDate(dateFrom + 'T00:00:00', exportTz) : 'начало'} — ${dateTo ? fmtLongDate(dateTo + 'T00:00:00', exportTz) : 'сегодня'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reportData = {
|
const reportData = {
|
||||||
@@ -4438,13 +4475,13 @@ app.get('/api/groups/:id/export/files', requireAuth, async (req, res) => {
|
|||||||
const params = [gid];
|
const params = [gid];
|
||||||
if (dateFrom) {
|
if (dateFrom) {
|
||||||
params.push(dateFrom);
|
params.push(dateFrom);
|
||||||
conds.push(`e.created_at >= $${params.length}::date`);
|
conds.push(`e.created_at >= ${tzDayStart(params.length)}`);
|
||||||
}
|
}
|
||||||
if (dateTo) {
|
if (dateTo) {
|
||||||
params.push(dateTo);
|
params.push(dateTo);
|
||||||
conds.push(`e.created_at < ($${params.length}::date + interval '1 day')`);
|
conds.push(`e.created_at < ${tzDayEnd(params.length)}`);
|
||||||
}
|
}
|
||||||
const condStr = conds.join(' AND ');
|
const condStr = bindTz(conds.join(' AND '), params, await appTimezone()).sql;
|
||||||
const [studentsRes, filesRes, photosRes, entryPhotosRes, originalsRes] = await Promise.all([
|
const [studentsRes, filesRes, photosRes, entryPhotosRes, originalsRes] = await Promise.all([
|
||||||
pool.query('SELECT id, name FROM students WHERE group_id = $1 ORDER BY name', [gid]),
|
pool.query('SELECT id, name FROM students WHERE group_id = $1 ORDER BY name', [gid]),
|
||||||
includeFiles
|
includeFiles
|
||||||
@@ -4602,8 +4639,8 @@ app.get('/api/entries', requireAuth, async (req, res) => {
|
|||||||
else conditions.push('e.deleted_at IS NULL');
|
else conditions.push('e.deleted_at IS NULL');
|
||||||
if (group_id) { params.push(group_id); conditions.push(`e.group_id = $${params.length}`); }
|
if (group_id) { params.push(group_id); conditions.push(`e.group_id = $${params.length}`); }
|
||||||
if (module_id) { params.push(module_id); conditions.push(`e.module_id = $${params.length}`); }
|
if (module_id) { params.push(module_id); conditions.push(`e.module_id = $${params.length}`); }
|
||||||
if (date_from) { params.push(date_from); conditions.push(`e.created_at >= $${params.length}::date`); }
|
if (date_from) { params.push(date_from); conditions.push(`e.created_at >= ${tzDayStart(params.length)}`); }
|
||||||
if (date_to) { params.push(date_to); conditions.push(`e.created_at < ($${params.length}::date + interval '1 day')`); }
|
if (date_to) { params.push(date_to); conditions.push(`e.created_at < ${tzDayEnd(params.length)}`); }
|
||||||
if (student_name) { params.push(student_name); conditions.push(`e.student_name = $${params.length}`); }
|
if (student_name) { params.push(student_name); conditions.push(`e.student_name = $${params.length}`); }
|
||||||
if (search) { params.push(`%${search}%`); conditions.push(`(e.student_name ILIKE $${params.length} OR e.description ILIKE $${params.length})`); }
|
if (search) { params.push(`%${search}%`); conditions.push(`(e.student_name ILIKE $${params.length} OR e.description ILIKE $${params.length})`); }
|
||||||
if (req.user.role !== 'admin') {
|
if (req.user.role !== 'admin') {
|
||||||
@@ -4618,7 +4655,7 @@ app.get('/api/entries', requireAuth, async (req, res) => {
|
|||||||
conditions.push(`g.branch_id IN (${ph})`);
|
conditions.push(`g.branch_id IN (${ph})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
|
const where = conditions.length ? ' WHERE ' + bindTz(conditions.join(' AND '), params, await appTimezone()).sql : '';
|
||||||
const { rows: crows } = await pool.query(
|
const { rows: crows } = await pool.query(
|
||||||
`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id${where}`,
|
`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id${where}`,
|
||||||
params
|
params
|
||||||
@@ -4838,8 +4875,8 @@ app.get('/api/files', requireAuth, async (req, res) => {
|
|||||||
if (search) { params.push(`%${search}%`); conditions.push(`pf.name ILIKE $${params.length}`); }
|
if (search) { params.push(`%${search}%`); conditions.push(`pf.name ILIKE $${params.length}`); }
|
||||||
if (student_name) { params.push(student_name); conditions.push(`e.student_name = $${params.length}`); }
|
if (student_name) { params.push(student_name); conditions.push(`e.student_name = $${params.length}`); }
|
||||||
if (group_id) { params.push(group_id); conditions.push(`e.group_id = $${params.length}`); }
|
if (group_id) { params.push(group_id); conditions.push(`e.group_id = $${params.length}`); }
|
||||||
if (date_from) { params.push(date_from); conditions.push(`e.created_at >= $${params.length}::date`); }
|
if (date_from) { params.push(date_from); conditions.push(`e.created_at >= ${tzDayStart(params.length)}`); }
|
||||||
if (date_to) { params.push(date_to); conditions.push(`e.created_at < ($${params.length}::date + interval '1 day')`); }
|
if (date_to) { params.push(date_to); conditions.push(`e.created_at < ${tzDayEnd(params.length)}`); }
|
||||||
if (req.user.role !== 'admin') {
|
if (req.user.role !== 'admin') {
|
||||||
if (group_id && !(await groupBelongsToBranches(req.user, group_id))) {
|
if (group_id && !(await groupBelongsToBranches(req.user, group_id))) {
|
||||||
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
||||||
@@ -4852,7 +4889,7 @@ app.get('/api/files', requireAuth, async (req, res) => {
|
|||||||
conditions.push(`g.branch_id IN (${ph})`);
|
conditions.push(`g.branch_id IN (${ph})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
|
const where = conditions.length ? ' WHERE ' + bindTz(conditions.join(' AND '), params, await appTimezone()).sql : '';
|
||||||
const { rows: crows } = await pool.query(
|
const { rows: crows } = await pool.query(
|
||||||
`SELECT count(*)::int AS n FROM project_files pf JOIN entries e ON e.id = pf.entry_id JOIN groups g ON g.id = e.group_id${where}`,
|
`SELECT count(*)::int AS n FROM project_files pf JOIN entries e ON e.id = pf.entry_id JOIN groups g ON g.id = e.group_id${where}`,
|
||||||
params
|
params
|
||||||
@@ -4975,8 +5012,8 @@ app.get('/api/photos', requireAuth, async (req, res) => {
|
|||||||
if (search) { params.push(`%${search}%`); conditions.push(`t.title ILIKE $${params.length}`); }
|
if (search) { params.push(`%${search}%`); conditions.push(`t.title ILIKE $${params.length}`); }
|
||||||
if (student_name) { params.push(student_name); conditions.push(`t.student_name = $${params.length}`); }
|
if (student_name) { params.push(student_name); conditions.push(`t.student_name = $${params.length}`); }
|
||||||
if (group_id) { params.push(group_id); conditions.push(`t.group_id = $${params.length}`); }
|
if (group_id) { params.push(group_id); conditions.push(`t.group_id = $${params.length}`); }
|
||||||
if (date_from) { params.push(date_from); conditions.push(`t.created_at >= $${params.length}::date`); }
|
if (date_from) { params.push(date_from); conditions.push(`t.created_at >= ${tzDayStart(params.length)}`); }
|
||||||
if (date_to) { params.push(date_to); conditions.push(`t.created_at < ($${params.length}::date + interval '1 day')`); }
|
if (date_to) { params.push(date_to); conditions.push(`t.created_at < ${tzDayEnd(params.length)}`); }
|
||||||
if (req.user.role !== 'admin') {
|
if (req.user.role !== 'admin') {
|
||||||
if (group_id && !(await groupBelongsToBranches(req.user, group_id))) {
|
if (group_id && !(await groupBelongsToBranches(req.user, group_id))) {
|
||||||
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
||||||
@@ -4989,7 +5026,7 @@ app.get('/api/photos', requireAuth, async (req, res) => {
|
|||||||
conditions.push(`t.branch_id IN (${ph})`);
|
conditions.push(`t.branch_id IN (${ph})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
|
const where = conditions.length ? ' WHERE ' + bindTz(conditions.join(' AND '), params, await appTimezone()).sql : '';
|
||||||
const from = `FROM (
|
const from = `FROM (
|
||||||
SELECT 'entry'::text AS source_type, 'Главное фото записи'::text AS source_label,
|
SELECT 'entry'::text AS source_type, 'Главное фото записи'::text AS source_label,
|
||||||
e.photo_path AS path, e.id AS source_id, e.student_name, e.group_id,
|
e.photo_path AS path, e.id AS source_id, e.student_name, e.group_id,
|
||||||
@@ -5053,6 +5090,7 @@ app.get('/api/photos', requireAuth, async (req, res) => {
|
|||||||
|
|
||||||
app.get('/api/stats', requireAuth, async (req, res) => {
|
app.get('/api/stats', requireAuth, async (req, res) => {
|
||||||
const payload = await cacheWrap('stats:' + scopeKey(req.user), STATS_TTL_MS, async () => {
|
const payload = await cacheWrap('stats:' + scopeKey(req.user), STATS_TTL_MS, async () => {
|
||||||
|
const tz = await appTimezone();
|
||||||
const isAdmin = req.user.role === 'admin';
|
const isAdmin = req.user.role === 'admin';
|
||||||
const s = branchScope(req.user);
|
const s = branchScope(req.user);
|
||||||
let groupFilter;
|
let groupFilter;
|
||||||
@@ -5076,7 +5114,7 @@ app.get('/api/stats', requireAuth, async (req, res) => {
|
|||||||
pool.query(`SELECT count(*)::int AS n FROM groups g${gTrash('visible')}`, groupsParams),
|
pool.query(`SELECT count(*)::int AS n FROM groups g${gTrash('visible')}`, groupsParams),
|
||||||
pool.query(`SELECT count(*)::int AS n FROM groups g${groupsWhere}`, groupsParams),
|
pool.query(`SELECT count(*)::int AS n FROM groups g${groupsWhere}`, groupsParams),
|
||||||
pool.query(`SELECT count(DISTINCT e.student_name)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL${groupFilter.where}`, groupFilter.params),
|
pool.query(`SELECT count(DISTINCT e.student_name)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL${groupFilter.where}`, groupFilter.params),
|
||||||
pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL AND e.created_at >= now()::date${groupFilter.where}`, groupFilter.params),
|
(() => { const p = groupFilter.params.slice(); p.push(tz); return pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL AND e.created_at >= ${tzWall()}::date${groupFilter.where}`.replace(/\$TZ\$/g, '$' + p.length), p); })(),
|
||||||
pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.purge_at IS NOT NULL${groupFilter.where}`, groupFilter.params),
|
pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.purge_at IS NOT NULL${groupFilter.where}`, groupFilter.params),
|
||||||
pool.query(`SELECT count(*)::int AS n FROM groups g${gTrash('pending')}`, groupsParams),
|
pool.query(`SELECT count(*)::int AS n FROM groups g${gTrash('pending')}`, groupsParams),
|
||||||
]);
|
]);
|
||||||
@@ -5197,6 +5235,7 @@ app.get('/api/system-info', requireAdmin, async (_, res) => {
|
|||||||
app.get('/api/dashboard', requireAuth, async (req, res) => {
|
app.get('/api/dashboard', requireAuth, async (req, res) => {
|
||||||
const payload = await cacheWrap('dashboard:' + scopeKey(req.user), STATS_TTL_MS, async () => {
|
const payload = await cacheWrap('dashboard:' + scopeKey(req.user), STATS_TTL_MS, async () => {
|
||||||
const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
|
const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
|
||||||
|
const tz = await appTimezone();
|
||||||
const isAdmin = req.user.role === 'admin';
|
const isAdmin = req.user.role === 'admin';
|
||||||
const s = branchScope(req.user);
|
const s = branchScope(req.user);
|
||||||
const branchIds = isAdmin ? [] : s.ids;
|
const branchIds = isAdmin ? [] : s.ids;
|
||||||
@@ -5213,24 +5252,24 @@ app.get('/api/dashboard', requireAuth, async (req, res) => {
|
|||||||
pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NOT NULL${ew ? whereEntry : ''}`, ew ? whereGroupParams : []),
|
pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NOT NULL${ew ? whereEntry : ''}`, ew ? whereGroupParams : []),
|
||||||
pool.query(`SELECT count(*)::int AS n FROM groups g${isAdmin ? '' : (branchIds.length ? ` WHERE g.branch_id IN (${branchIds.map((_, i) => `$${i + 1}`).join(',')})` : ' WHERE 1 = 0')}`, isAdmin ? [] : branchIds),
|
pool.query(`SELECT count(*)::int AS n FROM groups g${isAdmin ? '' : (branchIds.length ? ` WHERE g.branch_id IN (${branchIds.map((_, i) => `$${i + 1}`).join(',')})` : ' WHERE 1 = 0')}`, isAdmin ? [] : branchIds),
|
||||||
pool.query(`SELECT count(DISTINCT e.student_name)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL${ew ? whereEntry : ''}`, ew ? whereGroupParams : []),
|
pool.query(`SELECT count(DISTINCT e.student_name)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL${ew ? whereEntry : ''}`, ew ? whereGroupParams : []),
|
||||||
pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL AND e.created_at >= now()::date${ew ? whereEntry : ''}`, ew ? whereGroupParams : []),
|
(() => { const p = (ew ? whereGroupParams : []).slice(); p.push(tz); return pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL AND e.created_at >= ${tzWall()}::date${ew ? whereEntry : ''}`.replace(/\$TZ\$/g, '$' + p.length), p); })(),
|
||||||
]);
|
]);
|
||||||
return { entries: entries.rows[0].n, trash: trash.rows[0].n, groups: groups.rows[0].n, students: students.rows[0].n, today: today.rows[0].n };
|
return { entries: entries.rows[0].n, trash: trash.rows[0].n, groups: groups.rows[0].n, students: students.rows[0].n, today: today.rows[0].n };
|
||||||
})(),
|
})(),
|
||||||
pool.query(
|
pool.query(
|
||||||
`SELECT to_char(e.created_at, 'YYYY-MM-DD') AS d, count(*)::int AS n
|
`SELECT to_char(e.created_at AT TIME ZONE $TZ$, 'YYYY-MM-DD') AS d, count(*)::int AS n
|
||||||
FROM entries e JOIN groups g ON g.id = e.group_id
|
FROM entries e JOIN groups g ON g.id = e.group_id
|
||||||
WHERE e.deleted_at IS NULL AND e.created_at >= (now() - interval '13 days')::date${whereEntry}
|
WHERE e.deleted_at IS NULL AND e.created_at >= (${tzWall()} - interval '13 days')::date${whereEntry}
|
||||||
GROUP BY 1 ORDER BY 1`,
|
GROUP BY 1 ORDER BY 1`.replace(/\$TZ\$/g, '$' + (whereEntry ? whereGroupParams.length : 0) + 1),
|
||||||
whereEntry ? whereGroupParams : []
|
(whereEntry ? whereGroupParams : []).concat([tz])
|
||||||
),
|
),
|
||||||
pool.query(
|
pool.query(
|
||||||
`SELECT g.* FROM groups g
|
`SELECT g.* FROM groups g
|
||||||
WHERE g.day_of_week IS NOT NULL AND g.time_start IS NOT NULL AND g.time_end IS NOT NULL
|
WHERE g.day_of_week IS NOT NULL AND g.time_start IS NOT NULL AND g.time_end IS NOT NULL
|
||||||
AND g.day_of_week = EXTRACT(DOW FROM (now() AT TIME ZONE 'Europe/Moscow'))::int
|
AND g.day_of_week = EXTRACT(DOW FROM ${tzWall()})::int
|
||||||
AND (now() AT TIME ZONE 'Europe/Moscow')::time BETWEEN g.time_start AND g.time_end${whereGroup}
|
AND ${tzWall()}::time BETWEEN g.time_start AND g.time_end${whereGroup}
|
||||||
ORDER BY g.id`,
|
ORDER BY g.id`.replace(/\$TZ\$/g, '$' + (whereGroup ? whereGroupParams.length : 0) + 1),
|
||||||
whereGroup ? whereGroupParams : []
|
(whereGroup ? whereGroupParams : []).concat([tz])
|
||||||
),
|
),
|
||||||
pool.query(
|
pool.query(
|
||||||
`SELECT e.id, e.student_name, e.photo_path, e.created_at, g.name AS group_name
|
`SELECT e.id, e.student_name, e.photo_path, e.created_at, g.name AS group_name
|
||||||
|
|||||||
Reference in New Issue
Block a user