frontend: full modular migration (collisions/dropdown/timeline/styles), staged at /next/, prod unchanged
This commit is contained in:
parent
c6363cd8cf
commit
6704a50b33
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,3 +3,4 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
static/next/
|
||||||
|
|||||||
@ -3,10 +3,80 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>mapdash — frontend scaffold</title>
|
<title>Адресная программа</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
42
frontend/src/collisions.ts
Normal file
42
frontend/src/collisions.ts
Normal 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
103
frontend/src/dropdown.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,23 +1,100 @@
|
|||||||
// Scaffold entry point. NOT wired into production yet — the live UI is still
|
import './styles.css';
|
||||||
// served from /opt/mapdash/static/index.html. This only exercises the typed
|
import 'vis-timeline/styles/vis-timeline-graph2d.min.css';
|
||||||
// api/types modules so the toolchain (tsc + vite) has a real entry to compile.
|
|
||||||
import { api } from './api';
|
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 meta = await api.meta();
|
||||||
const [boards, bookings] = await Promise.all([
|
cityDropdown.setValues(meta.cities, 'Все города');
|
||||||
api.boards(NO_FILTERS),
|
dimensionDropdown.setValues(meta.dimensions, 'Все размеры');
|
||||||
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}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
224
frontend/src/styles.css
Normal 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
314
frontend/src/timeline.ts
Normal 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
13
frontend/src/util.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
// Small shared helpers.
|
||||||
|
|
||||||
|
const ESCAPE_MAP: Record<string, string> = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
};
|
||||||
|
|
||||||
|
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
1
frontend/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@ -1,10 +1,12 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
// Scaffold config: build output goes to frontend/dist for now (does NOT touch the
|
// Build output goes to frontend/dist; a deploy step copies it to the served
|
||||||
// live /opt/mapdash/static). In the full-migration phase we switch outDir to
|
// location (static/ for prod, static/next/ for staging).
|
||||||
// '../static' so `vite build` produces the served bundle directly.
|
// - 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({
|
export default defineConfig({
|
||||||
root: '.',
|
root: '.',
|
||||||
|
base: process.env.VITE_BASE || '/',
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user