- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
208 lines
9.4 KiB
JavaScript
208 lines
9.4 KiB
JavaScript
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 });
|
||
};
|