/** * TimeWorkedModal — редизайн окна «Time worked on this article». * Mantine v7. Light/dark. * * ───────────────────────────────────────────────────────────────────────── * ЧТО ЭТО * ───────────────────────────────────────────────────────────────────────── * Сводка трудозатрат по дням в виде суточных таймлайнов. Главное отличие от * старой версии: на барах ВИДНО время суток. Реализовано двумя способами * (проп `axis`): * - 'grid' — ось часов 00–06–12–18–24 сверху + вертикальные деления, * ночные часы (0–6, 21–24) слегка затемнены. По умолчанию. * - 'phases' — цветные полосы фаз дня за блоками (Ночь/Утро/День/Вечер), * период суток читается сразу, без счёта делений. * * Блоки позиционируются по времени: left = start/24, width = dur/24. * Интенсивность (opacity) блока = длительность/плотность работы, чтобы * очень короткие сессии не терялись (минимальная видимая ширина задана). * Hover-тултип на блоке: начало–конец · длительность. * * ───────────────────────────────────────────────────────────────────────── * УСТАНОВКА / ИСПОЛЬЗОВАНИЕ * ───────────────────────────────────────────────────────────────────────── * import { TimeWorkedModal, DaySummary } from './TimeWorkedModal'; * * setOpen(false)} * totalLabel="≈ 34h" * agentLabel="≈ 1h 20m" // undefined → строку agent не показываем * days={days} // DaySummary[] * axis="grid" // 'grid' | 'phases' * tz="Europe/Moscow" * inactivityGapMin={15} * /> * * Требует MantineProvider на корне. * * ───────────────────────────────────────────────────────────────────────── * ФОРМАТ ДАННЫХ * ───────────────────────────────────────────────────────────────────────── * interface Block { * start: number; // час начала в сутках, 0..24 (напр. 13.7 = 13:42) * end: number; // час конца * kind: 'work' | 'agent'; * } * interface DaySummary { * label: string; // «Mon 29 Jun» * totalLabel: string; // «1h 24m» | «—» для пустого дня * blocks: Block[]; // [] → пустой день (полупрозрачная дорожка, итог «—») * isToday?: boolean; // сегодня — неполные сутки (рисуем границу «сейчас») * nowFraction?: number;// 0..1 позиция «сейчас» для isToday * } * * Данные уже есть в бэкенде трудозатрат: сессии с началом/концом + тип * (work/agent). Часы = локальные к tz. Изменений API не требуется. * * ───────────────────────────────────────────────────────────────────────── * СОСТОЯНИЯ (нарисованы/поддержаны) * ───────────────────────────────────────────────────────────────────────── * - День: с активностью (work+agent) / только work / только agent / пустой (—). * - Сегодня: граница «сейчас» на дорожке (nowFraction). * - Короткий блок: минимальная ширина, не исчезает. * - Пустая панель целиком: нет трудозатрат → EmptyState. * - Длинный период: вертикальный скролл списка дней, шапка/легенда липкие. * - Тёмная тема: токены Mantine. */ import { Modal, Box, Group, Stack, Text, ActionIcon, ScrollArea, Tooltip, useMantineColorScheme } from '@mantine/core'; /* ─────────────────────────── Типы ─────────────────────────── */ export interface Block { start: number; end: number; kind: 'work' | 'agent'; } export interface DaySummary { label: string; totalLabel: string; blocks: Block[]; isToday?: boolean; nowFraction?: number; } export interface TimeWorkedModalProps { opened: boolean; onClose: () => void; totalLabel: string; agentLabel?: string; days: DaySummary[]; axis?: 'grid' | 'phases'; tz?: string; inactivityGapMin?: number; } const WORK = '#3b82f6'; const AGENT = '#c026d3'; const PHASES = [ { name: 'Ночь', s: 0, e: 6, bg: 'rgba(99,102,241,.10)', lg: '#e8eaff', fg: '#5b60c9' }, { name: 'Утро', s: 6, e: 12, bg: 'rgba(245,159,0,.10)', lg: '#fff2dc', fg: '#b5820e' }, { name: 'День', s: 12, e: 18, bg: 'rgba(56,178,172,.10)', lg: '#dcf5f2', fg: '#1a857d' }, { name: 'Вечер', s: 18, e: 24, bg: 'rgba(139,92,246,.11)', lg: '#efe7ff', fg: '#6d43c0' }, ]; /* ─────────────────────────── Блок активности ─────────────────────────── */ function fmtHour(h: number) { const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`; } function fmtDur(h: number) { const total = Math.round(h * 60); const hh = Math.floor(total / 60), mm = total % 60; return hh ? `${hh}h ${mm}m` : `${mm}m`; } function ActivityBlock({ b }: { b: Block }) { const left = (b.start / 24) * 100; const width = Math.max(((b.end - b.start) / 24) * 100, 0.6); const dur = b.end - b.start; const opacity = dur < 0.16 ? 0.5 : dur < 0.35 ? 0.8 : 1; return ( ); } /* ─────────────────────────── Дорожка дня ─────────────────────────── */ function DayTrack({ d, axis, dark }: { d: DaySummary; axis: 'grid' | 'phases'; dark: boolean }) { const trackBg = axis === 'grid' ? 'linear-gradient(90deg, rgba(90,100,130,.10) 0 25%, rgba(90,100,130,.02) 25% 87.5%, rgba(90,100,130,.10) 87.5% 100%)' : (dark ? 'rgba(255,255,255,.04)' : '#f6f8fa'); return ( {d.label} {/* фон: полосы фаз или деления сетки */} {axis === 'phases' ? PHASES.map((ph) => ( )) : [25, 50, 75].map((p) => ( ))} {/* блоки */} {d.blocks.map((b, i) => )} {/* граница «сейчас» для сегодняшнего дня */} {d.isToday && d.nowFraction != null && ( )} {d.totalLabel} ); } /* ─────────────────────────── Окно ─────────────────────────── */ export function TimeWorkedModal(props: TimeWorkedModalProps) { const { opened, onClose, totalLabel, agentLabel, days, axis = 'grid', tz = 'Europe/Moscow', inactivityGapMin = 15 } = props; const { colorScheme } = useMantineColorScheme(); const dark = colorScheme === 'dark'; const isEmpty = days.length === 0 || days.every((d) => d.blocks.length === 0); return ( {/* header */} Time worked on this article {/* summary */} {totalLabel} {agentLabel && agent: {agentLabel}} {/* legend (grid only) */} {axis === 'grid' && ( Work Agent )} {isEmpty ? ( No time tracked yet По этой статье ещё нет трудозатрат. ) : ( <> {/* axis header (sticky) */} {axis === 'grid' ? ( {[['0%', '00', 'flex-start'], ['25%', '06', 'center'], ['50%', '12', 'center'], ['75%', '18', 'center'], ['100%', '24', 'flex-end']].map(([l, t, al]) => ( {t} ))} ) : ( {PHASES.map((ph) => ( {ph.name} ))} )} {/* day rows */} {days.map((d, i) => )} )} Estimate · timezone {tz} · inactivity gap {inactivityGapMin} min ); } export default TimeWorkedModal;