feat(fields): цветные опции select — глобальная подсветка задач (главная, реестр, канбан, шапка)
This commit is contained in:
101
shared/tag-color.ts
Normal file
101
shared/tag-color.ts
Normal 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';
|
||||
}
|
||||
Reference in New Issue
Block a user