const React = ctx.libs.React; const { useState, useEffect, useMemo, useCallback, useRef } = React; const { Card, Typography, Spin, Input, Button, Empty, Dropdown, Modal, Row, Col, Tag, Space, Grid, } = ctx.libs.antd; const { MoreOutlined } = ctx.libs.antdIcons; const { Title, Text } = Typography; const { useBreakpoint } = Grid; const TASK_ID = 1; const THEME = { bg: '#17191d', bg2: '#1d2025', panel: '#1f2329', panel2: '#242931', panel3: '#2a3038', border: '#343b46', text: '#eef2f6', muted: '#9aa4b2', faint: '#768191', accent: '#27c2b1', accent2: '#2f7cf6', accentSoft: 'rgba(39,194,177,.12)', msgBg: '#2a2e35', msgBgAlt: '#252a31', quoteBg: '#21262d', danger: '#ff7373', inputBg: '#1b1f25', top: '#1b1e23', }; const STATUS = { new: { label: 'Новая', color: '#8b949e' }, in_progress: { label: 'В работе', color: '#2f81f7' }, done: { label: 'Выполнена', color: '#3fb950' }, cancelled: { label: 'Отменена', color: '#f85149' }, }; const URGENCY = { today: { label: 'Сегодня', color: '#f85149' }, tomorrow: { label: 'Завтра', color: '#d29922' }, later: { label: 'Позже', color: '#2f81f7' }, }; function getCurrentUserId() { try { return Number(ctx?.auth?.user?.id || 1); } catch (e) { return 1; } } function getUserLabel(id) { return `User #${id || '?'}`; } function formatDateTime(value) { if (!value) return ''; try { return new Date(value).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', }); } catch (e) { return String(value); } } function formatClock(value) { if (!value) return ''; try { return new Date(value).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', }); } catch (e) { return ''; } } function formatDayLabel(value) { if (!value) return ''; const d = new Date(value); const today = new Date(); const yesterday = new Date(); yesterday.setDate(today.getDate() - 1); const key = (x) => `${x.getFullYear()}-${x.getMonth()}-${x.getDate()}`; if (key(d) === key(today)) return 'Сегодня'; if (key(d) === key(yesterday)) return 'Вчера'; return d.toLocaleDateString('ru-RU'); } function sortFlat(list) { return [...(Array.isArray(list) ? list : [])].sort( (a, b) => new Date(a.createdAt) - new Date(b.createdAt) ); } function avatarColor(id) { const colors = ['#b45309', '#7c3aed', '#0ea5e9', '#15803d', '#db2777', '#ea580c']; return colors[Math.abs(Number(id || 0)) % colors.length]; } function Avatar({ userId, size = 34 }) { return React.createElement('div', { style: { width: size, height: size, borderRadius: 10, background: avatarColor(userId), color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, fontWeight: 700, flexShrink: 0, } }, String(userId || '?')); } function InfoRow({ label, value }) { return React.createElement('div', { style: { display: 'grid', gridTemplateColumns: '110px 1fr', gap: 10, padding: '10px 0', borderBottom: `1px solid ${THEME.border}`, alignItems: 'start', } }, React.createElement('div', { style: { color: THEME.faint, fontSize: 12, textTransform: 'uppercase', letterSpacing: '.04em', fontWeight: 700, } }, label), React.createElement('div', { style: { color: THEME.text, fontSize: 14, minWidth: 0, wordBreak: 'break-word', } }, value || '—') ); } function App() { const screens = useBreakpoint(); const isMobile = !screens.lg; const [task, setTask] = useState(null); const [comments, setComments] = useState([]); const [loadingTask, setLoadingTask] = useState(true); const [loadingComments, setLoadingComments] = useState(true); const [posting, setPosting] = useState(false); const [savingEdit, setSavingEdit] = useState(false); const [text, setText] = useState(''); const [replyTo, setReplyTo] = useState(null); const [editing, setEditing] = useState(null); const [editText, setEditText] = useState(''); const [allowedDestroyIds, setAllowedDestroyIds] = useState([]); const [highlightId, setHighlightId] = useState(null); const [activeTab, setActiveTab] = useState('info'); const listRef = useRef(null); const messageRefs = useRef({}); const previousCountRef = useRef(0); const currentUserId = getCurrentUserId(); const loadTask = useCallback(async () => { setLoadingTask(true); try { const resp = await ctx.request({ url: 'tasks:get', params: { filterByTk: TASK_ID } }); setTask(resp?.data?.data || null); } catch (e) { console.error(e); ctx.message.error('Не удалось загрузить задачу'); } finally { setLoadingTask(false); } }, []); const loadComments = useCallback(async () => { setLoadingComments(true); try { const resp = await ctx.request({ url: `tasks/${TASK_ID}/comments:list`, params: { pageSize: 300, sort: ['createdAt'] } }); const rows = Array.isArray(resp?.data?.data) ? resp.data.data : []; const destroyIds = resp?.data?.meta?.allowedActions?.destroy || []; setComments(rows); setAllowedDestroyIds(Array.isArray(destroyIds) ? destroyIds : []); } catch (e) { console.error(e); ctx.message.error('Не удалось загрузить комментарии'); } finally { setLoadingComments(false); } }, []); useEffect(() => { loadTask(); loadComments(); }, []); const sortedComments = useMemo(() => sortFlat(comments), [comments]); const commentsMap = useMemo(() => { const map = new Map(); comments.forEach((item) => map.set(item.id, item)); return map; }, [comments]); const commentBlocks = useMemo(() => { const blocks = []; let lastDay = null; sortedComments.forEach((item) => { const day = formatDayLabel(item.createdAt); if (day !== lastDay) { blocks.push({ type: 'day', key: `day-${day}-${item.id}`, label: day }); lastDay = day; } blocks.push({ type: 'message', key: `msg-${item.id}`, item }); }); return blocks; }, [sortedComments]); useEffect(() => { const prevCount = previousCountRef.current; const hasNewMessages = comments.length > prevCount; previousCountRef.current = comments.length; if (!listRef.current || !hasNewMessages) return; const timer = setTimeout(() => { if (listRef.current) listRef.current.scrollTop = listRef.current.scrollHeight; }, 0); return () => clearTimeout(timer); }, [comments.length]); useEffect(() => { if (!highlightId) return; const timer = setTimeout(() => setHighlightId(null), 1800); return () => clearTimeout(timer); }, [highlightId]); const jumpToMessage = (id) => { const node = messageRefs.current[id]; if (node && typeof node.scrollIntoView === 'function') { node.scrollIntoView({ behavior: 'smooth', block: 'center' }); setHighlightId(id); } }; const submit = async () => { if (!text.trim()) return; setPosting(true); try { await ctx.request({ url: `tasks/${TASK_ID}/comments:create`, method: 'post', data: { content: text.trim(), task_id: TASK_ID, parentId: replyTo?.id ?? null, } }); setText(''); setReplyTo(null); await loadComments(); ctx.message.success('Комментарий отправлен'); } catch (e) { console.error(e); ctx.message.error('Не удалось отправить комментарий'); } finally { setPosting(false); } }; const openEdit = (item) => { setEditing(item); setEditText(item?.content || ''); }; const updateComment = async () => { if (!editing?.id || !editText.trim()) return; setSavingEdit(true); try { await ctx.request({ url: `tasks/${TASK_ID}/comments:update`, method: 'post', params: { filterByTk: editing.id }, data: { content: editText.trim() } }); setEditing(null); setEditText(''); await loadComments(); ctx.message.success('Комментарий изменён'); } catch (e) { console.error(e); ctx.message.error('Не удалось изменить комментарий'); } finally { setSavingEdit(false); } }; const removeComment = async (item) => { try { await ctx.request({ url: `tasks/${TASK_ID}/comments:destroy`, method: 'post', params: { filterByTk: item.id } }); await loadComments(); ctx.message.success('Комментарий удалён'); } catch (e) { console.error(e); ctx.message.error('Не удалось удалить комментарий'); } }; const renderMessage = (item, index) => { const parent = item.parentId ? commentsMap.get(item.parentId) : null; const canDelete = allowedDestroyIds.includes(item.id) || allowedDestroyIds.length > 0; const canEdit = Number(item.createdById) === Number(currentUserId); const menuItems = [ { key: 'reply', label: 'Ответить' }, { key: 'edit', label: 'Изменить', disabled: !canEdit }, { key: 'delete', label: React.createElement('span', { style: { color: THEME.danger } }, 'Удалить'), disabled: !canDelete, }, ]; return React.createElement('div', { key: item.id, ref: (el) => { messageRefs.current[item.id] = el; }, style: { display: 'flex', alignItems: 'flex-start', gap: isMobile ? 10 : 12, padding: isMobile ? '12px' : '14px 16px', background: index % 2 === 0 ? THEME.msgBg : THEME.msgBgAlt, borderTop: `1px solid rgba(255,255,255,.03)`, outline: highlightId === item.id ? '1px solid rgba(39,194,177,.75)' : '1px solid transparent', } }, React.createElement(Avatar, { userId: item.createdById, size: isMobile ? 30 : 34 }), React.createElement('div', { style: { flex: 1, minWidth: 0 } }, React.createElement('div', { style: { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, marginBottom: 6, } }, React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', minWidth: 0, } }, React.createElement('span', { style: { color: THEME.text, fontWeight: 700, fontSize: isMobile ? 14 : 15, } }, getUserLabel(item.createdById)), React.createElement('span', { style: { color: THEME.muted, fontSize: 12, }, title: formatDateTime(item.createdAt) }, formatClock(item.createdAt)) ), React.createElement(Dropdown, { trigger: ['click'], placement: 'bottomRight', menu: { items: menuItems, onClick: ({ key }) => { if (key === 'reply') setReplyTo(item); if (key === 'edit') openEdit(item); if (key === 'delete') removeComment(item); }, }, }, React.createElement(Button, { type: 'text', size: 'small', icon: React.createElement(MoreOutlined), style: { color: THEME.muted, minWidth: 36, height: 36, borderRadius: 10, } }) ) ), parent && React.createElement('button', { onClick: () => jumpToMessage(parent.id), style: { display: 'block', width: '100%', textAlign: 'left', border: 'none', background: THEME.quoteBg, borderLeft: `3px solid ${THEME.accent2}`, padding: '8px 10px', marginBottom: 10, borderRadius: 8, cursor: 'pointer', } }, React.createElement('div', { style: { color: THEME.accent2, fontWeight: 700, fontSize: 13, marginBottom: 2, } }, getUserLabel(parent.createdById)), React.createElement('div', { style: { color: THEME.muted, fontSize: 13, whiteSpace: 'pre-wrap', overflow: 'hidden', textOverflow: 'ellipsis', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', } }, parent.content || '') ), React.createElement('div', { style: { color: THEME.text, fontSize: isMobile ? 15 : 16, lineHeight: 1.55, whiteSpace: 'pre-wrap', wordBreak: 'break-word', } }, item.content || '') ) ); }; const taskStatus = STATUS[task?.status]; const taskUrgency = URGENCY[task?.urgency]; const renderSideContent = () => { if (loadingTask) return React.createElement(Spin); if (!task) return React.createElement(Empty, { description: 'Задача не найдена' }); if (activeTab === 'info') { return React.createElement('div', null, React.createElement(InfoRow, { label: 'ID', value: String(task.id || TASK_ID) }), React.createElement(InfoRow, { label: 'Задача', value: task.what_to_do || '—' }), React.createElement(InfoRow, { label: 'Статус', value: taskStatus?.label || task.status || '—' }), React.createElement(InfoRow, { label: 'Срочность', value: taskUrgency?.label || task.urgency || '—' }), React.createElement(InfoRow, { label: 'Срок', value: task.deadline ? formatDateTime(task.deadline) : '—' }), React.createElement(InfoRow, { label: 'Создана', value: task.createdAt ? formatDateTime(task.createdAt) : '—' }), React.createElement(InfoRow, { label: 'Комментариев', value: String(comments.length) }) ); } if (activeTab === 'related') { return React.createElement('div', { style: { color: THEME.muted, fontSize: 14, lineHeight: 1.6 } }, 'Здесь можно будет показать связанные записи, подзадачи, ссылки на объекты и связи задачи. Сейчас вкладка подготовлена как контейнер под дальнейшую интеграцию.'); } if (activeTab === 'files') { return React.createElement('div', { style: { color: THEME.muted, fontSize: 14, lineHeight: 1.6 } }, 'Здесь можно будет вывести вложения задачи и файловые записи. Сейчас вкладка готова как UX-каркас под файлы и действия с ними.'); } return null; }; const tabButton = (key, label) => React.createElement('button', { onClick: () => setActiveTab(key), style: { minHeight: 42, padding: '10px 12px', border: 'none', cursor: 'pointer', background: activeTab === key ? THEME.panel3 : 'transparent', color: activeTab === key ? THEME.text : THEME.muted, borderBottom: !isMobile && activeTab === key ? `2px solid ${THEME.accent}` : '2px solid transparent', borderRadius: isMobile ? 10 : 0, fontSize: 13, fontWeight: 700, whiteSpace: 'nowrap', } }, label); return React.createElement('div', { style: { background: THEME.bg, minHeight: '100vh', padding: isMobile ? 8 : 12, } }, React.createElement(Card, { style: { background: THEME.panel, borderColor: THEME.border, borderRadius: isMobile ? 12 : 14, marginBottom: 12, }, bodyStyle: { padding: isMobile ? 10 : 12 } }, React.createElement('div', { style: { display: 'flex', flexDirection: isMobile ? 'column' : 'row', alignItems: isMobile ? 'stretch' : 'center', justifyContent: 'space-between', gap: 10, } }, React.createElement('div', null, React.createElement('div', { style: { color: THEME.text, fontSize: isMobile ? 20 : 24, fontWeight: 700, lineHeight: 1.2, } }, task?.what_to_do || `Задача #${TASK_ID}`), React.createElement('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 8, color: THEME.muted, fontSize: 12, } }, taskStatus && React.createElement(Tag, { color: taskStatus.color }, taskStatus.label), taskUrgency && React.createElement(Tag, { color: taskUrgency.color }, taskUrgency.label), React.createElement('span', null, `${comments.length} комментариев`) ) ), React.createElement('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap', } }, React.createElement(Button, { style: { minHeight: 40, borderRadius: 10, background: THEME.panel2, color: THEME.text, borderColor: THEME.border, } }, 'Назад'), React.createElement(Button, { style: { minHeight: 40, borderRadius: 10, background: THEME.panel2, color: THEME.text, borderColor: THEME.border, } }, 'Действия'), React.createElement(Button, { type: 'primary', style: { minHeight: 40, borderRadius: 10, background: THEME.accent, borderColor: THEME.accent, color: '#08201d', boxShadow: 'none', } }, 'Утвердить') ) ) ), {isMobile ? React.createElement('div', { style: { display: 'flex', gap: 8, overflowX: 'auto', paddingBottom: 8, marginBottom: 10, } }, tabButton('info', 'Инфо'), tabButton('related', 'Связанные'), tabButton('files', 'Файлы') ) : null} ,React.createElement(Row, { gutter: isMobile ? 0 : 16, align: 'stretch' }, React.createElement(Col, { xs: 24, lg: 15 }, React.createElement(Card, { style: { background: THEME.panel, borderColor: THEME.border, borderRadius: isMobile ? 12 : 14, overflow: 'hidden', marginBottom: isMobile ? 12 : 0, }, bodyStyle: { padding: 0 } }, React.createElement('div', { style: { padding: isMobile ? '10px 12px' : '12px 16px', borderBottom: `1px solid ${THEME.border}`, background: THEME.top, position: 'sticky', top: 0, zIndex: 2, } }, React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, } }, React.createElement('div', { style: { color: THEME.text, fontWeight: 700, fontSize: 15 } }, 'Обсуждение'), React.createElement(Text, { style: { color: THEME.muted, fontSize: 12 } }, `${comments.length} сообщений`) ) ), React.createElement('div', { ref: listRef, style: { maxHeight: isMobile ? '55vh' : '62vh', overflowY: 'auto', background: THEME.panel2, } }, loadingComments ? React.createElement('div', { style: { padding: 16 } }, React.createElement(Spin)) : commentBlocks.length === 0 ? React.createElement('div', { style: { padding: 16 } }, React.createElement(Empty, { description: 'Комментариев пока нет' })) : commentBlocks.map((block, idx) => { if (block.type === 'day') { return React.createElement('div', { key: block.key, style: { display: 'flex', justifyContent: 'center', padding: '12px 10px 8px', background: THEME.panel2, } }, React.createElement('span', { style: { background: THEME.panel3, color: THEME.muted, fontSize: 12, padding: '4px 10px', borderRadius: 999, border: `1px solid ${THEME.border}`, } }, block.label) ); } return renderMessage(block.item, idx); }) ), React.createElement('div', { style: { padding: isMobile ? 12 : 16, borderTop: `1px solid ${THEME.border}`, background: THEME.panel, position: 'sticky', bottom: 0, zIndex: 2, } }, replyTo && React.createElement('div', { style: { marginBottom: 10, padding: '8px 10px', borderLeft: `3px solid ${THEME.accent}`, background: THEME.accentSoft, borderRadius: 8, display: 'flex', justifyContent: 'space-between', gap: 10, } }, React.createElement('button', { onClick: () => jumpToMessage(replyTo.id), style: { flex: 1, textAlign: 'left', border: 'none', background: 'transparent', cursor: 'pointer', padding: 0, } }, React.createElement('div', { style: { color: THEME.accent, fontWeight: 700, fontSize: 13, marginBottom: 2 } }, getUserLabel(replyTo.createdById)), React.createElement('div', { style: { color: THEME.muted, fontSize: 13, whiteSpace: 'pre-wrap', maxHeight: 36, overflow: 'hidden', } }, replyTo.content || '') ), React.createElement(Button, { type: 'text', size: 'small', onClick: () => setReplyTo(null), style: { color: THEME.muted, minWidth: 36, height: 36, } }, '✕') ), React.createElement('div', { style: { display: 'flex', gap: 10, alignItems: 'flex-start', } }, !isMobile && React.createElement(Avatar, { userId: currentUserId, size: 32 }), React.createElement('div', { style: { flex: 1 } }, React.createElement(Input.TextArea, { value: text, onChange: (e) => setText(e.target.value), placeholder: isMobile ? 'Комментарий...' : 'Написать комментарий...', autoSize: { minRows: isMobile ? 3 : 4, maxRows: 8 }, style: { background: THEME.inputBg, color: THEME.text, borderColor: THEME.border, borderRadius: 12, resize: 'none', fontSize: 16, }, onKeyDown: (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); } } }), React.createElement('div', { style: { marginTop: 10, display: 'flex', flexDirection: isMobile ? 'column' : 'row', justifyContent: 'space-between', alignItems: isMobile ? 'stretch' : 'center', gap: 10, } }, React.createElement(Text, { style: { fontSize: 12, color: THEME.muted } }, isMobile ? 'Enter — отправить' : 'Enter — отправить, Shift + Enter — новая строка'), React.createElement(Button, { type: 'primary', loading: posting, disabled: !text.trim(), onClick: submit, block: isMobile, style: { minHeight: 44, borderRadius: 12, background: THEME.accent, borderColor: THEME.accent, color: '#08201d', boxShadow: 'none', fontWeight: 700, } }, replyTo ? 'Ответить' : 'Отправить') ) ) ) ) ) ), !isMobile && React.createElement(Col, { xs: 24, lg: 9 }, React.createElement(Card, { style: { background: THEME.panel, borderColor: THEME.border, borderRadius: 14, height: '100%', }, bodyStyle: { padding: 0 } }, React.createElement('div', { style: { display: 'flex', borderBottom: `1px solid ${THEME.border}`, paddingInline: 8, gap: 2, } }, tabButton('info', 'Инфо'), tabButton('related', 'Связанные'), tabButton('files', 'Файлы') ), React.createElement('div', { style: { padding: 16, minHeight: 420, } }, renderSideContent()) ) ) ), {isMobile ? React.createElement(Card, { style: { background: THEME.panel, borderColor: THEME.border, borderRadius: 12, }, bodyStyle: { padding: 14 } }, renderSideContent()) : null} ,React.createElement(Modal, { open: !!editing, title: 'Изменить комментарий', onCancel: () => { setEditing(null); setEditText(''); }, onOk: updateComment, okText: 'Сохранить', cancelText: 'Отмена', confirmLoading: savingEdit, }, React.createElement(Input.TextArea, { value: editText, onChange: (e) => setEditText(e.target.value), autoSize: { minRows: 4, maxRows: 10 }, placeholder: 'Новый текст комментария...' }) ) ); } ctx.render(React.createElement(App));