From 6704a50b33b9b1868c9cbe6c7c0328dcc3c3f453 Mon Sep 17 00:00:00 2001 From: aaverbitskiy Date: Fri, 17 Jul 2026 16:17:00 +0000 Subject: [PATCH] frontend: full modular migration (collisions/dropdown/timeline/styles), staged at /next/, prod unchanged --- .gitignore | 1 + frontend/index.html | 76 ++++++++- frontend/src/collisions.ts | 42 +++++ frontend/src/dropdown.ts | 103 ++++++++++++ frontend/src/main.ts | 113 ++++++++++--- frontend/src/styles.css | 224 ++++++++++++++++++++++++++ frontend/src/timeline.ts | 314 +++++++++++++++++++++++++++++++++++++ frontend/src/util.ts | 13 ++ frontend/src/vite-env.d.ts | 1 + frontend/vite.config.ts | 8 +- 10 files changed, 871 insertions(+), 24 deletions(-) create mode 100644 frontend/src/collisions.ts create mode 100644 frontend/src/dropdown.ts create mode 100644 frontend/src/styles.css create mode 100644 frontend/src/timeline.ts create mode 100644 frontend/src/util.ts create mode 100644 frontend/src/vite-env.d.ts diff --git a/.gitignore b/.gitignore index fc6b980..7eb321b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ __pycache__/ node_modules/ dist/ +static/next/ diff --git a/frontend/index.html b/frontend/index.html index 0cb2e60..249357d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,10 +3,80 @@ - mapdash — frontend scaffold + Адресная программа -
- + +
+
+
Green Media
+
Адресная программа
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+ +
+
+ +
+
+
+ +
+ +
+ + diff --git a/frontend/src/collisions.ts b/frontend/src/collisions.ts new file mode 100644 index 0000000..44ecac9 --- /dev/null +++ b/frontend/src/collisions.ts @@ -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 { + const byBoard = new Map(); + 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(); + 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; +} diff --git a/frontend/src/dropdown.ts b/frontend/src/dropdown.ts new file mode 100644 index 0000000..ff7ca24 --- /dev/null +++ b/frontend/src/dropdown.ts @@ -0,0 +1,103 @@ +// Reusable checkbox dropdown filter (multi-select) — ported from the original +// createDropdown(). Expects markup: #-dropdown, #-btn, #-btn-text, +// #-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(); + 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('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; + }, + }; +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 6b495b7..b7484be 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -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 { - 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}`; - } +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, + }; } -void bootstrap(); +function currentFilters(): Filters { + return { + cities: cityDropdown.getSelected(), + dimensions: dimensionDropdown.getSelected(), + search: el.search.value.trim(), + }; +} + +async function loadMeta(): Promise { + const meta = await api.meta(); + cityDropdown.setValues(meta.cities, 'Все города'); + dimensionDropdown.setValues(meta.dimensions, 'Все размеры'); +} + +async function loadData(fit: boolean): Promise { + 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 { + 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(); diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..8e7411b --- /dev/null +++ b/frontend/src/styles.css @@ -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; +} diff --git a/frontend/src/timeline.ts b/frontend/src/timeline.ts new file mode 100644 index 0000000..3ce91c5 --- /dev/null +++ b/frontend/src/timeline.ts @@ -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(); + const itemsDS = new DataSet(); + 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('.vis-labelset'); + const axisEl = el.timelineEl.querySelector('.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('.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('#timeline .vis-label'); + const rows = document.querySelectorAll('#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(); + + 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 }; +} diff --git a/frontend/src/util.ts b/frontend/src/util.ts new file mode 100644 index 0000000..c5501d6 --- /dev/null +++ b/frontend/src/util.ts @@ -0,0 +1,13 @@ +// Small shared helpers. + +const ESCAPE_MAP: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +}; + +export function escapeHtml(str: string): string { + return String(str).replace(/[&<>"']/g, (c) => ESCAPE_MAP[c] ?? c); +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 0ac5b0e..ff8f142 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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,