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 { 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 Вариант 2 | value2 Вариант 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)}>
|
||||||
Отмена
|
Отмена
|
||||||
|
|||||||
@@ -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) && (
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
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