diff --git a/frontend/package.json b/frontend/package.json index cdb17af..ad84e62 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mapdash-frontend", "private": true, - "version": "0.2.0", + "version": "0.2.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/appearance.ts b/frontend/src/appearance.ts index e863448..a4b344a 100644 --- a/frontend/src/appearance.ts +++ b/frontend/src/appearance.ts @@ -20,17 +20,23 @@ export interface Appearance { const LS_KEY = 'mapdash.appearance.v1'; -/** Sensible out-of-the-box colours for the known statuses. */ +/** Sensible out-of-the-box colours for the known statuses (lifecycle order). */ export const DEFAULT_STATUS_COLORS: Record = { 'РК. Новая': '#4f46e5', - 'РК. Размещено': '#4f46e5', + 'РК. Предварительное бронирование': '#4f46e5', 'РК. Макет РИМ согласован': '#4f46e5', + 'РК. Размещено': '#4f46e5', 'РК. Ожидает монтаж': '#4f46e5', 'РК. Ожидает демонтаж': '#4f46e5', 'РК. Ожидает перемонтаж': '#4f46e5', 'РК. Архив': '#4f46e5', }; +/** Display name for a status: strip the systemic "РК. " prefix. */ +export function statusLabel(status: string): string { + return status.replace(/^РК\.\s+/, ''); +} + export function defaultAppearance(): Appearance { return { colors: { ...DEFAULT_STATUS_COLORS }, @@ -124,6 +130,44 @@ function colorRow(labelText: string, value: string, onInput: (v: string) => void return row; } +// Circular two-arrows "reset" glyph (Feather refresh-cw). +const RESET_ICON = + ''; + +/** + * A status colour row: clean status name, a grey reset glyph that restores this + * status's default colour, then the colour picker. + */ +function statusColorRow(status: string, value: string, defColor: string, onInput: (v: string) => void): HTMLElement { + const row = document.createElement('div'); + row.className = 'decor-row'; + const span = document.createElement('span'); + span.textContent = statusLabel(status); + const wrap = document.createElement('div'); + wrap.className = 'decor-color-wrap'; + const reset = document.createElement('button'); + reset.type = 'button'; + reset.className = 'decor-color-reset'; + reset.title = 'Сбросить цвет статуса'; + reset.setAttribute('aria-label', 'Сбросить цвет статуса'); + reset.innerHTML = RESET_ICON; + const input = document.createElement('input'); + input.type = 'color'; + input.value = value; + input.addEventListener('input', () => onInput(input.value)); + reset.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + input.value = defColor; + onInput(defColor); + }); + wrap.appendChild(reset); + wrap.appendChild(input); + row.appendChild(span); + row.appendChild(wrap); + return row; +} + function section(title: string): HTMLElement { const s = document.createElement('div'); s.className = 'decor-section'; @@ -153,7 +197,7 @@ export function buildAppearanceMenu( const all = Array.from(new Set([...known, ...statuses])); for (const st of all) { colours.appendChild( - colorRow(st, colorForStatus(a, st), (v) => { + statusColorRow(st, colorForStatus(a, st), DEFAULT_STATUS_COLORS[st] || a.defaultColor, (v) => { a.colors[st] = v; saveAppearance(a); onChange(); @@ -167,13 +211,6 @@ export function buildAppearanceMenu( onChange(); }), ); - colours.appendChild( - colorRow('Прочие статусы', a.defaultColor, (v) => { - a.defaultColor = v; - saveAppearance(a); - onChange(); - }), - ); panel.appendChild(colours); // 2) Sizes diff --git a/frontend/src/dropdown.ts b/frontend/src/dropdown.ts index 2f2f2d0..69a1b9a 100644 --- a/frontend/src/dropdown.ts +++ b/frontend/src/dropdown.ts @@ -14,7 +14,9 @@ export interface Dropdown { onChange(fn: () => void): void; } -export function createDropdown(id: string): Dropdown { +// `formatLabel` maps a raw value to its display text (values stay raw for +// filtering/URLs); defaults to identity. +export function createDropdown(id: string, formatLabel: (v: string) => string = (v) => v): 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; @@ -28,7 +30,7 @@ export function createDropdown(id: string): Dropdown { 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 if (selected.size <= 2) btnText.textContent = Array.from(selected).map(formatLabel).join(', '); else btnText.textContent = 'Выбрано: ' + selected.size; } @@ -80,7 +82,7 @@ export function createDropdown(id: string): Dropdown { onChange(); }); label.appendChild(cb); - label.appendChild(document.createTextNode(v)); + label.appendChild(document.createTextNode(formatLabel(v))); panel.appendChild(label); } } diff --git a/frontend/src/main.ts b/frontend/src/main.ts index e018b04..819b267 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -15,7 +15,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, colorForStatus } from './appearance'; +import { loadAppearance, applyCssVars, buildAppearanceMenu, colorForStatus, statusLabel } from './appearance'; import { createMapView } from './map'; import { createViewModes, type ViewMode } from './viewmode'; import { escapeHtml } from './util'; @@ -113,7 +113,7 @@ const cityDropdown = createDropdown('city'); const brandDropdown = createDropdown('brand'); const dimensionDropdown = createDropdown('dimension'); const managerDropdown = createDropdown('manager'); -const statusDropdown = createDropdown('status'); +const statusDropdown = createDropdown('status', statusLabel); const view = createTimelineView( { timelineEl: el.timelineEl, @@ -180,9 +180,23 @@ function rebuildDecorMenu(): void { }); } +// Position the (fixed) appearance menu: top 25px below the header, bottom no +// closer than 30px to the window edge; overflow scrolls inside. +function positionDecorMenu(): void { + const header = document.querySelector('header'); + const headerBottom = header ? header.getBoundingClientRect().bottom : 0; + const top = headerBottom + 25; + el.decorMenu.style.top = top + 'px'; + el.decorMenu.style.maxHeight = Math.max(120, window.innerHeight - top - 30) + 'px'; +} el.decorBtn.addEventListener('click', (e) => { e.stopPropagation(); + const willOpen = !el.decorMenu.classList.contains('open'); el.decorMenu.classList.toggle('open'); + if (willOpen) positionDecorMenu(); +}); +window.addEventListener('resize', () => { + if (el.decorMenu.classList.contains('open')) positionDecorMenu(); }); document.addEventListener('click', (e) => { if (!el.decorMenu.contains(e.target as Node) && e.target !== el.decorBtn) { @@ -249,8 +263,9 @@ function resetFilters(): void { function renderChips(): void { const items: { label: string; remove: () => void }[] = []; for (const dd of [cityDropdown, brandDropdown, dimensionDropdown, managerDropdown, statusDropdown]) { + const fmt = dd === statusDropdown ? statusLabel : (x: string) => x; for (const v of dd.getSelected()) { - items.push({ label: v, remove: () => { dd.setSelected(dd.getSelected().filter((x) => x !== v)); void loadData(true); } }); + items.push({ label: fmt(v), remove: () => { dd.setSelected(dd.getSelected().filter((x) => x !== v)); void loadData(true); } }); } } if (el.search.value.trim()) items.push({ label: 'Поиск: ' + el.search.value.trim(), remove: () => { el.search.value = ''; void loadData(true); } }); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 7c87bfb..413c623 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -256,10 +256,12 @@ input::placeholder { color: var(--text-muted); } } .decor-btn:hover::after { opacity: 1; } .decor-menu { - display: none; position: absolute; top: calc(100% + 6px); right: 0; + /* Fixed to the viewport: JS sets `top` (25px below the header) and `max-height` + (ending ≥30px above the window bottom) on open, so it never overflows. */ + display: none; position: fixed; right: 18px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); 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; + width: max-content; max-width: min(420px, 92vw); overflow-y: auto; } .decor-menu.open { display: block; } .decor-section { margin-bottom: 22px; padding-bottom: 14px; border-bottom: 1px solid var(--border); } @@ -281,6 +283,14 @@ input::placeholder { color: var(--text-muted); } width: 42px; height: 28px; padding: 0; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); cursor: pointer; } +.decor-color-wrap { display: inline-flex; align-items: center; gap: 8px; } +.decor-color-reset { + display: inline-flex; align-items: center; justify-content: center; + width: 24px; height: 24px; padding: 0; border: none; background: none; + color: var(--text-muted); cursor: pointer; border-radius: var(--radius-sm); + transition: color var(--transition), background var(--transition); +} +.decor-color-reset:hover { color: var(--accent); background: var(--surface-2); } .decor-reset { margin-top: 6px; width: 100%; padding: 8px; font-size: 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); diff --git a/frontend/src/timeline.ts b/frontend/src/timeline.ts index 5dd397d..69b5b1b 100644 --- a/frontend/src/timeline.ts +++ b/frontend/src/timeline.ts @@ -3,7 +3,7 @@ import { Timeline, DataSet } from 'vis-timeline/standalone'; import type { TimelineOptions } from 'vis-timeline/standalone'; import { escapeHtml } from './util'; import { computeCollisions } from './collisions'; -import { colorForStatus } from './appearance'; +import { colorForStatus, statusLabel } from './appearance'; import type { Appearance } from './appearance'; import type { Board, Booking } from './types'; @@ -347,7 +347,7 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance) if (brand && company) tt.push(`
${esc(company)}
`); tt.push('
'); tt.push(datesRow); - if (bk.task_status) tt.push(`
${esc(bk.task_status)}
`); + if (bk.task_status) tt.push(`
${esc(statusLabel(bk.task_status))}
`); if (managers) tt.push(`
👤${esc(managers)}
`); if (isCollision) { tt.push('
⚠ Коллизия
');