feat(fields): цветные опции select — глобальная подсветка задач (главная, реестр, канбан, шапка)

This commit is contained in:
2026-09-21 11:13:44 +03:00
parent aa316141be
commit c1463c71a1
12 changed files with 334 additions and 20 deletions

101
shared/tag-color.ts Normal file
View File

@@ -0,0 +1,101 @@
/**
* Цветные теги задач из select-полей (вариант A: опция select хранит color).
*
* «Главный» цвет задачи = цвет первой (по порядку полей формы) цветной опции,
* чьё значение выбрано в задаче. Используется для глобальной подсветки задачи
* (главная, реестр, канбан, шапка задачи). Чистая функция — общая для клиента и сервера.
*/
export interface ColoredSelectOption {
label: string;
value: string;
color?: string;
}
export interface TaskTagInfo {
color: string;
label: string;
fieldId: number;
fieldName: string;
}
const COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
/** Нормализует options поля в [{label, value, color?}]; строки -> {label=value}, color undefined. */
export function normalizeColoredOptions(options: unknown): ColoredSelectOption[] {
if (!Array.isArray(options)) return [];
return options
.map((opt: unknown): ColoredSelectOption | null => {
if (typeof opt === 'string') return { label: opt, value: opt };
if (typeof opt === 'object' && opt !== null) {
const obj = opt as Record<string, unknown>;
const label = String(obj.label ?? obj.name ?? '');
const value = String(obj.value ?? obj.id ?? label);
const rawColor = typeof obj.color === 'string' ? obj.color.trim() : '';
const color = COLOR_RE.test(rawColor) ? rawColor : undefined;
if (label || value) return { label: label || value, value, color };
}
return null;
})
.filter((opt): opt is ColoredSelectOption => opt !== null);
}
interface TagFieldLike {
id: number;
name: string;
type: string;
options?: unknown;
position?: number | null;
}
/** Типы полей, значения которых участвуют в цветном теге. */
const TAG_FIELD_TYPES = new Set(['select', 'radio-group', 'multiselect', 'checklist']);
function fieldValueOf(fieldValues: Record<string | number, unknown> | undefined | null, fieldId: number): unknown {
if (!fieldValues) return undefined;
if (fieldId in fieldValues) return fieldValues[fieldId];
const prefixed = `field_${fieldId}`;
if (prefixed in fieldValues) return fieldValues[prefixed];
return undefined;
}
/**
* Возвращает «главный» цветной тег задачи или null.
* fields — поля формы (в порядке отображения), fieldValues — значения задачи
* (ключи числовые id или строки "field_<id>"; значение строка или массив строк).
*/
export function computeTaskTagColor(
fields: TagFieldLike[] | undefined | null,
fieldValues: Record<string | number, unknown> | undefined | null
): TaskTagInfo | null {
if (!fields || fields.length === 0) return null;
const ordered = [...fields].sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
for (const field of ordered) {
if (!TAG_FIELD_TYPES.has(field.type)) continue;
const options = normalizeColoredOptions(field.options);
if (options.length === 0 || !options.some(o => o.color)) continue;
const raw = fieldValueOf(fieldValues, field.id);
if (raw === undefined || raw === null || raw === '') continue;
const values = Array.isArray(raw) ? raw : [raw];
for (const v of values) {
const str = typeof v === 'object' && v !== null ? String((v as Record<string, unknown>).value ?? '') : String(v);
const opt = options.find(o => o.value === str && o.color);
if (opt?.color) {
return { color: opt.color, label: opt.label, fieldId: field.id, fieldName: field.name };
}
}
}
return null;
}
/** Контрастный цвет текста для фона color (белый/тёмный) по относительной светлоте. */
export function contrastTextColor(color: string): string {
const hex = color.replace('#', '');
const full = hex.length === 3 ? hex.split('').map(c => c + c).join('') : hex.slice(0, 6);
const r = parseInt(full.slice(0, 2), 16);
const g = parseInt(full.slice(2, 4), 16);
const b = parseInt(full.slice(4, 6), 16);
// Относительная светлота (ITU-R BT.709)
const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
return luminance > 0.55 ? '#1e293b' : '#ffffff';
}