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:
parent
5cabad9c33
commit
aeb4651f6f
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mapdash-frontend",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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); } });
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user