diff --git a/frontend/index.html b/frontend/index.html
index 0136314..521f0f6 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -16,6 +16,15 @@
+
+
-
-
-
Нет данных по выбранным фильтрам
+
+
+
+
+
+
Нет данных по выбранным фильтрам
+
+
+
+
+
+
+ свободна сейчас
+ занята сейчас
+
+
+
Нет поверхностей с координатами
+
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 9f4c8d1..e2480fd 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -1,12 +1,13 @@
// Typed wrappers around the mapdash HTTP API. All network access for the app
// goes through here, so response shapes stay in one typed place.
-import type { Meta, Board, Booking, Filters } from './types';
+import type { Meta, Board, Booking, Filters, MapSurface } from './types';
function buildQuery(f: Filters): string {
const params = new URLSearchParams({
city: f.cities.join(','),
dimension: f.dimensions.join(','),
brand: f.brands.join(','),
+ manager: f.managers.join(','),
search: f.search,
date_start: f.dateStart,
date_end: f.dateEnd,
@@ -26,4 +27,7 @@ export const api = {
meta: (f: Filters): Promise
=> getJson
('/api/meta?' + buildQuery(f)),
boards: (f: Filters): Promise
=> getJson('/api/boards?' + buildQuery(f)),
bookings: (f: Filters): Promise => getJson('/api/bookings?' + buildQuery(f)),
+ // Map surfaces. The backend ignores the date params here (occupancy is "today"),
+ // but we reuse buildQuery — extra query params are harmless.
+ map: (f: Filters): Promise => getJson('/api/map?' + buildQuery(f)),
};
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index a45a7f7..47f4272 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -6,6 +6,8 @@ import type { Board, Booking, Filters } from './types';
import { createDropdown } from './dropdown';
import { createTimelineView, zoomSteps, type RenderFlags } from './timeline';
import { loadAppearance, applyCssVars, buildAppearanceMenu } from './appearance';
+import { createMapView } from './map';
+import { createViewModes, type ViewMode } from './viewmode';
const el = {
search: document.getElementById('search') as HTMLInputElement,
@@ -24,14 +26,25 @@ const el = {
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,
};
+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 view = createTimelineView(
{
timelineEl: el.timelineEl,
@@ -43,6 +56,11 @@ const view = createTimelineView(
appearance,
);
+const mapView = createMapView(
+ { container: el.mapEl, empty: el.mapEmpty, counter: el.mapCounter },
+ YANDEX_KEY,
+);
+
let lastBoards: Board[] = [];
let lastBookings: Booking[] = [];
@@ -59,6 +77,7 @@ function currentFilters(): Filters {
cities: cityDropdown.getSelected(),
dimensions: dimensionDropdown.getSelected(),
brands: brandDropdown.getSelected(),
+ managers: managerDropdown.getSelected(),
search: el.search.value.trim(),
dateStart: el.dateStart.value,
dateEnd: el.dateEnd.value,
@@ -96,13 +115,63 @@ async function loadData(fit: boolean): Promise {
cityDropdown.updateValues(meta.cities);
brandDropdown.updateValues(meta.brands);
dimensionDropdown.updateValues(meta.dimensions);
+ managerDropdown.updateValues(meta.managers);
lastBoards = boards;
lastBookings = bookings;
view.render(boards, bookings, fit, flags());
el.status.innerHTML = `Бордов: ${boards.length}
Бронирований: ${bookings.length}`;
rebuildDecorMenu(); // status list may have changed
+ // Keep the map in sync when it is visible (facets filter it; dates do not).
+ if (mapVisible()) void refreshMap();
}
+// ---- 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.search]);
+}
+function mapVisible(): boolean {
+ return !!viewModes && viewModes.getMode() !== 'timeline';
+}
+async function refreshMap(): Promise {
+ 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);
+ }
+}
+
+async function onModeChange(mode: ViewMode): Promise {
+ 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);
@@ -117,6 +186,7 @@ for (const cb of [el.showBrand, el.showCompany, el.showCollisions]) {
cityDropdown.onChange(scheduleReload);
brandDropdown.onChange(scheduleReload);
dimensionDropdown.onChange(scheduleReload);
+managerDropdown.onChange(scheduleReload);
el.search.addEventListener('input', scheduleReload);
el.dateStart.addEventListener('change', scheduleReload);
el.dateEnd.addEventListener('change', scheduleReload);
@@ -131,6 +201,7 @@ async function init(): Promise {
cityDropdown.setValues([], 'Все города');
brandDropdown.setValues([], 'Все бренды');
dimensionDropdown.setValues([], 'Все размеры');
+ managerDropdown.setValues([], 'Все менеджеры');
rebuildDecorMenu();
view.applyLayout();
await loadData(true);
@@ -138,6 +209,8 @@ async function init(): Promise {
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();
diff --git a/frontend/src/map.ts b/frontend/src/map.ts
new file mode 100644
index 0000000..c34548d
--- /dev/null
+++ b/frontend/src/map.ts
@@ -0,0 +1,216 @@
+// Yandex Maps view: surfaces from /api/map as clustered green/red markers.
+// The map is created lazily (only when a map-containing view mode is first shown)
+// because ymaps needs a sized, visible container to initialise correctly.
+import { escapeHtml } from './util';
+import type { MapSurface } from './types';
+
+const PLANFIX_TASK_URL = 'https://green-media.planfix.ru/task/';
+// Marker colour is intentionally "occupied today", independent of the date
+// filters. Kept as a single function so the colour scheme is easy to change later.
+function presetFor(s: MapSurface): string {
+ return s.occupied_now ? 'islands#redCircleDotIcon' : 'islands#greenCircleDotIcon';
+}
+function dot(occupied: boolean): string {
+ return ``;
+}
+
+let ymapsPromise: Promise | null = null;
+function loadYmaps(apiKey: string): Promise {
+ if (ymapsPromise) return ymapsPromise;
+ ymapsPromise = new Promise((resolve, reject) => {
+ const w = window as any;
+ if (w.ymaps && w.ymaps.Map) {
+ w.ymaps.ready(() => resolve(w.ymaps));
+ return;
+ }
+ const s = document.createElement('script');
+ s.src = `https://api-maps.yandex.ru/2.1/?apikey=${encodeURIComponent(apiKey)}&lang=ru_RU`;
+ s.async = true;
+ s.onload = () => (window as any).ymaps.ready(() => resolve((window as any).ymaps));
+ s.onerror = () => reject(new Error('Не удалось загрузить Яндекс.Карты'));
+ document.head.appendChild(s);
+ });
+ return ymapsPromise;
+}
+
+export interface MapElements {
+ container: HTMLElement;
+ empty: HTMLElement;
+ counter: HTMLElement;
+}
+
+export interface MapView {
+ render(surfaces: MapSurface[]): void;
+ ensureInit(): Promise;
+ invalidateSize(): void;
+}
+
+// Balloon (click) — richer, with PlanFix links.
+function balloonBody(s: MapSurface): string {
+ const rows: string[] = [];
+ rows.push(`${escapeHtml(s.board_id)} · ${escapeHtml(s.dimension || '')} · ${escapeHtml(s.board_type || '')}
`);
+ rows.push(`${escapeHtml(s.city || '')}, ${escapeHtml(s.address || '')}
`);
+ rows.push(
+ `` +
+ (s.occupied_now ? 'Занята сейчас' : 'Свободна сейчас') +
+ '
',
+ );
+ if (s.bookings.length) {
+ rows.push('Текущие размещения:
');
+ for (const b of s.bookings) {
+ const label = escapeHtml(b.brand || b.company_name || 'Без названия');
+ const link = `${escapeHtml(b.task_id)}`;
+ rows.push(`• ${label} (${escapeHtml(b.start_date)} — ${escapeHtml(b.end_date)}) · ${link}
`);
+ }
+ }
+ return rows.join('');
+}
+
+// Hover tooltip for a single marker: code, size, address (+ brand/company if booked).
+function singleTip(s: MapSurface): string {
+ const parts: string[] = [];
+ parts.push(`${dot(s.occupied_now)}${escapeHtml(s.board_id)} · ${escapeHtml(s.dimension || '')}
`);
+ parts.push(`${escapeHtml(s.city || '')}, ${escapeHtml(s.address || '')}
`);
+ if (s.occupied_now && s.bookings.length) {
+ for (const b of s.bookings) {
+ const brand = escapeHtml(b.brand || '');
+ const company = escapeHtml(b.company_name || '');
+ const line = [brand, company].filter(Boolean).join(' — ') || 'Без названия';
+ parts.push(`${line}
`);
+ }
+ }
+ return parts.join('');
+}
+
+// Hover tooltip for a cluster: list surfaces with their colour.
+function clusterTip(list: MapSurface[]): string {
+ const busy = list.filter((s) => s.occupied_now).length;
+ const rows: string[] = [];
+ rows.push(`Поверхностей: ${list.length} (занято ${busy}, свободно ${list.length - busy})
`);
+ const LIMIT = 25;
+ for (const s of list.slice(0, LIMIT)) {
+ rows.push(`${dot(s.occupied_now)}${escapeHtml(s.board_id)} · ${escapeHtml(s.dimension || '')}
`);
+ }
+ if (list.length > LIMIT) rows.push(`…и ещё ${list.length - LIMIT}
`);
+ return rows.join('');
+}
+
+export function createMapView(els: MapElements, apiKey: string): MapView {
+ let ymaps: any = null;
+ let map: any = null;
+ let objectManager: any = null;
+ let pending: MapSurface[] | null = null;
+ // Guards against a double-init race: ensureInit may be called concurrently
+ // (e.g. from loadData and from applying the saved view mode) and the ymaps
+ // script load is async, so a plain `if (map)` check before the await isn't
+ // enough — without this the map would be created twice in one container and
+ // the marker layer would be clobbered.
+ let initPromise: Promise | null = null;
+ // feature id (index) -> surface, so hover handlers can look data up.
+ let drawn: MapSurface[] = [];
+
+ // Custom floating tooltip (ymaps hints are too limited for our content).
+ const tip = document.createElement('div');
+ tip.className = 'map-tip';
+ tip.style.display = 'none';
+ document.body.appendChild(tip);
+ let mx = 0;
+ let my = 0;
+ function positionTip(): void {
+ tip.style.left = mx + 14 + 'px';
+ tip.style.top = my + 14 + 'px';
+ }
+ document.addEventListener('mousemove', (e) => {
+ mx = e.clientX;
+ my = e.clientY;
+ if (tip.style.display !== 'none') positionTip();
+ });
+ function showTip(html: string): void {
+ tip.innerHTML = html;
+ tip.style.display = 'block';
+ positionTip();
+ }
+ function hideTip(): void {
+ tip.style.display = 'none';
+ }
+
+ function draw(surfaces: MapSurface[]): void {
+ if (!objectManager) return;
+ const withCoords = surfaces.filter((s) => s.lat != null && s.lon != null);
+ drawn = withCoords;
+ const features = withCoords.map((s, i) => ({
+ type: 'Feature',
+ id: i,
+ geometry: { type: 'Point', coordinates: [s.lat as number, s.lon as number] },
+ properties: {
+ balloonContentHeader: escapeHtml(s.board_id),
+ balloonContentBody: balloonBody(s),
+ },
+ options: { preset: presetFor(s) },
+ }));
+ hideTip();
+ objectManager.removeAll();
+ objectManager.add({ type: 'FeatureCollection', features });
+
+ const noCoords = surfaces.length - withCoords.length;
+ els.counter.textContent = `Поверхностей: ${withCoords.length}` + (noCoords ? ` (без координат: ${noCoords})` : '');
+ els.empty.style.display = withCoords.length ? 'none' : 'block';
+
+ if (features.length) {
+ const points = features.map((f) => f.geometry.coordinates);
+ const bounds = ymaps.util.bounds.fromPoints(points);
+ map.setBounds(bounds, { checkZoomRange: true, zoomMargin: 40 });
+ }
+ }
+
+ return {
+ render(surfaces: MapSurface[]): void {
+ if (!objectManager) {
+ pending = surfaces;
+ return;
+ }
+ draw(surfaces);
+ },
+ ensureInit(): Promise {
+ if (map) return Promise.resolve();
+ if (initPromise) return initPromise;
+ initPromise = (async () => {
+ ymaps = await loadYmaps(apiKey);
+ map = new ymaps.Map(
+ els.container,
+ { center: [56.85, 53.2], zoom: 7, controls: ['zoomControl', 'geolocationControl', 'fullscreenControl'] },
+ { suppressMapOpenBlock: true },
+ );
+ objectManager = new ymaps.ObjectManager({
+ clusterize: true,
+ gridSize: 64,
+ clusterDisableClickZoom: false,
+ });
+ map.geoObjects.add(objectManager);
+
+ // Hover tooltips: single markers and clusters.
+ objectManager.objects.events.add(['mouseenter'], (e: any) => {
+ const s = drawn[e.get('objectId') as number];
+ if (s) showTip(singleTip(s));
+ });
+ objectManager.objects.events.add(['mouseleave'], hideTip);
+ objectManager.clusters.events.add(['mouseenter'], (e: any) => {
+ const cluster = objectManager.clusters.getById(e.get('objectId'));
+ const objs = cluster ? cluster.properties.geoObjects : [];
+ const list = objs.map((o: any) => drawn[o.id as number]).filter(Boolean);
+ if (list.length) showTip(clusterTip(list));
+ });
+ objectManager.clusters.events.add(['mouseleave'], hideTip);
+
+ if (pending) {
+ draw(pending);
+ pending = null;
+ }
+ })();
+ return initPromise;
+ },
+ invalidateSize(): void {
+ if (map) map.container.fitToViewport();
+ },
+ };
+}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index a0c6627..01ac3cd 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -29,12 +29,14 @@ header {
display: flex;
gap: 14px;
align-items: stretch;
- flex-wrap: wrap;
+ /* Single row: keep every block (incl. the counters) on one line and, when the
+ window is too narrow, compress the fields group first instead of wrapping. */
+ flex-wrap: nowrap;
background: #fff;
z-index: 20;
}
-/* Header groups (four boxed blocks) */
+/* Header groups (boxed blocks). By default they keep their natural width. */
.hgroup {
display: flex;
align-items: center;
@@ -43,7 +45,14 @@ header {
background: #f2f2f2;
border: 1px solid #e2e2e2;
border-radius: 8px;
+ flex-shrink: 0;
}
+/* The fields group is the one that gives way first when space runs out. */
+.hgroup-fields { flex-shrink: 1; min-width: 0; }
+.hgroup-fields .field,
+.hgroup-fields .fcol,
+.hgroup-fields .dropdown,
+.hgroup-fields .dropdown-btn { min-width: 0; }
.hgroup-brand { background: #eaeaea; }
/* Fields group: top-align so the date input sits UNDER Город/Бренд. */
.hgroup-fields { align-items: flex-start; }
@@ -53,7 +62,8 @@ input[type=date] {
border-radius: 4px;
padding: 6px 8px;
font-size: 13px;
- min-width: 180px;
+ min-width: 0;
+ width: 100%;
box-sizing: border-box;
}
/* Compact single-column layout: the three checkboxes stack in three rows so the
@@ -78,7 +88,8 @@ input[type=text] {
border-radius: 4px;
padding: 6px 8px;
font-size: 13px;
- min-width: 180px;
+ min-width: 0;
+ width: 100%;
}
/* Counters as a compact gray block, pushed to the end of the header row so it
sits in line with the other boxed groups instead of floating below. */
@@ -157,6 +168,68 @@ input[type=text] {
.decor-reset { margin-top: 6px; width: 100%; padding: 6px; font-size: 12px; border: 1px solid #ccc; border-radius: 4px; background: #f7f7f7; cursor: pointer; }
.decor-reset:hover { background: #efefef; }
+/* ---- view modes: timeline / map / split ---- */
+/* Stacked, individual buttons (compact vertical column). */
+.mode-switch { display: flex; flex-direction: column; gap: 6px; }
+.mode-btn {
+ border: 1px solid #ccc; border-radius: 6px; background: #fff; color: #333; cursor: pointer;
+ padding: 6px 12px; font-size: 13px; white-space: nowrap; text-align: left; width: 100%;
+}
+.mode-btn:hover { background: #f0f0f0; }
+.mode-btn.active { background: #2e66d6; border-color: #2e66d6; color: #fff; }
+
+#view { display: flex; flex: 1 1 auto; min-height: 0; min-width: 0; --split-left: 50%; }
+#pane-timeline { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; }
+#pane-map { position: relative; min-width: 0; min-height: 0; overflow: hidden; }
+#map { width: 100%; height: 100%; }
+
+/* timeline-only */
+#view.mode-timeline #pane-timeline { flex: 1 1 auto; }
+#view.mode-timeline #pane-map,
+#view.mode-timeline #split-resizer { display: none; }
+/* map-only */
+#view.mode-map #pane-map { flex: 1 1 auto; }
+#view.mode-map #pane-timeline,
+#view.mode-map #split-resizer { display: none; }
+/* split */
+#view.mode-split #pane-timeline { flex: 0 0 var(--split-left); }
+#view.mode-split #pane-map { flex: 1 1 auto; }
+#view.mode-split #split-resizer { display: block; }
+
+#split-resizer {
+ flex: 0 0 6px; background: #e2e2e2; cursor: col-resize; position: relative;
+}
+#split-resizer:hover { background: #b9c6e6; }
+
+#map-legend {
+ position: absolute; left: 10px; bottom: 10px; z-index: 5;
+ background: rgba(255,255,255,.92); border: 1px solid #ddd; border-radius: 6px;
+ padding: 6px 10px; font-size: 12px; color: #333; display: flex; gap: 14px; align-items: center;
+ box-shadow: 0 2px 8px rgba(0,0,0,.12);
+}
+#map-legend .lg-item { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
+#map-legend .lg-dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; }
+#map-legend .lg-free { background: #59a831; }
+#map-legend .lg-busy { background: #e35b45; }
+#map-empty {
+ position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%);
+ color: #888; font-size: 14px; z-index: 5;
+}
+/* Custom hover tooltip for map markers and clusters. */
+.map-tip {
+ position: fixed; z-index: 1000; pointer-events: none;
+ background: rgba(33,33,33,.94); color: #fff; font-size: 12px; line-height: 1.35;
+ padding: 8px 10px; border-radius: 6px; max-width: 340px;
+ box-shadow: 0 4px 14px rgba(0,0,0,.3);
+}
+.map-tip .tip-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 6px; vertical-align: baseline; }
+.map-tip .tip-free { background: #59a831; }
+.map-tip .tip-busy { background: #e35b45; }
+.map-tip .tip-row { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.map-tip .tip-head { font-weight: 700; margin-bottom: 3px; }
+.map-tip .tip-sub { color: #cfd3d6; }
+.map-tip .tip-more { color: #cfd3d6; margin-top: 3px; }
+
#chart-wrap { flex: 1 1 auto; min-height: 0; position: relative; overflow: hidden; }
#timeline { border: none; }
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 1077eca..c1e60f2 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -7,6 +7,7 @@ export interface Meta {
cities: string[];
dimensions: string[];
brands: string[];
+ managers: string[];
}
/** One row of GET /api/boards (grouped per board_id). */
@@ -33,10 +34,36 @@ export interface Filters {
cities: string[];
dimensions: string[];
brands: string[];
+ managers: string[];
search: string;
dateStart: string; // YYYY-MM-DD or '' — start_date >= dateStart
dateEnd: string; // YYYY-MM-DD or '' — end_date <= dateEnd
}
+/** A booking active "today" attached to a map surface (for the balloon). */
+export interface MapBooking {
+ task_id: string;
+ task_status: string;
+ start_date: string;
+ end_date: string;
+ brand: string;
+ company_name: string;
+}
+
+/** One surface returned by GET /api/map (from board_info, joined to bookings). */
+export interface MapSurface {
+ board_key: number;
+ board_id: string;
+ city: string;
+ address: string;
+ dimension: string;
+ board_type: string;
+ side: string;
+ lat: number | null;
+ lon: number | null;
+ occupied_now: boolean;
+ bookings: MapBooking[];
+}
+
/** task_status value that marks an archived booking (painted slate #708090). */
export const ARCHIVED_STATUS = 'РК. Архив';
diff --git a/frontend/src/viewmode.ts b/frontend/src/viewmode.ts
new file mode 100644
index 0000000..a6ab2cd
--- /dev/null
+++ b/frontend/src/viewmode.ts
@@ -0,0 +1,103 @@
+// Header segmented control: "График" / "Карта" / "Карта и график" plus the
+// draggable vertical splitter used in the combined mode. State (mode + split
+// ratio) persists in localStorage.
+export type ViewMode = 'timeline' | 'map' | 'split';
+
+const LS_MODE = 'mapdash.viewmode';
+const LS_SPLIT = 'mapdash.splitLeft';
+const MIN_PCT = 20;
+const MAX_PCT = 80;
+
+export interface ViewModeElements {
+ view: HTMLElement;
+ splitResizer: HTMLElement;
+ btnTimeline: HTMLElement;
+ btnMap: HTMLElement;
+ btnSplit: HTMLElement;
+}
+
+export interface ViewModeController {
+ getMode(): ViewMode;
+ setMode(mode: ViewMode): void;
+}
+
+export interface ViewModeCallbacks {
+ // Called after the mode changes (layout already applied). mapVisible/timelineVisible
+ // tell the caller what to (re)initialise or resize.
+ onChange(mode: ViewMode): void;
+ // Called continuously while the splitter is dragged (rAF-throttled).
+ onResize(): void;
+}
+
+export function createViewModes(els: ViewModeElements, cb: ViewModeCallbacks): ViewModeController {
+ let mode: ViewMode = 'timeline';
+
+ const savedSplit = parseFloat(localStorage.getItem(LS_SPLIT) || '');
+ if (savedSplit >= MIN_PCT && savedSplit <= MAX_PCT) {
+ els.view.style.setProperty('--split-left', savedSplit + '%');
+ }
+
+ function apply(): void {
+ els.view.classList.remove('mode-timeline', 'mode-map', 'mode-split');
+ els.view.classList.add('mode-' + mode);
+ els.btnTimeline.classList.toggle('active', mode === 'timeline');
+ els.btnMap.classList.toggle('active', mode === 'map');
+ els.btnSplit.classList.toggle('active', mode === 'split');
+ }
+
+ function setMode(next: ViewMode): void {
+ mode = next;
+ localStorage.setItem(LS_MODE, mode);
+ apply();
+ cb.onChange(mode);
+ }
+
+ els.btnTimeline.addEventListener('click', () => setMode('timeline'));
+ els.btnMap.addEventListener('click', () => setMode('map'));
+ els.btnSplit.addEventListener('click', () => setMode('split'));
+
+ // ---- splitter drag ----
+ let dragging = false;
+ let rafPending = false;
+ let lastPct = savedSplit >= MIN_PCT ? savedSplit : 50;
+ function applyDrag(): void {
+ rafPending = false;
+ els.view.style.setProperty('--split-left', lastPct + '%');
+ cb.onResize();
+ }
+ els.splitResizer.addEventListener('mousedown', (e) => {
+ if (mode !== 'split') return;
+ dragging = true;
+ document.body.style.userSelect = 'none';
+ document.body.style.cursor = 'col-resize';
+ e.preventDefault();
+ });
+ window.addEventListener('mousemove', (e) => {
+ if (!dragging) return;
+ const rect = els.view.getBoundingClientRect();
+ let pct = ((e.clientX - rect.left) / rect.width) * 100;
+ pct = Math.max(MIN_PCT, Math.min(MAX_PCT, pct));
+ lastPct = pct;
+ if (rafPending) return;
+ rafPending = true;
+ requestAnimationFrame(applyDrag);
+ });
+ window.addEventListener('mouseup', () => {
+ if (!dragging) return;
+ dragging = false;
+ document.body.style.userSelect = '';
+ document.body.style.cursor = '';
+ localStorage.setItem(LS_SPLIT, String(lastPct));
+ cb.onResize();
+ });
+
+ // initial mode from storage
+ const saved = localStorage.getItem(LS_MODE) as ViewMode | null;
+ mode = saved === 'map' || saved === 'split' ? saved : 'timeline';
+ apply();
+
+ return {
+ getMode: () => mode,
+ setMode,
+ };
+}
diff --git a/main.py b/main.py
index 100cadf..a54eea7 100644
--- a/main.py
+++ b/main.py
@@ -17,6 +17,9 @@ app = FastAPI()
CITY_COND = "(length({cities:Array(String)}) = 0 OR city IN {cities:Array(String)})"
DIM_COND = "(length({dimensions:Array(String)}) = 0 OR dimension IN {dimensions:Array(String)})"
BRAND_COND = "(length({brands:Array(String)}) = 0 OR brand IN {brands:Array(String)})"
+# manager is Array(String) in pf_board (a task may have several managers), so we
+# match with hasAny: keep the row if any selected manager is among its managers.
+MANAGER_COND = "(length({managers:Array(String)}) = 0 OR hasAny(manager, {managers:Array(String)}))"
SEARCH_COND = (
"({search:String} = ''"
" OR positionCaseInsensitive(address, {search:String}) > 0"
@@ -24,11 +27,28 @@ SEARCH_COND = (
)
# Independent date bounds: start_date >= X ("Дата начала"), end_date <= Y
# ("Дата окончания"). Each included only when its value is provided.
+# NB: the bookings SELECT aliases toString(start_date) AS start_date, which would
+# shadow the Date column in WHERE and cause a String-vs-Date type error. Qualify
+# with the table name so the comparison always binds to the real Date column.
START_COND = "pf_board.start_date >= {date_start:Date}"
END_COND = "pf_board.end_date <= {date_end:Date}"
+# Map endpoint filters board_info (the surface inventory that carries coordinates).
+# board_info has no `brand` column, so the brand facet is expressed as "this
+# surface has at least one booking of the selected brand" via a subquery on pf_board.
+MAP_BRAND_COND = (
+ "(length({brands:Array(String)}) = 0"
+ " OR board_key IN (SELECT board_key FROM default.pf_board WHERE brand IN {brands:Array(String)}))"
+)
+# Same idea for the manager facet on the map: keep surfaces that have at least one
+# booking managed by one of the selected managers.
+MAP_MANAGER_COND = (
+ "(length({managers:Array(String)}) = 0"
+ " OR board_key IN (SELECT board_key FROM default.pf_board WHERE hasAny(manager, {managers:Array(String)})))"
+)
-def build_where(city=False, dimension=False, brand=False, search=False, date_start=False, date_end=False):
+
+def build_where(city=False, dimension=False, brand=False, manager=False, search=False, date_start=False, date_end=False):
# Faceted WHERE: include only the requested facet conditions. For the
# dependent-filter option lists we exclude a facet's own condition so its
# available values reflect the OTHER filters (all-but-self).
@@ -39,6 +59,8 @@ def build_where(city=False, dimension=False, brand=False, search=False, date_sta
parts.append(DIM_COND)
if brand:
parts.append(BRAND_COND)
+ if manager:
+ parts.append(MANAGER_COND)
if search:
parts.append(SEARCH_COND)
if date_start:
@@ -65,14 +87,16 @@ async def ch_query(sql: str, params: dict):
return [json.loads(line) for line in text.splitlines()]
-def parse_filters(city: str, dimension: str, brand: str, search: str, date_start: str, date_end: str):
+def parse_filters(city: str, dimension: str, brand: str, manager: str, search: str, date_start: str, date_end: str):
cities = [c for c in city.split(",") if c] if city else []
dimensions = [d for d in dimension.split(",") if d] if dimension else []
brands = [b for b in brand.split(",") if b] if brand else []
+ managers = [m for m in manager.split(",") if m] if manager else []
return {
"cities": cities,
"dimensions": dimensions,
"brands": brands,
+ "managers": managers,
"search": search or "",
"date_start": date_start or "",
"date_end": date_end or "",
@@ -80,33 +104,39 @@ def parse_filters(city: str, dimension: str, brand: str, search: str, date_start
@app.get("/api/meta")
-async def meta(city: str = "", dimension: str = "", brand: str = "", search: str = "", date_start: str = "", date_end: str = ""):
- params = parse_filters(city, dimension, brand, search, date_start, date_end)
+async def meta(city: str = "", dimension: str = "", brand: str = "", manager: str = "", search: str = "", date_start: str = "", date_end: str = ""):
+ params = parse_filters(city, dimension, brand, manager, search, date_start, date_end)
hds = bool(params["date_start"])
hde = bool(params["date_end"])
cities = await ch_query(
- f"SELECT DISTINCT city FROM default.pf_board WHERE {build_where(dimension=True, brand=True, search=True, date_start=hds, date_end=hde)} AND char_length(city) > 0 ORDER BY city",
+ f"SELECT DISTINCT city FROM default.pf_board WHERE {build_where(dimension=True, brand=True, manager=True, search=True, date_start=hds, date_end=hde)} AND char_length(city) > 0 ORDER BY city",
params,
)
brands = await ch_query(
- f"SELECT DISTINCT brand FROM default.pf_board WHERE {build_where(city=True, dimension=True, search=True, date_start=hds, date_end=hde)} AND char_length(brand) > 0 ORDER BY brand",
+ f"SELECT DISTINCT brand FROM default.pf_board WHERE {build_where(city=True, dimension=True, manager=True, search=True, date_start=hds, date_end=hde)} AND char_length(brand) > 0 ORDER BY brand",
params,
)
dimensions = await ch_query(
- f"SELECT DISTINCT dimension FROM default.pf_board WHERE {build_where(city=True, brand=True, search=True, date_start=hds, date_end=hde)} AND char_length(dimension) > 0 ORDER BY dimension",
+ f"SELECT DISTINCT dimension FROM default.pf_board WHERE {build_where(city=True, brand=True, manager=True, search=True, date_start=hds, date_end=hde)} AND char_length(dimension) > 0 ORDER BY dimension",
+ params,
+ )
+ # manager is an Array(String) column — ARRAY JOIN to enumerate distinct names.
+ managers = await ch_query(
+ f"SELECT DISTINCT m AS manager FROM default.pf_board ARRAY JOIN manager AS m WHERE {build_where(city=True, dimension=True, brand=True, search=True, date_start=hds, date_end=hde)} AND char_length(m) > 0 ORDER BY manager",
params,
)
return {
"cities": [r["city"] for r in cities],
"dimensions": [r["dimension"] for r in dimensions],
"brands": [r["brand"] for r in brands],
+ "managers": [r["manager"] for r in managers],
}
@app.get("/api/boards")
-async def boards(city: str = "", dimension: str = "", brand: str = "", search: str = "", date_start: str = "", date_end: str = ""):
- params = parse_filters(city, dimension, brand, search, date_start, date_end)
- where = build_where(city=True, dimension=True, brand=True, search=True, date_start=bool(params["date_start"]), date_end=bool(params["date_end"]))
+async def boards(city: str = "", dimension: str = "", brand: str = "", manager: str = "", search: str = "", date_start: str = "", date_end: str = ""):
+ params = parse_filters(city, dimension, brand, manager, search, date_start, date_end)
+ where = build_where(city=True, dimension=True, brand=True, manager=True, search=True, date_start=bool(params["date_start"]), date_end=bool(params["date_end"]))
sql = f"""
SELECT
board_id,
@@ -123,9 +153,9 @@ async def boards(city: str = "", dimension: str = "", brand: str = "", search: s
@app.get("/api/bookings")
-async def bookings(city: str = "", dimension: str = "", brand: str = "", search: str = "", date_start: str = "", date_end: str = ""):
- params = parse_filters(city, dimension, brand, search, date_start, date_end)
- where = build_where(city=True, dimension=True, brand=True, search=True, date_start=bool(params["date_start"]), date_end=bool(params["date_end"]))
+async def bookings(city: str = "", dimension: str = "", brand: str = "", manager: str = "", search: str = "", date_start: str = "", date_end: str = ""):
+ params = parse_filters(city, dimension, brand, manager, search, date_start, date_end)
+ where = build_where(city=True, dimension=True, brand=True, manager=True, search=True, date_start=bool(params["date_start"]), date_end=bool(params["date_end"]))
sql = f"""
SELECT
board_id,
@@ -143,4 +173,67 @@ async def bookings(city: str = "", dimension: str = "", brand: str = "", search:
return rows
+@app.get("/api/map")
+async def map_data(city: str = "", dimension: str = "", brand: str = "", manager: str = "", search: str = ""):
+ # Surfaces come from board_info (has coordinates). Occupancy is computed "as of
+ # today" and is intentionally NOT affected by the date-range filters — only the
+ # Город/Бренд/Размер/Менеджер/Поиск facets narrow which surfaces are shown.
+ params = parse_filters(city, dimension, brand, manager, search, "", "")
+ where = " AND ".join([
+ "char_length(city) > 0",
+ CITY_COND,
+ DIM_COND,
+ SEARCH_COND,
+ MAP_BRAND_COND,
+ MAP_MANAGER_COND,
+ ])
+ surfaces_sql = f"""
+ SELECT
+ board_key,
+ board_id,
+ city,
+ address,
+ dimension,
+ board_type,
+ side,
+ latitude AS lat,
+ longitude AS lon
+ FROM default.board_info
+ WHERE {where}
+ ORDER BY city, address
+ """
+ surfaces = await ch_query(surfaces_sql, params)
+
+ # All bookings active today, keyed by surface, for marker color + balloon.
+ current_sql = """
+ SELECT
+ board_key,
+ toString(task_id) AS task_id,
+ task_status,
+ toString(start_date) AS start_date,
+ toString(end_date) AS end_date,
+ brand,
+ company_name
+ FROM default.pf_board
+ WHERE board_key != 0
+ AND pf_board.start_date <= today()
+ AND pf_board.end_date >= today()
+ """
+ current = await ch_query(current_sql, params)
+
+ by_key: dict = {}
+ for b in current:
+ by_key.setdefault(b["board_key"], []).append(b)
+
+ out = []
+ for s in surfaces:
+ active = by_key.get(s["board_key"], [])
+ out.append({
+ **s,
+ "occupied_now": len(active) > 0,
+ "bookings": active,
+ })
+ return out
+
+
app.mount("/", StaticFiles(directory="static", html=True), name="static")