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

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

208 lines
9.4 KiB
JavaScript
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, 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 });
};