feat(fields): цветные опции select — глобальная подсветка задач (главная, реестр, канбан, шапка)
This commit is contained in:
81
client/src/components/ColoredTag.tsx
Normal file
81
client/src/components/ColoredTag.tsx
Normal 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}` };
|
||||
}
|
||||
@@ -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 { useAuth } from '@/hooks/useAuth';
|
||||
import ContractNumberDisplay from '@/components/ContractNumberDisplay';
|
||||
import { SelectValueOrText, tagStripeStyle } from '@/components/ColoredTag';
|
||||
import { resolveFieldDisplayValue, type FieldDisplayContext } from '@/lib/fieldDisplay';
|
||||
import { statusBadgeStyle } from '@/lib/statusColor';
|
||||
import {
|
||||
@@ -52,6 +53,9 @@ interface TaskData {
|
||||
isCompleted?: boolean;
|
||||
dueDate?: string;
|
||||
fieldValues?: { [fieldId: number]: unknown };
|
||||
/** «Главный» цветной тег (первая цветная опция select-поля) — подсветка карточки */
|
||||
tagColor?: string | null;
|
||||
tagLabel?: string | null;
|
||||
}
|
||||
|
||||
interface FormFieldData {
|
||||
@@ -696,8 +700,11 @@ const TaskKanban = ({ tasks, relatedTaskTitles = {}, fields, statuses, users = [
|
||||
const cardOverdue = !isCompleted && isOverdue(task.dueDate);
|
||||
|
||||
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}`}>
|
||||
<CardContent className="p-2">
|
||||
<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' : ''}`}
|
||||
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="flex items-start justify-between">
|
||||
@@ -800,7 +807,7 @@ const TaskKanban = ({ tasks, relatedTaskTitles = {}, fields, statuses, users = [
|
||||
</span>
|
||||
) : (
|
||||
<span className="ml-1 font-medium text-foreground">
|
||||
{fieldValue || '(пусто)'}
|
||||
<SelectValueOrText field={field} value={task.fieldValues?.[fieldId]} fallback={fieldValue || '(пусто)'} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,7 @@ import { CSS } from '@dnd-kit/utilities';
|
||||
import { useTableMaps } from '@/hooks/useTableMaps';
|
||||
import { ColumnFilter } from '@/components/ui/ColumnFilter';
|
||||
import { ActiveFilterBadges } from '@/components/ui/ActiveFilterBadges';
|
||||
import { SelectValueOrText, tagStripeStyle } from '@/components/ColoredTag';
|
||||
import { apiRequest, queryClient, OfflineQueuedError } from '@/lib/queryClient';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { addFileToken, openFilePresigned } from '@/lib/fileUtils';
|
||||
@@ -60,6 +61,9 @@ interface TaskData {
|
||||
isCompleted?: boolean;
|
||||
dueDate?: string;
|
||||
fieldValues?: { [fieldId: number]: unknown };
|
||||
/** «Главный» цветной тег (первая цветная опция select-поля) — подсветка строки */
|
||||
tagColor?: string | null;
|
||||
tagLabel?: string | null;
|
||||
}
|
||||
|
||||
interface FormFieldData {
|
||||
@@ -1091,6 +1095,7 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
|
||||
data-index={virtualIndex ?? undefined}
|
||||
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' : ''}`}
|
||||
style={task.tagColor ? tagStripeStyle(task.tagColor) : undefined}
|
||||
onClick={(e) => handleRowClick(task, e)}
|
||||
onMouseDown={handleRowMouseDown}
|
||||
>
|
||||
@@ -1274,14 +1279,14 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
|
||||
onClick={() => handleQuickToggle(task.id, field.id, rawValue)}
|
||||
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>
|
||||
) : REGISTRY_INLINE_TYPES.has(field.type) ? (
|
||||
<InlineField
|
||||
ariaLabel={field.name}
|
||||
readView={
|
||||
<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>
|
||||
}
|
||||
editor={
|
||||
@@ -1309,7 +1314,7 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
@@ -14,8 +14,23 @@ import { Check, Copy, Download } from 'lucide-react';
|
||||
import { JsComponentRenderer } from '@/components/tabs/JsComponentRenderer';
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import { ButtonCopyFieldsDialog, type ButtonCopyFieldMapping } from '@/components/ButtonCopyFieldsDialog';
|
||||
import { ColoredTag } from '@/components/ColoredTag';
|
||||
import { textToSelectOptions } from '@/lib/fieldOptions';
|
||||
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 FormEditorDialogsProps = Pick<C,
|
||||
@@ -192,16 +207,17 @@ export function FormEditorDialogs({
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Каждая строка — один вариант. Формат: <code>Название</code> или <code>Название | Значение</code>.
|
||||
Каждая строка — один вариант. Формат: <code>Название</code>, <code>Название | Значение</code> или <code>Название | Значение | #цвет</code> (например <code>#f59e0b</code>). Цвет подсвечивает задачу в реестре, канбане и шапке.
|
||||
</p>
|
||||
<Textarea
|
||||
value={selectOptionsText}
|
||||
onChange={(e) => setSelectOptionsText(e.target.value)}
|
||||
placeholder="Вариант 1 Вариант 2 | value2 Вариант 3"
|
||||
placeholder={"В работе | work | #3b82f6\nСрочно | urgent | #ef4444\nРешено | done | #22c55e"}
|
||||
rows={10}
|
||||
className="text-xs"
|
||||
data-testid="textarea-select-options"
|
||||
/>
|
||||
<SelectOptionsPreview text={selectOptionsText} />
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button variant="outline" className="h-7 text-xs flex-1" onClick={() => setSelectOptionsDialogOpen(false)}>
|
||||
Отмена
|
||||
|
||||
@@ -10,6 +10,8 @@ import type { TaskData } from '@/components/task-fields/FieldValueRow';
|
||||
import type { Task } from '@shared/schema';
|
||||
import type { FormStatus, FormTransition } from './types';
|
||||
import { TaskTitleInline } from './TaskTitleInline';
|
||||
import { ColoredTag } from '@/components/ColoredTag';
|
||||
import type { TaskTagInfo } from '@shared/tag-color';
|
||||
|
||||
interface TaskDetailHeaderProps {
|
||||
task: TaskData;
|
||||
@@ -24,6 +26,8 @@ interface TaskDetailHeaderProps {
|
||||
toggleHideMutation: UseMutationResult<void, Error, void>;
|
||||
onShowReminderDialog: (open: boolean) => void;
|
||||
onShowDeleteDialog: (open: boolean) => void;
|
||||
/** «Главный» цветной тег задачи (первая цветная опция select-поля) */
|
||||
tag?: TaskTagInfo | null;
|
||||
}
|
||||
|
||||
export function TaskDetailHeader({
|
||||
@@ -39,6 +43,7 @@ export function TaskDetailHeader({
|
||||
toggleHideMutation,
|
||||
onShowReminderDialog: setShowReminderDialog,
|
||||
onShowDeleteDialog: setShowDeleteDialog,
|
||||
tag,
|
||||
}: TaskDetailHeaderProps) {
|
||||
const [, setLocation] = useLocation();
|
||||
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" />}
|
||||
</button>
|
||||
<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 />
|
||||
{/* Удаление отделено от нейтральных иконок и стоит в конце ряда */}
|
||||
{isAdmin(currentUser) && (
|
||||
|
||||
@@ -12,6 +12,16 @@
|
||||
export interface SelectOption {
|
||||
label: 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 {
|
||||
@@ -41,8 +51,11 @@ export function normalizeSelectOptions(options: unknown): SelectOption[] {
|
||||
const obj = opt as Record<string, unknown>;
|
||||
const label = String(obj.label ?? obj.name ?? '');
|
||||
const value = String(obj.value ?? obj.id ?? label);
|
||||
const color = parseOptionColor(obj.color);
|
||||
if (label || value) {
|
||||
return { label: label || value, value };
|
||||
const normalized: SelectOption = { label: label || value, value };
|
||||
if (color) normalized.color = color;
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -52,18 +65,21 @@ export function normalizeSelectOptions(options: unknown): SelectOption[] {
|
||||
|
||||
/**
|
||||
* Преобразует options в текст для textarea-редактора.
|
||||
* Формат: "Название" или "Название | Значение".
|
||||
* Формат: "Название" | "Название | Значение" | "Название | Значение | #цвет".
|
||||
*/
|
||||
export function selectOptionsToText(options: unknown): string {
|
||||
const normalized = normalizeSelectOptions(options);
|
||||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит текст редактора в массив объектов {label, value}.
|
||||
* Каждая строка: "Название" или "Название | Значение".
|
||||
* Парсит текст редактора в массив объектов {label, value, color?}.
|
||||
* Каждая строка: "Название" | "Название | Значение" | "Название | Значение | #цвет".
|
||||
*/
|
||||
export function textToSelectOptions(text: string): SelectOption[] {
|
||||
return text
|
||||
@@ -72,10 +88,12 @@ export function textToSelectOptions(text: string): SelectOption[] {
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const parts = line.split('|').map((p) => p.trim());
|
||||
if (parts.length >= 2 && parts[1]) {
|
||||
return { label: parts[0], value: parts[1] };
|
||||
}
|
||||
return { label: parts[0], value: parts[0] };
|
||||
const color = parts.length >= 3 ? parseOptionColor(parts[2]) : undefined;
|
||||
const base: SelectOption = parts.length >= 2 && parts[1]
|
||||
? { label: parts[0], value: parts[1] }
|
||||
: { label: parts[0], value: parts[0] };
|
||||
if (color) base.color = color;
|
||||
return base;
|
||||
})
|
||||
.filter((opt) => opt.label);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { tagStripeStyle } from '@/components/ColoredTag';
|
||||
import { format, isToday, isYesterday, differenceInDays } from 'date-fns';
|
||||
import { ru } from 'date-fns/locale';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
@@ -104,6 +105,9 @@ interface HomeTask {
|
||||
remindAt?: string;
|
||||
createdByName?: string;
|
||||
hiddenAt?: string;
|
||||
/** «Главный» цветной тег задачи (первая цветная опция select-поля) — сервер считает */
|
||||
tagColor?: string | null;
|
||||
tagLabel?: string | null;
|
||||
fieldValues?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -342,7 +346,11 @@ function TaskTableRow({
|
||||
const hasUnread = !task.isCompleted && !task.statusIsFinal && (task.unreadNotifications ?? 0) > 0;
|
||||
|
||||
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">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<div className="mt-0.5 shrink-0 relative">
|
||||
|
||||
@@ -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<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-код
|
||||
const hasCustomLayout = detailLayoutInfo.kind !== 'standard';
|
||||
const allTransitionsForLayout: FormTransition[] = effectiveDetailData?.transitions || [];
|
||||
@@ -732,6 +743,7 @@ const TaskDetail = () => {
|
||||
toggleHideMutation={toggleHideMutation}
|
||||
onShowReminderDialog={setShowReminderDialog}
|
||||
onShowDeleteDialog={setShowDeleteDialog}
|
||||
tag={taskTag}
|
||||
/>
|
||||
|
||||
<TaskTabsBar
|
||||
|
||||
@@ -50,6 +50,9 @@ interface TaskData {
|
||||
dueDate?: string;
|
||||
parentTaskId?: number | null;
|
||||
depth?: number;
|
||||
/** «Главный» цветной тег (первая цветная опция select-поля) — подсветка строки */
|
||||
tagColor?: string | null;
|
||||
tagLabel?: string | null;
|
||||
}
|
||||
|
||||
interface UserData {
|
||||
|
||||
@@ -2,9 +2,33 @@ import { Router } from 'express';
|
||||
import { authenticateToken, type AuthenticatedRequest } from '../middleware/auth.middleware';
|
||||
import { tenantIsolation } from '../middleware/tenant.middleware';
|
||||
import { pool } from '../db';
|
||||
import { storage } from '../storage';
|
||||
import { computeTaskTagColor } from '@shared/tag-color';
|
||||
|
||||
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> = {
|
||||
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,
|
||||
|
||||
@@ -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<typeof import("exp
|
||||
const formFields = await storage.getFormFields(formId, req.organizationId!);
|
||||
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 });
|
||||
} catch (error) {
|
||||
console.error('Get tasks with fields error:', error);
|
||||
|
||||
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