frontend UI: header 4 groups, Оформление menu (colors/sizes/fonts), centered bars via inner div, clickable Planfix links
This commit is contained in:
parent
2d66871b62
commit
b393562c3a
@ -8,62 +8,67 @@
|
||||
<body>
|
||||
|
||||
<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>
|
||||
<!-- Group 1: brand -->
|
||||
<div class="hgroup hgroup-brand">
|
||||
<div class="brand">
|
||||
<div class="brand-name">Green Media</div>
|
||||
<div class="brand-sub">Адресная программа</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>
|
||||
<!-- Group 2: fields -->
|
||||
<div class="hgroup">
|
||||
<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>
|
||||
|
||||
<div class="field">
|
||||
<label for="search">Поиск (адрес / код)</label>
|
||||
<input type="text" id="search" placeholder="например, Азина" />
|
||||
<!-- Group 3: checkboxes -->
|
||||
<div class="hgroup">
|
||||
<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>
|
||||
|
||||
<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" />
|
||||
<!-- Group 4: appearance settings -->
|
||||
<div class="hgroup">
|
||||
<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 class="decor-wrap">
|
||||
<button type="button" class="decor-btn" id="decor-btn">Оформление ▾</button>
|
||||
<div class="decor-menu" id="decor-menu"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status"></div>
|
||||
|
||||
229
frontend/src/appearance.ts
Normal file
229
frontend/src/appearance.ts
Normal file
@ -0,0 +1,229 @@
|
||||
// Appearance settings: per-status bar colours, row/bar sizing, font sizes.
|
||||
// Persisted in localStorage; applied via CSS variables + per-item inline styles.
|
||||
|
||||
export interface Appearance {
|
||||
/** Colour per task_status (hex #rrggbb). Unknown statuses fall back to defaultColor. */
|
||||
colors: Record<string, string>;
|
||||
/** Colour for bars flagged as a date-collision (overrides the status colour). */
|
||||
collisionColor: string;
|
||||
/** Fallback colour for statuses without an explicit entry. */
|
||||
defaultColor: string;
|
||||
/** Height of a board row (container), in pixels. */
|
||||
rowHeightPx: number;
|
||||
/** Bar height as a percentage of the row height. */
|
||||
barHeightPct: number;
|
||||
/** Font size of the text inside bars, in pixels. */
|
||||
barFontPx: number;
|
||||
/** Font size of the address/code column (first column), in pixels. */
|
||||
labelFontPx: number;
|
||||
}
|
||||
|
||||
const LS_KEY = 'mapdash.appearance.v1';
|
||||
|
||||
/** Sensible out-of-the-box colours for the known statuses. */
|
||||
export const DEFAULT_STATUS_COLORS: Record<string, string> = {
|
||||
'РК. Новая': '#2e66d6',
|
||||
'РК. Размещено': '#2e66d6',
|
||||
'РК. Макет РИМ согласован': '#2e66d6',
|
||||
'РК. Ожидает монтаж': '#2e66d6',
|
||||
'РК. Ожидает демонтаж': '#2e66d6',
|
||||
'РК. Ожидает перемонтаж': '#2e66d6',
|
||||
'РК. Архив': '#2e66d6',
|
||||
};
|
||||
|
||||
export function defaultAppearance(): Appearance {
|
||||
return {
|
||||
colors: { ...DEFAULT_STATUS_COLORS },
|
||||
collisionColor: '#BA55D3',
|
||||
defaultColor: '#2e66d6',
|
||||
rowHeightPx: 30,
|
||||
barHeightPct: 62,
|
||||
barFontPx: 11,
|
||||
labelFontPx: 12,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadAppearance(): Appearance {
|
||||
const base = defaultAppearance();
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
if (!raw) return base;
|
||||
const saved = JSON.parse(raw) as Partial<Appearance>;
|
||||
return {
|
||||
...base,
|
||||
...saved,
|
||||
colors: { ...base.colors, ...(saved.colors ?? {}) },
|
||||
};
|
||||
} catch {
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveAppearance(a: Appearance): void {
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(a));
|
||||
} catch {
|
||||
/* storage unavailable — settings simply won't persist */
|
||||
}
|
||||
}
|
||||
|
||||
export function barHeightPx(a: Appearance): number {
|
||||
return Math.max(1, Math.round((a.rowHeightPx * a.barHeightPct) / 100));
|
||||
}
|
||||
|
||||
/** Symmetric top/bottom gap that centres the bar and yields rowHeightPx total. */
|
||||
export function marginVertical(a: Appearance): number {
|
||||
return Math.max(0, Math.round((a.rowHeightPx - barHeightPx(a)) / 2));
|
||||
}
|
||||
|
||||
export function colorForStatus(a: Appearance, status: string): string {
|
||||
return a.colors[status] || a.defaultColor;
|
||||
}
|
||||
|
||||
/** Push size/font settings into CSS custom properties on :root. */
|
||||
export function applyCssVars(a: Appearance): void {
|
||||
const root = document.documentElement.style;
|
||||
root.setProperty('--row-h', a.rowHeightPx + 'px');
|
||||
root.setProperty('--bar-h', barHeightPx(a) + 'px');
|
||||
root.setProperty('--bar-font', a.barFontPx + 'px');
|
||||
root.setProperty('--label-font', a.labelFontPx + 'px');
|
||||
}
|
||||
|
||||
// ---- settings menu UI ----------------------------------------------------
|
||||
|
||||
function numberRow(labelText: string, value: number, min: number, max: number, onInput: (v: number) => void): HTMLElement {
|
||||
const row = document.createElement('label');
|
||||
row.className = 'decor-row';
|
||||
const span = document.createElement('span');
|
||||
span.textContent = labelText;
|
||||
const input = document.createElement('input');
|
||||
input.type = 'number';
|
||||
input.min = String(min);
|
||||
input.max = String(max);
|
||||
input.value = String(value);
|
||||
input.addEventListener('input', () => {
|
||||
const v = parseInt(input.value, 10);
|
||||
if (!Number.isNaN(v)) onInput(Math.max(min, Math.min(max, v)));
|
||||
});
|
||||
row.appendChild(span);
|
||||
row.appendChild(input);
|
||||
return row;
|
||||
}
|
||||
|
||||
function colorRow(labelText: string, value: string, onInput: (v: string) => void): HTMLElement {
|
||||
const row = document.createElement('label');
|
||||
row.className = 'decor-row';
|
||||
const span = document.createElement('span');
|
||||
span.textContent = labelText;
|
||||
const input = document.createElement('input');
|
||||
input.type = 'color';
|
||||
input.value = value;
|
||||
input.addEventListener('input', () => onInput(input.value));
|
||||
row.appendChild(span);
|
||||
row.appendChild(input);
|
||||
return row;
|
||||
}
|
||||
|
||||
function section(title: string): HTMLElement {
|
||||
const s = document.createElement('div');
|
||||
s.className = 'decor-section';
|
||||
const h = document.createElement('div');
|
||||
h.className = 'decor-section-title';
|
||||
h.textContent = title;
|
||||
s.appendChild(h);
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* (Re)build the appearance menu contents into `panel`.
|
||||
* `statuses` — union of statuses to expose colour pickers for.
|
||||
* `onChange` — called after every edit (already persisted).
|
||||
*/
|
||||
export function buildAppearanceMenu(
|
||||
panel: HTMLElement,
|
||||
statuses: string[],
|
||||
a: Appearance,
|
||||
onChange: () => void,
|
||||
): void {
|
||||
panel.innerHTML = '';
|
||||
|
||||
// 1) Colours
|
||||
const colours = section('Цвета баров');
|
||||
const known = Object.keys(DEFAULT_STATUS_COLORS);
|
||||
const all = Array.from(new Set([...known, ...statuses]));
|
||||
for (const st of all) {
|
||||
colours.appendChild(
|
||||
colorRow(st, colorForStatus(a, st), (v) => {
|
||||
a.colors[st] = v;
|
||||
saveAppearance(a);
|
||||
onChange();
|
||||
}),
|
||||
);
|
||||
}
|
||||
colours.appendChild(
|
||||
colorRow('Коллизия', a.collisionColor, (v) => {
|
||||
a.collisionColor = v;
|
||||
saveAppearance(a);
|
||||
onChange();
|
||||
}),
|
||||
);
|
||||
colours.appendChild(
|
||||
colorRow('Прочие статусы', a.defaultColor, (v) => {
|
||||
a.defaultColor = v;
|
||||
saveAppearance(a);
|
||||
onChange();
|
||||
}),
|
||||
);
|
||||
panel.appendChild(colours);
|
||||
|
||||
// 2) Sizes
|
||||
const sizes = section('Размеры строк и баров');
|
||||
sizes.appendChild(
|
||||
numberRow('Высота строки, px', a.rowHeightPx, 8, 120, (v) => {
|
||||
a.rowHeightPx = v;
|
||||
saveAppearance(a);
|
||||
onChange();
|
||||
}),
|
||||
);
|
||||
sizes.appendChild(
|
||||
numberRow('Высота бара, % от строки', a.barHeightPct, 10, 100, (v) => {
|
||||
a.barHeightPct = v;
|
||||
saveAppearance(a);
|
||||
onChange();
|
||||
}),
|
||||
);
|
||||
panel.appendChild(sizes);
|
||||
|
||||
// 3) Fonts
|
||||
const fonts = section('Размер шрифта');
|
||||
fonts.appendChild(
|
||||
numberRow('В барах, px', a.barFontPx, 6, 32, (v) => {
|
||||
a.barFontPx = v;
|
||||
saveAppearance(a);
|
||||
onChange();
|
||||
}),
|
||||
);
|
||||
fonts.appendChild(
|
||||
numberRow('В колонке адресов, px', a.labelFontPx, 6, 32, (v) => {
|
||||
a.labelFontPx = v;
|
||||
saveAppearance(a);
|
||||
onChange();
|
||||
}),
|
||||
);
|
||||
panel.appendChild(fonts);
|
||||
|
||||
// Reset
|
||||
const reset = document.createElement('button');
|
||||
reset.type = 'button';
|
||||
reset.className = 'decor-reset';
|
||||
reset.textContent = 'Сбросить к значениям по умолчанию';
|
||||
reset.addEventListener('click', () => {
|
||||
const d = defaultAppearance();
|
||||
Object.assign(a, d);
|
||||
a.colors = { ...d.colors };
|
||||
saveAppearance(a);
|
||||
buildAppearanceMenu(panel, statuses, a, onChange);
|
||||
onChange();
|
||||
});
|
||||
panel.appendChild(reset);
|
||||
}
|
||||
@ -5,6 +5,7 @@ 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';
|
||||
|
||||
const el = {
|
||||
search: document.getElementById('search') as HTMLInputElement,
|
||||
@ -19,17 +20,25 @@ const el = {
|
||||
zoomLabel: document.getElementById('zoom-label') as HTMLElement,
|
||||
chartWrap: document.getElementById('chart-wrap') as HTMLElement,
|
||||
colResizer: document.getElementById('col-resizer') as HTMLElement,
|
||||
decorBtn: document.getElementById('decor-btn') as HTMLButtonElement,
|
||||
decorMenu: document.getElementById('decor-menu') as HTMLElement,
|
||||
};
|
||||
|
||||
const appearance = loadAppearance();
|
||||
applyCssVars(appearance);
|
||||
|
||||
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,
|
||||
});
|
||||
const view = createTimelineView(
|
||||
{
|
||||
timelineEl: el.timelineEl,
|
||||
chartWrap: el.chartWrap,
|
||||
tooltip: el.tooltip,
|
||||
empty: el.empty,
|
||||
colResizer: el.colResizer,
|
||||
},
|
||||
appearance,
|
||||
);
|
||||
|
||||
let lastBoards: Board[] = [];
|
||||
let lastBookings: Booking[] = [];
|
||||
@ -50,6 +59,29 @@ function currentFilters(): Filters {
|
||||
};
|
||||
}
|
||||
|
||||
// ---- appearance menu ----
|
||||
function currentStatuses(): string[] {
|
||||
return Array.from(new Set(lastBookings.map((b) => b.task_status).filter(Boolean)));
|
||||
}
|
||||
|
||||
function rebuildDecorMenu(): void {
|
||||
buildAppearanceMenu(el.decorMenu, currentStatuses(), appearance, () => {
|
||||
applyCssVars(appearance);
|
||||
view.applyLayout();
|
||||
view.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
el.decorBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
el.decorMenu.classList.toggle('open');
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!el.decorMenu.contains(e.target as Node) && e.target !== el.decorBtn) {
|
||||
el.decorMenu.classList.remove('open');
|
||||
}
|
||||
});
|
||||
|
||||
async function loadMeta(): Promise<void> {
|
||||
const meta = await api.meta();
|
||||
cityDropdown.setValues(meta.cities, 'Все города');
|
||||
@ -64,6 +96,7 @@ async function loadData(fit: boolean): Promise<void> {
|
||||
lastBookings = bookings;
|
||||
view.render(boards, bookings, fit, flags());
|
||||
el.status.textContent = `Бордов: ${boards.length}, бронирований: ${bookings.length}`;
|
||||
rebuildDecorMenu(); // status list may have changed
|
||||
}
|
||||
|
||||
let debounceTimer: number | undefined;
|
||||
@ -88,9 +121,10 @@ el.zoomSlider.addEventListener('input', () => {
|
||||
});
|
||||
|
||||
async function init(): Promise<void> {
|
||||
rebuildDecorMenu();
|
||||
view.applyLayout();
|
||||
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;
|
||||
|
||||
@ -6,13 +6,14 @@
|
||||
--row-odd: #FFFFFF;
|
||||
--row-hover: #FFECB3;
|
||||
--month-hover: rgba(65, 105, 225, 0.10);
|
||||
/* appearance (overridden at runtime by appearance.ts) */
|
||||
--row-h: 30px;
|
||||
--bar-h: 18px;
|
||||
--bar-font: 11px;
|
||||
--label-font: 12px;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
html, body { height: 100%; margin: 0; overflow: hidden; }
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -20,26 +21,35 @@ body {
|
||||
background: #fff;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
header {
|
||||
flex: 0 0 auto;
|
||||
padding: 14px 20px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e2e2e2;
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
align-items: stretch;
|
||||
flex-wrap: wrap;
|
||||
background: #fff;
|
||||
z-index: 20;
|
||||
}
|
||||
.brand {
|
||||
|
||||
/* Header groups (four boxed blocks) */
|
||||
.hgroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
line-height: 1.2;
|
||||
margin-right: 8px;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 10px 14px;
|
||||
background: #f2f2f2;
|
||||
border: 1px solid #e2e2e2;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.hgroup-brand { background: #eaeaea; }
|
||||
|
||||
.brand { display: flex; flex-direction: column; justify-content: center; line-height: 1.2; }
|
||||
.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] {
|
||||
@ -53,15 +63,9 @@ input[type=text] {
|
||||
|
||||
.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;
|
||||
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; }
|
||||
|
||||
@ -71,154 +75,126 @@ input[type=text] {
|
||||
/* 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;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
/* Оформление button + context menu */
|
||||
.decor-wrap { position: relative; align-self: center; }
|
||||
.decor-btn {
|
||||
border: 1px solid #ccc; border-radius: 4px; background: #fff;
|
||||
padding: 8px 14px; font-size: 13px; cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.decor-btn:hover { border-color: #999; }
|
||||
.decor-menu {
|
||||
display: none; position: absolute; top: calc(100% + 6px); right: 0;
|
||||
background: #fff; border: 1px solid #ccc; border-radius: 6px;
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,.15); padding: 10px 12px; z-index: 50;
|
||||
width: max-content; max-width: min(420px, 92vw); max-height: calc(100vh - 96px); overflow-y: auto;
|
||||
}
|
||||
.decor-menu.open { display: block; }
|
||||
.decor-section { margin-bottom: 22px; padding-bottom: 14px; border-bottom: 1px solid #eee; }
|
||||
.decor-section:last-of-type { border-bottom: none; margin-bottom: 8px; }
|
||||
.decor-section-title {
|
||||
font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .03em;
|
||||
color: #666; margin: 6px 0 4px;
|
||||
}
|
||||
.decor-row {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
padding: 3px 0; font-size: 13px;
|
||||
}
|
||||
.decor-row > span { color: #222; }
|
||||
.decor-row input[type=number] { width: 84px; padding: 4px 6px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px; }
|
||||
.decor-row input[type=color] { width: 42px; height: 26px; padding: 0; border: 1px solid #ccc; border-radius: 4px; background: #fff; cursor: pointer; }
|
||||
.decor-reset { margin-top: 6px; width: 100%; padding: 6px; font-size: 12px; border: 1px solid #ccc; border-radius: 4px; background: #f7f7f7; cursor: pointer; }
|
||||
.decor-reset:hover { background: #efefef; }
|
||||
|
||||
#chart-wrap { flex: 1 1 auto; min-height: 0; position: relative; overflow: hidden; }
|
||||
#timeline { border: none; }
|
||||
|
||||
/* vis-timeline overrides */
|
||||
/* vis-timeline bars: colour comes from each item's inline style (per status /
|
||||
collision). Height and vertical centring come from --bar-h + symmetric vis
|
||||
margin (see appearance.ts marginVertical); flex centres the text too. */
|
||||
/* The vis item spans the FULL row height and is transparent; the visible bar
|
||||
is an inner div (.bar-inner) of --bar-h height, flex-centred. This makes
|
||||
vertical centring independent of vis-timeline's own item positioning. */
|
||||
.vis-item.vis-range {
|
||||
background-color: var(--bar-color);
|
||||
border-color: #2c50b0;
|
||||
height: var(--row-h);
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.vis-item.vis-range .vis-item-overflow { display: flex; align-items: center; height: 100%; width: 100%; overflow: hidden; }
|
||||
.vis-item.vis-range .vis-item-content { padding: 0; width: 100%; }
|
||||
.bar-inner {
|
||||
height: var(--bar-h);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 6px;
|
||||
color: #FFFFFF;
|
||||
font-size: var(--bar-font);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.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; }
|
||||
.vis-item.vis-range:hover .bar-inner { filter: brightness(0.92); }
|
||||
.vis-foreground .vis-group { min-height: var(--row-h); }
|
||||
.vis-labelset .vis-label { font-size: var(--label-font); display: flex; align-items: center; min-height: var(--row-h); }
|
||||
|
||||
/* 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). */
|
||||
/* Zebra striping */
|
||||
.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 */
|
||||
.vis-label.row-hover, .vis-group.row-hover { background-color: var(--row-hover) !important; }
|
||||
/* Fixed/resizable label column */
|
||||
.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;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Drag handle to resize the address/code column. */
|
||||
/* Column resize handle */
|
||||
#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;
|
||||
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. */
|
||||
/* 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;
|
||||
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; }
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
// 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 { colorForStatus } from './appearance';
|
||||
import type { Appearance } from './appearance';
|
||||
import type { Board, Booking } from './types';
|
||||
|
||||
export interface RenderFlags {
|
||||
@ -21,8 +21,9 @@ export interface TimelineElements {
|
||||
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.
|
||||
// Clicking a bar opens the corresponding Planfix task.
|
||||
const PLANFIX_TASK_URL = 'https://green-media.planfix.ru/task/';
|
||||
|
||||
export const zoomSteps: ReadonlyArray<{ days: number; label: string }> = [
|
||||
{ days: 91, label: '3 месяца' },
|
||||
{ days: 183, label: '6 месяцев' },
|
||||
@ -42,13 +43,19 @@ const MAX_LABEL_W = 760;
|
||||
|
||||
export interface TimelineView {
|
||||
render(boards: Board[], bookings: Booking[], fit: boolean, flags: RenderFlags): void;
|
||||
/** Re-run the last render (e.g. after colours changed). */
|
||||
refresh(): void;
|
||||
/** Re-apply row height / centring from appearance and redraw. */
|
||||
applyLayout(): void;
|
||||
setScale(days: number): void;
|
||||
}
|
||||
|
||||
export function createTimelineView(el: TimelineElements): TimelineView {
|
||||
export function createTimelineView(el: TimelineElements, appearance: Appearance): TimelineView {
|
||||
const groupsDS = new DataSet<any>();
|
||||
const itemsDS = new DataSet<any>();
|
||||
let lastBoards: Board[] = [];
|
||||
let lastBookings: Booking[] = [];
|
||||
let lastFlags: RenderFlags = { withBrand: true, withCompany: false, withCollisions: true };
|
||||
|
||||
const options: TimelineOptions = {
|
||||
editable: false,
|
||||
@ -59,17 +66,17 @@ export function createTimelineView(el: TimelineElements): TimelineView {
|
||||
verticalScroll: true,
|
||||
zoomMin: zoomSteps[0]!.days * 86400000,
|
||||
zoomMax: zoomSteps[zoomSteps.length - 1]!.days * 86400000,
|
||||
groupHeightMode: 'fixed',
|
||||
margin: { item: { horizontal: 2, vertical: 6 } },
|
||||
groupHeightMode: 'auto',
|
||||
margin: { item: { horizontal: 2, vertical: 0 } },
|
||||
orientation: { axis: 'top' },
|
||||
tooltip: { followMouse: false, delay: 0 },
|
||||
locale: 'ru',
|
||||
xss: { disabled: true },
|
||||
};
|
||||
|
||||
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.
|
||||
// ---- dynamic height / scroll fix ----
|
||||
let lastSetHeightPx: number | null = null;
|
||||
function fitTimelineHeight(): void {
|
||||
const available = el.chartWrap.clientHeight;
|
||||
@ -101,7 +108,16 @@ export function createTimelineView(el: TimelineElements): TimelineView {
|
||||
window.addEventListener('resize', scheduleHeightFit);
|
||||
scheduleHeightFit();
|
||||
|
||||
// ---- draggable label-column width (drives --label-w; vis panel follows it).
|
||||
// ---- clickable bars -> Planfix task ----
|
||||
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');
|
||||
}
|
||||
});
|
||||
|
||||
// ---- draggable label-column width ----
|
||||
function currentLabelW(): number {
|
||||
const v = getComputedStyle(document.documentElement).getPropertyValue('--label-w');
|
||||
return parseInt(v, 10) || 340;
|
||||
@ -158,7 +174,7 @@ export function createTimelineView(el: TimelineElements): TimelineView {
|
||||
scheduleHeightFit();
|
||||
});
|
||||
|
||||
// ---- tooltip following the cursor.
|
||||
// ---- tooltip ----
|
||||
let hoveredItemId: string | number | null = null;
|
||||
timeline.on('itemover', (props: any) => {
|
||||
hoveredItemId = props.item;
|
||||
@ -179,7 +195,7 @@ export function createTimelineView(el: TimelineElements): TimelineView {
|
||||
}
|
||||
});
|
||||
|
||||
// ---- row hover highlight (DOM class toggling, no DataSet churn).
|
||||
// ---- row hover highlight ----
|
||||
let hoveredRowIdx = -1;
|
||||
function setRowHover(idx: number): void {
|
||||
if (idx === hoveredRowIdx) return;
|
||||
@ -196,7 +212,7 @@ export function createTimelineView(el: TimelineElements): TimelineView {
|
||||
hoveredRowIdx = idx;
|
||||
}
|
||||
|
||||
// ---- hovered-month column highlight (single background item, month-snapped).
|
||||
// ---- hovered-month column highlight ----
|
||||
let lastMonthKey: string | null = null;
|
||||
timeline.on('mouseMove', (props: any) => {
|
||||
if (props.time) {
|
||||
@ -228,6 +244,8 @@ export function createTimelineView(el: TimelineElements): TimelineView {
|
||||
|
||||
function render(boards: Board[], bookings: Booking[], fit: boolean, flags: RenderFlags): void {
|
||||
lastBoards = boards;
|
||||
lastBookings = bookings;
|
||||
lastFlags = flags;
|
||||
if (boards.length === 0) {
|
||||
groupsDS.clear();
|
||||
itemsDS.clear();
|
||||
@ -249,7 +267,7 @@ export function createTimelineView(el: TimelineElements): TimelineView {
|
||||
|
||||
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
|
||||
end.setUTCDate(end.getUTCDate() + 1);
|
||||
const brand = bk.brand || '';
|
||||
const company = bk.company_name || '';
|
||||
|
||||
@ -258,7 +276,6 @@ export function createTimelineView(el: TimelineElements): TimelineView {
|
||||
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);
|
||||
@ -277,20 +294,19 @@ export function createTimelineView(el: TimelineElements): TimelineView {
|
||||
}
|
||||
}
|
||||
|
||||
const item: any = {
|
||||
// Colour: collision colour overrides the per-status colour.
|
||||
const color = isCollision ? appearance.collisionColor : colorForStatus(appearance, bk.task_status);
|
||||
|
||||
return {
|
||||
id: bk.board_id + '__' + idx,
|
||||
group: bk.board_id,
|
||||
start: bk.start_date,
|
||||
end: end.toISOString().slice(0, 10),
|
||||
content: barContent,
|
||||
content: `<div class="bar-inner" style="background-color:${color};">${barContent}</div>`,
|
||||
type: 'range',
|
||||
tooltipText: tooltipLines.join('\n'),
|
||||
taskId: bk.task_id,
|
||||
};
|
||||
// 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();
|
||||
@ -303,6 +319,16 @@ export function createTimelineView(el: TimelineElements): TimelineView {
|
||||
scheduleHeightFit();
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
if (lastBoards.length) render(lastBoards, lastBookings, false, lastFlags);
|
||||
}
|
||||
|
||||
function applyLayout(): void {
|
||||
timeline.setOptions({ margin: { item: { horizontal: 2, vertical: 0 } } });
|
||||
timeline.redraw();
|
||||
scheduleHeightFit();
|
||||
}
|
||||
|
||||
function setScale(days: number): void {
|
||||
const range = timeline.getWindow();
|
||||
const center = (range.start.getTime() + range.end.getTime()) / 2;
|
||||
@ -310,5 +336,5 @@ export function createTimelineView(el: TimelineElements): TimelineView {
|
||||
timeline.setWindow(new Date(center - half), new Date(center + half));
|
||||
}
|
||||
|
||||
return { render, setScale };
|
||||
return { render, refresh, applyLayout, setScale };
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user