map: Yandex map section (3 modes + resizable split), green/red markers, hover tooltips for markers+clusters; fix map init race; header keeps counters inline (fields shrink first); add Manager (Менеджер) faceted filter
This commit is contained in:
parent
d04e0ae024
commit
68417bf45e
@ -16,6 +16,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Group 1b: view mode switch -->
|
||||||
|
<div class="hgroup hgroup-modes">
|
||||||
|
<div class="mode-switch">
|
||||||
|
<button type="button" class="mode-btn active" id="mode-timeline">График</button>
|
||||||
|
<button type="button" class="mode-btn" id="mode-map">Карта</button>
|
||||||
|
<button type="button" class="mode-btn" id="mode-split">Карта и график</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Group 2: fields -->
|
<!-- Group 2: fields -->
|
||||||
<div class="hgroup hgroup-fields">
|
<div class="hgroup hgroup-fields">
|
||||||
<div class="fcol">
|
<div class="fcol">
|
||||||
@ -63,6 +72,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Менеджер</label>
|
||||||
|
<div class="dropdown" id="manager-dropdown">
|
||||||
|
<button type="button" class="dropdown-btn" id="manager-btn">
|
||||||
|
<span id="manager-btn-text">Все менеджеры</span>
|
||||||
|
<span class="caret">▾</span>
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-panel" id="manager-panel"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="search">Поиск (адрес / код)</label>
|
<label for="search">Поиск (адрес / код)</label>
|
||||||
<input type="text" id="search" placeholder="например, Азина" />
|
<input type="text" id="search" placeholder="например, Азина" />
|
||||||
@ -100,10 +120,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div id="chart-wrap">
|
<div id="view" class="mode-timeline">
|
||||||
|
<div id="pane-timeline">
|
||||||
|
<div id="chart-wrap">
|
||||||
<div id="timeline"></div>
|
<div id="timeline"></div>
|
||||||
<div id="col-resizer" title="Потяните, чтобы изменить ширину колонки"></div>
|
<div id="col-resizer" title="Потяните, чтобы изменить ширину колонки"></div>
|
||||||
<div id="empty" style="display:none">Нет данных по выбранным фильтрам</div>
|
<div id="empty" style="display:none">Нет данных по выбранным фильтрам</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="split-resizer" title="Потяните, чтобы изменить пропорции"></div>
|
||||||
|
<div id="pane-map">
|
||||||
|
<div id="map"></div>
|
||||||
|
<div id="map-legend">
|
||||||
|
<span class="lg-item"><span class="lg-dot lg-free"></span>свободна сейчас</span>
|
||||||
|
<span class="lg-item"><span class="lg-dot lg-busy"></span>занята сейчас</span>
|
||||||
|
<span class="lg-item" id="map-counter"></span>
|
||||||
|
</div>
|
||||||
|
<div id="map-empty" style="display:none">Нет поверхностей с координатами</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="tooltip"></div>
|
<div id="tooltip"></div>
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
// Typed wrappers around the mapdash HTTP API. All network access for the app
|
// Typed wrappers around the mapdash HTTP API. All network access for the app
|
||||||
// goes through here, so response shapes stay in one typed place.
|
// 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 {
|
function buildQuery(f: Filters): string {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
city: f.cities.join(','),
|
city: f.cities.join(','),
|
||||||
dimension: f.dimensions.join(','),
|
dimension: f.dimensions.join(','),
|
||||||
brand: f.brands.join(','),
|
brand: f.brands.join(','),
|
||||||
|
manager: f.managers.join(','),
|
||||||
search: f.search,
|
search: f.search,
|
||||||
date_start: f.dateStart,
|
date_start: f.dateStart,
|
||||||
date_end: f.dateEnd,
|
date_end: f.dateEnd,
|
||||||
@ -26,4 +27,7 @@ export const api = {
|
|||||||
meta: (f: Filters): Promise<Meta> => getJson<Meta>('/api/meta?' + buildQuery(f)),
|
meta: (f: Filters): Promise<Meta> => getJson<Meta>('/api/meta?' + buildQuery(f)),
|
||||||
boards: (f: Filters): Promise<Board[]> => getJson<Board[]>('/api/boards?' + buildQuery(f)),
|
boards: (f: Filters): Promise<Board[]> => getJson<Board[]>('/api/boards?' + buildQuery(f)),
|
||||||
bookings: (f: Filters): Promise<Booking[]> => getJson<Booking[]>('/api/bookings?' + buildQuery(f)),
|
bookings: (f: Filters): Promise<Booking[]> => getJson<Booking[]>('/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<MapSurface[]> => getJson<MapSurface[]>('/api/map?' + buildQuery(f)),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import type { Board, Booking, Filters } from './types';
|
|||||||
import { createDropdown } from './dropdown';
|
import { createDropdown } from './dropdown';
|
||||||
import { createTimelineView, zoomSteps, type RenderFlags } from './timeline';
|
import { createTimelineView, zoomSteps, type RenderFlags } from './timeline';
|
||||||
import { loadAppearance, applyCssVars, buildAppearanceMenu } from './appearance';
|
import { loadAppearance, applyCssVars, buildAppearanceMenu } from './appearance';
|
||||||
|
import { createMapView } from './map';
|
||||||
|
import { createViewModes, type ViewMode } from './viewmode';
|
||||||
|
|
||||||
const el = {
|
const el = {
|
||||||
search: document.getElementById('search') as HTMLInputElement,
|
search: document.getElementById('search') as HTMLInputElement,
|
||||||
@ -24,14 +26,25 @@ const el = {
|
|||||||
colResizer: document.getElementById('col-resizer') as HTMLElement,
|
colResizer: document.getElementById('col-resizer') as HTMLElement,
|
||||||
decorBtn: document.getElementById('decor-btn') as HTMLButtonElement,
|
decorBtn: document.getElementById('decor-btn') as HTMLButtonElement,
|
||||||
decorMenu: document.getElementById('decor-menu') as HTMLElement,
|
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();
|
const appearance = loadAppearance();
|
||||||
applyCssVars(appearance);
|
applyCssVars(appearance);
|
||||||
|
|
||||||
const cityDropdown = createDropdown('city');
|
const cityDropdown = createDropdown('city');
|
||||||
const brandDropdown = createDropdown('brand');
|
const brandDropdown = createDropdown('brand');
|
||||||
const dimensionDropdown = createDropdown('dimension');
|
const dimensionDropdown = createDropdown('dimension');
|
||||||
|
const managerDropdown = createDropdown('manager');
|
||||||
const view = createTimelineView(
|
const view = createTimelineView(
|
||||||
{
|
{
|
||||||
timelineEl: el.timelineEl,
|
timelineEl: el.timelineEl,
|
||||||
@ -43,6 +56,11 @@ const view = createTimelineView(
|
|||||||
appearance,
|
appearance,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const mapView = createMapView(
|
||||||
|
{ container: el.mapEl, empty: el.mapEmpty, counter: el.mapCounter },
|
||||||
|
YANDEX_KEY,
|
||||||
|
);
|
||||||
|
|
||||||
let lastBoards: Board[] = [];
|
let lastBoards: Board[] = [];
|
||||||
let lastBookings: Booking[] = [];
|
let lastBookings: Booking[] = [];
|
||||||
|
|
||||||
@ -59,6 +77,7 @@ function currentFilters(): Filters {
|
|||||||
cities: cityDropdown.getSelected(),
|
cities: cityDropdown.getSelected(),
|
||||||
dimensions: dimensionDropdown.getSelected(),
|
dimensions: dimensionDropdown.getSelected(),
|
||||||
brands: brandDropdown.getSelected(),
|
brands: brandDropdown.getSelected(),
|
||||||
|
managers: managerDropdown.getSelected(),
|
||||||
search: el.search.value.trim(),
|
search: el.search.value.trim(),
|
||||||
dateStart: el.dateStart.value,
|
dateStart: el.dateStart.value,
|
||||||
dateEnd: el.dateEnd.value,
|
dateEnd: el.dateEnd.value,
|
||||||
@ -96,13 +115,63 @@ async function loadData(fit: boolean): Promise<void> {
|
|||||||
cityDropdown.updateValues(meta.cities);
|
cityDropdown.updateValues(meta.cities);
|
||||||
brandDropdown.updateValues(meta.brands);
|
brandDropdown.updateValues(meta.brands);
|
||||||
dimensionDropdown.updateValues(meta.dimensions);
|
dimensionDropdown.updateValues(meta.dimensions);
|
||||||
|
managerDropdown.updateValues(meta.managers);
|
||||||
lastBoards = boards;
|
lastBoards = boards;
|
||||||
lastBookings = bookings;
|
lastBookings = bookings;
|
||||||
view.render(boards, bookings, fit, flags());
|
view.render(boards, bookings, fit, flags());
|
||||||
el.status.innerHTML = `Бордов: ${boards.length}<br>Бронирований: ${bookings.length}`;
|
el.status.innerHTML = `Бордов: ${boards.length}<br>Бронирований: ${bookings.length}`;
|
||||||
rebuildDecorMenu(); // status list may have changed
|
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<void> {
|
||||||
|
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<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;
|
let debounceTimer: number | undefined;
|
||||||
function scheduleReload(): void {
|
function scheduleReload(): void {
|
||||||
clearTimeout(debounceTimer);
|
clearTimeout(debounceTimer);
|
||||||
@ -117,6 +186,7 @@ for (const cb of [el.showBrand, el.showCompany, el.showCollisions]) {
|
|||||||
cityDropdown.onChange(scheduleReload);
|
cityDropdown.onChange(scheduleReload);
|
||||||
brandDropdown.onChange(scheduleReload);
|
brandDropdown.onChange(scheduleReload);
|
||||||
dimensionDropdown.onChange(scheduleReload);
|
dimensionDropdown.onChange(scheduleReload);
|
||||||
|
managerDropdown.onChange(scheduleReload);
|
||||||
el.search.addEventListener('input', scheduleReload);
|
el.search.addEventListener('input', scheduleReload);
|
||||||
el.dateStart.addEventListener('change', scheduleReload);
|
el.dateStart.addEventListener('change', scheduleReload);
|
||||||
el.dateEnd.addEventListener('change', scheduleReload);
|
el.dateEnd.addEventListener('change', scheduleReload);
|
||||||
@ -131,6 +201,7 @@ async function init(): Promise<void> {
|
|||||||
cityDropdown.setValues([], 'Все города');
|
cityDropdown.setValues([], 'Все города');
|
||||||
brandDropdown.setValues([], 'Все бренды');
|
brandDropdown.setValues([], 'Все бренды');
|
||||||
dimensionDropdown.setValues([], 'Все размеры');
|
dimensionDropdown.setValues([], 'Все размеры');
|
||||||
|
managerDropdown.setValues([], 'Все менеджеры');
|
||||||
rebuildDecorMenu();
|
rebuildDecorMenu();
|
||||||
view.applyLayout();
|
view.applyLayout();
|
||||||
await loadData(true);
|
await loadData(true);
|
||||||
@ -138,6 +209,8 @@ async function init(): Promise<void> {
|
|||||||
el.zoomSlider.value = String(defaultZoomIdx);
|
el.zoomSlider.value = String(defaultZoomIdx);
|
||||||
el.zoomLabel.textContent = zoomSteps[defaultZoomIdx]!.label;
|
el.zoomLabel.textContent = zoomSteps[defaultZoomIdx]!.label;
|
||||||
view.setScale(zoomSteps[defaultZoomIdx]!.days);
|
view.setScale(zoomSteps[defaultZoomIdx]!.days);
|
||||||
|
// Apply the persisted view mode (inits the map if it starts visible).
|
||||||
|
void onModeChange(viewModes.getMode());
|
||||||
}
|
}
|
||||||
|
|
||||||
void init();
|
void init();
|
||||||
|
|||||||
216
frontend/src/map.ts
Normal file
216
frontend/src/map.ts
Normal file
@ -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 `<span class="tip-dot ${occupied ? 'tip-busy' : 'tip-free'}"></span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ymapsPromise: Promise<any> | null = null;
|
||||||
|
function loadYmaps(apiKey: string): Promise<any> {
|
||||||
|
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<void>;
|
||||||
|
invalidateSize(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Balloon (click) — richer, with PlanFix links.
|
||||||
|
function balloonBody(s: MapSurface): string {
|
||||||
|
const rows: string[] = [];
|
||||||
|
rows.push(`<div><b>${escapeHtml(s.board_id)}</b> · ${escapeHtml(s.dimension || '')} · ${escapeHtml(s.board_type || '')}</div>`);
|
||||||
|
rows.push(`<div>${escapeHtml(s.city || '')}, ${escapeHtml(s.address || '')}</div>`);
|
||||||
|
rows.push(
|
||||||
|
`<div style="margin:6px 0;font-weight:600;color:${s.occupied_now ? '#c0392b' : '#2e7d32'}">` +
|
||||||
|
(s.occupied_now ? 'Занята сейчас' : 'Свободна сейчас') +
|
||||||
|
'</div>',
|
||||||
|
);
|
||||||
|
if (s.bookings.length) {
|
||||||
|
rows.push('<div style="font-size:12px">Текущие размещения:</div>');
|
||||||
|
for (const b of s.bookings) {
|
||||||
|
const label = escapeHtml(b.brand || b.company_name || 'Без названия');
|
||||||
|
const link = `<a href="${PLANFIX_TASK_URL}${encodeURIComponent(b.task_id)}" target="_blank" rel="noopener">${escapeHtml(b.task_id)}</a>`;
|
||||||
|
rows.push(`<div style="font-size:12px">• ${label} (${escapeHtml(b.start_date)} — ${escapeHtml(b.end_date)}) · ${link}</div>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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(`<div class="tip-head">${dot(s.occupied_now)}${escapeHtml(s.board_id)} · ${escapeHtml(s.dimension || '')}</div>`);
|
||||||
|
parts.push(`<div class="tip-sub">${escapeHtml(s.city || '')}, ${escapeHtml(s.address || '')}</div>`);
|
||||||
|
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(`<div>${line}</div>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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(`<div class="tip-head">Поверхностей: ${list.length} (занято ${busy}, свободно ${list.length - busy})</div>`);
|
||||||
|
const LIMIT = 25;
|
||||||
|
for (const s of list.slice(0, LIMIT)) {
|
||||||
|
rows.push(`<div class="tip-row">${dot(s.occupied_now)}${escapeHtml(s.board_id)} · ${escapeHtml(s.dimension || '')}</div>`);
|
||||||
|
}
|
||||||
|
if (list.length > LIMIT) rows.push(`<div class="tip-more">…и ещё ${list.length - LIMIT}</div>`);
|
||||||
|
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<void> | 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<void> {
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -29,12 +29,14 @@ header {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
align-items: stretch;
|
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;
|
background: #fff;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header groups (four boxed blocks) */
|
/* Header groups (boxed blocks). By default they keep their natural width. */
|
||||||
.hgroup {
|
.hgroup {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -43,7 +45,14 @@ header {
|
|||||||
background: #f2f2f2;
|
background: #f2f2f2;
|
||||||
border: 1px solid #e2e2e2;
|
border: 1px solid #e2e2e2;
|
||||||
border-radius: 8px;
|
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; }
|
.hgroup-brand { background: #eaeaea; }
|
||||||
/* Fields group: top-align so the date input sits UNDER Город/Бренд. */
|
/* Fields group: top-align so the date input sits UNDER Город/Бренд. */
|
||||||
.hgroup-fields { align-items: flex-start; }
|
.hgroup-fields { align-items: flex-start; }
|
||||||
@ -53,7 +62,8 @@ input[type=date] {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
min-width: 180px;
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
/* Compact single-column layout: the three checkboxes stack in three rows so the
|
/* Compact single-column layout: the three checkboxes stack in three rows so the
|
||||||
@ -78,7 +88,8 @@ input[type=text] {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
font-size: 13px;
|
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
|
/* 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. */
|
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 { 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; }
|
.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; }
|
#chart-wrap { flex: 1 1 auto; min-height: 0; position: relative; overflow: hidden; }
|
||||||
#timeline { border: none; }
|
#timeline { border: none; }
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ export interface Meta {
|
|||||||
cities: string[];
|
cities: string[];
|
||||||
dimensions: string[];
|
dimensions: string[];
|
||||||
brands: string[];
|
brands: string[];
|
||||||
|
managers: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One row of GET /api/boards (grouped per board_id). */
|
/** One row of GET /api/boards (grouped per board_id). */
|
||||||
@ -33,10 +34,36 @@ export interface Filters {
|
|||||||
cities: string[];
|
cities: string[];
|
||||||
dimensions: string[];
|
dimensions: string[];
|
||||||
brands: string[];
|
brands: string[];
|
||||||
|
managers: string[];
|
||||||
search: string;
|
search: string;
|
||||||
dateStart: string; // YYYY-MM-DD or '' — start_date >= dateStart
|
dateStart: string; // YYYY-MM-DD or '' — start_date >= dateStart
|
||||||
dateEnd: string; // YYYY-MM-DD or '' — end_date <= dateEnd
|
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). */
|
/** task_status value that marks an archived booking (painted slate #708090). */
|
||||||
export const ARCHIVED_STATUS = 'РК. Архив';
|
export const ARCHIVED_STATUS = 'РК. Архив';
|
||||||
|
|||||||
103
frontend/src/viewmode.ts
Normal file
103
frontend/src/viewmode.ts
Normal file
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
119
main.py
119
main.py
@ -17,6 +17,9 @@ app = FastAPI()
|
|||||||
CITY_COND = "(length({cities:Array(String)}) = 0 OR city IN {cities:Array(String)})"
|
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)})"
|
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)})"
|
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_COND = (
|
||||||
"({search:String} = ''"
|
"({search:String} = ''"
|
||||||
" OR positionCaseInsensitive(address, {search:String}) > 0"
|
" OR positionCaseInsensitive(address, {search:String}) > 0"
|
||||||
@ -24,11 +27,28 @@ SEARCH_COND = (
|
|||||||
)
|
)
|
||||||
# Independent date bounds: start_date >= X ("Дата начала"), end_date <= Y
|
# Independent date bounds: start_date >= X ("Дата начала"), end_date <= Y
|
||||||
# ("Дата окончания"). Each included only when its value is provided.
|
# ("Дата окончания"). 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}"
|
START_COND = "pf_board.start_date >= {date_start:Date}"
|
||||||
END_COND = "pf_board.end_date <= {date_end: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
|
# Faceted WHERE: include only the requested facet conditions. For the
|
||||||
# dependent-filter option lists we exclude a facet's own condition so its
|
# dependent-filter option lists we exclude a facet's own condition so its
|
||||||
# available values reflect the OTHER filters (all-but-self).
|
# 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)
|
parts.append(DIM_COND)
|
||||||
if brand:
|
if brand:
|
||||||
parts.append(BRAND_COND)
|
parts.append(BRAND_COND)
|
||||||
|
if manager:
|
||||||
|
parts.append(MANAGER_COND)
|
||||||
if search:
|
if search:
|
||||||
parts.append(SEARCH_COND)
|
parts.append(SEARCH_COND)
|
||||||
if date_start:
|
if date_start:
|
||||||
@ -65,14 +87,16 @@ async def ch_query(sql: str, params: dict):
|
|||||||
return [json.loads(line) for line in text.splitlines()]
|
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 []
|
cities = [c for c in city.split(",") if c] if city else []
|
||||||
dimensions = [d for d in dimension.split(",") if d] if dimension 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 []
|
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 {
|
return {
|
||||||
"cities": cities,
|
"cities": cities,
|
||||||
"dimensions": dimensions,
|
"dimensions": dimensions,
|
||||||
"brands": brands,
|
"brands": brands,
|
||||||
|
"managers": managers,
|
||||||
"search": search or "",
|
"search": search or "",
|
||||||
"date_start": date_start or "",
|
"date_start": date_start or "",
|
||||||
"date_end": date_end 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")
|
@app.get("/api/meta")
|
||||||
async def meta(city: str = "", dimension: str = "", brand: str = "", search: str = "", date_start: str = "", date_end: str = ""):
|
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, search, date_start, date_end)
|
params = parse_filters(city, dimension, brand, manager, search, date_start, date_end)
|
||||||
hds = bool(params["date_start"])
|
hds = bool(params["date_start"])
|
||||||
hde = bool(params["date_end"])
|
hde = bool(params["date_end"])
|
||||||
cities = await ch_query(
|
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,
|
params,
|
||||||
)
|
)
|
||||||
brands = await ch_query(
|
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,
|
params,
|
||||||
)
|
)
|
||||||
dimensions = await ch_query(
|
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,
|
params,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"cities": [r["city"] for r in cities],
|
"cities": [r["city"] for r in cities],
|
||||||
"dimensions": [r["dimension"] for r in dimensions],
|
"dimensions": [r["dimension"] for r in dimensions],
|
||||||
"brands": [r["brand"] for r in brands],
|
"brands": [r["brand"] for r in brands],
|
||||||
|
"managers": [r["manager"] for r in managers],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/boards")
|
@app.get("/api/boards")
|
||||||
async def boards(city: str = "", dimension: str = "", brand: str = "", search: str = "", date_start: str = "", date_end: str = ""):
|
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, search, date_start, date_end)
|
params = parse_filters(city, dimension, brand, manager, 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"]))
|
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"""
|
sql = f"""
|
||||||
SELECT
|
SELECT
|
||||||
board_id,
|
board_id,
|
||||||
@ -123,9 +153,9 @@ async def boards(city: str = "", dimension: str = "", brand: str = "", search: s
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/bookings")
|
@app.get("/api/bookings")
|
||||||
async def bookings(city: str = "", dimension: str = "", brand: str = "", search: str = "", date_start: str = "", date_end: str = ""):
|
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, search, date_start, date_end)
|
params = parse_filters(city, dimension, brand, manager, 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"]))
|
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"""
|
sql = f"""
|
||||||
SELECT
|
SELECT
|
||||||
board_id,
|
board_id,
|
||||||
@ -143,4 +173,67 @@ async def bookings(city: str = "", dimension: str = "", brand: str = "", search:
|
|||||||
return rows
|
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")
|
app.mount("/", StaticFiles(directory="static", html=True), name="static")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user