diff --git a/client/src/components/TaskKanban.tsx b/client/src/components/TaskKanban.tsx index dd77102..7b904d6 100644 --- a/client/src/components/TaskKanban.tsx +++ b/client/src/components/TaskKanban.tsx @@ -12,6 +12,7 @@ import { Settings2, Plus, Calendar, User, Clock, AlertCircle, Search, X as XIcon import { useAuth } from '@/hooks/useAuth'; import ContractNumberDisplay from '@/components/ContractNumberDisplay'; import { resolveFieldDisplayValue, type FieldDisplayContext } from '@/lib/fieldDisplay'; +import { statusBadgeStyle } from '@/lib/statusColor'; import { DndContext, closestCenter, @@ -708,10 +709,10 @@ const TaskKanban = ({ tasks, relatedTaskTitles = {}, fields, statuses, users = [ {task.title} {visibleCardFields.status && status && ( - {status.name} diff --git a/client/src/components/TaskRegistry.tsx b/client/src/components/TaskRegistry.tsx index 8ea6fb4..5e56f0c 100644 --- a/client/src/components/TaskRegistry.tsx +++ b/client/src/components/TaskRegistry.tsx @@ -10,6 +10,7 @@ import { Label } from '@/components/ui/label'; import { Settings2, Calendar, User, Search, X as XIcon, Paperclip, AlertCircle, CheckCircle2, GripVertical } from 'lucide-react'; import { type ColumnSort } from '@/hooks/useColumnFilters'; import { resolveFieldDisplayValue, type FieldDisplayContext } from '@/lib/fieldDisplay'; +import { statusBadgeStyle } from '@/lib/statusColor'; import { DndContext, closestCenter, @@ -582,8 +583,8 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users = if (!status) return Неизвестно; return ( - {status.name} @@ -1080,6 +1081,7 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users = ) : REGISTRY_INLINE_TYPES.has(field.type) ? ( {displayFieldValue(field, rawValue, displayContext) || —} diff --git a/client/src/components/TaskRegularTable.tsx b/client/src/components/TaskRegularTable.tsx index 198ac7a..b7d4b9f 100644 --- a/client/src/components/TaskRegularTable.tsx +++ b/client/src/components/TaskRegularTable.tsx @@ -425,6 +425,7 @@ export default function TaskRegularTable({ taskId, tabId, columns, readOnly = fa /> ) : ( {cellValue === '__auto_prolongation__' diff --git a/client/src/components/fields/InlineField.tsx b/client/src/components/fields/InlineField.tsx index d47296a..890707d 100644 --- a/client/src/components/fields/InlineField.tsx +++ b/client/src/components/fields/InlineField.tsx @@ -39,6 +39,8 @@ export interface InlineFieldProps { closeOnClickOutside?: boolean; /** Активировать редактирование по одиночному клику (по умолчанию false — только double-click). */ activateOnClick?: boolean; + /** Доступное имя для скринридера (например, название поля/колонки). */ + ariaLabel?: string; } export function InlineField({ @@ -57,6 +59,7 @@ export function InlineField({ allowInteractiveReadView = true, closeOnClickOutside = true, activateOnClick = false, + ariaLabel, }: InlineFieldProps) { const containerRef = useRef(null); const [internalEditing, setInternalEditing] = useState(false); @@ -111,6 +114,19 @@ export function InlineField({ [allowInteractiveReadView, enterEdit] ); + // Клавиатурный доступ к read-view: Enter/Space открывают редактор + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (isEditing) return; + if (e.key !== 'Enter' && e.key !== ' ') return; + if (allowInteractiveReadView && isInteractiveTarget(e.target)) return; + e.preventDefault(); + e.stopPropagation(); + enterEdit(); + }, + [isEditing, allowInteractiveReadView, enterEdit] + ); + // Закрытие по клику вне + игнорирование Radix-порталов useEffect(() => { if (!isEditing || !closeOnClickOutside) return; @@ -138,10 +154,18 @@ export function InlineField({ return (
{readView} {isEditing && ( diff --git a/client/src/components/ui/button.tsx b/client/src/components/ui/button.tsx index d6e01b2..f50ec39 100644 --- a/client/src/components/ui/button.tsx +++ b/client/src/components/ui/button.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const buttonVariants = cva( - "inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + "inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-[color,background-color,border-color,box-shadow,transform] duration-150 active:not-disabled:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", { variants: { variant: { diff --git a/client/src/index.css b/client/src/index.css index b984763..c3a1f55 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -37,6 +37,13 @@ --status-overdue: hsl(0 70% 55%); --status-paused: hsl(220 5% 60%); + --warning: hsl(48 96% 89%); + --warning-foreground: hsl(25 83% 31%); + --success: hsl(141 84% 93%); + --success-foreground: hsl(142 72% 24%); + --info: hsl(214 95% 93%); + --info-foreground: hsl(224 71% 40%); + --chart-1: hsl(220 10% 25%); --chart-2: hsl(142 50% 45%); --chart-3: hsl(45 90% 50%); @@ -101,6 +108,13 @@ --status-overdue: hsl(0 70% 60%); --status-paused: hsl(220 5% 55%); + --warning: hsl(32 92% 14%); + --warning-foreground: hsl(43 96% 56%); + --success: hsl(144 80% 10%); + --success-foreground: hsl(142 71% 58%); + --info: hsl(224 64% 21%); + --info-foreground: hsl(213 97% 81%); + --chart-1: hsl(220 5% 70%); --chart-2: hsl(142 50% 55%); --chart-3: hsl(45 90% 55%); diff --git a/client/src/lib/statusColor.ts b/client/src/lib/statusColor.ts new file mode 100644 index 0000000..3d9a468 --- /dev/null +++ b/client/src/lib/statusColor.ts @@ -0,0 +1,59 @@ +/** + * Подбор контрастного цвета текста для произвольного фона (цвет статуса, кнопки перехода). + * Считаем относительную яркость фона (WCAG) и выбираем из белого/тёмного тот, + * который даёт бо́льший коэффициент контраста. + */ + +function parseColorToRgb(color: string): { r: number; g: number; b: number } | null { + const c = color.trim(); + // #rgb / #rrggbb / #rrggbbaa + const hexMatch = c.match(/^#([0-9a-f]{3,8})$/i); + if (hexMatch) { + let h = hexMatch[1]; + if (h.length === 3 || h.length === 4) { + h = h.slice(0, 3).split('').map((ch) => ch + ch).join(''); + } + if (h.length >= 6) { + return { + r: parseInt(h.slice(0, 2), 16), + g: parseInt(h.slice(2, 4), 16), + b: parseInt(h.slice(4, 6), 16), + }; + } + return null; + } + // rgb() / rgba() + const rgbMatch = c.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i); + if (rgbMatch) { + return { r: Number(rgbMatch[1]), g: Number(rgbMatch[2]), b: Number(rgbMatch[3]) }; + } + return null; +} + +function relativeLuminance(r: number, g: number, b: number): number { + const channel = (v: number) => { + const s = v / 255; + return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); + }; + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); +} + +const DARK_TEXT = '#111827'; +const LIGHT_TEXT = '#ffffff'; +// Яркость тёмного текста (для расчёта контраста) +const DARK_TEXT_LUMINANCE = 0.011; + +/** Возвращает '#111827' или '#ffffff' — вариант с наибольшим контрастом к фону. */ +export function getContrastTextColor(bgColor: string): string { + const rgb = parseColorToRgb(bgColor); + if (!rgb) return LIGHT_TEXT; + const l = relativeLuminance(rgb.r, rgb.g, rgb.b); + const contrastWithLight = 1.05 / (l + 0.05); + const contrastWithDark = (l + 0.05) / (DARK_TEXT_LUMINANCE + 0.05); + return contrastWithDark >= contrastWithLight ? DARK_TEXT : LIGHT_TEXT; +} + +/** Стиль бейджа статуса: сплошная заливка цветом статуса + гарантированно читаемый текст. */ +export function statusBadgeStyle(color: string): { backgroundColor: string; color: string } { + return { backgroundColor: color, color: getContrastTextColor(color) }; +} diff --git a/client/src/pages/TaskDetail.tsx b/client/src/pages/TaskDetail.tsx index 31563c0..a0d2647 100644 --- a/client/src/pages/TaskDetail.tsx +++ b/client/src/pages/TaskDetail.tsx @@ -20,6 +20,7 @@ import { format } from 'date-fns'; import { formatOrg, parseOrgDateTime } from '@/lib/timezone'; import { addFileToken, openFilePresigned } from '@/lib/fileUtils'; import { normalizeSelectOptions, getSelectOptionLabel } from '@/lib/fieldOptions'; +import { statusBadgeStyle, getContrastTextColor } from '@/lib/statusColor'; import { SearchableMultiSelect } from '@/components/fields/SearchableMultiSelect'; import TaskChat from '../components/TaskChat'; import { JsComponentRenderer } from '@/components/tabs/JsComponentRenderer'; @@ -236,12 +237,12 @@ function LinkedTaskDisplay({ linkedFormId, taskId, relationType }: { linkedFormI {relationType === 'parent' && ( - + Родитель )} {relationType === 'child' && ( - + Дочерняя )} @@ -505,7 +506,7 @@ function FileFieldRow({ toast({ title: 'Файл загружен', description: uploaded.name }); } catch (err: unknown) { if (err instanceof OfflineQueuedError) return; - const msg = err instanceof Error ? err.message : 'Неизвестная ошибка'; + const msg = err instanceof Error ? err.message : 'Не удалось загрузить файл. Проверьте соединение и попробуйте ещё раз.'; toast({ title: 'Ошибка загрузки', description: msg, variant: 'destructive' }); } finally { setIsUploading(false); @@ -525,7 +526,7 @@ function FileFieldRow({ toast({ title: 'Файл удалён' }); } catch (err: unknown) { if (err instanceof OfflineQueuedError) return; - const msg = err instanceof Error ? err.message : 'Неизвестная ошибка'; + const msg = err instanceof Error ? err.message : 'Не удалось удалить файл. Проверьте соединение и попробуйте ещё раз.'; toast({ title: 'Ошибка удаления', description: msg, variant: 'destructive' }); } }; @@ -590,7 +591,7 @@ function FileFieldRow({ )} @@ -1510,7 +1523,9 @@ function FieldValueRow({ disabled={!canEdit} onChange={(e) => commitValue(e.target.checked, { close: false })} onClick={(e) => e.stopPropagation()} + aria-label={field.name} aria-invalid={!!effectiveError} + aria-describedby={effectiveError ? fieldErrorId : undefined} /> {initialValue === true || initialValue === 'true' ? 'Да' : 'Нет'} @@ -1538,7 +1553,7 @@ function FieldValueRow({
{isFieldPending && ( @@ -1546,7 +1561,7 @@ function FieldValueRow({ )} {effectiveError && ( - {effectiveError} + {effectiveError} )} @@ -1781,7 +1796,7 @@ function TaskAccessTab({ taskId, isAdmin }: { taskId: number; isAdmin: boolean } }, onError: (err: unknown) => { const msg = err instanceof Error ? err.message : 'Произошла ошибка'; - toast({ title: 'Ошибка', description: msg, variant: 'destructive' }); + toast({ title: 'Не удалось добавить роль', description: msg, variant: 'destructive' }); }, }); @@ -1793,7 +1808,7 @@ function TaskAccessTab({ taskId, isAdmin }: { taskId: number; isAdmin: boolean } }, onError: (err: unknown) => { const msg = err instanceof Error ? err.message : 'Произошла ошибка'; - toast({ title: 'Ошибка', description: msg, variant: 'destructive' }); + toast({ title: 'Не удалось удалить роль', description: msg, variant: 'destructive' }); }, }); @@ -1805,7 +1820,7 @@ function TaskAccessTab({ taskId, isAdmin }: { taskId: number; isAdmin: boolean } }, onError: (err: unknown) => { const msg = err instanceof Error ? err.message : 'Произошла ошибка'; - toast({ title: 'Ошибка', description: msg, variant: 'destructive' }); + toast({ title: 'Не удалось отозвать доступ', description: msg, variant: 'destructive' }); }, }); @@ -1843,14 +1858,14 @@ function TaskAccessTab({ taskId, isAdmin }: { taskId: number; isAdmin: boolean } {roleDropdownOpen && (
-
+
setRoleSearch(e.target.value)} - placeholder="Поиск ролей..." + placeholder="Поиск ролей…" className="flex-1 text-xs bg-transparent outline-none" />
@@ -1863,7 +1878,7 @@ function TaskAccessTab({ taskId, isAdmin }: { taskId: number; isAdmin: boolean } key={role.id} onClick={() => addRoleMutation.mutate(role.id)} disabled={addRoleMutation.isPending} - className="w-full text-left px-2 py-1.5 text-xs rounded hover:bg-accent" + className="w-full text-left px-2 py-1.5 text-xs rounded-sm hover:bg-accent" > {role.name} {role.description && — {role.description}} @@ -1889,7 +1904,7 @@ function TaskAccessTab({ taskId, isAdmin }: { taskId: number; isAdmin: boolean }
{roleGroup.users.length === 0 ? (

Нет взаимодействий

@@ -1940,7 +1955,7 @@ function TaskAccessTab({ taskId, isAdmin }: { taskId: number; isAdmin: boolean } {isRevoked && · отозван} {!isRevoked && ( - )} @@ -1959,7 +1974,7 @@ function TaskAccessTab({ taskId, isAdmin }: { taskId: number; isAdmin: boolean }
Прямой доступ - · {directUsers.length} польз. + · {directUsers.length} польз.
{directUsers.map(record => { @@ -1969,14 +1984,14 @@ function TaskAccessTab({ taskId, isAdmin }: { taskId: number; isAdmin: boolean } {getUserDisplayName(record.user)}
{record.grantedVia === 'mention' && !isRevoked && ( - Упоминание + Упоминание )} {new Date(record.firstInteractionAt).toLocaleDateString('ru-RU')} {isRevoked && · отозван} {!isRevoked && ( - )} @@ -1996,7 +2011,7 @@ function TaskAccessTab({ taskId, isAdmin }: { taskId: number; isAdmin: boolean } {isAdmin && transitionApprovals.length > 0 && (

- + Согласования переходов ({transitionApprovals.length})

@@ -2007,7 +2022,7 @@ function TaskAccessTab({ taskId, isAdmin }: { taskId: number; isAdmin: boolean } const roleName = meta.roleName ? String(meta.roleName) : null; return (
- +

{entry.botId != null && ( @@ -2015,10 +2030,10 @@ function TaskAccessTab({ taskId, isAdmin }: { taskId: number; isAdmin: boolean } )} {entry.changedByName || '—'} {entry.metadata?.source === 'mcp' && ( - MCP + MCP )} {entry.metadata?.source === 'api' && ( - API + API )} {roleName && ( · роль: {roleName} @@ -2049,6 +2064,7 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde const wrapperRef = useRef(null); const [assigneeDropdownOpen, setAssigneeDropdownOpen] = useState(false); const [assigneeSearch, setAssigneeSearch] = useState(''); + const assigneeTriggerRef = useRef(null); // Per-field редактирование срока: локальный черновик даты/времени const [isEditingDue, setIsEditingDue] = useState(false); @@ -2111,7 +2127,7 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde } catch (err) { if (err instanceof OfflineQueuedError) return; queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'detail'] }); - const msg = err instanceof Error ? err.message : 'Неизвестная ошибка'; + const msg = err instanceof Error ? err.message : 'Не удалось сохранить срок. Проверьте соединение и попробуйте ещё раз.'; toast({ title: 'Ошибка сохранения срока', description: msg, variant: 'destructive' }); } }; @@ -2186,7 +2202,7 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde queryClient.invalidateQueries({ queryKey: ['/api/home/inbox'] }); toast({ title: 'Ответственный добавлен' }); }, - onError: (err: Error) => { const msg = err instanceof Error ? err.message : 'Произошла ошибка'; toast({ title: 'Ошибка', description: msg, variant: 'destructive' }); }, + onError: (err: Error) => { const msg = err instanceof Error ? err.message : 'Произошла ошибка'; toast({ title: 'Не удалось добавить ответственного', description: msg, variant: 'destructive' }); }, }); const removeAssigneeMutation = useMutation<{ success: boolean; task?: Task; autoTransition?: boolean }, Error, number>({ @@ -2213,7 +2229,7 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde queryClient.invalidateQueries({ queryKey: ['/api/home/inbox'] }); toast({ title: 'Ответственный снят' }); }, - onError: (err: Error) => { const msg = err instanceof Error ? err.message : 'Произошла ошибка'; toast({ title: 'Ошибка', description: msg, variant: 'destructive' }); }, + onError: (err: Error) => { const msg = err instanceof Error ? err.message : 'Произошла ошибка'; toast({ title: 'Не удалось снять ответственного', description: msg, variant: 'destructive' }); }, }); const addRoleMutation = useMutation({ @@ -2226,7 +2242,7 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde setAssigneeDropdownOpen(false); setAssigneeSearch(''); }, - onError: (err: unknown) => { const msg = err instanceof Error ? err.message : 'Произошла ошибка'; toast({ title: 'Ошибка', description: msg, variant: 'destructive' }); }, + onError: (err: unknown) => { const msg = err instanceof Error ? err.message : 'Произошла ошибка'; toast({ title: 'Не удалось добавить роль', description: msg, variant: 'destructive' }); }, }); const removeRoleMutation = useMutation({ @@ -2237,20 +2253,30 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'audit-log'] }); toast({ title: 'Роль удалена' }); }, - onError: (err: unknown) => { const msg = err instanceof Error ? err.message : 'Произошла ошибка'; toast({ title: 'Ошибка', description: msg, variant: 'destructive' }); }, + onError: (err: unknown) => { const msg = err instanceof Error ? err.message : 'Произошла ошибка'; toast({ title: 'Не удалось удалить роль', description: msg, variant: 'destructive' }); }, }); const filteredOrgRoles = orgRoles.filter(r => !assignedRoleIds.has(r.id) && r.name.toLowerCase().includes(assigneeSearch.toLowerCase())); - // Клик вне блока системных полей закрывает dropdown ответственных + // Клик вне блока системных полей закрывает dropdown ответственных; Escape — закрыть и вернуть фокус на триггер useEffect(() => { if (!assigneeDropdownOpen) return; const handleOutsideClick = (e: MouseEvent) => { if (wrapperRef.current?.contains(e.target as Node)) return; setAssigneeDropdownOpen(false); }; + const handleEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setAssigneeDropdownOpen(false); + assigneeTriggerRef.current?.focus(); + } + }; document.addEventListener('click', handleOutsideClick); - return () => document.removeEventListener('click', handleOutsideClick); + document.addEventListener('keydown', handleEsc); + return () => { + document.removeEventListener('click', handleOutsideClick); + document.removeEventListener('keydown', handleEsc); + }; }, [assigneeDropdownOpen]); // Левая колонка: карандашик для назначения ответственного @@ -2260,7 +2286,7 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde

{/* Combined dropdown — unified search */} {assigneeDropdownOpen && !readOnly && ( -
-
+
+
setAssigneeSearch(e.target.value)} - placeholder="Поиск..." + placeholder="Поиск…" className="flex-1 text-xs bg-transparent outline-none" />
@@ -2374,10 +2414,12 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde {filteredUsers.map((u: any) => (
@@ -2426,7 +2472,7 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde ? (() => { const overdue = !readOnly && !isDuePending && new Date(effectiveDueDate) < new Date(); return ( - + {(() => { const isAllDay = /T00:00:00(\.000)?Z$/.test(effectiveDueDate); const dateStr = formatOrg(effectiveDueDate, 'dd.MM.yyyy'); @@ -2442,11 +2488,11 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde ); })() - : Указать срок... + : Указать срок… } {isDuePending && ( - + )} @@ -2455,8 +2501,9 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde
{ const v = e.target.value; dueDraftRef.current.date = v; @@ -2474,8 +2521,9 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde /> { dueDraftRef.current.time = e.target.value; setDueTimeDraft(e.target.value); @@ -2490,7 +2538,7 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde ); @@ -3783,6 +3837,44 @@ const TaskDetail = () => { /> ) : ( + {/* Fallback: форма без вкладок — системные поля и все поля одной секцией «Задача» */} + {!hasTabs && ( + + + Задача + + + + + + + )} {/* Пользовательские вкладки формы */} {formTabs.map(tab => { const { isTable, isCustomModule, customModule } = getTabMeta(tab); @@ -3904,17 +3996,13 @@ const TaskDetail = () => { ) ); - // Компонент статуса задачи + // Компонент статуса задачи: сплошная заливка цветом статуса + контрастный текст (WCAG) const TaskStatusBadge = () => { if (!currentStatus) return null; return ( -
{currentStatus.isFinal && } @@ -3929,9 +4017,9 @@ const TaskDetail = () => {
{/* Offline cache banner */} {isUsingOfflineCache && cacheTimestamp && ( -
- ⚡ - +
+ ⚡ + Офлайн. Кеш от {new Date(cacheTimestamp).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}. {taskPendingCount > 0 ? ` ${taskPendingCount} изм. ожидают синхронизации.` @@ -3941,10 +4029,10 @@ const TaskDetail = () => { )} {/* Pending operations banner (online but unsent) */} {!isUsingOfflineCache && taskPendingCount > 0 && ( -
- ↻ - - {taskPendingCount} изм. ожидают синхронизации с сервером... +
+ ↻ + + {taskPendingCount} изм. ожидают синхронизации с сервером…
)} @@ -3976,10 +4064,13 @@ const TaskDetail = () => {
{/* Статус и действия в шапке */} -
+
{/* Кнопки согласования */} {approvalTransitions.map((transition) => { const transitionDisabled = executeTransitionMutation.isPending || isTransitionPending; + // Дефолты с контрастом ≥4.5 к белому тексту; для произвольного цвета текст подбирается по яркости + const approveColor = transition.approveButtonColor || '#15803d'; + const rejectColor = transition.rejectButtonColor || '#b91c1c'; if (transition.actionType === 'custom') { return (
); })} - {isAdmin(currentUser) && ( - - )} + {/* Удаление отделено от нейтральных иконок и стоит в конце ряда */} + {isAdmin(currentUser) && ( + + )}
@@ -4102,7 +4194,7 @@ const TaskDetail = () => {
{showLeftArrow && (
{showRightArrow && ( @@ -4341,7 +4440,7 @@ const TaskDetail = () => {
) : ( -
+
{opt.label.charAt(0).toUpperCase()}
)} @@ -4426,10 +4525,8 @@ const TaskDetail = () => { - Удаление задачи + Удалить задачу «{task?.title}»? - Вы уверены, что хотите удалить задачу "{task?.title}"? -

Задача будет удалена безвозвратно вместе со всеми сообщениями, файлами и историей изменений. @@ -4449,7 +4546,7 @@ const TaskDetail = () => { onClick={() => deleteTaskMutation.mutate()} disabled={deleteTaskMutation.isPending} > - {deleteTaskMutation.isPending ? 'Удаление...' : 'Удалить'} + {deleteTaskMutation.isPending ? 'Удаление…' : 'Удалить задачу'}
diff --git a/client/src/themes/skins/iistwin.ts b/client/src/themes/skins/iistwin.ts index edb7b0b..dfbd118 100644 --- a/client/src/themes/skins/iistwin.ts +++ b/client/src/themes/skins/iistwin.ts @@ -76,6 +76,13 @@ export const iistwinSkin: Skin = { statusOverdue: 'hsl(0 70% 55%)', statusPaused: 'hsl(220 5% 60%)', + warning: 'hsl(48 96% 89%)', + warningForeground: 'hsl(25 83% 31%)', + success: 'hsl(141 84% 93%)', + successForeground: 'hsl(142 72% 24%)', + info: 'hsl(214 95% 93%)', + infoForeground: 'hsl(224 71% 40%)', + chart1: 'hsl(220 10% 25%)', chart2: 'hsl(142 50% 45%)', chart3: 'hsl(45 90% 50%)', @@ -201,6 +208,13 @@ export const iistwinSkin: Skin = { statusOverdue: 'hsl(0 70% 60%)', statusPaused: 'hsl(220 5% 55%)', + warning: 'hsl(32 92% 14%)', + warningForeground: 'hsl(43 96% 56%)', + success: 'hsl(144 80% 10%)', + successForeground: 'hsl(142 71% 58%)', + info: 'hsl(224 64% 21%)', + infoForeground: 'hsl(213 97% 81%)', + chart1: 'hsl(220 5% 70%)', chart2: 'hsl(142 50% 55%)', chart3: 'hsl(45 90% 55%)', diff --git a/client/src/themes/skins/omegaprom.ts b/client/src/themes/skins/omegaprom.ts index 559ff2e..2ed6356 100644 --- a/client/src/themes/skins/omegaprom.ts +++ b/client/src/themes/skins/omegaprom.ts @@ -76,6 +76,13 @@ export const omegapromSkin: Skin = { statusOverdue: 'hsl(0 70% 55%)', statusPaused: 'hsl(220 9% 60%)', + warning: 'hsl(48 96% 89%)', + warningForeground: 'hsl(25 83% 31%)', + success: 'hsl(141 84% 93%)', + successForeground: 'hsl(142 72% 24%)', + info: 'hsl(214 95% 93%)', + infoForeground: 'hsl(224 71% 40%)', + chart1: 'hsl(221 83% 53%)', chart2: 'hsl(142 55% 45%)', chart3: 'hsl(45 90% 50%)', @@ -201,6 +208,13 @@ export const omegapromSkin: Skin = { statusOverdue: 'hsl(0 70% 60%)', statusPaused: 'hsl(220 10% 55%)', + warning: 'hsl(32 92% 14%)', + warningForeground: 'hsl(43 96% 56%)', + success: 'hsl(144 80% 10%)', + successForeground: 'hsl(142 71% 58%)', + info: 'hsl(224 64% 21%)', + infoForeground: 'hsl(213 97% 81%)', + chart1: 'hsl(217 91% 60%)', chart2: 'hsl(142 55% 55%)', chart3: 'hsl(45 90% 55%)', diff --git a/client/src/themes/skins/twenty.ts b/client/src/themes/skins/twenty.ts index 3b15635..de42ec0 100644 --- a/client/src/themes/skins/twenty.ts +++ b/client/src/themes/skins/twenty.ts @@ -80,6 +80,13 @@ export const twentySkin: Skin = { statusOverdue: '#e6474c', statusPaused: '#a1a1aa', + warning: '#fef3c7', + warningForeground: '#92400e', + success: '#dcfce7', + successForeground: '#166534', + info: '#dbeafe', + infoForeground: '#1e40af', + chart1: '#303030', chart2: '#009239', chart3: '#fd7600', @@ -207,6 +214,13 @@ export const twentySkin: Skin = { statusOverdue: '#f87171', statusPaused: '#a1a1aa', + warning: '#451a03', + warningForeground: '#fbbf24', + success: '#052e16', + successForeground: '#4ade80', + info: '#172554', + infoForeground: '#93c5fd', + chart1: '#d4d4d8', chart2: '#4ade80', chart3: '#fbbf24', diff --git a/client/src/themes/styles/generated-skins.css b/client/src/themes/styles/generated-skins.css index c8b05da..3667bfb 100644 --- a/client/src/themes/styles/generated-skins.css +++ b/client/src/themes/styles/generated-skins.css @@ -57,6 +57,12 @@ --status-progress: hsl(45 90% 50%); --status-overdue: hsl(0 70% 55%); --status-paused: hsl(220 5% 60%); + --warning: hsl(48 96% 89%); + --warning-foreground: hsl(25 83% 31%); + --success: hsl(141 84% 93%); + --success-foreground: hsl(142 72% 24%); + --info: hsl(214 95% 93%); + --info-foreground: hsl(224 71% 40%); --chart1: hsl(220 10% 25%); --chart2: hsl(142 50% 45%); --chart3: hsl(45 90% 50%); @@ -157,6 +163,12 @@ --status-progress: hsl(45 90% 55%); --status-overdue: hsl(0 70% 60%); --status-paused: hsl(220 5% 55%); + --warning: hsl(32 92% 14%); + --warning-foreground: hsl(43 96% 56%); + --success: hsl(144 80% 10%); + --success-foreground: hsl(142 71% 58%); + --info: hsl(224 64% 21%); + --info-foreground: hsl(213 97% 81%); --chart1: hsl(220 5% 70%); --chart2: hsl(142 50% 55%); --chart3: hsl(45 90% 55%); @@ -257,6 +269,12 @@ --status-progress: #fd7600; --status-overdue: #e6474c; --status-paused: #a1a1aa; + --warning: #fef3c7; + --warning-foreground: #92400e; + --success: #dcfce7; + --success-foreground: #166534; + --info: #dbeafe; + --info-foreground: #1e40af; --chart1: #303030; --chart2: #009239; --chart3: #fd7600; @@ -357,6 +375,12 @@ --status-progress: #fbbf24; --status-overdue: #f87171; --status-paused: #a1a1aa; + --warning: #451a03; + --warning-foreground: #fbbf24; + --success: #052e16; + --success-foreground: #4ade80; + --info: #172554; + --info-foreground: #93c5fd; --chart1: #d4d4d8; --chart2: #4ade80; --chart3: #fbbf24; @@ -457,6 +481,12 @@ --status-progress: hsl(45 90% 50%); --status-overdue: hsl(0 70% 55%); --status-paused: hsl(220 9% 60%); + --warning: hsl(48 96% 89%); + --warning-foreground: hsl(25 83% 31%); + --success: hsl(141 84% 93%); + --success-foreground: hsl(142 72% 24%); + --info: hsl(214 95% 93%); + --info-foreground: hsl(224 71% 40%); --chart1: hsl(221 83% 53%); --chart2: hsl(142 55% 45%); --chart3: hsl(45 90% 50%); @@ -557,6 +587,12 @@ --status-progress: hsl(45 90% 55%); --status-overdue: hsl(0 70% 60%); --status-paused: hsl(220 10% 55%); + --warning: hsl(32 92% 14%); + --warning-foreground: hsl(43 96% 56%); + --success: hsl(144 80% 10%); + --success-foreground: hsl(142 71% 58%); + --info: hsl(224 64% 21%); + --info-foreground: hsl(213 97% 81%); --chart1: hsl(217 91% 60%); --chart2: hsl(142 55% 55%); --chart3: hsl(45 90% 55%); diff --git a/client/src/themes/tokens.ts b/client/src/themes/tokens.ts index 80cdc96..37bf7e0 100644 --- a/client/src/themes/tokens.ts +++ b/client/src/themes/tokens.ts @@ -92,6 +92,14 @@ export interface ColorTokens { statusOverdue: string; statusPaused: string; + // Семантические цвета состояний (предупреждение / успех / информация) + warning: string; + warningForeground: string; + success: string; + successForeground: string; + info: string; + infoForeground: string; + // Chart colors chart1: string; chart2: string; @@ -199,6 +207,12 @@ export const EDITABLE_COLOR_TOKENS: Array<{ key: string; label: string; category { key: 'colors.statusProgress', label: 'В работе', category: 'Status' }, { key: 'colors.statusOverdue', label: 'Просрочено', category: 'Status' }, { key: 'colors.statusPaused', label: 'Приостановлено', category: 'Status' }, + { key: 'colors.warning', label: 'Предупреждение', category: 'Status' }, + { key: 'colors.warningForeground', label: 'Предупреждение текст', category: 'Status' }, + { key: 'colors.success', label: 'Успех', category: 'Status' }, + { key: 'colors.successForeground', label: 'Успех текст', category: 'Status' }, + { key: 'colors.info', label: 'Инфо', category: 'Status' }, + { key: 'colors.infoForeground', label: 'Инфо текст', category: 'Status' }, { key: 'colors.sidebar', label: 'Фон сайдбара', category: 'Sidebar' }, { key: 'colors.sidebarForeground', label: 'Текст сайдбара', category: 'Sidebar' }, @@ -236,6 +250,7 @@ const SHADCN_COLOR_VARS = new Set([ 'primary', 'primaryForeground', 'secondary', 'secondaryForeground', 'muted', 'mutedForeground', 'accent', 'accentForeground', 'destructive', 'destructiveForeground', 'border', 'input', 'ring', + 'warning', 'warningForeground', 'success', 'successForeground', 'info', 'infoForeground', ]); const STATUS_VARS = new Set(['statusDone', 'statusProgress', 'statusOverdue', 'statusPaused']); diff --git a/tailwind.config.ts b/tailwind.config.ts index c0afd04..6ed77a4 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -51,6 +51,18 @@ export default { DEFAULT: "var(--destructive)", foreground: "var(--destructive-foreground)", }, + warning: { + DEFAULT: "var(--warning)", + foreground: "var(--warning-foreground)", + }, + success: { + DEFAULT: "var(--success)", + foreground: "var(--success-foreground)", + }, + info: { + DEFAULT: "var(--info)", + foreground: "var(--info-foreground)", + }, border: "var(--border)", input: "var(--input)", ring: "var(--ring)",