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:
2026-09-29 15:09:22 +03:00
parent c56ec26b49
commit c0011b49a3
5 changed files with 330 additions and 44 deletions

View File

@@ -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>

View File

@@ -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">

View File

@@ -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,
});

View File

@@ -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,

View File

@@ -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,