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>
267 lines
8.6 KiB
TypeScript
267 lines
8.6 KiB
TypeScript
// 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 (lifecycle order). */
|
||
export const DEFAULT_STATUS_COLORS: Record<string, string> = {
|
||
'РК. Новая': '#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 },
|
||
collisionColor: '#e8590c',
|
||
defaultColor: '#4f46e5',
|
||
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;
|
||
}
|
||
|
||
// 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';
|
||
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(
|
||
statusColorRow(st, colorForStatus(a, st), DEFAULT_STATUS_COLORS[st] || a.defaultColor, (v) => {
|
||
a.colors[st] = v;
|
||
saveAppearance(a);
|
||
onChange();
|
||
}),
|
||
);
|
||
}
|
||
colours.appendChild(
|
||
colorRow('Коллизия', a.collisionColor, (v) => {
|
||
a.collisionColor = 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);
|
||
}
|