// 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 { createDateRange } from './datepicker'; import { createTimelineView, zoomSteps, type RenderFlags } from './timeline'; import { loadAppearance, applyCssVars, buildAppearanceMenu, statusLabel } from './appearance'; import { createMapView } from './map'; import { createViewModes, type ViewMode } from './viewmode'; 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 dateRange = createDateRange(); 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'); let refBottom = header ? header.getBoundingClientRect().bottom : 0; // Drop below the active-filter chips row too, when it is showing. const chips = el.filterChips; if (chips && chips.style.display !== 'none' && chips.getClientRects().length) { refBottom = Math.max(refBottom, chips.getBoundingClientRect().bottom); } const top = refBottom + 20; 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; }; // ---- toast notifications (transient action feedback) ---- let toastHost: HTMLElement | null = null; function showToast(message: string): void { if (!toastHost) { toastHost = document.createElement('div'); toastHost.className = 'toasts'; document.body.appendChild(toastHost); } const t = document.createElement('div'); t.className = 'toast'; const ico = document.createElement('span'); ico.className = 'toast-ico'; ico.innerHTML = ''; const txt = document.createElement('span'); txt.textContent = message; t.appendChild(ico); t.appendChild(txt); toastHost.appendChild(t); void t.offsetWidth; // reflow so the enter transition runs (rAF is paused in bg tabs) t.classList.add('show'); window.setTimeout(() => { t.classList.remove('show'); window.setTimeout(() => t.remove(), 250); }, 2500); } // ---- 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 = ''; // "Скопировать ссылку" — always leftmost, separated from the chips by a rule. const share = document.createElement('button'); share.type = 'button'; share.className = 'chips-share'; share.textContent = 'Скопировать ссылку'; share.addEventListener('click', () => { navigator.clipboard ?.writeText(location.href) .then(() => showToast('Ссылка скопирована')) .catch(() => showToast('Не удалось скопировать')); }); el.filterChips.appendChild(share); const divider = document.createElement('span'); divider.className = 'chips-divider'; divider.textContent = '|'; el.filterChips.appendChild(divider); 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); } el.emptyResetTimeline.addEventListener('click', resetFilters); el.emptyResetMap.addEventListener('click', resetFilters); async function loadData(fit: boolean): Promise { el.status.innerHTML = 'Поверхности'; 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.innerHTML = `Поверхности${boards.length}`; rebuildDecorMenu(); // status list may have changed renderChips(); dateRange.sync(); // keep the date-range field in step with reset / chip-remove / URL 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 { 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, isManager()); } catch (e) { console.error('map load failed', e); } finally { loadEnd(); } } async function onModeChange(mode: ViewMode): Promise { if (mode !== 'map') view.applyLayout(); // timeline visible (timeline or split) if (mode === 'timeline') mapView.closePanel(); // map hidden — drop its floating card 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); // Fill the track up to the thumb and ride the value bubble above the thumb. function updateZoomUi(): void { const max = parseInt(el.zoomSlider.max, 10) || 1; const frac = max ? parseInt(el.zoomSlider.value, 10) / max : 0; el.zoomSlider.style.setProperty('--zoom-fill', frac * 100 + '%'); const thumb = 16; const w = el.zoomSlider.offsetWidth || 120; el.zoomLabel.style.left = thumb / 2 + frac * (w - thumb) + 'px'; } el.zoomSlider.addEventListener('input', () => { const step = zoomSteps[parseInt(el.zoomSlider.value, 10)]!; el.zoomLabel.textContent = step.label; view.setScale(step.days); updateZoomUi(); }); window.addEventListener('resize', updateZoomUi); // Clicking a surface in the timeline's left column flies the map to it: switch to // the combined "Карта и график" view if only the timeline is open, then focus the // surface's cluster. Works for anonymous and manager users alike. function goToSurfaceOnMap(boardId: string): void { if (viewModes.getMode() === 'timeline') viewModes.setMode('split'); mapView.focusBoard(boardId); } view.setOnBoardClick(goToSurfaceOnMap); async function init(): Promise { 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 = 1; // 12М el.zoomSlider.value = String(defaultZoomIdx); el.zoomLabel.textContent = zoomSteps[defaultZoomIdx]!.label; view.setScale(zoomSteps[defaultZoomIdx]!.days); updateZoomUi(); // Apply the persisted view mode (inits the map if it starts visible). void onModeChange(viewModes.getMode()); } void init();