From c0011b49a34bcd4daf22e1e6d03a0651dd5dfc9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Tue, 29 Sep 2026 15:09:22 +0300 Subject: [PATCH] =?UTF-8?q?feat(users):=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD=D0=BE=D0=BA=20=D0=B2=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B5=D1=81=D1=82=D1=80=D0=B5=20=D0=BF=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=B9=20+=20?= =?UTF-8?q?=D0=BA=D0=BB=D0=B8=D0=BA=D0=B0=D0=B1=D0=B5=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D0=B0=D1=8F=20=D0=9E=D1=86=D0=B5=D0=BD=D0=BA=D0=B0=20=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=BE=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=B3=D1=80=D0=B0=D1=84=D0=B8=D0=BA=20=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D1=80=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реестр /users: - кнопка «Колонки» — чекбоксы базовых колонок и кастомных полей профиля (Оценка, Средняя оценка и др.), скрытые ключи в localStorage; - /api/users/list?includeFieldValues=1 — значения полей текущей страницы; - поля типа history-number (Оценка) кликабельны — открывают график истории. График истории (FieldHistoryDialog): - точки красные и крупные (на тёмном фоне дефолтные не видны), линия primary; - клик по точке — под графиком список оценок за бакет со ссылками на задачи (название задачи → /forms//tasks/); - API field-history возвращает entries (value, taskId, taskTitle, formId, bucket). --- .../components/user/FieldHistoryDialog.tsx | 77 +++++- client/src/pages/Users.tsx | 227 +++++++++++++++--- server/routes/user-profile.routes.ts | 9 + server/routes/user.routes.ts | 18 +- server/storage/user-profile.storage.ts | 43 ++++ 5 files changed, 330 insertions(+), 44 deletions(-) diff --git a/client/src/components/user/FieldHistoryDialog.tsx b/client/src/components/user/FieldHistoryDialog.tsx index 390a551..55288d3 100644 --- a/client/src/components/user/FieldHistoryDialog.tsx +++ b/client/src/components/user/FieldHistoryDialog.tsx @@ -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('month'); + // Выбранная точка графика (bucket-дата) — под графиком показываем задачи, + // из которых выставлены оценки за этот период + const [selectedBucket, setSelectedBucket] = useState(null); const { data, isLoading } = useQuery({ 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 ( @@ -73,7 +95,7 @@ export function FieldHistoryDialog({ {fieldName}: история - setPeriod(v as HistoryPeriod)}> + {PERIODS.map((p) => ( @@ -95,7 +117,16 @@ export function FieldHistoryDialog({ ) : ( <> - + { + const label = (state as { activeLabel?: string } | null)?.activeLabel; + if (label) { + setSelectedBucket((prev) => (prev === label ? null : String(label))); + } + }} + > } /> + {/* Точки красные и крупные — на тёмном фоне дефолтные не видны; + клик по точке — список задач за бакет под графиком */} + + {selectedBucket && ( +
+

+ Оценки за {formatBucketDate(selectedBucket, period)} (нажмите на задачу, чтобы открыть): +

+ {bucketEntries.length === 0 ? ( +

Нет оценок за период

+ ) : ( + bucketEntries.map((e, i) => ( +
+ + {formatAvg(e.value)} + + {e.taskId && e.formId ? ( + onOpenChange(false)} + > + {e.taskTitle || `Задача #${e.taskId}`} + + + ) : ( + Без привязки к задаче + )} +
+ )) + )} +
+ )} +

Среднее за период: {data?.overallAvg != null ? formatAvg(data.overallAvg) : '—'} · оценок: {data?.totalCount ?? 0}

diff --git a/client/src/pages/Users.tsx b/client/src/pages/Users.tsx index d1ad1db..9a076b1 100644 --- a/client/src/pages/Users.tsx +++ b/client/src/pages/Users.tsx @@ -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(() => { + 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 = () => { )}
+
+ + + + + + {baseColumnOptions + .filter((c) => !c.adminOnly || isAdmin(user)) + .map((c) => ( + { e.preventDefault(); toggleColumn(c.key); }} + className="flex items-center gap-2 text-xs" + > + + {c.label} + + ))} + {profileFields.length > 0 && ( + <> +
+ Поля профиля +
+ {profileFields.map((f) => ( + { e.preventDefault(); toggleColumn(`field_${f.id}`); }} + className="flex items-center gap-2 text-xs" + > + + {f.name} + + ))} + + )} +
+
+
- ID - - - - Телефон - - - - Орг. роли - {isAdmin(user) && ( - <> - GPS - Финансы - + {isColVisible('id') && ID} + {isColVisible('email') && ( + + + + )} + {isColVisible('phone') && Телефон} + {isColVisible('role') && ( + + + + )} + {isColVisible('orgRoles') && Орг. роли} + {profileFields.filter((f) => isColVisible(`field_${f.id}`)).map((f) => ( + {f.name} + ))} + {isAdmin(user) && isColVisible('gps') && ( + GPS + )} + {isAdmin(user) && isColVisible('finance') && ( + Финансы + )} + {isColVisible('userStatus') && Статус} + {isColVisible('activity') && ( + + + )} - Статус - - - Действия @@ -1089,25 +1195,53 @@ const UsersPage = () => { - {tableUser.id} - -
{tableUser.email}
-
- -
{tableUser.phone || '—'}
-
- - - {tableUser.appRole === 'admin' ? 'Администратор' : tableUser.appRole === 'accountant' ? 'Бухгалтер' : 'Пользователь'} - - - - - {(tableUser.organizationalRoles || []).map((r: any) => r.name).join(', ') || '—'} - - - {isAdmin(user) && ( - <> + {isColVisible('id') && {tableUser.id}} + {isColVisible('email') && ( + +
{tableUser.email}
+
+ )} + {isColVisible('phone') && ( + +
{tableUser.phone || '—'}
+
+ )} + {isColVisible('role') && ( + + + {tableUser.appRole === 'admin' ? 'Администратор' : tableUser.appRole === 'accountant' ? 'Бухгалтер' : 'Пользователь'} + + + )} + {isColVisible('orgRoles') && ( + + + {(tableUser.organizationalRoles || []).map((r: any) => r.name).join(', ') || '—'} + + + )} + {profileFields.filter((f) => isColVisible(`field_${f.id}`)).map((f) => { + const value = tableUser.fieldValues?.[f.id]; + return ( + e.stopPropagation()}> + {f.type === 'history-number' ? ( + // Кликабельная оценка — открывает график истории + + ) : ( + {formatProfileFieldValue(value, f.type)} + )} + + ); + })} + {isAdmin(user) && isColVisible('gps') && ( e.stopPropagation()}> { onChange={(v) => userModuleAccessMutation.mutate({ userId: tableUser.id, field: 'gpsAccess', value: v })} /> + )} + {isAdmin(user) && isColVisible('finance') && ( e.stopPropagation()}> { onChange={(v) => userModuleAccessMutation.mutate({ userId: tableUser.id, field: 'financeAccess', value: v })} /> - )} + {isColVisible('userStatus') && ( e.stopPropagation()}> {hasPermission(user, 'users.manage') ? (