fix(number-fields): избегаем потери точности длинных чисел

- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
2026-07-07 21:03:40 +03:00
commit 1f5ecb6da4
1089 changed files with 237546 additions and 0 deletions

View File

@@ -0,0 +1,919 @@
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));