mapdash/frontend/src/main.ts
aaverbitskiy aeb4651f6f polish: appearance menu + clean status names everywhere (v0.2.1)
Appearance ("Оформление") menu:
- add missing status "РК. Предварительное бронирование" (8 statuses total)
- strip the systemic "РК. " prefix from status names in the UI (menu,
  tooltip, status filter dropdown, active-filter chips); raw values stay
  intact for filtering/URLs
- fixed-position menu: opens 25px below the header, ends >=30px above the
  window bottom, scrolls inside — never overflows the viewport
- drop the "Прочие статусы" row
- per-status grey reset glyph (circular arrows) restoring its default colour

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 15:37:03 +00:00

500 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// IBM Plex Sans — self-hosted (no runtime Google Fonts dependency), latin + cyrillic.
import '@fontsource/ibm-plex-sans/400.css';
import '@fontsource/ibm-plex-sans/500.css';
import '@fontsource/ibm-plex-sans/600.css';
import '@fontsource/ibm-plex-sans/700.css';
import '@fontsource/ibm-plex-sans/cyrillic-400.css';
import '@fontsource/ibm-plex-sans/cyrillic-500.css';
import '@fontsource/ibm-plex-sans/cyrillic-600.css';
import '@fontsource/ibm-plex-sans/cyrillic-700.css';
import './styles.css';
import 'vis-timeline/styles/vis-timeline-graph2d.min.css';
import { api } from './api';
import type { Board, Booking, Filters } from './types';
import { createDropdown } from './dropdown';
import { createTimelineView, zoomSteps, type RenderFlags } from './timeline';
import { loadAppearance, applyCssVars, buildAppearanceMenu, colorForStatus, statusLabel } from './appearance';
import { createMapView } from './map';
import { createViewModes, type ViewMode } from './viewmode';
import { escapeHtml } from './util';
import { initAuth, isManager, isAuthenticated, login, logout } from './auth';
const el = {
search: document.getElementById('search') as HTMLInputElement,
dateStart: document.getElementById('date-start') as HTMLInputElement,
dateEnd: document.getElementById('date-end') as HTMLInputElement,
status: document.getElementById('status') as HTMLElement,
timelineEl: document.getElementById('timeline') as HTMLElement,
empty: document.getElementById('empty') as HTMLElement,
tooltip: document.getElementById('tooltip') as HTMLElement,
showBrand: document.getElementById('show-brand') as HTMLInputElement,
showCompany: document.getElementById('show-company') as HTMLInputElement,
showCollisions: document.getElementById('show-collisions') as HTMLInputElement,
showAllSurfaces: document.getElementById('show-all-surfaces') as HTMLInputElement,
zoomSlider: document.getElementById('zoom-slider') as HTMLInputElement,
zoomLabel: document.getElementById('zoom-label') as HTMLElement,
chartWrap: document.getElementById('chart-wrap') as HTMLElement,
colResizer: document.getElementById('col-resizer') as HTMLElement,
decorBtn: document.getElementById('decor-btn') as HTMLButtonElement,
decorMenu: document.getElementById('decor-menu') as HTMLElement,
view: document.getElementById('view') as HTMLElement,
splitResizer: document.getElementById('split-resizer') as HTMLElement,
modeTimeline: document.getElementById('mode-timeline') as HTMLElement,
modeMap: document.getElementById('mode-map') as HTMLElement,
modeSplit: document.getElementById('mode-split') as HTMLElement,
mapEl: document.getElementById('map') as HTMLElement,
mapEmpty: document.getElementById('map-empty') as HTMLElement,
mapCounter: document.getElementById('map-counter') as HTMLElement,
themeToggle: document.getElementById('theme-toggle') as HTMLButtonElement,
filterChips: document.getElementById('filter-chips') as HTMLElement,
loadBar: document.getElementById('load-bar') as HTMLElement,
emptyResetTimeline: document.getElementById('empty-reset-timeline') as HTMLButtonElement,
emptyResetMap: document.getElementById('empty-reset-map') as HTMLButtonElement,
authBtn: document.getElementById('auth-btn') as HTMLButtonElement,
};
// ---- role-based UI (anonymous vs manager) ----
// Backend enforces truncation; here we just hide the controls that only make
// sense for the full-data (manager) tier so the anonymous view stays clean.
function applyRoleUi(): void {
const manager = isManager();
// Role resolved — leave the pre-paint "pending" state; from here explicit
// per-element display below is the source of truth.
document.documentElement.classList.remove('role-pending');
const hideField = (node: Element | null): void => {
const f = node?.closest('.field') as HTMLElement | null;
if (f) f.style.display = manager ? '' : 'none';
};
// Client-facing facets: Brand, Manager, Status. (Search stays — it filters by
// address/board code, which anonymous visitors are allowed to use.)
hideField(document.getElementById('brand-dropdown'));
hideField(document.getElementById('manager-dropdown'));
hideField(document.getElementById('status-dropdown'));
// Brand/Company toggles and the appearance (colour) menu.
const checks = document.querySelector('.checks') as HTMLElement | null;
if (checks) checks.style.display = manager ? '' : 'none';
const decor = document.querySelector('.decor-wrap') as HTMLElement | null;
if (decor) decor.style.display = manager ? '' : 'none';
// Login / logout button.
el.authBtn.style.display = '';
el.authBtn.textContent = isAuthenticated() ? 'Выйти' : 'Войти';
// Anonymous "Войти" is highlighted (filled accent) so it stands out; once
// logged in the "Выйти" button reverts to the subtle outline style.
el.authBtn.classList.toggle('auth-btn--login', !isAuthenticated());
el.authBtn.onclick = () => (isAuthenticated() ? logout() : login());
}
// ---- theme toggle (data-theme is set pre-paint by the inline head script) ----
function currentTheme(): 'light' | 'dark' {
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
}
function applyThemeIcon(): void {
const dark = currentTheme() === 'dark';
el.themeToggle.textContent = dark ? '☀' : '☾';
el.themeToggle.title = dark ? 'Светлая тема' : 'Тёмная тема';
}
el.themeToggle.addEventListener('click', () => {
const next = currentTheme() === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
try { localStorage.setItem('mapdash.theme', next); } catch { /* ignore */ }
applyThemeIcon();
});
applyThemeIcon();
const YANDEX_KEY = ((import.meta as any).env?.VITE_YANDEX_KEY as string) || '';
const appearance = loadAppearance();
applyCssVars(appearance);
const cityDropdown = createDropdown('city');
const brandDropdown = createDropdown('brand');
const dimensionDropdown = createDropdown('dimension');
const managerDropdown = createDropdown('manager');
const statusDropdown = createDropdown('status', statusLabel);
const view = createTimelineView(
{
timelineEl: el.timelineEl,
chartWrap: el.chartWrap,
tooltip: el.tooltip,
empty: el.empty,
colResizer: el.colResizer,
},
appearance,
);
const mapView = createMapView(
{ container: el.mapEl, empty: el.mapEmpty, counter: el.mapCounter },
YANDEX_KEY,
);
let lastBoards: Board[] = [];
let lastBookings: Booking[] = [];
function flags(): RenderFlags {
// Anonymous tier: no labels (data is blanked anyway) and no collision
// highlighting, so every bar renders in the single neutral status colour.
if (!isManager()) {
return { withBrand: false, withCompany: false, withCollisions: false, managerView: false };
}
return {
withBrand: el.showBrand.checked,
withCompany: el.showCompany.checked,
withCollisions: el.showCollisions.checked,
managerView: true,
};
}
// Whether the timeline should list every inventory surface (not just booked
// ones). The checkbox is manager-only; anonymous visitors always get all
// surfaces so they can spot what's currently free with no future bookings.
function allSurfacesParam(): boolean {
return !isManager() || el.showAllSurfaces.checked;
}
function currentFilters(): Filters {
return {
cities: cityDropdown.getSelected(),
dimensions: dimensionDropdown.getSelected(),
brands: brandDropdown.getSelected(),
managers: managerDropdown.getSelected(),
statuses: statusDropdown.getSelected(),
search: el.search.value.trim(),
dateStart: el.dateStart.value,
dateEnd: el.dateEnd.value,
};
}
// ---- appearance menu ----
function currentStatuses(): string[] {
return Array.from(new Set(lastBookings.map((b) => b.task_status).filter(Boolean)));
}
function rebuildDecorMenu(): void {
buildAppearanceMenu(el.decorMenu, currentStatuses(), appearance, () => {
applyCssVars(appearance);
view.applyLayout();
view.refresh();
});
}
// Position the (fixed) appearance menu: top 25px below the header, bottom no
// closer than 30px to the window edge; overflow scrolls inside.
function positionDecorMenu(): void {
const header = document.querySelector('header');
const headerBottom = header ? header.getBoundingClientRect().bottom : 0;
const top = headerBottom + 25;
el.decorMenu.style.top = top + 'px';
el.decorMenu.style.maxHeight = Math.max(120, window.innerHeight - top - 30) + 'px';
}
el.decorBtn.addEventListener('click', (e) => {
e.stopPropagation();
const willOpen = !el.decorMenu.classList.contains('open');
el.decorMenu.classList.toggle('open');
if (willOpen) positionDecorMenu();
});
window.addEventListener('resize', () => {
if (el.decorMenu.classList.contains('open')) positionDecorMenu();
});
document.addEventListener('click', (e) => {
if (!el.decorMenu.contains(e.target as Node) && e.target !== el.decorBtn) {
el.decorMenu.classList.remove('open');
}
});
// ---- loading indicator (delayed, so fast loads don't flash) ----
let loadTimer: number | undefined;
let inflight = 0;
function loadStart(): void {
inflight++;
clearTimeout(loadTimer);
loadTimer = window.setTimeout(() => el.loadBar.classList.add('active'), 250);
}
function loadEnd(): void {
inflight = Math.max(0, inflight - 1);
if (inflight === 0) {
clearTimeout(loadTimer);
el.loadBar.classList.remove('active');
}
}
// ---- filters: reset + active-filter chips ----
const fmtD = (iso: string): string => {
const p = iso.split('-');
return p.length === 3 ? `${p[2]}.${p[1]}.${p[0]}` : iso;
};
// ---- shareable filter links: current filters <-> URL query ----
function filtersToUrl(): void {
const f = currentFilters();
const p = new URLSearchParams();
f.cities.forEach((v) => p.append('city', v));
f.brands.forEach((v) => p.append('brand', v));
f.dimensions.forEach((v) => p.append('dimension', v));
f.managers.forEach((v) => p.append('manager', v));
f.statuses.forEach((v) => p.append('status', v));
if (f.search) p.set('q', f.search);
if (f.dateStart) p.set('from', f.dateStart);
if (f.dateEnd) p.set('to', f.dateEnd);
const qs = p.toString();
history.replaceState(null, '', qs ? `${location.pathname}?${qs}` : location.pathname);
}
function applyUrlToFilters(): void {
const p = new URLSearchParams(location.search);
cityDropdown.setSelected(p.getAll('city'));
brandDropdown.setSelected(p.getAll('brand'));
dimensionDropdown.setSelected(p.getAll('dimension'));
managerDropdown.setSelected(p.getAll('manager'));
statusDropdown.setSelected(p.getAll('status'));
el.search.value = p.get('q') || '';
el.dateStart.value = p.get('from') || '';
el.dateEnd.value = p.get('to') || '';
}
function resetFilters(): void {
for (const d of [cityDropdown, brandDropdown, dimensionDropdown, managerDropdown, statusDropdown]) d.clear();
el.search.value = '';
el.dateStart.value = '';
el.dateEnd.value = '';
void loadData(true);
}
function renderChips(): void {
const items: { label: string; remove: () => void }[] = [];
for (const dd of [cityDropdown, brandDropdown, dimensionDropdown, managerDropdown, statusDropdown]) {
const fmt = dd === statusDropdown ? statusLabel : (x: string) => x;
for (const v of dd.getSelected()) {
items.push({ label: fmt(v), remove: () => { dd.setSelected(dd.getSelected().filter((x) => x !== v)); void loadData(true); } });
}
}
if (el.search.value.trim()) items.push({ label: 'Поиск: ' + el.search.value.trim(), remove: () => { el.search.value = ''; void loadData(true); } });
if (el.dateStart.value) items.push({ label: 'с ' + fmtD(el.dateStart.value), remove: () => { el.dateStart.value = ''; void loadData(true); } });
if (el.dateEnd.value) items.push({ label: 'по ' + fmtD(el.dateEnd.value), remove: () => { el.dateEnd.value = ''; void loadData(true); } });
el.filterChips.innerHTML = '';
if (!items.length) { el.filterChips.style.display = 'none'; return; }
el.filterChips.style.display = '';
for (const it of items) {
const chip = document.createElement('span');
chip.className = 'chip';
chip.appendChild(document.createTextNode(it.label));
const x = document.createElement('button');
x.type = 'button';
x.className = 'chip-x';
x.setAttribute('aria-label', 'Убрать фильтр');
x.textContent = '×';
x.addEventListener('click', it.remove);
chip.appendChild(x);
el.filterChips.appendChild(chip);
}
const clear = document.createElement('button');
clear.type = 'button';
clear.className = 'chips-clear';
clear.textContent = 'Сбросить всё';
clear.addEventListener('click', resetFilters);
el.filterChips.appendChild(clear);
const share = document.createElement('button');
share.type = 'button';
share.className = 'chips-share';
share.textContent = 'Скопировать ссылку';
share.addEventListener('click', () => {
navigator.clipboard
?.writeText(location.href)
.then(() => {
share.textContent = 'Ссылка скопирована';
window.setTimeout(() => { share.textContent = 'Скопировать ссылку'; }, 1600);
})
.catch(() => { /* clipboard unavailable */ });
});
el.filterChips.appendChild(share);
}
el.emptyResetTimeline.addEventListener('click', resetFilters);
el.emptyResetMap.addEventListener('click', resetFilters);
async function loadData(fit: boolean): Promise<void> {
el.status.textContent = 'Загрузка…';
loadStart();
try {
const f = currentFilters();
const [meta, boards, bookings] = await Promise.all([api.meta(f), api.boards(f, allSurfacesParam()), api.bookings(f)]);
// Dependent filters: refresh each dropdown's options (selections preserved).
cityDropdown.updateValues(meta.cities);
brandDropdown.updateValues(meta.brands);
dimensionDropdown.updateValues(meta.dimensions);
managerDropdown.updateValues(meta.managers);
statusDropdown.updateValues(meta.statuses);
lastBoards = boards;
lastBookings = bookings;
view.render(boards, bookings, fit, flags());
el.status.textContent = `Поверхности: ${boards.length}`;
rebuildDecorMenu(); // status list may have changed
renderChips();
filtersToUrl();
// Keep the map in sync when it is visible (facets filter it; dates do not).
if (mapVisible()) void refreshMap();
} finally {
loadEnd();
}
}
// ---- map ----
// Map surfaces are keyed by the facet filters only. We cache the last key so we
// don't refetch when merely toggling view modes.
let mapLoadedKey = '';
function facetKey(): string {
const f = currentFilters();
return JSON.stringify([f.cities, f.dimensions, f.brands, f.managers, f.statuses, f.search]);
}
function mapVisible(): boolean {
return !!viewModes && viewModes.getMode() !== 'timeline';
}
async function refreshMap(): Promise<void> {
loadStart();
try {
await mapView.ensureInit();
mapView.invalidateSize();
const key = facetKey();
if (key === mapLoadedKey) return;
mapLoadedKey = key;
const surfaces = await api.map(currentFilters());
mapView.render(surfaces);
} catch (e) {
console.error('map load failed', e);
} finally {
loadEnd();
}
}
async function onModeChange(mode: ViewMode): Promise<void> {
if (mode !== 'map') view.applyLayout(); // timeline visible (timeline or split)
if (mode !== 'timeline') await refreshMap(); // map visible (map or split)
}
const viewModes = createViewModes(
{
view: el.view,
splitResizer: el.splitResizer,
btnTimeline: el.modeTimeline,
btnMap: el.modeMap,
btnSplit: el.modeSplit,
},
{
onChange: (mode) => void onModeChange(mode),
onResize: () => {
view.applyLayout();
mapView.invalidateSize();
},
},
);
let debounceTimer: number | undefined;
function scheduleReload(): void {
clearTimeout(debounceTimer);
debounceTimer = window.setTimeout(() => {
void loadData(true);
}, 250);
}
for (const cb of [el.showBrand, el.showCompany, el.showCollisions]) {
cb.addEventListener('change', () => view.render(lastBoards, lastBookings, false, flags()));
}
// "All surfaces" changes which rows exist (server-side), so it must refetch.
el.showAllSurfaces.addEventListener('change', scheduleReload);
cityDropdown.onChange(scheduleReload);
brandDropdown.onChange(scheduleReload);
dimensionDropdown.onChange(scheduleReload);
managerDropdown.onChange(scheduleReload);
statusDropdown.onChange(scheduleReload);
el.search.addEventListener('input', scheduleReload);
el.dateStart.addEventListener('change', scheduleReload);
el.dateEnd.addEventListener('change', scheduleReload);
el.zoomSlider.addEventListener('input', () => {
const step = zoomSteps[parseInt(el.zoomSlider.value, 10)]!;
el.zoomLabel.textContent = step.label;
view.setScale(step.days);
});
// ---- board detail panel (opened from a timeline row label) ----
const PLANFIX_TASK_URL = 'https://green-media.planfix.ru/task/';
const boardPanel = document.getElementById('board-panel') as HTMLElement;
const boardBackdrop = document.getElementById('board-backdrop') as HTMLElement;
const boardPanelTitle = document.getElementById('board-panel-title') as HTMLElement;
const boardPanelBody = document.getElementById('board-panel-body') as HTMLElement;
function closeBoardPanel(): void {
boardPanel.hidden = true;
boardBackdrop.hidden = true;
}
function openBoardPanel(boardId: string): void {
// Detail panel exposes client/brand/manager + Planfix deep-links — managers only.
if (!isManager()) return;
const board = lastBoards.find((b) => b.board_id === boardId);
const bookings = lastBookings
.filter((b) => b.board_id === boardId)
.sort((a, b) => a.start_date.localeCompare(b.start_date));
boardPanelTitle.textContent = boardId;
const parts: string[] = [];
if (board) {
const line1 = [board.board_city, board.board_dimension].filter(Boolean).map(escapeHtml).join(' · ');
if (line1) parts.push(`<div class="bp-meta">${line1}</div>`);
if (board.board_address) parts.push(`<div class="bp-meta">${escapeHtml(board.board_address)}</div>`);
}
parts.push(`<div class="bp-label">Брони (${bookings.length})</div>`);
if (!bookings.length) {
parts.push('<div class="bp-empty">Броней нет</div>');
} else {
for (const b of bookings) {
const title = escapeHtml(b.brand || b.company_name || 'Без названия');
const sub = b.brand && b.company_name ? `<div class="bp-b-sub">${escapeHtml(b.company_name)}</div>` : '';
const color = colorForStatus(appearance, b.task_status);
const mgr = (b.manager || []).filter(Boolean).join(', ');
parts.push(
'<div class="bp-booking">' +
`<div class="bp-b-head"><span class="bp-b-title">${title}</span>` +
`<a class="bp-b-link" href="${PLANFIX_TASK_URL}${encodeURIComponent(b.task_id)}" target="_blank" rel="noopener">№${escapeHtml(b.task_id)} ↗</a></div>` +
sub +
`<div class="bp-b-dates">${fmtD(b.start_date)} ${fmtD(b.end_date)}</div>` +
`<div class="bp-b-row"><span class="bp-dot" style="background:${color}"></span>${escapeHtml(b.task_status || '')}</div>` +
(mgr ? `<div class="bp-b-row">👤 ${escapeHtml(mgr)}</div>` : '') +
'</div>',
);
}
}
boardPanelBody.innerHTML = parts.join('');
boardPanel.hidden = false;
boardBackdrop.hidden = false;
}
(document.getElementById('board-panel-close') as HTMLElement).addEventListener('click', closeBoardPanel);
boardBackdrop.addEventListener('click', closeBoardPanel);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeBoardPanel(); });
view.setOnBoardClick(openBoardPanel);
async function init(): Promise<void> {
cityDropdown.setValues([], 'Все города');
brandDropdown.setValues([], 'Все бренды');
dimensionDropdown.setValues([], 'Все размеры');
managerDropdown.setValues([], 'Все менеджеры');
statusDropdown.setValues([], 'Все статусы');
await initAuth(); // resolve role before first data load (degrades to anon on failure)
applyRoleUi();
applyUrlToFilters(); // restore filters from a shared link
if (!isManager()) {
// A shared link may carry hidden facets; drop them so the anonymous view
// neither queries nor re-serialises Brand/Manager/Status.
for (const d of [brandDropdown, managerDropdown, statusDropdown]) d.clear();
}
rebuildDecorMenu();
view.applyLayout();
await loadData(true);
const defaultZoomIdx = 3;
el.zoomSlider.value = String(defaultZoomIdx);
el.zoomLabel.textContent = zoomSteps[defaultZoomIdx]!.label;
view.setScale(zoomSteps[defaultZoomIdx]!.days);
// Apply the persisted view mode (inits the map if it starts visible).
void onModeChange(viewModes.getMode());
}
void init();