polish: appearance menu + clean status names everywhere (v0.2.1)

Appearance ("Оформление") menu:
- add missing status "РК. Предварительное бронирование" (8 statuses total)
- strip the systemic "РК. " prefix from status names in the UI (menu,
  tooltip, status filter dropdown, active-filter chips); raw values stay
  intact for filtering/URLs
- fixed-position menu: opens 25px below the header, ends >=30px above the
  window bottom, scrolls inside — never overflows the viewport
- drop the "Прочие статусы" row
- per-status grey reset glyph (circular arrows) restoring its default colour

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
aaverbitskiy 2026-08-14 15:37:03 +00:00
parent 5cabad9c33
commit aeb4651f6f
6 changed files with 85 additions and 21 deletions

View File

@ -1,7 +1,7 @@
{
"name": "mapdash-frontend",
"private": true,
"version": "0.2.0",
"version": "0.2.1",
"type": "module",
"scripts": {
"dev": "vite",

View File

@ -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<string, string> = {
'РК. Новая': '#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 =
'<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>';
/**
* 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

View File

@ -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);
}
}

View File

@ -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); } });

View File

@ -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);

View File

@ -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(`<div class="tt-sub">${esc(company)}</div>`);
tt.push('<div class="tt-sep"></div>');
tt.push(datesRow);
if (bk.task_status) tt.push(`<div class="tt-row"><span class="tt-dot" style="background:${statusColor}"></span><span>${esc(bk.task_status)}</span></div>`);
if (bk.task_status) tt.push(`<div class="tt-row"><span class="tt-dot" style="background:${statusColor}"></span><span>${esc(statusLabel(bk.task_status))}</span></div>`);
if (managers) tt.push(`<div class="tt-row"><span class="tt-ico">👤</span><span>${esc(managers)}</span></div>`);
if (isCollision) {
tt.push('<div class="tt-warn"><div class="tt-warn-head">⚠ Коллизия</div>');