From 4425bd394c12389bfb7a9e39576aab7003b215de Mon Sep 17 00:00:00 2001 From: aaverbitskiy Date: Mon, 10 Aug 2026 18:08:45 +0000 Subject: [PATCH] features: shareable filter links, board detail panel, spacing rhythm on tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Shareable filter links: current filters sync to the URL query (?city=&brand=&dimension=&manager=&status=&q=&from=&to=, repeated params for multi-selects) and are restored on load; a "Скопировать ссылку" action in the chips row copies the current URL. - Board detail panel: clicking a timeline row label opens a right-side panel with the surface's code, city · size, address and the full list of its bookings (brand/company, dates, status dot, manager, Planfix link). Close via ✕ / backdrop / Esc. Bars still open the Planfix task. Dropdown gains a public setOnBoardClick on the timeline view. - Fix: the panel showed empty on load because .board-panel{display:flex} overrode the UA [hidden] rule — added an explicit [hidden]{display:none}. - Spacing: tokenised the secondary chrome (header groups, fields, dropdown panel, appearance menu, legend, map tooltip) onto the Open Props --size-* scale; structural density of the header/fields/inputs deliberately kept. Co-Authored-By: Claude Opus 4.8 --- frontend/index.html | 9 ++++ frontend/src/main.ts | 100 ++++++++++++++++++++++++++++++++++++++- frontend/src/styles.css | 56 ++++++++++++++++++---- frontend/src/timeline.ts | 26 +++++++--- 4 files changed, 176 insertions(+), 15 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 577edd8..f752f8c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -165,6 +165,15 @@
+ + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts index dce8be2..d9ee014 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -15,9 +15,10 @@ import { api } from './api'; import type { Board, Booking, Filters } from './types'; import { createDropdown } from './dropdown'; import { createTimelineView, zoomSteps, type RenderFlags } from './timeline'; -import { loadAppearance, applyCssVars, buildAppearanceMenu } from './appearance'; +import { loadAppearance, applyCssVars, buildAppearanceMenu, colorForStatus } from './appearance'; import { createMapView } from './map'; import { createViewModes, type ViewMode } from './viewmode'; +import { escapeHtml } from './util'; const el = { search: document.getElementById('search') as HTMLInputElement, @@ -162,6 +163,34 @@ const fmtD = (iso: string): string => { const p = iso.split('-'); return p.length === 3 ? `${p[2]}.${p[1]}.${p[0]}` : iso; }; + +// ---- shareable filter links: current filters <-> URL query ---- +function filtersToUrl(): void { + const f = currentFilters(); + const p = new URLSearchParams(); + f.cities.forEach((v) => p.append('city', v)); + f.brands.forEach((v) => p.append('brand', v)); + f.dimensions.forEach((v) => p.append('dimension', v)); + f.managers.forEach((v) => p.append('manager', v)); + f.statuses.forEach((v) => p.append('status', v)); + if (f.search) p.set('q', f.search); + if (f.dateStart) p.set('from', f.dateStart); + if (f.dateEnd) p.set('to', f.dateEnd); + const qs = p.toString(); + history.replaceState(null, '', qs ? `${location.pathname}?${qs}` : location.pathname); +} +function applyUrlToFilters(): void { + const p = new URLSearchParams(location.search); + cityDropdown.setSelected(p.getAll('city')); + brandDropdown.setSelected(p.getAll('brand')); + dimensionDropdown.setSelected(p.getAll('dimension')); + managerDropdown.setSelected(p.getAll('manager')); + statusDropdown.setSelected(p.getAll('status')); + el.search.value = p.get('q') || ''; + el.dateStart.value = p.get('from') || ''; + el.dateEnd.value = p.get('to') || ''; +} + function resetFilters(): void { for (const d of [cityDropdown, brandDropdown, dimensionDropdown, managerDropdown, statusDropdown]) d.clear(); el.search.value = ''; @@ -202,6 +231,21 @@ function renderChips(): void { clear.textContent = 'Сбросить всё'; clear.addEventListener('click', resetFilters); el.filterChips.appendChild(clear); + + const share = document.createElement('button'); + share.type = 'button'; + share.className = 'chips-share'; + share.textContent = 'Скопировать ссылку'; + share.addEventListener('click', () => { + navigator.clipboard + ?.writeText(location.href) + .then(() => { + share.textContent = 'Ссылка скопирована'; + window.setTimeout(() => { share.textContent = 'Скопировать ссылку'; }, 1600); + }) + .catch(() => { /* clipboard unavailable */ }); + }); + el.filterChips.appendChild(share); } el.emptyResetTimeline.addEventListener('click', resetFilters); el.emptyResetMap.addEventListener('click', resetFilters); @@ -224,6 +268,7 @@ async function loadData(fit: boolean): Promise { el.status.innerHTML = `Поверхности: ${boards.length}
Брони: ${bookings.length}`; rebuildDecorMenu(); // status list may have changed renderChips(); + filtersToUrl(); // Keep the map in sync when it is visible (facets filter it; dates do not). if (mapVisible()) void refreshMap(); } finally { @@ -307,12 +352,65 @@ el.zoomSlider.addEventListener('input', () => { view.setScale(step.days); }); +// ---- board detail panel (opened from a timeline row label) ---- +const PLANFIX_TASK_URL = 'https://green-media.planfix.ru/task/'; +const boardPanel = document.getElementById('board-panel') as HTMLElement; +const boardBackdrop = document.getElementById('board-backdrop') as HTMLElement; +const boardPanelTitle = document.getElementById('board-panel-title') as HTMLElement; +const boardPanelBody = document.getElementById('board-panel-body') as HTMLElement; +function closeBoardPanel(): void { + boardPanel.hidden = true; + boardBackdrop.hidden = true; +} +function openBoardPanel(boardId: string): void { + const board = lastBoards.find((b) => b.board_id === boardId); + const bookings = lastBookings + .filter((b) => b.board_id === boardId) + .sort((a, b) => a.start_date.localeCompare(b.start_date)); + boardPanelTitle.textContent = boardId; + const parts: string[] = []; + if (board) { + const line1 = [board.board_city, board.board_dimension].filter(Boolean).map(escapeHtml).join(' · '); + if (line1) parts.push(`
${line1}
`); + if (board.board_address) parts.push(`
${escapeHtml(board.board_address)}
`); + } + parts.push(`
Брони (${bookings.length})
`); + if (!bookings.length) { + parts.push('
Броней нет
'); + } else { + for (const b of bookings) { + const title = escapeHtml(b.brand || b.company_name || 'Без названия'); + const sub = b.brand && b.company_name ? `
${escapeHtml(b.company_name)}
` : ''; + const color = colorForStatus(appearance, b.task_status); + const mgr = (b.manager || []).filter(Boolean).join(', '); + parts.push( + '
' + + `` + + sub + + `
${fmtD(b.start_date)} – ${fmtD(b.end_date)}
` + + `
${escapeHtml(b.task_status || '')}
` + + (mgr ? `
👤 ${escapeHtml(mgr)}
` : '') + + '
', + ); + } + } + boardPanelBody.innerHTML = parts.join(''); + boardPanel.hidden = false; + boardBackdrop.hidden = false; +} +(document.getElementById('board-panel-close') as HTMLElement).addEventListener('click', closeBoardPanel); +boardBackdrop.addEventListener('click', closeBoardPanel); +document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeBoardPanel(); }); +view.setOnBoardClick(openBoardPanel); + async function init(): Promise { cityDropdown.setValues([], 'Все города'); brandDropdown.setValues([], 'Все бренды'); dimensionDropdown.setValues([], 'Все размеры'); managerDropdown.setValues([], 'Все менеджеры'); statusDropdown.setValues([], 'Все статусы'); + applyUrlToFilters(); // restore filters from a shared link rebuildDecorMenu(); view.applyLayout(); await loadData(true); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index df48a64..7586f80 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -98,8 +98,8 @@ header { .hgroup { display: flex; align-items: center; - gap: 16px; - padding: 4px 0; + gap: var(--size-3); + padding: var(--size-1) 0; flex-shrink: 1; min-width: 0; } @@ -171,7 +171,7 @@ input::placeholder { color: var(--text-muted); } .brand-name { font-size: 18px; font-weight: 700; white-space: nowrap; } .brand-sub { font-size: 15px; font-weight: 400; color: var(--text-muted); white-space: nowrap; } -.field { display: flex; flex-direction: column; gap: 5px; } +.field { display: flex; flex-direction: column; gap: var(--size-1); } .field label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: .05em; font-weight: 500; @@ -218,7 +218,7 @@ input::placeholder { color: var(--text-muted); } } .dropdown.open .dropdown-panel { display: block; } .dropdown-panel label { - display: flex; align-items: center; gap: 8px; padding: 6px 12px; + display: flex; align-items: center; gap: var(--size-2); padding: var(--size-2) var(--size-3); font-size: 13px; cursor: pointer; white-space: nowrap; color: var(--text); /* Option labels are