diff --git a/client/src/components/ColoredTag.tsx b/client/src/components/ColoredTag.tsx new file mode 100644 index 0000000..535de7a --- /dev/null +++ b/client/src/components/ColoredTag.tsx @@ -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 ( + + {children} + + ); +} + +/** + * Значение 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).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 => ( + {o.label} + ))} + + ); +} + +/** Ячейка: цветные чипы при совпадении, иначе 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}` }; +} diff --git a/client/src/components/TaskKanban.tsx b/client/src/components/TaskKanban.tsx index 9a6fa3a..aa9affe 100644 --- a/client/src/components/TaskKanban.tsx +++ b/client/src/components/TaskKanban.tsx @@ -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 ( - - +
{/* Заголовок и статус */}
@@ -800,7 +807,7 @@ const TaskKanban = ({ tasks, relatedTaskTitles = {}, fields, statuses, users = [ ) : ( - {fieldValue || '(пусто)'} + )}
diff --git a/client/src/components/TaskRegistry.tsx b/client/src/components/TaskRegistry.tsx index d366580..d9e876f 100644 --- a/client/src/components/TaskRegistry.tsx +++ b/client/src/components/TaskRegistry.tsx @@ -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) || —} + {—} />} ) : REGISTRY_INLINE_TYPES.has(field.type) ? ( - {displayFieldValue(field, rawValue, displayContext) || —} + {—} />} } editor={ @@ -1309,7 +1314,7 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users = /> ) : ( - {displayFieldValue(field, rawValue, displayContext) || —} + {—} />} )} diff --git a/client/src/components/form-editor/FormEditorDialogs.tsx b/client/src/components/form-editor/FormEditorDialogs.tsx index e1937b6..95c8d71 100644 --- a/client/src/components/form-editor/FormEditorDialogs.tsx +++ b/client/src/components/form-editor/FormEditorDialogs.tsx @@ -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 ( +
+ {options.map((opt, i) => ( + {opt.label} + ))} +
+ ); +} + type C = ReturnType; type FormEditorDialogsProps = Pick

- Каждая строка — один вариант. Формат: Название или Название | Значение. + Каждая строка — один вариант. Формат: Название, Название | Значение или Название | Значение | #цвет (например #f59e0b). Цвет подсвечивает задачу в реестре, канбане и шапке.