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

View File

@@ -0,0 +1,81 @@
import { contrastTextColor, normalizeColoredOptions } from '@shared/tag-color';
import { cn } from '@/lib/utils';
interface ColoredTagProps {
color: string;
children: React.ReactNode;
className?: string;
}
/**
* Цветной чип тега: фон = цвет опции, текст — контрастный (белый/тёмный),
* чтобы надпись читалась на любом цвете. Используется в реестре, канбане и шапке задачи.
*/
export function ColoredTag({ color, children, className }: ColoredTagProps) {
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded px-1.5 py-px text-[11px] font-medium leading-4 whitespace-nowrap',
className
)}
style={{
backgroundColor: color,
color: contrastTextColor(color),
}}
>
{children}
</span>
);
}
/**
* Значение select-подобного поля цветными чипами.
* Возвращает null, если у поля нет цветных опций или значение не совпало —
* тогда вызывающий код рендерит обычный текст (fallback).
*/
export function renderColoredSelectValue(
field: { type?: string; options?: unknown },
value: unknown
): React.ReactNode {
const type = field.type ?? '';
if (!['select', 'radio-group', 'multiselect', 'checklist'].includes(type)) return null;
const options = normalizeColoredOptions(field.options);
if (options.length === 0 || !options.some(o => o.color)) return null;
if (value === null || value === undefined || value === '') return null;
const values = Array.isArray(value) ? value : [value];
const matched = values
.map(v => (typeof v === 'object' && v !== null ? String((v as Record<string, unknown>).value ?? '') : String(v)))
.map(str => options.find(o => o.value === str && o.color))
.filter((o): o is { label: string; value: string; color: string } => !!o);
if (matched.length === 0) return null;
return (
<>
{matched.map(o => (
<ColoredTag key={o.value} color={o.color}>{o.label}</ColoredTag>
))}
</>
);
}
/** Ячейка: цветные чипы при совпадении, иначе fallback-текст. */
export function SelectValueOrText({
field,
value,
fallback,
}: {
field: { type?: string; options?: unknown };
value: unknown;
fallback: React.ReactNode;
}): React.ReactElement {
return <>{renderColoredSelectValue(field, value) ?? fallback}</>;
}
/** Лёгкий тинт фона из цвета тега (для ячеек/строк — color-mix с прозрачностью). */
export function tagTintStyle(color: string, opacity = 0.14): React.CSSProperties {
return { backgroundColor: `color-mix(in srgb, ${color} ${Math.round(opacity * 100)}%, transparent)` };
}
/** Тонкая цветная полоска слева (для строк таблиц и карточек канбана). */
export function tagStripeStyle(color: string, width = 3): React.CSSProperties {
return { boxShadow: `inset ${width}px 0 0 0 ${color}` };
}

View File

@@ -11,6 +11,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Settings2, Plus, Calendar, User, Clock, AlertCircle, Search, X as XIcon, CheckCircle2, GripVertical } from 'lucide-react'; import { Settings2, Plus, Calendar, User, Clock, AlertCircle, Search, X as XIcon, CheckCircle2, GripVertical } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import ContractNumberDisplay from '@/components/ContractNumberDisplay'; import ContractNumberDisplay from '@/components/ContractNumberDisplay';
import { SelectValueOrText, tagStripeStyle } from '@/components/ColoredTag';
import { resolveFieldDisplayValue, type FieldDisplayContext } from '@/lib/fieldDisplay'; import { resolveFieldDisplayValue, type FieldDisplayContext } from '@/lib/fieldDisplay';
import { statusBadgeStyle } from '@/lib/statusColor'; import { statusBadgeStyle } from '@/lib/statusColor';
import { import {
@@ -52,6 +53,9 @@ interface TaskData {
isCompleted?: boolean; isCompleted?: boolean;
dueDate?: string; dueDate?: string;
fieldValues?: { [fieldId: number]: unknown }; fieldValues?: { [fieldId: number]: unknown };
/** «Главный» цветной тег (первая цветная опция select-поля) — подсветка карточки */
tagColor?: string | null;
tagLabel?: string | null;
} }
interface FormFieldData { interface FormFieldData {
@@ -696,8 +700,11 @@ const TaskKanban = ({ tasks, relatedTaskTitles = {}, fields, statuses, users = [
const cardOverdue = !isCompleted && isOverdue(task.dueDate); const cardOverdue = !isCompleted && isOverdue(task.dueDate);
return ( return (
<Card className={`mb-2 cursor-pointer hover:shadow-md transition-shadow ${isCompleted ? 'bg-muted opacity-70' : ''} ${cardOverdue ? 'border-l-4 border-l-red-500 bg-red-50 dark:bg-red-950/20' : ''}`} data-testid={`card-task-${task.id}`}> <Card
<CardContent className="p-2"> className={`mb-2 cursor-pointer hover:shadow-md transition-shadow ${isCompleted ? 'bg-muted opacity-70' : ''} ${cardOverdue ? 'border-l-4 border-l-red-500 bg-red-50 dark:bg-red-950/20' : ''}`}
style={task.tagColor ? tagStripeStyle(task.tagColor, 4) : undefined}
data-testid={`card-task-${task.id}`}
> <CardContent className="p-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
{/* Заголовок и статус */} {/* Заголовок и статус */}
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
@@ -800,7 +807,7 @@ const TaskKanban = ({ tasks, relatedTaskTitles = {}, fields, statuses, users = [
</span> </span>
) : ( ) : (
<span className="ml-1 font-medium text-foreground"> <span className="ml-1 font-medium text-foreground">
{fieldValue || '(пусто)'} <SelectValueOrText field={field} value={task.fieldValues?.[fieldId]} fallback={fieldValue || '(пусто)'} />
</span> </span>
)} )}
</div> </div>

View File

@@ -32,6 +32,7 @@ import { CSS } from '@dnd-kit/utilities';
import { useTableMaps } from '@/hooks/useTableMaps'; import { useTableMaps } from '@/hooks/useTableMaps';
import { ColumnFilter } from '@/components/ui/ColumnFilter'; import { ColumnFilter } from '@/components/ui/ColumnFilter';
import { ActiveFilterBadges } from '@/components/ui/ActiveFilterBadges'; import { ActiveFilterBadges } from '@/components/ui/ActiveFilterBadges';
import { SelectValueOrText, tagStripeStyle } from '@/components/ColoredTag';
import { apiRequest, queryClient, OfflineQueuedError } from '@/lib/queryClient'; import { apiRequest, queryClient, OfflineQueuedError } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { addFileToken, openFilePresigned } from '@/lib/fileUtils'; import { addFileToken, openFilePresigned } from '@/lib/fileUtils';
@@ -60,6 +61,9 @@ interface TaskData {
isCompleted?: boolean; isCompleted?: boolean;
dueDate?: string; dueDate?: string;
fieldValues?: { [fieldId: number]: unknown }; fieldValues?: { [fieldId: number]: unknown };
/** «Главный» цветной тег (первая цветная опция select-поля) — подсветка строки */
tagColor?: string | null;
tagLabel?: string | null;
} }
interface FormFieldData { interface FormFieldData {
@@ -1091,6 +1095,7 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
data-index={virtualIndex ?? undefined} data-index={virtualIndex ?? undefined}
data-testid={`row-task-${task.id}`} data-testid={`row-task-${task.id}`}
className={`${isCompleted ? 'bg-muted opacity-70' : ''} ${isOverdue ? 'border-l-2 border-l-red-500 bg-red-50 dark:bg-red-950/20' : ''} ${isAdmin ? 'cursor-pointer' : ''}`} className={`${isCompleted ? 'bg-muted opacity-70' : ''} ${isOverdue ? 'border-l-2 border-l-red-500 bg-red-50 dark:bg-red-950/20' : ''} ${isAdmin ? 'cursor-pointer' : ''}`}
style={task.tagColor ? tagStripeStyle(task.tagColor) : undefined}
onClick={(e) => handleRowClick(task, e)} onClick={(e) => handleRowClick(task, e)}
onMouseDown={handleRowMouseDown} onMouseDown={handleRowMouseDown}
> >
@@ -1274,14 +1279,14 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
onClick={() => handleQuickToggle(task.id, field.id, rawValue)} onClick={() => handleQuickToggle(task.id, field.id, rawValue)}
onDoubleClick={() => setEditingCell({ taskId: task.id, columnKey: key })} onDoubleClick={() => setEditingCell({ taskId: task.id, columnKey: key })}
> >
{displayFieldValue(field, rawValue, displayContext) || <span className="text-muted-foreground">—</span>} {<SelectValueOrText field={field} value={rawValue} fallback={displayFieldValue(field, rawValue, displayContext) || <span className="text-muted-foreground">—</span>} />}
</button> </button>
) : REGISTRY_INLINE_TYPES.has(field.type) ? ( ) : REGISTRY_INLINE_TYPES.has(field.type) ? (
<InlineField <InlineField
ariaLabel={field.name} ariaLabel={field.name}
readView={ readView={
<span className="text-sm" style={clampStyle(maxLines)}> <span className="text-sm" style={clampStyle(maxLines)}>
{displayFieldValue(field, rawValue, displayContext) || <span className="text-muted-foreground">—</span>} {<SelectValueOrText field={field} value={rawValue} fallback={displayFieldValue(field, rawValue, displayContext) || <span className="text-muted-foreground">—</span>} />}
</span> </span>
} }
editor={ editor={
@@ -1309,7 +1314,7 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
/> />
) : ( ) : (
<span className="text-sm" style={clampStyle(maxLines)}> <span className="text-sm" style={clampStyle(maxLines)}>
{displayFieldValue(field, rawValue, displayContext) || <span className="text-muted-foreground">—</span>} {<SelectValueOrText field={field} value={rawValue} fallback={displayFieldValue(field, rawValue, displayContext) || <span className="text-muted-foreground">—</span>} />}
</span> </span>
)} )}
</TableCell> </TableCell>

View File

@@ -14,8 +14,23 @@ import { Check, Copy, Download } from 'lucide-react';
import { JsComponentRenderer } from '@/components/tabs/JsComponentRenderer'; import { JsComponentRenderer } from '@/components/tabs/JsComponentRenderer';
import { ErrorBoundary } from '@/components/ErrorBoundary'; import { ErrorBoundary } from '@/components/ErrorBoundary';
import { ButtonCopyFieldsDialog, type ButtonCopyFieldMapping } from '@/components/ButtonCopyFieldsDialog'; import { ButtonCopyFieldsDialog, type ButtonCopyFieldMapping } from '@/components/ButtonCopyFieldsDialog';
import { ColoredTag } from '@/components/ColoredTag';
import { textToSelectOptions } from '@/lib/fieldOptions';
import type { useFormEditorController } from './useFormEditorController'; import type { useFormEditorController } from './useFormEditorController';
/** Живое превью вариантов с цветами под textarea-редактором опций */
function SelectOptionsPreview({ text }: { text: string }) {
const options = textToSelectOptions(text).filter(o => o.color);
if (options.length === 0) return null;
return (
<div className="flex flex-wrap items-center gap-1.5" data-testid="select-options-preview">
{options.map((opt, i) => (
<ColoredTag key={`${opt.value}-${i}`} color={opt.color!}>{opt.label}</ColoredTag>
))}
</div>
);
}
type C = ReturnType<typeof useFormEditorController>; type C = ReturnType<typeof useFormEditorController>;
type FormEditorDialogsProps = Pick<C, type FormEditorDialogsProps = Pick<C,
@@ -192,16 +207,17 @@ export function FormEditorDialogs({
</DialogHeader> </DialogHeader>
<div className="space-y-3 py-2"> <div className="space-y-3 py-2">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Каждая строка — один вариант. Формат: <code>Название</code> или <code>Название | Значение</code>. Каждая строка — один вариант. Формат: <code>Название</code>, <code>Название | Значение</code> или <code>Название | Значение | #цвет</code> (например <code>#f59e0b</code>). Цвет подсвечивает задачу в реестре, канбане и шапке.
</p> </p>
<Textarea <Textarea
value={selectOptionsText} value={selectOptionsText}
onChange={(e) => setSelectOptionsText(e.target.value)} onChange={(e) => setSelectOptionsText(e.target.value)}
placeholder="Вариант 1&#10;Вариант 2 | value2&#10;Вариант 3" placeholder={"В работе | work | #3b82f6\nСрочно | urgent | #ef4444\nРешено | done | #22c55e"}
rows={10} rows={10}
className="text-xs" className="text-xs"
data-testid="textarea-select-options" data-testid="textarea-select-options"
/> />
<SelectOptionsPreview text={selectOptionsText} />
<div className="flex gap-2 pt-1"> <div className="flex gap-2 pt-1">
<Button variant="outline" className="h-7 text-xs flex-1" onClick={() => setSelectOptionsDialogOpen(false)}> <Button variant="outline" className="h-7 text-xs flex-1" onClick={() => setSelectOptionsDialogOpen(false)}>
Отмена Отмена

View File

@@ -10,6 +10,8 @@ import type { TaskData } from '@/components/task-fields/FieldValueRow';
import type { Task } from '@shared/schema'; import type { Task } from '@shared/schema';
import type { FormStatus, FormTransition } from './types'; import type { FormStatus, FormTransition } from './types';
import { TaskTitleInline } from './TaskTitleInline'; import { TaskTitleInline } from './TaskTitleInline';
import { ColoredTag } from '@/components/ColoredTag';
import type { TaskTagInfo } from '@shared/tag-color';
interface TaskDetailHeaderProps { interface TaskDetailHeaderProps {
task: TaskData; task: TaskData;
@@ -24,6 +26,8 @@ interface TaskDetailHeaderProps {
toggleHideMutation: UseMutationResult<void, Error, void>; toggleHideMutation: UseMutationResult<void, Error, void>;
onShowReminderDialog: (open: boolean) => void; onShowReminderDialog: (open: boolean) => void;
onShowDeleteDialog: (open: boolean) => void; onShowDeleteDialog: (open: boolean) => void;
/** «Главный» цветной тег задачи (первая цветная опция select-поля) */
tag?: TaskTagInfo | null;
} }
export function TaskDetailHeader({ export function TaskDetailHeader({
@@ -39,6 +43,7 @@ export function TaskDetailHeader({
toggleHideMutation, toggleHideMutation,
onShowReminderDialog: setShowReminderDialog, onShowReminderDialog: setShowReminderDialog,
onShowDeleteDialog: setShowDeleteDialog, onShowDeleteDialog: setShowDeleteDialog,
tag,
}: TaskDetailHeaderProps) { }: TaskDetailHeaderProps) {
const [, setLocation] = useLocation(); const [, setLocation] = useLocation();
const { user: currentUser } = useAuth(); const { user: currentUser } = useAuth();
@@ -170,6 +175,11 @@ export function TaskDetailHeader({
{hideStatus?.isHidden ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />} {hideStatus?.isHidden ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
</button> </button>
<TaskQrButton formId={formId} taskId={taskIdNumber} title={task.title} /> <TaskQrButton formId={formId} taskId={taskIdNumber} title={task.title} />
{tag && (
<ColoredTag color={tag.color} className="gap-0.5" >
<span title={`${tag.fieldName}`}>{tag.label}</span>
</ColoredTag>
)}
<TaskStatusBadge /> <TaskStatusBadge />
{/* Удаление отделено от нейтральных иконок и стоит в конце ряда */} {/* Удаление отделено от нейтральных иконок и стоит в конце ряда */}
{isAdmin(currentUser) && ( {isAdmin(currentUser) && (

View File

@@ -12,6 +12,16 @@
export interface SelectOption { export interface SelectOption {
label: string; label: string;
value: string; value: string;
/** Опциональный цвет тега (#rrggbb) — подсветка задачи в реестре/канбане/шапке */
color?: string;
}
const OPTION_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
function parseOptionColor(raw: unknown): string | undefined {
if (typeof raw !== 'string') return undefined;
const trimmed = raw.trim();
return OPTION_COLOR_RE.test(trimmed) ? trimmed : undefined;
} }
export function isSelectOption(value: unknown): value is SelectOption { export function isSelectOption(value: unknown): value is SelectOption {
@@ -41,8 +51,11 @@ export function normalizeSelectOptions(options: unknown): SelectOption[] {
const obj = opt as Record<string, unknown>; const obj = opt as Record<string, unknown>;
const label = String(obj.label ?? obj.name ?? ''); const label = String(obj.label ?? obj.name ?? '');
const value = String(obj.value ?? obj.id ?? label); const value = String(obj.value ?? obj.id ?? label);
const color = parseOptionColor(obj.color);
if (label || value) { if (label || value) {
return { label: label || value, value }; const normalized: SelectOption = { label: label || value, value };
if (color) normalized.color = color;
return normalized;
} }
} }
return null; return null;
@@ -52,18 +65,21 @@ export function normalizeSelectOptions(options: unknown): SelectOption[] {
/** /**
* Преобразует options в текст для textarea-редактора. * Преобразует options в текст для textarea-редактора.
* Формат: "Название" или "Название | Значение". * Формат: "Название" | "Название | Значение" | "Название | Значение | #цвет".
*/ */
export function selectOptionsToText(options: unknown): string { export function selectOptionsToText(options: unknown): string {
const normalized = normalizeSelectOptions(options); const normalized = normalizeSelectOptions(options);
return normalized return normalized
.map((opt) => (opt.label === opt.value ? opt.label : `${opt.label}|${opt.value}`)) .map((opt) => {
const base = opt.label === opt.value ? opt.label : `${opt.label}|${opt.value}`;
return opt.color ? `${base}|${opt.color}` : base;
})
.join('\n'); .join('\n');
} }
/** /**
* Парсит текст редактора в массив объектов {label, value}. * Парсит текст редактора в массив объектов {label, value, color?}.
* Каждая строка: "Название" или "Название | Значение". * Каждая строка: "Название" | "Название | Значение" | "Название | Значение | #цвет".
*/ */
export function textToSelectOptions(text: string): SelectOption[] { export function textToSelectOptions(text: string): SelectOption[] {
return text return text
@@ -72,10 +88,12 @@ export function textToSelectOptions(text: string): SelectOption[] {
.filter(Boolean) .filter(Boolean)
.map((line) => { .map((line) => {
const parts = line.split('|').map((p) => p.trim()); const parts = line.split('|').map((p) => p.trim());
if (parts.length >= 2 && parts[1]) { const color = parts.length >= 3 ? parseOptionColor(parts[2]) : undefined;
return { label: parts[0], value: parts[1] }; const base: SelectOption = parts.length >= 2 && parts[1]
} ? { label: parts[0], value: parts[1] }
return { label: parts[0], value: parts[0] }; : { label: parts[0], value: parts[0] };
if (color) base.color = color;
return base;
}) })
.filter((opt) => opt.label); .filter((opt) => opt.label);
} }

View File

@@ -18,6 +18,7 @@ import {
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion'; import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { tagStripeStyle } from '@/components/ColoredTag';
import { format, isToday, isYesterday, differenceInDays } from 'date-fns'; import { format, isToday, isYesterday, differenceInDays } from 'date-fns';
import { ru } from 'date-fns/locale'; import { ru } from 'date-fns/locale';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
@@ -104,6 +105,9 @@ interface HomeTask {
remindAt?: string; remindAt?: string;
createdByName?: string; createdByName?: string;
hiddenAt?: string; hiddenAt?: string;
/** «Главный» цветной тег задачи (первая цветная опция select-поля) — сервер считает */
tagColor?: string | null;
tagLabel?: string | null;
fieldValues?: Record<string, unknown>; fieldValues?: Record<string, unknown>;
} }
@@ -342,7 +346,11 @@ function TaskTableRow({
const hasUnread = !task.isCompleted && !task.statusIsFinal && (task.unreadNotifications ?? 0) > 0; const hasUnread = !task.isCompleted && !task.statusIsFinal && (task.unreadNotifications ?? 0) > 0;
return ( return (
<TableRow className={cn("cursor-pointer hover:bg-muted/40 group", hasUnread && "bg-primary/[0.03]")} onClick={() => navigate(`/forms/${task.formId}/tasks/${task.id}`)}> <TableRow
className={cn("cursor-pointer hover:bg-muted/40 group", hasUnread && "bg-primary/[0.03]")}
style={task.tagColor ? tagStripeStyle(task.tagColor) : undefined}
onClick={() => navigate(`/forms/${task.formId}/tasks/${task.id}`)}
>
<TableCell className="py-1.5 pl-4 pr-2 min-w-0"> <TableCell className="py-1.5 pl-4 pr-2 min-w-0">
<div className="flex items-start gap-1.5"> <div className="flex items-start gap-1.5">
<div className="mt-0.5 shrink-0 relative"> <div className="mt-0.5 shrink-0 relative">

View File

@@ -21,6 +21,7 @@ import { useOfflineSyncContext } from '@/contexts/OfflineSyncContext';
import { useTaskWithCache } from '@/hooks/useTaskWithCache'; import { useTaskWithCache } from '@/hooks/useTaskWithCache';
import { saveTask, patchTaskCache } from '@/lib/tasksCache'; import { saveTask, patchTaskCache } from '@/lib/tasksCache';
import type { Task } from '@shared/schema'; import type { Task } from '@shared/schema';
import { computeTaskTagColor } from '@shared/tag-color';
import { evaluateAllFormulas } from '@/lib/formulaEngine'; import { evaluateAllFormulas } from '@/lib/formulaEngine';
import { TaskContent } from '@/components/task-detail/TaskContent'; import { TaskContent } from '@/components/task-detail/TaskContent';
import { TaskDetailHeader } from '@/components/task-detail/TaskDetailHeader'; import { TaskDetailHeader } from '@/components/task-detail/TaskDetailHeader';
@@ -535,6 +536,16 @@ const TaskDetail = () => {
rawFieldValuesMap, rawFieldValuesMap,
); );
// «Главный» цветной тег задачи (первая цветная опция select-поля) — чип в шапке
const tagValuesMap: Record<string, unknown> = {};
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:<id>) или JS-код // Кастомный вид задачи: пресет (@preset:<id>) или JS-код
const hasCustomLayout = detailLayoutInfo.kind !== 'standard'; const hasCustomLayout = detailLayoutInfo.kind !== 'standard';
const allTransitionsForLayout: FormTransition[] = effectiveDetailData?.transitions || []; const allTransitionsForLayout: FormTransition[] = effectiveDetailData?.transitions || [];
@@ -732,6 +743,7 @@ const TaskDetail = () => {
toggleHideMutation={toggleHideMutation} toggleHideMutation={toggleHideMutation}
onShowReminderDialog={setShowReminderDialog} onShowReminderDialog={setShowReminderDialog}
onShowDeleteDialog={setShowDeleteDialog} onShowDeleteDialog={setShowDeleteDialog}
tag={taskTag}
/> />
<TaskTabsBar <TaskTabsBar

View File

@@ -50,6 +50,9 @@ interface TaskData {
dueDate?: string; dueDate?: string;
parentTaskId?: number | null; parentTaskId?: number | null;
depth?: number; depth?: number;
/** «Главный» цветной тег (первая цветная опция select-поля) — подсветка строки */
tagColor?: string | null;
tagLabel?: string | null;
} }
interface UserData { interface UserData {

View File

@@ -2,9 +2,33 @@ import { Router } from 'express';
import { authenticateToken, type AuthenticatedRequest } from '../middleware/auth.middleware'; import { authenticateToken, type AuthenticatedRequest } from '../middleware/auth.middleware';
import { tenantIsolation } from '../middleware/tenant.middleware'; import { tenantIsolation } from '../middleware/tenant.middleware';
import { pool } from '../db'; import { pool } from '../db';
import { storage } from '../storage';
import { computeTaskTagColor } from '@shared/tag-color';
const homeRouter = Router(); const homeRouter = Router();
/**
* Добавляет каждой строке задачи tagColor/tagLabel — «главный» цветной тег
* (первая цветная опция select-поля по порядку полей формы). Одна загрузка
* полей на форму на запрос; вычисление в JS (shared/tag-color).
*/
async function decorateTagColor(
rows: Array<{ formId: number; fieldValues?: Record<string, unknown> }>,
orgId: number,
): Promise<void> {
if (rows.length === 0) return;
const formIds = [...new Set(rows.map(r => r.formId).filter(Boolean))] as number[];
const fieldsByForm = new Map<number, Awaited<ReturnType<typeof storage.getFormFields>>>();
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<string, unknown>).tagColor = tag?.color ?? null;
(row as Record<string, unknown>).tagLabel = tag?.label ?? null;
}
}
const INBOX_SORT_COLUMNS: Record<string, string> = { const INBOX_SORT_COLUMNS: Record<string, string> = {
title: 't.title', title: 't.title',
status: 'fs.name', status: 'fs.name',
@@ -253,6 +277,8 @@ homeRouter.get('/api/home/inbox',
) )
`, [orgId, userId, search, formId, status, assignee, overdue]); `, [orgId, userId, search, formId, status, assignee, overdue]);
await decorateTagColor(result.rows, orgId);
res.json({ res.json({
success: true, success: true,
tasks: result.rows, tasks: result.rows,
@@ -333,7 +359,11 @@ homeRouter.get('/api/home/recent',
t.updated_at AS "updatedAt", t.updated_at AS "updatedAt",
t.due_date AS "dueDate", t.due_date AS "dueDate",
t.is_completed AS "isCompleted", 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 FROM recent_max rm
JOIN tasks t ON t.id = rm.task_id AND t.organization_id = $1 JOIN tasks t ON t.id = rm.task_id AND t.organization_id = $1
JOIN accessible acc ON acc.task_id = t.id 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 || '%') AND ($6 = '' OR CONCAT(u.first_name, ' ', u.last_name) ILIKE '%' || $6 || '%')
`, [orgId, userId, search, formId, status, assignee]); `, [orgId, userId, search, formId, status, assignee]);
await decorateTagColor(result.rows, orgId);
res.json({ res.json({
success: true, success: true,
tasks: result.rows, tasks: result.rows,
@@ -487,6 +519,10 @@ homeRouter.get('/api/home/snoozed',
t.updated_at AS "updatedAt", t.updated_at AS "updatedAt",
t.due_date AS "dueDate", t.due_date AS "dueDate",
t.is_completed AS "isCompleted", 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", MIN(tr.remind_at) OVER (PARTITION BY t.id) AS "remindAt",
CONCAT(cu.first_name, ' ', cu.last_name) AS "createdByName" CONCAT(cu.first_name, ' ', cu.last_name) AS "createdByName"
FROM task_reminders tr FROM task_reminders tr
@@ -541,6 +577,8 @@ homeRouter.get('/api/home/snoozed',
AND ($3 = '' OR t.title ILIKE '%' || $3 || '%') AND ($3 = '' OR t.title ILIKE '%' || $3 || '%')
`, [orgId, userId, search]); `, [orgId, userId, search]);
await decorateTagColor(result.rows, orgId);
res.json({ res.json({
success: true, success: true,
tasks: result.rows, tasks: result.rows,
@@ -583,7 +621,11 @@ homeRouter.get('/api/home/hidden',
t.updated_at AS "updatedAt", t.updated_at AS "updatedAt",
t.due_date AS "dueDate", t.due_date AS "dueDate",
t.is_completed AS "isCompleted", 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 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 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 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 || '%') AND ($3 = '' OR t.title ILIKE '%' || $3 || '%')
`, [orgId, userId, search]); `, [orgId, userId, search]);
await decorateTagColor(result.rows, orgId);
res.json({ res.json({
success: true, success: true,
tasks: result.rows, tasks: result.rows,

View File

@@ -8,6 +8,7 @@ import { eq, inArray } from "drizzle-orm";
import { tasksMinimalCache } from "../utils/cache"; import { tasksMinimalCache } from "../utils/cache";
import { clampTasksLimit, parseTasksCursor, buildTasksNextCursor } from "../utils/task-pagination"; import { clampTasksLimit, parseTasksCursor, buildTasksNextCursor } from "../utils/task-pagination";
import { clampDirectoryRowsLimit } from "../utils/data-table-rows-query"; import { clampDirectoryRowsLimit } from "../utils/data-table-rows-query";
import { computeTaskTagColor } from "@shared/tag-color";
// Лимиты выдачи уникальных значений в /column-values (автокомплит фильтров) // Лимиты выдачи уникальных значений в /column-values (автокомплит фильтров)
const COLUMN_VALUES_DEFAULT_LIMIT = 20; const COLUMN_VALUES_DEFAULT_LIMIT = 20;
@@ -339,6 +340,14 @@ export function registerTaskCrudListRoutes(router: ReturnType<typeof import("exp
const formFields = await storage.getFormFields(formId, req.organizationId!); const formFields = await storage.getFormFields(formId, req.organizationId!);
const relatedTaskTitles = await buildRelatedTaskTitles(tasks, formFields); const relatedTaskTitles = await buildRelatedTaskTitles(tasks, formFields);
// «Главный» цветной тег задачи (первая цветная опция select-поля) —
// подсветка в реестре и канбане
for (const t of tasks as Array<Task & { fieldValues?: Record<number, unknown>; 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 }); res.json({ success: true, tasks, total: tasks.length, limit, offset, relatedTaskTitles });
} catch (error) { } catch (error) {
console.error('Get tasks with fields error:', error); console.error('Get tasks with fields error:', error);

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';
}