Files
iistwin/attached_assets/код_1775994765939.json
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

919 lines
28 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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));