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:
parent
52a1a7cb2d
commit
4425bd394c
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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; },
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user