feat(users): выбор колонок в реестре пользователей + кликабельная Оценка и доработанный график истории
Реестр /users: - кнопка «Колонки» — чекбоксы базовых колонок и кастомных полей профиля (Оценка, Средняя оценка и др.), скрытые ключи в localStorage; - /api/users/list?includeFieldValues=1 — значения полей текущей страницы; - поля типа history-number (Оценка) кликабельны — открывают график истории. График истории (FieldHistoryDialog): - точки красные и крупные (на тёмном фоне дефолтные не видны), линия primary; - клик по точке — под графиком список оценок за бакет со ссылками на задачи (название задачи → /forms/<formId>/tasks/<id>); - API field-history возвращает entries (value, taskId, taskTitle, formId, bucket).
This commit is contained in:
@@ -1,19 +1,31 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts';
|
||||
import { Loader2, TrendingUp } from 'lucide-react';
|
||||
import { Loader2, TrendingUp, ExternalLink } from 'lucide-react';
|
||||
import { Link } from 'wouter';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart';
|
||||
import { apiRequest } from '@/lib/queryClient';
|
||||
|
||||
type HistoryPeriod = 'week' | 'month' | 'quarter' | 'year';
|
||||
|
||||
interface HistoryEntry {
|
||||
value: number;
|
||||
taskId: number | null;
|
||||
taskTitle: string | null;
|
||||
formId: number | null;
|
||||
bucket: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface FieldHistoryResponse {
|
||||
success: boolean;
|
||||
field: { id: number; name: string };
|
||||
period: HistoryPeriod;
|
||||
points: { date: string; avg: number; count: number }[];
|
||||
entries: HistoryEntry[];
|
||||
overallAvg: number | null;
|
||||
totalCount: number;
|
||||
}
|
||||
@@ -53,6 +65,9 @@ export function FieldHistoryDialog({
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const [period, setPeriod] = useState<HistoryPeriod>('month');
|
||||
// Выбранная точка графика (bucket-дата) — под графиком показываем задачи,
|
||||
// из которых выставлены оценки за этот период
|
||||
const [selectedBucket, setSelectedBucket] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery<FieldHistoryResponse>({
|
||||
queryKey: ['/api/users', userId, 'field-history', fieldId, period],
|
||||
@@ -64,7 +79,14 @@ export function FieldHistoryDialog({
|
||||
});
|
||||
|
||||
const points = data?.points ?? [];
|
||||
const entries = data?.entries ?? [];
|
||||
const hasData = (data?.totalCount ?? 0) > 0;
|
||||
const bucketEntries = selectedBucket ? entries.filter((e) => e.bucket === selectedBucket) : [];
|
||||
|
||||
const handlePeriodChange = (v: string) => {
|
||||
setPeriod(v as HistoryPeriod);
|
||||
setSelectedBucket(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -73,7 +95,7 @@ export function FieldHistoryDialog({
|
||||
<DialogTitle>{fieldName}: история</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs value={period} onValueChange={(v) => setPeriod(v as HistoryPeriod)}>
|
||||
<Tabs value={period} onValueChange={handlePeriodChange}>
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
{PERIODS.map((p) => (
|
||||
<TabsTrigger key={p.value} value={p.value}>
|
||||
@@ -95,7 +117,16 @@ export function FieldHistoryDialog({
|
||||
) : (
|
||||
<>
|
||||
<ChartContainer config={chartConfig} className="h-[260px] w-full aspect-auto">
|
||||
<LineChart data={points} margin={{ top: 8, right: 12, left: 0, bottom: 0 }}>
|
||||
<LineChart
|
||||
data={points}
|
||||
margin={{ top: 8, right: 12, left: 0, bottom: 0 }}
|
||||
onClick={(state) => {
|
||||
const label = (state as { activeLabel?: string } | null)?.activeLabel;
|
||||
if (label) {
|
||||
setSelectedBucket((prev) => (prev === label ? null : String(label)));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
@@ -113,15 +144,51 @@ export function FieldHistoryDialog({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{/* Точки красные и крупные — на тёмном фоне дефолтные не видны;
|
||||
клик по точке — список задач за бакет под графиком */}
|
||||
<Line
|
||||
dataKey="avg"
|
||||
type="monotone"
|
||||
stroke="var(--color-avg)"
|
||||
stroke="hsl(var(--primary))"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3 }}
|
||||
cursor="pointer"
|
||||
dot={{ r: 4, fill: '#ef4444', stroke: 'hsl(var(--background))', strokeWidth: 1.5 }}
|
||||
activeDot={{ r: 6, fill: '#ef4444', stroke: 'hsl(var(--background))', strokeWidth: 2 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
|
||||
{selectedBucket && (
|
||||
<div className="border border-border rounded-md p-2 max-h-36 overflow-auto space-y-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Оценки за {formatBucketDate(selectedBucket, period)} (нажмите на задачу, чтобы открыть):
|
||||
</p>
|
||||
{bucketEntries.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">Нет оценок за период</p>
|
||||
) : (
|
||||
bucketEntries.map((e, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm min-w-0">
|
||||
<Badge variant="secondary" className="text-[10px] shrink-0">
|
||||
{formatAvg(e.value)}
|
||||
</Badge>
|
||||
{e.taskId && e.formId ? (
|
||||
<Link
|
||||
href={`/forms/${e.formId}/tasks/${e.taskId}`}
|
||||
className="inline-flex items-center gap-1 text-primary hover:underline truncate"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<span className="truncate">{e.taskTitle || `Задача #${e.taskId}`}</span>
|
||||
<ExternalLink className="w-3 h-3 shrink-0" />
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground truncate">Без привязки к задаче</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Среднее за период: {data?.overallAvg != null ? formatAvg(data.overallAvg) : '—'} · оценок: {data?.totalCount ?? 0}
|
||||
</p>
|
||||
|
||||
@@ -82,6 +82,7 @@ import {
|
||||
import { useColumnFilters } from '@/hooks/useColumnFilters';
|
||||
import { ColumnFilter } from '@/components/ui/ColumnFilter';
|
||||
import { ActiveFilterBadges } from '@/components/ui/ActiveFilterBadges';
|
||||
import { FieldHistoryDialog } from '@/components/user/FieldHistoryDialog';
|
||||
import { apiRequest, queryClient } from '@/lib/queryClient';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -547,6 +548,7 @@ const UsersPage = () => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(page));
|
||||
params.set('pageSize', String(pageSize));
|
||||
params.set('includeFieldValues', '1'); // значения кастомных полей для колонок реестра
|
||||
if (_filtersStr) params.set('filters', _filtersStr);
|
||||
if (sort) { params.set('sortColumn', sort.column); params.set('sortDirection', sort.direction); }
|
||||
const res = await apiRequest('GET', `/api/users/list?${params.toString()}`);
|
||||
@@ -555,6 +557,60 @@ const UsersPage = () => {
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
// Кастомные поля профиля (Оценка, Средняя оценка и т.п.) — как колонки реестра
|
||||
const { data: profileFieldsData } = useQuery<{ success: boolean; fields: any[] }>({
|
||||
queryKey: ['/api/user-profile/fields'],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest('GET', '/api/user-profile/fields');
|
||||
return res.json();
|
||||
},
|
||||
enabled: !!user,
|
||||
});
|
||||
const profileFields: any[] = profileFieldsData?.fields || [];
|
||||
|
||||
// Видимость колонок: ключи скрытых колонок в localStorage (по умолчанию всё видно,
|
||||
// включая кастомные поля — иначе их «не видать», как до выбора колонок)
|
||||
const HIDDEN_COLUMNS_KEY = 'users-hidden-columns-v1';
|
||||
const [hiddenColumns, setHiddenColumns] = useState<string[]>(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(HIDDEN_COLUMNS_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
const isColVisible = (key: string) => !hiddenColumns.includes(key);
|
||||
const toggleColumn = (key: string) => {
|
||||
setHiddenColumns((prev) => {
|
||||
const next = prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key];
|
||||
try { localStorage.setItem(HIDDEN_COLUMNS_KEY, JSON.stringify(next)); } catch {}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Диалог истории оценки (клик по значению history-number поля в реестре)
|
||||
const [historyDialog, setHistoryDialog] = useState<{ userId: number; fieldId: number; fieldName: string } | null>(null);
|
||||
|
||||
// Базовые колонки, которыми можно управлять (Пользователь и Действия — всегда видны)
|
||||
const baseColumnOptions: { key: string; label: string; adminOnly?: boolean }[] = [
|
||||
{ key: 'id', label: 'ID' },
|
||||
{ key: 'email', label: 'Email' },
|
||||
{ key: 'phone', label: 'Телефон' },
|
||||
{ key: 'role', label: 'Роль' },
|
||||
{ key: 'orgRoles', label: 'Орг. роли' },
|
||||
{ key: 'gps', label: 'GPS', adminOnly: true },
|
||||
{ key: 'finance', label: 'Финансы', adminOnly: true },
|
||||
{ key: 'userStatus', label: 'Статус' },
|
||||
{ key: 'activity', label: 'Активность' },
|
||||
];
|
||||
|
||||
const formatProfileFieldValue = (value: unknown, type: string): string => {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
if (type === 'checkbox' || type === 'toggle') return value ? 'Да' : 'Нет';
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
};
|
||||
|
||||
// All org users for members dialog (no filters applied)
|
||||
const { data: allUsersData, refetch: refetchAllUsers } = useQuery<{ success: boolean; users: any[] }>({
|
||||
queryKey: ['/api/users/all'],
|
||||
@@ -1036,31 +1092,81 @@ const UsersPage = () => {
|
||||
</div>
|
||||
)}
|
||||
<div className="p-3">
|
||||
<div className="flex justify-end mb-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs">
|
||||
<Settings2 className="w-3 h-3 mr-1.5" />
|
||||
Колонки
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="max-h-80 overflow-auto min-w-[180px]">
|
||||
{baseColumnOptions
|
||||
.filter((c) => !c.adminOnly || isAdmin(user))
|
||||
.map((c) => (
|
||||
<DropdownMenuItem
|
||||
key={c.key}
|
||||
onSelect={(e) => { e.preventDefault(); toggleColumn(c.key); }}
|
||||
className="flex items-center gap-2 text-xs"
|
||||
>
|
||||
<Checkbox checked={isColVisible(c.key)} className="pointer-events-none" />
|
||||
{c.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{profileFields.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1.5 text-[10px] text-muted-foreground border-t border-border mt-1 pt-2">
|
||||
Поля профиля
|
||||
</div>
|
||||
{profileFields.map((f) => (
|
||||
<DropdownMenuItem
|
||||
key={`field_${f.id}`}
|
||||
onSelect={(e) => { e.preventDefault(); toggleColumn(`field_${f.id}`); }}
|
||||
className="flex items-center gap-2 text-xs"
|
||||
>
|
||||
<Checkbox checked={isColVisible(`field_${f.id}`)} className="pointer-events-none" />
|
||||
{f.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<Table className="text-xs">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<ColumnFilter column="name" label="Пользователь" filterValue={filters['name'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchUserColumnSuggestions('name')} />
|
||||
</TableHead>
|
||||
<TableHead className="w-[60px]">ID</TableHead>
|
||||
<TableHead>
|
||||
<ColumnFilter column="email" label="Email" filterValue={filters['email'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchUserColumnSuggestions('email')} />
|
||||
</TableHead>
|
||||
<TableHead>Телефон</TableHead>
|
||||
<TableHead>
|
||||
<ColumnFilter column="role" label="Роль" filterValue={filters['role'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchUserColumnSuggestions('role')} />
|
||||
</TableHead>
|
||||
<TableHead>Орг. роли</TableHead>
|
||||
{isAdmin(user) && (
|
||||
<>
|
||||
<TableHead className="text-center w-[50px]" title="Доступ к GPS-модулю">GPS</TableHead>
|
||||
<TableHead className="text-center w-[70px]" title="Доступ к финансам">Финансы</TableHead>
|
||||
</>
|
||||
{isColVisible('id') && <TableHead className="w-[60px]">ID</TableHead>}
|
||||
{isColVisible('email') && (
|
||||
<TableHead>
|
||||
<ColumnFilter column="email" label="Email" filterValue={filters['email'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchUserColumnSuggestions('email')} />
|
||||
</TableHead>
|
||||
)}
|
||||
{isColVisible('phone') && <TableHead>Телефон</TableHead>}
|
||||
{isColVisible('role') && (
|
||||
<TableHead>
|
||||
<ColumnFilter column="role" label="Роль" filterValue={filters['role'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchUserColumnSuggestions('role')} />
|
||||
</TableHead>
|
||||
)}
|
||||
{isColVisible('orgRoles') && <TableHead>Орг. роли</TableHead>}
|
||||
{profileFields.filter((f) => isColVisible(`field_${f.id}`)).map((f) => (
|
||||
<TableHead key={`field_${f.id}`}>{f.name}</TableHead>
|
||||
))}
|
||||
{isAdmin(user) && isColVisible('gps') && (
|
||||
<TableHead className="text-center w-[50px]" title="Доступ к GPS-модулю">GPS</TableHead>
|
||||
)}
|
||||
{isAdmin(user) && isColVisible('finance') && (
|
||||
<TableHead className="text-center w-[70px]" title="Доступ к финансам">Финансы</TableHead>
|
||||
)}
|
||||
{isColVisible('userStatus') && <TableHead>Статус</TableHead>}
|
||||
{isColVisible('activity') && (
|
||||
<TableHead>
|
||||
<ColumnFilter column="status" label="Активность" filterValue={filters['status'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchUserColumnSuggestions('status')} />
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead>
|
||||
<ColumnFilter column="status" label="Активность" filterValue={filters['status'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchUserColumnSuggestions('status')} />
|
||||
</TableHead>
|
||||
<TableHead className="text-center">Действия</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -1089,25 +1195,53 @@ const UsersPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{tableUser.id}</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-xs" data-testid={`text-email-${tableUser.id}`}>{tableUser.email}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-xs text-muted-foreground" data-testid={`text-phone-${tableUser.id}`}>{tableUser.phone || '—'}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${isAdmin(tableUser) ? 'bg-muted text-foreground font-medium' : 'text-muted-foreground'}`}>
|
||||
{tableUser.appRole === 'admin' ? 'Администратор' : tableUser.appRole === 'accountant' ? 'Бухгалтер' : 'Пользователь'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{(tableUser.organizationalRoles || []).map((r: any) => r.name).join(', ') || '—'}
|
||||
</span>
|
||||
</TableCell>
|
||||
{isAdmin(user) && (
|
||||
<>
|
||||
{isColVisible('id') && <TableCell className="text-xs text-muted-foreground">{tableUser.id}</TableCell>}
|
||||
{isColVisible('email') && (
|
||||
<TableCell>
|
||||
<div className="text-xs" data-testid={`text-email-${tableUser.id}`}>{tableUser.email}</div>
|
||||
</TableCell>
|
||||
)}
|
||||
{isColVisible('phone') && (
|
||||
<TableCell>
|
||||
<div className="text-xs text-muted-foreground" data-testid={`text-phone-${tableUser.id}`}>{tableUser.phone || '—'}</div>
|
||||
</TableCell>
|
||||
)}
|
||||
{isColVisible('role') && (
|
||||
<TableCell>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${isAdmin(tableUser) ? 'bg-muted text-foreground font-medium' : 'text-muted-foreground'}`}>
|
||||
{tableUser.appRole === 'admin' ? 'Администратор' : tableUser.appRole === 'accountant' ? 'Бухгалтер' : 'Пользователь'}
|
||||
</span>
|
||||
</TableCell>
|
||||
)}
|
||||
{isColVisible('orgRoles') && (
|
||||
<TableCell>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{(tableUser.organizationalRoles || []).map((r: any) => r.name).join(', ') || '—'}
|
||||
</span>
|
||||
</TableCell>
|
||||
)}
|
||||
{profileFields.filter((f) => isColVisible(`field_${f.id}`)).map((f) => {
|
||||
const value = tableUser.fieldValues?.[f.id];
|
||||
return (
|
||||
<TableCell key={`field_${f.id}`} onClick={(e) => e.stopPropagation()}>
|
||||
{f.type === 'history-number' ? (
|
||||
// Кликабельная оценка — открывает график истории
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-primary hover:underline disabled:text-muted-foreground disabled:no-underline disabled:cursor-default"
|
||||
disabled={value === null || value === undefined || value === ''}
|
||||
onClick={() => setHistoryDialog({ userId: tableUser.id, fieldId: f.id, fieldName: f.name })}
|
||||
data-testid={`rating-cell-${tableUser.id}-${f.id}`}
|
||||
>
|
||||
{value === null || value === undefined || value === '' ? '—' : String(value)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">{formatProfileFieldValue(value, f.type)}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
{isAdmin(user) && isColVisible('gps') && (
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<ModuleAccessCell
|
||||
checked={!!tableUser.gpsAccess}
|
||||
@@ -1119,6 +1253,8 @@ const UsersPage = () => {
|
||||
onChange={(v) => userModuleAccessMutation.mutate({ userId: tableUser.id, field: 'gpsAccess', value: v })}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{isAdmin(user) && isColVisible('finance') && (
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<ModuleAccessCell
|
||||
checked={!!tableUser.financeAccess}
|
||||
@@ -1130,8 +1266,8 @@ const UsersPage = () => {
|
||||
onChange={(v) => userModuleAccessMutation.mutate({ userId: tableUser.id, field: 'financeAccess', value: v })}
|
||||
/>
|
||||
</TableCell>
|
||||
</>
|
||||
)}
|
||||
{isColVisible('userStatus') && (
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
{hasPermission(user, 'users.manage') ? (
|
||||
<Select
|
||||
@@ -1166,6 +1302,8 @@ const UsersPage = () => {
|
||||
)
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
{isColVisible('activity') && (
|
||||
<TableCell className="text-[10px] text-muted-foreground" data-testid={`text-last-activity-${tableUser.id}`}>
|
||||
{(() => {
|
||||
const activityAt = tableUser.lastActivityAt || tableUser.lastLogin;
|
||||
@@ -1182,6 +1320,7 @@ const UsersPage = () => {
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
{hasPermission(user, 'users.manage') && (
|
||||
<DropdownMenu>
|
||||
@@ -1385,6 +1524,18 @@ const UsersPage = () => {
|
||||
editingUser={editingUser}
|
||||
/>
|
||||
|
||||
{/* Диалог истории оценки — открывается по клику на значение
|
||||
history-number поля (Оценка) в колонке реестра */}
|
||||
{historyDialog && (
|
||||
<FieldHistoryDialog
|
||||
userId={historyDialog.userId}
|
||||
fieldId={historyDialog.fieldId}
|
||||
fieldName={historyDialog.fieldName}
|
||||
open={!!historyDialog}
|
||||
onOpenChange={(o) => { if (!o) setHistoryDialog(null); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Invite Modal */}
|
||||
<Dialog open={inviteModalOpen} onOpenChange={setInviteModalOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
|
||||
@@ -224,11 +224,20 @@ export function registerUserProfileRoutes(router: Router): void {
|
||||
period.days,
|
||||
period.bucket
|
||||
);
|
||||
// Сырые записи с привязкой к задачам — для клика по точке графика
|
||||
const entries = await storage.getUserFieldHistoryEntries(
|
||||
userId,
|
||||
fieldId,
|
||||
req.organizationId!,
|
||||
period.days,
|
||||
period.bucket
|
||||
);
|
||||
res.json({
|
||||
success: true,
|
||||
field: { id: field.id, name: field.name },
|
||||
period: periodParam,
|
||||
points,
|
||||
entries,
|
||||
overallAvg,
|
||||
totalCount,
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
bulkAssignDelegate,
|
||||
} from "../services/delegation.service";
|
||||
import { db } from "../db";
|
||||
import { roles, roleMembers, userStatuses, insertUserStatusSchema, updateUserStatusSchema } from "@shared/schema";
|
||||
import { roles, roleMembers, userStatuses, userProfileFieldValues, insertUserStatusSchema, updateUserStatusSchema } from "@shared/schema";
|
||||
import { eq, and, inArray } from "drizzle-orm";
|
||||
|
||||
export function registerUserRoutes(router: ReturnType<typeof import("express").Router>): void {
|
||||
@@ -135,6 +135,21 @@ export function registerUserRoutes(router: ReturnType<typeof import("express").R
|
||||
rolesByUser.get(ur.userId)!.push({ id: ur.roleId, name: ur.roleName });
|
||||
}
|
||||
|
||||
// Значения кастомных полей профиля — для реестра пользователей (выбор колонок).
|
||||
// Только для текущей страницы (userIds), опционально по includeFieldValues=1.
|
||||
const includeFieldValues = req.query.includeFieldValues === '1';
|
||||
const fieldValuesByUser = new Map<number, Record<number, unknown>>();
|
||||
if (includeFieldValues && userIds.length > 0) {
|
||||
const fvRows = await db
|
||||
.select({ userId: userProfileFieldValues.userId, fieldId: userProfileFieldValues.fieldId, value: userProfileFieldValues.value })
|
||||
.from(userProfileFieldValues)
|
||||
.where(inArray(userProfileFieldValues.userId, userIds));
|
||||
for (const row of fvRows) {
|
||||
if (!fieldValuesByUser.has(row.userId)) fieldValuesByUser.set(row.userId, {});
|
||||
fieldValuesByUser.get(row.userId)![row.fieldId] = row.value;
|
||||
}
|
||||
}
|
||||
|
||||
const allStatuses = await userStatusesStorage.listByOrganization(req.organizationId!);
|
||||
const statusById = new Map(allStatuses.map(s => [s.id, s]));
|
||||
|
||||
@@ -158,6 +173,7 @@ export function registerUserRoutes(router: ReturnType<typeof import("express").R
|
||||
lastLogin: u.lastLogin,
|
||||
createdAt: u.createdAt,
|
||||
organizationalRoles: rolesByUser.get(u.id) || [],
|
||||
...(includeFieldValues ? { fieldValues: fieldValuesByUser.get(u.id) || {} } : {}),
|
||||
};
|
||||
}),
|
||||
total: managedIds === null ? result.total : filteredUsers.length,
|
||||
|
||||
@@ -565,6 +565,49 @@ export class UserProfileStorage extends UsersStorage {
|
||||
};
|
||||
}
|
||||
|
||||
// Сырые записи истории с привязкой к задачам — для клика по точке графика
|
||||
// (список задач за бакет: название + ссылка). bucket считается тем же
|
||||
// date_trunc, что и у точек графика, чтобы клиент мог их сопоставить.
|
||||
async getUserFieldHistoryEntries(
|
||||
userId: number,
|
||||
fieldId: number,
|
||||
organizationId: number,
|
||||
days: number,
|
||||
bucket: 'day' | 'week' | 'month'
|
||||
): Promise<Array<{
|
||||
value: number;
|
||||
taskId: number | null;
|
||||
taskTitle: string | null;
|
||||
formId: number | null;
|
||||
bucket: string;
|
||||
createdAt: string;
|
||||
}>> {
|
||||
const result = await db.execute(sql`
|
||||
SELECT ufh.value::float8 AS value,
|
||||
ufh.task_id AS "taskId",
|
||||
t.title AS "taskTitle",
|
||||
t.form_id AS "formId",
|
||||
to_char(date_trunc(${bucket}, ufh.created_at), 'YYYY-MM-DD') AS bucket,
|
||||
ufh.created_at AS "createdAt"
|
||||
FROM user_field_history ufh
|
||||
LEFT JOIN tasks t ON ufh.task_id = t.id
|
||||
WHERE ufh.user_id = ${userId}
|
||||
AND ufh.field_id = ${fieldId}
|
||||
AND ufh.organization_id = ${organizationId}
|
||||
AND ufh.created_at >= now() - make_interval(days => ${days})
|
||||
ORDER BY ufh.created_at DESC
|
||||
LIMIT 500
|
||||
`);
|
||||
return result.rows as Array<{
|
||||
value: number;
|
||||
taskId: number | null;
|
||||
taskTitle: string | null;
|
||||
formId: number | null;
|
||||
bucket: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ========== DETAIL ==========
|
||||
async getUserProfileDetail(
|
||||
userId: number,
|
||||
|
||||
Reference in New Issue
Block a user