frontend: full modular migration (collisions/dropdown/timeline/styles), staged at /next/, prod unchanged

This commit is contained in:
aaverbitskiy 2026-07-17 16:17:00 +00:00
parent c6363cd8cf
commit 6704a50b33
10 changed files with 871 additions and 24 deletions

1
.gitignore vendored
View File

@ -3,3 +3,4 @@
__pycache__/
node_modules/
dist/
static/next/

View File

@ -3,10 +3,80 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>mapdash — frontend scaffold</title>
<title>Адресная программа</title>
</head>
<body>
<div id="app"></div>
<header>
<div class="brand">
<div class="brand-name">Green Media</div>
<div class="brand-sub">Адресная программа</div>
</div>
<div class="field">
<label>Город</label>
<div class="dropdown" id="city-dropdown">
<button type="button" class="dropdown-btn" id="city-btn">
<span id="city-btn-text">Все города</span>
<span class="caret"></span>
</button>
<div class="dropdown-panel" id="city-panel"></div>
</div>
</div>
<div class="field">
<label>Размер</label>
<div class="dropdown" id="dimension-dropdown">
<button type="button" class="dropdown-btn" id="dimension-btn">
<span id="dimension-btn-text">Все размеры</span>
<span class="caret"></span>
</button>
<div class="dropdown-panel" id="dimension-panel"></div>
</div>
</div>
<div class="field">
<label for="search">Поиск (адрес / код)</label>
<input type="text" id="search" placeholder="например, Азина" />
</div>
<div class="field checkbox-field">
<label for="show-brand">
<input type="checkbox" id="show-brand" checked />
Бренд
</label>
</div>
<div class="field checkbox-field">
<label for="show-company">
<input type="checkbox" id="show-company" />
Компания
</label>
</div>
<div class="field checkbox-field">
<label for="show-collisions">
<input type="checkbox" id="show-collisions" checked />
Коллизии размещения
</label>
</div>
<div class="field zoom-field">
<label for="zoom-slider">Масштаб: <span id="zoom-label">12 месяцев</span></label>
<input type="range" id="zoom-slider" min="0" max="9" step="1" value="3" />
</div>
<div id="status"></div>
</header>
<div id="chart-wrap">
<div id="timeline"></div>
<div id="col-resizer" title="Потяните, чтобы изменить ширину колонки"></div>
<div id="empty" style="display:none">Нет данных по выбранным фильтрам</div>
</div>
<div id="tooltip"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@ -0,0 +1,42 @@
// Date-collision detection between bookings on the same board.
import type { Booking } from './types';
// Two bookings collide only if their date ranges genuinely overlap by at least
// one day. A booking ending the same day another starts is a normal same-day
// handoff, not a collision (strict inequality on both sides excludes that exact
// boundary touch). Known limitation: two bookings that are BOTH a single,
// identical day (start === end for both) are not flagged — an edge case not
// expected for month-long board bookings.
export function datesOverlap(a: Booking, b: Booking): boolean {
const aStart = new Date(a.start_date + 'T00:00:00Z').getTime();
const aEnd = new Date(a.end_date + 'T00:00:00Z').getTime();
const bStart = new Date(b.start_date + 'T00:00:00Z').getTime();
const bEnd = new Date(b.end_date + 'T00:00:00Z').getTime();
return aStart < bEnd && bStart < aEnd;
}
// Map from booking index -> array of colliding partner indices. Comparison is
// scoped per board_id and only over the bookings array passed in (already
// filtered by city/dimension/search), matching what's visible on screen.
export function computeCollisions(bookings: Booking[]): Map<number, number[]> {
const byBoard = new Map<string, number[]>();
bookings.forEach((bk, idx) => {
const arr = byBoard.get(bk.board_id);
if (arr) arr.push(idx);
else byBoard.set(bk.board_id, [idx]);
});
const partners = new Map<number, number[]>();
for (const idxs of byBoard.values()) {
for (let i = 0; i < idxs.length; i++) {
for (let j = i + 1; j < idxs.length; j++) {
const ia = idxs[i]!;
const ib = idxs[j]!;
if (datesOverlap(bookings[ia]!, bookings[ib]!)) {
(partners.get(ia) ?? partners.set(ia, []).get(ia)!).push(ib);
(partners.get(ib) ?? partners.set(ib, []).get(ib)!).push(ia);
}
}
}
}
return partners;
}

103
frontend/src/dropdown.ts Normal file
View File

@ -0,0 +1,103 @@
// Reusable checkbox dropdown filter (multi-select) — ported from the original
// createDropdown(). Expects markup: #<id>-dropdown, #<id>-btn, #<id>-btn-text,
// #<id>-panel already present in the DOM.
export interface Dropdown {
setValues(values: string[], label: string): void;
getSelected(): string[];
onChange(fn: () => void): void;
}
export function createDropdown(id: string): Dropdown {
const root = document.getElementById(id + '-dropdown') as HTMLElement;
const btn = document.getElementById(id + '-btn') as HTMLElement;
const btnText = document.getElementById(id + '-btn-text') as HTMLElement;
const panel = document.getElementById(id + '-panel') as HTMLElement;
let allValues: string[] = [];
let selected = new Set<string>();
let defaultLabel = '';
let onChange: () => void = () => {};
function updateBtnText(): void {
if (selected.size === 0) btnText.textContent = defaultLabel;
else if (selected.size === allValues.length) btnText.textContent = 'Все (' + allValues.length + ')';
else if (selected.size <= 2) btnText.textContent = Array.from(selected).join(', ');
else btnText.textContent = 'Выбрано: ' + selected.size;
}
function syncCheckboxes(): void {
panel.querySelectorAll<HTMLInputElement>('input[type=checkbox]').forEach((cb) => {
cb.checked = selected.has(cb.value);
});
}
function renderPanel(): void {
panel.innerHTML = '';
const actions = document.createElement('div');
actions.className = 'dp-actions';
const selAll = document.createElement('a');
selAll.textContent = 'Выбрать все';
selAll.addEventListener('click', () => {
selected = new Set(allValues);
syncCheckboxes();
updateBtnText();
onChange();
});
const selNone = document.createElement('a');
selNone.textContent = 'Сбросить';
selNone.addEventListener('click', () => {
selected.clear();
syncCheckboxes();
updateBtnText();
onChange();
});
actions.appendChild(selAll);
actions.appendChild(selNone);
panel.appendChild(actions);
for (const v of allValues) {
const label = document.createElement('label');
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.value = v;
cb.checked = selected.has(v);
cb.addEventListener('change', () => {
if (cb.checked) selected.add(v);
else selected.delete(v);
updateBtnText();
onChange();
});
label.appendChild(cb);
label.appendChild(document.createTextNode(v));
panel.appendChild(label);
}
}
btn.addEventListener('click', (e) => {
e.stopPropagation();
document.querySelectorAll('.dropdown.open').forEach((d) => {
if (d !== root) d.classList.remove('open');
});
root.classList.toggle('open');
});
document.addEventListener('click', (e) => {
if (!root.contains(e.target as Node)) root.classList.remove('open');
});
return {
setValues(values: string[], label: string): void {
allValues = values;
defaultLabel = label;
selected = new Set();
updateBtnText();
renderPanel();
},
getSelected(): string[] {
return Array.from(selected);
},
onChange(fn: () => void): void {
onChange = fn;
},
};
}

View File

@ -1,23 +1,100 @@
// Scaffold entry point. NOT wired into production yet — the live UI is still
// served from /opt/mapdash/static/index.html. This only exercises the typed
// api/types modules so the toolchain (tsc + vite) has a real entry to compile.
import './styles.css';
import 'vis-timeline/styles/vis-timeline-graph2d.min.css';
import { api } from './api';
import type { Filters } from './types';
import type { Board, Booking, Filters } from './types';
import { createDropdown } from './dropdown';
import { createTimelineView, zoomSteps, type RenderFlags } from './timeline';
const NO_FILTERS: Filters = { cities: [], dimensions: [], search: '' };
const el = {
search: document.getElementById('search') 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,
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,
};
async function bootstrap(): Promise<void> {
const cityDropdown = createDropdown('city');
const dimensionDropdown = createDropdown('dimension');
const view = createTimelineView({
timelineEl: el.timelineEl,
chartWrap: el.chartWrap,
tooltip: el.tooltip,
empty: el.empty,
colResizer: el.colResizer,
});
let lastBoards: Board[] = [];
let lastBookings: Booking[] = [];
function flags(): RenderFlags {
return {
withBrand: el.showBrand.checked,
withCompany: el.showCompany.checked,
withCollisions: el.showCollisions.checked,
};
}
function currentFilters(): Filters {
return {
cities: cityDropdown.getSelected(),
dimensions: dimensionDropdown.getSelected(),
search: el.search.value.trim(),
};
}
async function loadMeta(): Promise<void> {
const meta = await api.meta();
const [boards, bookings] = await Promise.all([
api.boards(NO_FILTERS),
api.bookings(NO_FILTERS),
]);
const app = document.getElementById('app');
if (app) {
app.textContent =
`meta: ${meta.cities.length} cities / ${meta.dimensions.length} dimensions; ` +
`boards: ${boards.length}; bookings: ${bookings.length}`;
}
cityDropdown.setValues(meta.cities, 'Все города');
dimensionDropdown.setValues(meta.dimensions, 'Все размеры');
}
void bootstrap();
async function loadData(fit: boolean): Promise<void> {
el.status.textContent = 'Загрузка…';
const f = currentFilters();
const [boards, bookings] = await Promise.all([api.boards(f), api.bookings(f)]);
lastBoards = boards;
lastBookings = bookings;
view.render(boards, bookings, fit, flags());
el.status.textContent = `Бордов: ${boards.length}, бронирований: ${bookings.length}`;
}
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()));
}
cityDropdown.onChange(scheduleReload);
dimensionDropdown.onChange(scheduleReload);
el.search.addEventListener('input', scheduleReload);
el.zoomSlider.addEventListener('input', () => {
const step = zoomSteps[parseInt(el.zoomSlider.value, 10)]!;
el.zoomLabel.textContent = step.label;
view.setScale(step.days);
});
async function init(): Promise<void> {
await loadMeta();
await loadData(true);
// Default zoom on first load: 12 months (index 3), applied after the initial fit.
const defaultZoomIdx = 3;
el.zoomSlider.value = String(defaultZoomIdx);
el.zoomLabel.textContent = zoomSteps[defaultZoomIdx]!.label;
view.setScale(zoomSteps[defaultZoomIdx]!.days);
}
void init();

224
frontend/src/styles.css Normal file
View File

@ -0,0 +1,224 @@
:root {
--bar-color: #4169E1;
--grid-line: #e2e2e2;
--label-w: 340px;
--row-even: #f0f0f0;
--row-odd: #FFFFFF;
--row-hover: #FFECB3;
--month-hover: rgba(65, 105, 225, 0.10);
}
* { box-sizing: border-box; }
html, body {
height: 100%;
margin: 0;
overflow: hidden;
}
body {
display: flex;
flex-direction: column;
font-family: -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
background: #fff;
color: #222;
}
header {
flex: 0 0 auto;
padding: 14px 20px;
border-bottom: 1px solid #e2e2e2;
display: flex;
gap: 18px;
align-items: center;
flex-wrap: wrap;
background: #fff;
z-index: 20;
}
.brand {
display: flex;
flex-direction: column;
justify-content: center;
line-height: 1.2;
margin-right: 8px;
}
.brand-name { font-size: 18px; font-weight: 700; white-space: nowrap; }
.brand-sub { font-size: 15px; font-weight: 400; color: #666; white-space: nowrap; }
.field { display: flex; flex-direction: column; gap: 4px; }
.field label { font-size: 11px; color: #666; text-transform: uppercase; letter-spacing: .03em; }
input[type=text] {
border: 1px solid #ccc;
border-radius: 4px;
padding: 6px 8px;
font-size: 13px;
min-width: 180px;
}
#status { font-size: 12px; color: #888; margin-left: auto; align-self: center; }
.checkbox-field { flex-direction: row; align-items: center; gap: 6px; align-self: center; }
.checkbox-field label {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #222;
text-transform: none;
letter-spacing: normal;
cursor: pointer;
white-space: nowrap;
}
.checkbox-field input[type=checkbox] { cursor: pointer; }
.zoom-field input[type=range] { width: 150px; cursor: pointer; }
.zoom-field label { white-space: nowrap; }
/* checkbox dropdown filter */
.dropdown { position: relative; }
.dropdown-btn {
border: 1px solid #ccc;
border-radius: 4px;
background: #fff;
padding: 6px 10px;
font-size: 13px;
min-width: 180px;
text-align: left;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.dropdown-btn:hover { border-color: #999; }
.dropdown-btn .caret { font-size: 10px; color: #888; }
.dropdown-panel {
display: none;
position: absolute;
top: calc(100% + 4px);
left: 0;
background: #fff;
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: 0 4px 14px rgba(0,0,0,.12);
padding: 6px 0;
min-width: 220px;
max-height: 280px;
overflow-y: auto;
z-index: 30;
}
.dropdown.open .dropdown-panel { display: block; }
.dropdown-panel label {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 12px;
font-size: 13px;
cursor: pointer;
white-space: nowrap;
}
.dropdown-panel label:hover { background: #f4f4f4; }
.dropdown-panel .dp-actions {
display: flex;
gap: 10px;
padding: 4px 12px 8px;
border-bottom: 1px solid #eee;
margin-bottom: 4px;
}
.dropdown-panel .dp-actions a {
font-size: 11px;
color: #567;
cursor: pointer;
text-decoration: underline;
}
#chart-wrap {
flex: 1 1 auto;
min-height: 0;
position: relative;
overflow: hidden;
}
#timeline { border: none; }
/* vis-timeline overrides */
.vis-item.vis-range {
background-color: var(--bar-color);
border-color: #2c50b0;
border-radius: 3px;
}
.vis-item.vis-range:hover { filter: brightness(0.92); }
/* Date-collision highlight (purple). Two-class specificity wins over plain range. */
.vis-item.vis-range.collision {
background-color: #BA55D3;
border-color: #9932CC;
}
/* Archived bookings (task_status === "РК. Архив") — slate. */
.vis-item.vis-range.archived {
background-color: #708090;
border-color: #5a6472;
}
.vis-item .vis-item-content { font-size: 11px; color: #FFFFFF; padding: 2px 6px; line-height: 1.3; white-space: nowrap; }
.vis-labelset .vis-label { font-size: 12px; }
/* Zebra striping for board rows. */
.vis-label.row-even,
.vis-group.row-even { background-color: var(--row-even); }
.vis-label.row-odd,
.vis-group.row-odd { background-color: var(--row-odd); }
/* Row hover highlight (added via DOM classes). */
.vis-label.row-hover,
.vis-group.row-hover { background-color: var(--row-hover) !important; }
/* Fixed/resizable label column: pin content width to --label-w so the vis left
panel follows it exactly (both shrinking and growing beyond the longest label). */
.vis-label .vis-inner {
display: inline-block;
min-width: var(--label-w);
max-width: var(--label-w);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
/* Drag handle to resize the address/code column. */
#col-resizer {
position: absolute;
top: 0;
bottom: 0;
width: 9px;
margin-left: -4px;
cursor: col-resize;
z-index: 25;
display: none;
}
#col-resizer::after {
content: "";
position: absolute;
top: 0; bottom: 0; left: 4px;
width: 1px;
background: #ccc;
}
#col-resizer:hover::after,
#col-resizer.dragging::after {
background: var(--bar-color);
width: 2px;
left: 3px;
}
/* Hovered-month column highlight. */
.vis-item.vis-background.month-hover-bg { background-color: var(--month-hover); }
#tooltip {
position: fixed;
display: none;
background: #222;
color: #fff;
padding: 6px 10px;
border-radius: 4px;
font-size: 12px;
pointer-events: none;
z-index: 100;
max-width: 320px;
line-height: 1.4;
white-space: pre-line;
}
#empty {
padding: 40px;
text-align: center;
color: #999;
font-size: 13px;
}

314
frontend/src/timeline.ts Normal file
View File

@ -0,0 +1,314 @@
// Timeline view: wraps vis-timeline (groups = boards, range items = bookings).
// Ported 1:1 from the original static/index.html logic, split into a module.
import { Timeline, DataSet } from 'vis-timeline/standalone';
import type { TimelineOptions } from 'vis-timeline/standalone';
import { escapeHtml } from './util';
import { computeCollisions } from './collisions';
import { ARCHIVED_STATUS } from './types';
import type { Board, Booking } from './types';
export interface RenderFlags {
withBrand: boolean;
withCompany: boolean;
withCollisions: boolean;
}
export interface TimelineElements {
timelineEl: HTMLElement;
chartWrap: HTMLElement;
tooltip: HTMLElement;
empty: HTMLElement;
colResizer: HTMLElement;
}
// Discrete zoom steps for the header slider, one per 3-month increment from 3
// to 30 months. Index 3 (12 months) is the default on load.
export const zoomSteps: ReadonlyArray<{ days: number; label: string }> = [
{ days: 91, label: '3 месяца' },
{ days: 183, label: '6 месяцев' },
{ days: 274, label: '9 месяцев' },
{ days: 365, label: '12 месяцев' },
{ days: 457, label: '15 месяцев' },
{ days: 548, label: '18 месяцев' },
{ days: 639, label: '21 месяц' },
{ days: 731, label: '24 месяца' },
{ days: 822, label: '27 месяцев' },
{ days: 913, label: '30 месяцев' },
];
const MONTH_BG_ID = '__month_hover_bg';
const MIN_LABEL_W = 140;
const MAX_LABEL_W = 760;
export interface TimelineView {
render(boards: Board[], bookings: Booking[], fit: boolean, flags: RenderFlags): void;
setScale(days: number): void;
}
export function createTimelineView(el: TimelineElements): TimelineView {
const groupsDS = new DataSet<any>();
const itemsDS = new DataSet<any>();
let lastBoards: Board[] = [];
const options: TimelineOptions = {
editable: false,
selectable: false,
stack: true,
zoomable: false,
moveable: true,
verticalScroll: true,
zoomMin: zoomSteps[0]!.days * 86400000,
zoomMax: zoomSteps[zoomSteps.length - 1]!.days * 86400000,
groupHeightMode: 'fixed',
margin: { item: { horizontal: 2, vertical: 6 } },
orientation: { axis: 'top' },
tooltip: { followMouse: false, delay: 0 },
locale: 'ru',
};
const timeline = new Timeline(el.timelineEl, itemsDS, groupsDS, options);
// ---- dynamic height / scroll fix: push an explicit pixel height computed
// from the real rendered DOM, recomputed on every redraw and window resize.
let lastSetHeightPx: number | null = null;
function fitTimelineHeight(): void {
const available = el.chartWrap.clientHeight;
if (!available) return;
const labelSet = el.timelineEl.querySelector<HTMLElement>('.vis-labelset');
const axisEl = el.timelineEl.querySelector<HTMLElement>('.vis-panel.vis-top');
const groupCount = groupsDS.length;
const axisHeight = axisEl ? axisEl.offsetHeight : 0;
const contentHeight = labelSet ? axisHeight + labelSet.scrollHeight : 0;
if (!labelSet && groupCount > 0) return;
const target = groupCount === 0 ? available : Math.min(contentHeight || available, available);
const px = Math.max(1, Math.round(target));
if (px === lastSetHeightPx) return;
lastSetHeightPx = px;
timeline.setOptions({ height: px + 'px' });
}
let heightFitQueued = false;
function scheduleHeightFit(): void {
if (heightFitQueued) return;
heightFitQueued = true;
requestAnimationFrame(() => {
heightFitQueued = false;
fitTimelineHeight();
});
}
timeline.on('changed', scheduleHeightFit);
window.addEventListener('resize', scheduleHeightFit);
scheduleHeightFit();
// ---- draggable label-column width (drives --label-w; vis panel follows it).
function currentLabelW(): number {
const v = getComputedStyle(document.documentElement).getPropertyValue('--label-w');
return parseInt(v, 10) || 340;
}
function positionResizer(): void {
const leftPanel = el.timelineEl.querySelector<HTMLElement>('.vis-panel.vis-left');
if (!leftPanel || el.timelineEl.style.display === 'none') {
el.colResizer.style.display = 'none';
return;
}
const wrapRect = el.chartWrap.getBoundingClientRect();
const panelRect = leftPanel.getBoundingClientRect();
el.colResizer.style.left = panelRect.right - wrapRect.left + 'px';
el.colResizer.style.display = 'block';
}
timeline.on('changed', positionResizer);
window.addEventListener('resize', positionResizer);
let colDragging = false;
let colDragStartX = 0;
let colDragStartW = 0;
let colDragLastX = 0;
let colDragRafPending = false;
function applyColDrag(): void {
colDragRafPending = false;
let w = colDragStartW + (colDragLastX - colDragStartX);
w = Math.max(MIN_LABEL_W, Math.min(MAX_LABEL_W, w));
document.documentElement.style.setProperty('--label-w', w + 'px');
timeline.redraw();
positionResizer();
}
el.colResizer.addEventListener('mousedown', (e) => {
colDragging = true;
colDragStartX = e.clientX;
colDragStartW = currentLabelW();
el.colResizer.classList.add('dragging');
document.body.style.userSelect = 'none';
document.body.style.cursor = 'col-resize';
e.preventDefault();
});
window.addEventListener('mousemove', (e) => {
if (!colDragging) return;
colDragLastX = e.clientX;
if (colDragRafPending) return;
colDragRafPending = true;
requestAnimationFrame(applyColDrag);
});
window.addEventListener('mouseup', () => {
if (!colDragging) return;
colDragging = false;
el.colResizer.classList.remove('dragging');
document.body.style.userSelect = '';
document.body.style.cursor = '';
scheduleHeightFit();
});
// ---- tooltip following the cursor.
let hoveredItemId: string | number | null = null;
timeline.on('itemover', (props: any) => {
hoveredItemId = props.item;
const item = itemsDS.get(props.item) as any;
if (item) {
el.tooltip.textContent = item.tooltipText;
el.tooltip.style.display = 'block';
}
});
timeline.on('itemout', () => {
hoveredItemId = null;
el.tooltip.style.display = 'none';
});
document.addEventListener('mousemove', (e) => {
if (hoveredItemId !== null) {
el.tooltip.style.left = e.clientX + 12 + 'px';
el.tooltip.style.top = e.clientY + 12 + 'px';
}
});
// ---- row hover highlight (DOM class toggling, no DataSet churn).
let hoveredRowIdx = -1;
function setRowHover(idx: number): void {
if (idx === hoveredRowIdx) return;
const labels = document.querySelectorAll<HTMLElement>('#timeline .vis-label');
const rows = document.querySelectorAll<HTMLElement>('#timeline .vis-group');
if (hoveredRowIdx >= 0) {
labels[hoveredRowIdx]?.classList.remove('row-hover');
rows[hoveredRowIdx]?.classList.remove('row-hover');
}
if (idx >= 0) {
labels[idx]?.classList.add('row-hover');
rows[idx]?.classList.add('row-hover');
}
hoveredRowIdx = idx;
}
// ---- hovered-month column highlight (single background item, month-snapped).
let lastMonthKey: string | null = null;
timeline.on('mouseMove', (props: any) => {
if (props.time) {
const t: Date = props.time;
const y = t.getUTCFullYear();
const m = t.getUTCMonth();
const key = y + '-' + m;
if (key !== lastMonthKey) {
lastMonthKey = key;
const monthStart = new Date(Date.UTC(y, m, 1));
const monthEnd = new Date(Date.UTC(y, m + 1, 1));
itemsDS.update([
{ id: MONTH_BG_ID, type: 'background', start: monthStart, end: monthEnd, className: 'month-hover-bg' },
]);
}
}
if (props.group !== null && props.group !== undefined) {
const idx = lastBoards.findIndex((b) => b.board_id === props.group);
setRowHover(idx);
} else {
setRowHover(-1);
}
});
el.timelineEl.addEventListener('mouseleave', () => {
lastMonthKey = null;
itemsDS.remove(MONTH_BG_ID);
setRowHover(-1);
});
function render(boards: Board[], bookings: Booking[], fit: boolean, flags: RenderFlags): void {
lastBoards = boards;
if (boards.length === 0) {
groupsDS.clear();
itemsDS.clear();
el.timelineEl.style.display = 'none';
el.empty.style.display = 'block';
return;
}
el.timelineEl.style.display = '';
el.empty.style.display = 'none';
const collisionPartners = flags.withCollisions ? computeCollisions(bookings) : new Map<number, number[]>();
const groups = boards.map((b, idx) => ({
id: b.board_id,
content: escapeHtml(b.board_address) + ' (' + escapeHtml(b.board_id) + ')',
order: idx,
className: idx % 2 === 0 ? 'row-even' : 'row-odd',
}));
const items = bookings.map((bk, idx) => {
const end = new Date(bk.end_date + 'T00:00:00Z');
end.setUTCDate(end.getUTCDate() + 1); // exclusive end so the last day shows fully
const brand = bk.brand || '';
const company = bk.company_name || '';
const barParts: string[] = [];
if (flags.withBrand && brand) barParts.push(escapeHtml(brand));
if (flags.withCompany && company) barParts.push(escapeHtml(company));
const barContent = barParts.join(' | ');
// Tooltip: task_id / brand / company / start — end / task_status, then collisions.
const tooltipLines: string[] = [];
if (bk.task_id) tooltipLines.push(bk.task_id);
if (brand) tooltipLines.push(brand);
if (company) tooltipLines.push(company);
tooltipLines.push(bk.start_date + ' — ' + bk.end_date);
if (bk.task_status) tooltipLines.push(bk.task_status);
const partnerIdxs = collisionPartners.get(idx);
const isCollision = !!(partnerIdxs && partnerIdxs.length);
if (isCollision) {
tooltipLines.push('⚠ Коллизия с:');
for (const pIdx of partnerIdxs!) {
const p = bookings[pIdx]!;
const pLabel = p.brand || p.company_name || 'Без названия';
tooltipLines.push(' ' + pLabel + ' ' + p.start_date + ' — ' + p.end_date);
}
}
const item: any = {
id: bk.board_id + '__' + idx,
group: bk.board_id,
start: bk.start_date,
end: end.toISOString().slice(0, 10),
content: barContent,
type: 'range',
tooltipText: tooltipLines.join('\n'),
};
// Colour priority: collision (purple) > archived (slate) > normal (blue).
const isArchived = bk.task_status === ARCHIVED_STATUS;
if (isCollision) item.className = 'collision';
else if (isArchived) item.className = 'archived';
return item;
});
groupsDS.clear();
groupsDS.add(groups);
itemsDS.clear();
itemsDS.add(items);
lastMonthKey = null;
if (fit) timeline.fit({ animation: false });
scheduleHeightFit();
}
function setScale(days: number): void {
const range = timeline.getWindow();
const center = (range.start.getTime() + range.end.getTime()) / 2;
const half = (days * 86400000) / 2;
timeline.setWindow(new Date(center - half), new Date(center + half));
}
return { render, setScale };
}

13
frontend/src/util.ts Normal file
View File

@ -0,0 +1,13 @@
// Small shared helpers.
const ESCAPE_MAP: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
export function escapeHtml(str: string): string {
return String(str).replace(/[&<>"']/g, (c) => ESCAPE_MAP[c] ?? c);
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -1,10 +1,12 @@
import { defineConfig } from 'vite';
// Scaffold config: build output goes to frontend/dist for now (does NOT touch the
// live /opt/mapdash/static). In the full-migration phase we switch outDir to
// '../static' so `vite build` produces the served bundle directly.
// Build output goes to frontend/dist; a deploy step copies it to the served
// location (static/ for prod, static/next/ for staging).
// - base defaults to '/' (production, served at map.intense-sale.ru/)
// - VITE_BASE=/next/ npm run build → staging, served at map.intense-sale.ru/next/
export default defineConfig({
root: '.',
base: process.env.VITE_BASE || '/',
build: {
outDir: 'dist',
emptyOutDir: true,