features: shareable filter links, board detail panel, spacing rhythm on tokens

- 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 <noreply@anthropic.com>
This commit is contained in:
aaverbitskiy 2026-08-10 18:08:45 +00:00
parent 52a1a7cb2d
commit 4425bd394c
4 changed files with 176 additions and 15 deletions

View File

@ -165,6 +165,15 @@
<div id="tooltip"></div>
<div id="board-backdrop" class="bp-backdrop" hidden></div>
<aside id="board-panel" class="board-panel" hidden aria-label="Карточка поверхности">
<div class="bp-head">
<div id="board-panel-title" class="bp-title"></div>
<button type="button" class="bp-close" id="board-panel-close" aria-label="Закрыть"></button>
</div>
<div id="board-panel-body" class="bp-body"></div>
</aside>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@ -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<void> {
el.status.innerHTML = `Поверхности: ${boards.length}<br>Брони: ${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(`<div class="bp-meta">${line1}</div>`);
if (board.board_address) parts.push(`<div class="bp-meta">${escapeHtml(board.board_address)}</div>`);
}
parts.push(`<div class="bp-label">Брони (${bookings.length})</div>`);
if (!bookings.length) {
parts.push('<div class="bp-empty">Броней нет</div>');
} else {
for (const b of bookings) {
const title = escapeHtml(b.brand || b.company_name || 'Без названия');
const sub = b.brand && b.company_name ? `<div class="bp-b-sub">${escapeHtml(b.company_name)}</div>` : '';
const color = colorForStatus(appearance, b.task_status);
const mgr = (b.manager || []).filter(Boolean).join(', ');
parts.push(
'<div class="bp-booking">' +
`<div class="bp-b-head"><span class="bp-b-title">${title}</span>` +
`<a class="bp-b-link" href="${PLANFIX_TASK_URL}${encodeURIComponent(b.task_id)}" target="_blank" rel="noopener">№${escapeHtml(b.task_id)} ↗</a></div>` +
sub +
`<div class="bp-b-dates">${fmtD(b.start_date)} ${fmtD(b.end_date)}</div>` +
`<div class="bp-b-row"><span class="bp-dot" style="background:${color}"></span>${escapeHtml(b.task_status || '')}</div>` +
(mgr ? `<div class="bp-b-row">👤 ${escapeHtml(mgr)}</div>` : '') +
'</div>',
);
}
}
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<void> {
cityDropdown.setValues([], 'Все города');
brandDropdown.setValues([], 'Все бренды');
dimensionDropdown.setValues([], 'Все размеры');
managerDropdown.setValues([], 'Все менеджеры');
statusDropdown.setValues([], 'Все статусы');
applyUrlToFilters(); // restore filters from a shared link
rebuildDecorMenu();
view.applyLayout();
await loadData(true);

View File

@ -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 <label>s inside .field, so they'd inherit the uppercase /
weight / letter-spacing of `.field label`. Reset to clean body text. */
@ -257,7 +257,7 @@ input::placeholder { color: var(--text-muted); }
.decor-menu {
display: none; position: absolute; top: calc(100% + 6px); right: 0;
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: var(--shadow-md); padding: 12px 14px; z-index: 50;
box-shadow: var(--shadow-md); padding: var(--size-3); z-index: 50;
width: max-content; max-width: min(420px, 92vw); max-height: calc(100vh - 96px); overflow-y: auto;
}
.decor-menu.open { display: block; }
@ -349,8 +349,8 @@ input::placeholder { color: var(--text-muted); }
#map-legend {
position: absolute; left: 10px; bottom: 44px; z-index: 5;
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
padding: 8px 12px; font-size: 12px; color: var(--text);
display: flex; flex-direction: column; align-items: flex-start; gap: 5px;
padding: var(--size-2) var(--size-3); font-size: 12px; color: var(--text);
display: flex; flex-direction: column; align-items: flex-start; gap: var(--size-1);
box-shadow: var(--shadow-md); font-variant-numeric: tabular-nums;
}
#map-legend .lg-item { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; font-size: 12px; }
@ -402,7 +402,7 @@ input::placeholder { color: var(--text-muted); }
.map-tip {
position: fixed; z-index: 1000; pointer-events: none;
background: var(--tip-bg); color: var(--tip-text); font-size: 12px; line-height: 1.35;
padding: 8px 10px; border-radius: var(--radius-sm); max-width: 340px;
padding: var(--size-2); border-radius: var(--radius-sm); max-width: 340px;
max-height: calc(100vh - 24px); overflow: hidden;
box-shadow: var(--shadow-md);
}
@ -561,6 +561,12 @@ input::placeholder { color: var(--text-muted); }
transition: color var(--transition), background var(--transition);
}
.chips-clear:hover { color: var(--text); background: var(--surface-2); }
.chips-share {
border: none; background: transparent; color: var(--accent); cursor: pointer;
font-size: 12px; font-weight: 500; padding: 3px 8px; border-radius: var(--radius-sm);
margin-left: auto; transition: background var(--transition);
}
.chips-share:hover { background: var(--accent-weak); }
/* ---- polish: focus rings, animations, scrollbars, tabular figures ---- */
button:focus-visible, input[type=text]:focus-visible, input[type=date]:focus-visible,
@ -577,3 +583,37 @@ input[type=checkbox]:focus-visible { outline: 2px solid var(--accent); outline-o
background: var(--border-strong); border-radius: 999px; border: 2px solid var(--bg);
}
.vis-time-axis .vis-text, #tooltip, .map-tip, .bl, input[type=date] { font-variant-numeric: tabular-nums; }
/* ---- board detail panel (slide-in from the right) ---- */
.bp-backdrop { position: fixed; inset: 0; background: rgba(15, 20, 32, .35); z-index: 300; }
.board-panel {
position: fixed; top: 0; right: 0; bottom: 0; width: 380px; max-width: 92vw; z-index: 301;
background: var(--bg); border-left: 1px solid var(--border); box-shadow: var(--shadow-md);
display: flex; flex-direction: column; animation: slide-in .18s var(--ease-3);
}
.board-panel[hidden], .bp-backdrop[hidden] { display: none; }
@keyframes slide-in { from { transform: translateX(24px); opacity: .5; } to { transform: none; opacity: 1; } }
.bp-head {
display: flex; align-items: center; justify-content: space-between; gap: var(--size-2);
padding: var(--size-3); border-bottom: 1px solid var(--border);
}
.bp-title { font-size: 16px; font-weight: 600; word-break: break-word; }
.bp-close {
border: none; background: transparent; color: var(--text-muted); font-size: 17px; cursor: pointer;
width: 30px; height: 30px; border-radius: var(--radius-sm); flex: 0 0 auto;
display: inline-flex; align-items: center; justify-content: center; transition: background var(--transition), color var(--transition);
}
.bp-close:hover { background: var(--surface-2); color: var(--text); }
.bp-body { padding: var(--size-3); overflow-y: auto; flex: 1 1 auto; }
.bp-meta { font-size: 13px; color: var(--text-muted); margin-bottom: 2px; }
.bp-label { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted); font-weight: 600; margin: var(--size-3) 0 var(--size-2); }
.bp-empty { color: var(--text-muted); font-size: 13px; }
.bp-booking { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: var(--size-2) var(--size-3); margin-bottom: var(--size-2); }
.bp-b-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--size-2); }
.bp-b-title { font-size: 14px; font-weight: 500; }
.bp-b-link { font-size: 12px; color: var(--accent); text-decoration: none; font-weight: 500; white-space: nowrap; }
.bp-b-link:hover { text-decoration: underline; }
.bp-b-sub { font-size: 12px; color: var(--text-muted); margin-top: 1px; }
.bp-b-dates { font-size: 13px; margin-top: 4px; font-variant-numeric: tabular-nums; }
.bp-b-row { display: flex; align-items: center; gap: 7px; font-size: 12.5px; color: var(--text-muted); margin-top: 4px; }
.bp-dot { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto; }

View File

@ -72,6 +72,8 @@ export interface TimelineView {
/** Re-apply row height / centring from appearance and redraw. */
applyLayout(): void;
setScale(days: number): void;
/** Called with a board_id when its row label is clicked. */
setOnBoardClick(fn: (boardId: string) => void): void;
}
export function createTimelineView(el: TimelineElements, appearance: Appearance): TimelineView {
@ -80,6 +82,7 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
let lastBoards: Board[] = [];
let lastBookings: Booking[] = [];
let lastFlags: RenderFlags = { withBrand: true, withCompany: false, withCollisions: true };
let onBoardClick: ((boardId: string) => void) | null = null;
const options: TimelineOptions = {
editable: false,
@ -132,12 +135,17 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
window.addEventListener('resize', scheduleHeightFit);
scheduleHeightFit();
// ---- clickable bars -> Planfix task ----
// ---- clicks: bar -> Planfix task; row label -> board detail panel ----
timeline.on('click', (props: any) => {
if (!props.item) return;
const item = itemsDS.get(props.item) as any;
if (item && item.taskId) {
window.open(PLANFIX_TASK_URL + encodeURIComponent(item.taskId), '_blank', 'noopener');
if (props.item) {
const item = itemsDS.get(props.item) as any;
if (item && item.taskId) {
window.open(PLANFIX_TASK_URL + encodeURIComponent(item.taskId), '_blank', 'noopener');
}
return;
}
if (props.what === 'group-label' && props.group != null && onBoardClick) {
onBoardClick(String(props.group));
}
});
@ -373,5 +381,11 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
timeline.setWindow(new Date(center - half), new Date(center + half));
}
return { render, refresh, applyLayout, setScale };
return {
render,
refresh,
applyLayout,
setScale,
setOnBoardClick(fn: (boardId: string) => void): void { onBoardClick = fn; },
};
}