diff --git a/client/src/pages/TaskDetail.tsx b/client/src/pages/TaskDetail.tsx
index 676a3c5..f61e832 100644
--- a/client/src/pages/TaskDetail.tsx
+++ b/client/src/pages/TaskDetail.tsx
@@ -21,6 +21,7 @@ import { useOfflineSyncContext } from '@/contexts/OfflineSyncContext';
import { useTaskWithCache } from '@/hooks/useTaskWithCache';
import { saveTask, patchTaskCache } from '@/lib/tasksCache';
import type { Task } from '@shared/schema';
+import { computeTaskTagColor } from '@shared/tag-color';
import { evaluateAllFormulas } from '@/lib/formulaEngine';
import { TaskContent } from '@/components/task-detail/TaskContent';
import { TaskDetailHeader } from '@/components/task-detail/TaskDetailHeader';
@@ -535,6 +536,16 @@ const TaskDetail = () => {
rawFieldValuesMap,
);
+ // «Главный» цветной тег задачи (первая цветная опция select-поля) — чип в шапке
+ const tagValuesMap: Record = {};
+ for (const field of fields) {
+ const fv = fieldValues.find((v: TaskFieldValueData) => v.fieldId === field.id);
+ if (fv?.value !== undefined && fv?.value !== null && fv?.value !== '') {
+ tagValuesMap[`field_${field.id}`] = fv.value;
+ }
+ }
+ const taskTag = computeTaskTagColor(fields, tagValuesMap);
+
// Кастомный вид задачи: пресет (@preset:) или JS-код
const hasCustomLayout = detailLayoutInfo.kind !== 'standard';
const allTransitionsForLayout: FormTransition[] = effectiveDetailData?.transitions || [];
@@ -732,6 +743,7 @@ const TaskDetail = () => {
toggleHideMutation={toggleHideMutation}
onShowReminderDialog={setShowReminderDialog}
onShowDeleteDialog={setShowDeleteDialog}
+ tag={taskTag}
/>
}>,
+ orgId: number,
+): Promise {
+ if (rows.length === 0) return;
+ const formIds = [...new Set(rows.map(r => r.formId).filter(Boolean))] as number[];
+ const fieldsByForm = new Map>>();
+ await Promise.all(formIds.map(async (fid) => {
+ fieldsByForm.set(fid, await storage.getFormFields(fid, orgId));
+ }));
+ for (const row of rows) {
+ const tag = computeTaskTagColor(fieldsByForm.get(row.formId) ?? [], row.fieldValues);
+ (row as Record).tagColor = tag?.color ?? null;
+ (row as Record).tagLabel = tag?.label ?? null;
+ }
+}
+
const INBOX_SORT_COLUMNS: Record = {
title: 't.title',
status: 'fs.name',
@@ -253,6 +277,8 @@ homeRouter.get('/api/home/inbox',
)
`, [orgId, userId, search, formId, status, assignee, overdue]);
+ await decorateTagColor(result.rows, orgId);
+
res.json({
success: true,
tasks: result.rows,
@@ -333,7 +359,11 @@ homeRouter.get('/api/home/recent',
t.updated_at AS "updatedAt",
t.due_date AS "dueDate",
t.is_completed AS "isCompleted",
- rm.last_at AS "lastInteractionAt"
+ rm.last_at AS "lastInteractionAt",
+ (
+ SELECT jsonb_object_agg('field_' || tfv.field_id::text, tfv.value)
+ FROM task_field_values tfv WHERE tfv.task_id = t.id
+ ) AS "fieldValues"
FROM recent_max rm
JOIN tasks t ON t.id = rm.task_id AND t.organization_id = $1
JOIN accessible acc ON acc.task_id = t.id
@@ -376,6 +406,8 @@ homeRouter.get('/api/home/recent',
AND ($6 = '' OR CONCAT(u.first_name, ' ', u.last_name) ILIKE '%' || $6 || '%')
`, [orgId, userId, search, formId, status, assignee]);
+ await decorateTagColor(result.rows, orgId);
+
res.json({
success: true,
tasks: result.rows,
@@ -487,6 +519,10 @@ homeRouter.get('/api/home/snoozed',
t.updated_at AS "updatedAt",
t.due_date AS "dueDate",
t.is_completed AS "isCompleted",
+ (
+ SELECT jsonb_object_agg('field_' || tfv.field_id::text, tfv.value)
+ FROM task_field_values tfv WHERE tfv.task_id = t.id
+ ) AS "fieldValues",
MIN(tr.remind_at) OVER (PARTITION BY t.id) AS "remindAt",
CONCAT(cu.first_name, ' ', cu.last_name) AS "createdByName"
FROM task_reminders tr
@@ -541,6 +577,8 @@ homeRouter.get('/api/home/snoozed',
AND ($3 = '' OR t.title ILIKE '%' || $3 || '%')
`, [orgId, userId, search]);
+ await decorateTagColor(result.rows, orgId);
+
res.json({
success: true,
tasks: result.rows,
@@ -583,7 +621,11 @@ homeRouter.get('/api/home/hidden',
t.updated_at AS "updatedAt",
t.due_date AS "dueDate",
t.is_completed AS "isCompleted",
- tih.created_at AS "hiddenAt"
+ tih.created_at AS "hiddenAt",
+ (
+ SELECT jsonb_object_agg('field_' || tfv.field_id::text, tfv.value)
+ FROM task_field_values tfv WHERE tfv.task_id = t.id
+ ) AS "fieldValues"
FROM task_inbox_hidden tih
JOIN tasks t ON t.id = tih.task_id AND t.organization_id = $1 AND t.is_completed = false
JOIN forms f ON f.id = t.form_id
@@ -603,6 +645,8 @@ homeRouter.get('/api/home/hidden',
AND ($3 = '' OR t.title ILIKE '%' || $3 || '%')
`, [orgId, userId, search]);
+ await decorateTagColor(result.rows, orgId);
+
res.json({
success: true,
tasks: result.rows,
diff --git a/server/routes/task-crud-list.routes.ts b/server/routes/task-crud-list.routes.ts
index f52b896..04d8202 100644
--- a/server/routes/task-crud-list.routes.ts
+++ b/server/routes/task-crud-list.routes.ts
@@ -8,6 +8,7 @@ import { eq, inArray } from "drizzle-orm";
import { tasksMinimalCache } from "../utils/cache";
import { clampTasksLimit, parseTasksCursor, buildTasksNextCursor } from "../utils/task-pagination";
import { clampDirectoryRowsLimit } from "../utils/data-table-rows-query";
+import { computeTaskTagColor } from "@shared/tag-color";
// Лимиты выдачи уникальных значений в /column-values (автокомплит фильтров)
const COLUMN_VALUES_DEFAULT_LIMIT = 20;
@@ -339,6 +340,14 @@ export function registerTaskCrudListRoutes(router: ReturnType; tagColor?: string | null; tagLabel?: string | null }>) {
+ const tag = computeTaskTagColor(formFields, t.fieldValues);
+ t.tagColor = tag?.color ?? null;
+ t.tagLabel = tag?.label ?? null;
+ }
+
res.json({ success: true, tasks, total: tasks.length, limit, offset, relatedTaskTitles });
} catch (error) {
console.error('Get tasks with fields error:', error);
diff --git a/shared/tag-color.ts b/shared/tag-color.ts
new file mode 100644
index 0000000..5f25368
--- /dev/null
+++ b/shared/tag-color.ts
@@ -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;
+ 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 | 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_"; значение строка или массив строк).
+ */
+export function computeTaskTagColor(
+ fields: TagFieldLike[] | undefined | null,
+ fieldValues: Record | 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).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';
+}