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,207 @@
const { React, ui } = ctx.libs;
const { useState, useEffect, useMemo } = React;
const {
Card, CardContent, CardHeader, CardTitle,
Badge, Progress,
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
Button, Input,
Select, SelectTrigger, SelectContent, SelectItem, SelectValue
} = ui;
function Dashboard({ ctx }) {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [assigneeFilter, setAssigneeFilter] = useState('all');
useEffect(() => {
async function loadData() {
try {
const usersRes = await ctx.request({ url: '/api/users' });
setUsers(usersRes.users || []);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
}
loadData();
}, []);
function getUserName(uid) {
if (!uid) return 'Не назначен';
const u = users.find(x => x.id === uid);
return u ? (u.fullName || (u.firstName + ' ' + u.lastName).trim() || u.email) : String(uid);
}
const allTasks = ctx.forms[20] || [];
const statusIds = [...new Set(allTasks.map(t => t.currentStatusId).filter(Boolean))];
const lastStatusId = statusIds.length > 0 ? Math.max(...statusIds) : 0;
const filteredTasks = useMemo(() => {
return allTasks.filter(t => {
if (searchQuery && !t.title?.toLowerCase().includes(searchQuery.toLowerCase())) {
return false;
}
if (statusFilter === 'closed' && !(t.isCompleted || t.currentStatusId === lastStatusId)) {
return false;
}
if (statusFilter === 'active' && (t.isCompleted || t.currentStatusId === lastStatusId)) {
return false;
}
if (assigneeFilter !== 'all') {
if (assigneeFilter === 'unassigned' && t.assignedTo) return false;
if (assigneeFilter !== 'unassigned' && String(t.assignedTo) !== assigneeFilter) return false;
}
return true;
});
}, [allTasks, searchQuery, statusFilter, assigneeFilter, lastStatusId]);
const total = allTasks.length;
const closed = allTasks.filter(t => t.isCompleted || t.currentStatusId === lastStatusId).length;
const inProgress = total - closed;
const percent = total > 0 ? Math.round((closed / total) * 100) : 0;
const today = new Date().toISOString().split('T')[0];
const overdue = allTasks.filter(t => {
if (t.isCompleted || t.currentStatusId === lastStatusId) return false;
return t.dueDate && String(t.dueDate).slice(0, 10) < today;
}).length;
function StatCard({ title, value, colorClass }) {
return React.createElement(Card, { className: 'flex-1' },
React.createElement(CardContent, { className: 'p-4' },
React.createElement('div', { className: 'text-[10px] text-muted-foreground uppercase tracking-wide mb-1' }, title),
React.createElement('div', { className: 'text-2xl font-bold ' + (colorClass || '') }, value)
)
);
}
function getStatusBadge(statusId, isCompleted) {
if (isCompleted || statusId === lastStatusId) {
return React.createElement(Badge, { variant: 'secondary', className: 'text-[10px]' }, 'Закрыт');
}
return React.createElement(Badge, { className: 'text-[10px]' }, 'В работе');
}
const uniqueAssignees = [...new Set(allTasks.map(t => t.assignedTo).filter(Boolean))];
return React.createElement('div', { className: 'space-y-4 p-4' },
React.createElement('h2', { className: 'text-base font-semibold' }, 'Сводка по договорам'),
React.createElement('div', { className: 'flex gap-3' },
React.createElement(StatCard, { title: 'Всего', value: total }),
React.createElement(StatCard, { title: 'В работе', value: inProgress, colorClass: 'text-yellow-600' }),
React.createElement(StatCard, { title: 'Закрыто', value: closed, colorClass: 'text-green-600' }),
React.createElement(StatCard, { title: 'Просрочено', value: overdue, colorClass: overdue > 0 ? 'text-red-600' : 'text-green-600' })
),
React.createElement(Card, null,
React.createElement(CardHeader, { className: 'pb-2 pt-3 px-4' },
React.createElement(CardTitle, { className: 'text-sm' }, 'Выполнение')
),
React.createElement(CardContent, { className: 'px-4 pb-3' },
React.createElement(Progress, { value: percent, className: 'h-2 mb-1' }),
React.createElement('p', { className: 'text-xs text-muted-foreground' },
closed + ' из ' + total + ' завершены (' + percent + '%)'
)
)
),
React.createElement(Card, null,
React.createElement(CardHeader, { className: 'pb-2 pt-3 px-4' },
React.createElement(CardTitle, { className: 'text-sm' }, 'Договоры')
),
React.createElement(CardContent, { className: 'px-4 pb-3' },
React.createElement('div', { className: 'flex gap-2 mb-3 flex-wrap' },
React.createElement(Input, {
placeholder: 'Поиск...',
value: searchQuery,
onChange: e => setSearchQuery(e.target.value),
className: 'h-7 text-xs w-40'
}),
React.createElement(Select, {
value: statusFilter,
onValueChange: v => setStatusFilter(v)
},
React.createElement(SelectTrigger, { className: 'h-7 text-xs w-32' },
React.createElement(SelectValue, { placeholder: 'Статус' })
),
React.createElement(SelectContent, null,
React.createElement(SelectItem, { value: 'all', className: 'text-xs' }, 'Все статусы'),
React.createElement(SelectItem, { value: 'active', className: 'text-xs' }, 'В работе'),
React.createElement(SelectItem, { value: 'closed', className: 'text-xs' }, 'Закрытые')
)
),
React.createElement(Select, {
value: assigneeFilter,
onValueChange: v => setAssigneeFilter(v)
},
React.createElement(SelectTrigger, { className: 'h-7 text-xs w-36' },
React.createElement(SelectValue, { placeholder: 'Ответственный' })
),
React.createElement(SelectContent, null,
React.createElement(SelectItem, { value: 'all', className: 'text-xs' }, 'Все'),
React.createElement(SelectItem, { value: 'unassigned', className: 'text-xs' }, 'Не назначен'),
...uniqueAssignees.map(uid =>
React.createElement(SelectItem, { key: uid, value: String(uid), className: 'text-xs' },
getUserName(uid)
)
)
)
),
searchQuery || statusFilter !== 'all' || assigneeFilter !== 'all'
? React.createElement(Button, {
variant: 'ghost',
size: 'sm',
className: 'h-7 px-2 text-xs',
onClick: () => { setSearchQuery(''); setStatusFilter('all'); setAssigneeFilter('all'); }
}, 'Сбросить')
: null
),
loading
? React.createElement('div', { className: 'py-4 text-center text-xs text-muted-foreground' }, 'Загрузка...')
: React.createElement(Table, null,
React.createElement(TableHeader, null,
React.createElement(TableRow, null,
React.createElement(TableHead, { className: 'w-16' }, '№'),
React.createElement(TableHead, null, 'Название'),
React.createElement(TableHead, { className: 'w-28' }, 'Статус'),
React.createElement(TableHead, { className: 'w-40' }, 'Ответственный'),
React.createElement(TableHead, { className: 'w-28 text-right' }, 'Срок')
)
),
React.createElement(TableBody, null,
filteredTasks.length === 0
? React.createElement(TableRow, null,
React.createElement(TableCell, { colSpan: 5, className: 'text-center text-xs text-muted-foreground py-4' }, 'Нет договоров')
)
: filteredTasks.map(t =>
React.createElement(TableRow, { key: t.id },
React.createElement(TableCell, { className: 'text-xs font-mono text-muted-foreground' }, t.id),
React.createElement(TableCell, { className: 'text-xs' },
React.createElement('a', {
className: 'hover:underline cursor-pointer text-primary',
onClick: () => ctx.navigate('/forms/20/tasks/' + t.id)
}, t.title || '—')
),
React.createElement(TableCell, null, getStatusBadge(t.currentStatusId, t.isCompleted)),
React.createElement(TableCell, { className: 'text-xs' }, getUserName(t.assignedTo)),
React.createElement(TableCell, {
className: 'text-xs text-right ' + (
t.dueDate && String(t.dueDate).slice(0, 10) < today && !t.isCompleted
? 'text-red-600' : 'text-muted-foreground'
)
}, t.dueDate ? String(t.dueDate).slice(0, 10) : '—')
)
)
)
)
)
)
);
}
return function Widget({ ctx }) {
return React.createElement(Dashboard, { ctx });
};