feat(profile): read-first редизайн страницы пользователя, per-field как в задачах
- Раскладка: шапка-профиль → Основное/Контакты → Роль и доступ/Поля+Замещение → вторичные разделы чипами - ProfileFieldRow: двойной клик/карандаш, редактор в слоте, Esc, optimistic-кэш, все типы полей - Замещение: надпись + ⊕ popover, список под строкой - Удалены RHF-форма, zod-схема и кнопки «Сохранить» - server: PATCH /api/users/:id (частичное обновление, admin-градация, аудит) - server: history-number в REST PATCH пишет историю + пересчёт средней
This commit is contained in:
1431
client/src/components/profile/ProfileFieldRow.tsx
Normal file
1431
client/src/components/profile/ProfileFieldRow.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
const CONTACT_TAGS = ['Рабочий', 'Свой', 'Основной'];
|
||||
export const CONTACT_TAGS = ['Рабочий', 'Свой', 'Основной'];
|
||||
|
||||
type ContactItem = { value: string; tag: string };
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -345,12 +345,38 @@ export function registerUserProfileRoutes(router: Router): void {
|
||||
if (readOnlyError) {
|
||||
return res.status(400).json({ success: false, error: readOnlyError });
|
||||
}
|
||||
const field = await storage.getUserProfileFieldById(fieldId, req.organizationId!);
|
||||
const existing = await storage.getUserProfileFieldValue(userId, fieldId);
|
||||
if (value === null || value === undefined) {
|
||||
// Удаление (null): очищается только значение поля, история history-number не трогается
|
||||
if (existing) {
|
||||
await storage.deleteUserProfileFieldValueWithAudit(userId, fieldId, req.organizationId!, changedBy);
|
||||
}
|
||||
res.json({ success: true, fieldValue: null });
|
||||
} else if (field!.type === 'history-number') {
|
||||
// history-number: число пишется в user_field_history (актор — текущий пользователь),
|
||||
// становится значением поля, месячная средняя пересчитывается
|
||||
let num: number;
|
||||
try {
|
||||
num = await storage.recordUserFieldHistoryValue(userId, field!, value, req.organizationId!, {
|
||||
auditActor: {
|
||||
changedBy,
|
||||
changedByName: `${req.user!.firstName || ''} ${req.user!.lastName || ''}`.trim(),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : 'Значение должно быть числом',
|
||||
});
|
||||
}
|
||||
if (existing) {
|
||||
const updated = await storage.updateUserProfileFieldValueWithAudit(userId, fieldId, { value: num }, req.organizationId!, changedBy);
|
||||
res.json({ success: true, fieldValue: updated });
|
||||
} else {
|
||||
const created = await storage.createUserProfileFieldValueWithAudit({ userId, fieldId, value: num }, req.organizationId!, changedBy);
|
||||
res.json({ success: true, fieldValue: created });
|
||||
}
|
||||
} else {
|
||||
if (existing) {
|
||||
const updated = await storage.updateUserProfileFieldValueWithAudit(userId, fieldId, { value }, req.organizationId!, changedBy);
|
||||
|
||||
@@ -338,7 +338,154 @@ export function registerUserRoutes(router: ReturnType<typeof import("express").R
|
||||
}
|
||||
);
|
||||
|
||||
// Change user password endpoint for admins
|
||||
// Частичное обновление системных полей профиля (редизайн профиля):
|
||||
// применяются только переданные ключи, непереданные поля не затираются.
|
||||
// Права — как у PUT: admin / сам пользователь / руководитель по орг-дереву (canManageUser).
|
||||
router.patch('/api/users/:id(\\d+)',
|
||||
validateTenantAccess,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
if (!Number.isFinite(userId)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Некорректный ID пользователя'
|
||||
});
|
||||
}
|
||||
const user = await storage.getUser(userId);
|
||||
|
||||
if (!user || user.organizationId !== req.organizationId) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Пользователь не найден'
|
||||
});
|
||||
}
|
||||
|
||||
const canEdit = await canManageUser(req.user, userId, req.organizationId!);
|
||||
if (!canEdit) {
|
||||
return res.status(403).json({ success: false, error: 'Недостаточно прав' });
|
||||
}
|
||||
|
||||
const isAdmin = req.user?.appRole === 'admin';
|
||||
const body = req.body ?? {};
|
||||
|
||||
// appRole/isActive/statusId — только админ (как в PUT); попытка не-админа — явный 403
|
||||
const adminOnlyKeys = ['appRole', 'isActive', 'statusId'] as const;
|
||||
if (!isAdmin && adminOnlyKeys.some((key) => body[key] !== undefined)) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Изменение роли, активности и статуса доступно только администратору'
|
||||
});
|
||||
}
|
||||
|
||||
if (body.appRole !== undefined && !['admin', 'user', 'accountant'].includes(body.appRole)) {
|
||||
return res.status(400).json({ success: false, error: 'Некорректная роль пользователя' });
|
||||
}
|
||||
|
||||
if (body.email !== undefined) {
|
||||
const email = String(body.email).trim();
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return res.status(400).json({ success: false, error: 'Некорректный email' });
|
||||
}
|
||||
const existingWithEmail = await storage.getUserByEmail(email, req.organizationId!);
|
||||
if (existingWithEmail && existingWithEmail.id !== userId) {
|
||||
return res.status(400).json({ success: false, error: 'Пользователь с таким email уже существует' });
|
||||
}
|
||||
body.email = email;
|
||||
}
|
||||
|
||||
const editableKeys = [
|
||||
'firstName', 'lastName', 'middleName', 'email', 'phone', 'position',
|
||||
'additionalPhones', 'additionalEmails',
|
||||
] as const;
|
||||
const updates: Partial<typeof user> = {};
|
||||
for (const key of editableKeys) {
|
||||
if (body[key] !== undefined) {
|
||||
(updates as Record<string, unknown>)[key] = body[key];
|
||||
}
|
||||
}
|
||||
if (isAdmin) {
|
||||
for (const key of adminOnlyKeys) {
|
||||
if (body[key] !== undefined) {
|
||||
(updates as Record<string, unknown>)[key] = body[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return res.status(400).json({ success: false, error: 'Нет полей для обновления' });
|
||||
}
|
||||
|
||||
const updatedUser = await storage.updateUser(userId, updates);
|
||||
|
||||
// Аудит — как в PUT: контактные системные поля профиля (+ email, который PATCH также принимает)
|
||||
const auditFields = [
|
||||
{ name: 'Email', old: user.email, new: updates.email },
|
||||
{ name: 'Телефон', old: user.phone, new: updates.phone },
|
||||
{ name: 'Дополнительные телефоны', old: user.additionalPhones, new: updates.additionalPhones },
|
||||
{ name: 'Дополнительные email', old: user.additionalEmails, new: updates.additionalEmails },
|
||||
];
|
||||
for (const auditField of auditFields) {
|
||||
if (auditField.new === undefined) continue;
|
||||
if (JSON.stringify(auditField.old ?? null) !== JSON.stringify(auditField.new ?? null)) {
|
||||
await storage.createUserProfileAuditLog({
|
||||
userId,
|
||||
organizationId: req.organizationId!,
|
||||
action: 'field_value.updated',
|
||||
fieldName: auditField.name,
|
||||
oldValue: auditField.old ?? null,
|
||||
newValue: auditField.new ?? null,
|
||||
changedBy: req.user!.id,
|
||||
changedByName: req.user ? `${req.user.firstName || ''} ${req.user.lastName || ''}`.trim() : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isAdmin && body.appRole !== undefined && body.appRole !== user.appRole) {
|
||||
logAudit({
|
||||
action: 'user.role.changed',
|
||||
userId: req.user!.id,
|
||||
organizationId: req.organizationId!,
|
||||
details: {
|
||||
targetUserId: userId,
|
||||
oldRole: user.appRole,
|
||||
newRole: body.appRole,
|
||||
},
|
||||
ip: getClientIp(req),
|
||||
userAgent: req.headers['user-agent'] ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Пользователь обновлен',
|
||||
user: {
|
||||
id: updatedUser.id,
|
||||
email: updatedUser.email,
|
||||
firstName: updatedUser.firstName,
|
||||
lastName: updatedUser.lastName,
|
||||
middleName: updatedUser.middleName,
|
||||
position: updatedUser.position,
|
||||
phone: updatedUser.phone,
|
||||
appRole: updatedUser.appRole,
|
||||
isActive: updatedUser.isActive,
|
||||
statusId: updatedUser.statusId,
|
||||
emailVerified: updatedUser.emailVerified,
|
||||
lastLogin: updatedUser.lastLogin,
|
||||
createdAt: updatedUser.createdAt
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Patch user error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Ошибка при обновлении пользователя'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
router.put('/api/users/:id/password',
|
||||
requirePermission('users.manage'),
|
||||
validateTenantAccess,
|
||||
|
||||
@@ -155,19 +155,7 @@ export class UserProfileStorage extends UsersStorage {
|
||||
// среднее за месяц пересчитывается отдельно — в поле, указанном в options.avgFieldCode (или в само поле)
|
||||
let storedValue = value;
|
||||
if (field.type === 'history-number') {
|
||||
const num = typeof value === 'number' ? value : parseFloat(String(value).replace(',', '.').trim());
|
||||
if (!Number.isFinite(num)) {
|
||||
throw new Error(`Поле "${field.name}" имеет тип history-number: значение "${String(value)}" не является числом`);
|
||||
}
|
||||
await db.insert(userFieldHistory).values({
|
||||
organizationId,
|
||||
userId,
|
||||
fieldId: field.id,
|
||||
value: String(num),
|
||||
taskId: taskId ?? null,
|
||||
});
|
||||
storedValue = num;
|
||||
await this.recomputeUserFieldMonthlyAverage(userId, field.id, organizationId);
|
||||
storedValue = await this.recordUserFieldHistoryValue(userId, field, value, organizationId, { taskId });
|
||||
}
|
||||
|
||||
const existing = await this.getUserProfileFieldValue(userId, field.id);
|
||||
@@ -191,13 +179,51 @@ export class UserProfileStorage extends UsersStorage {
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Запись оценки в историю history-number поля: insert в user_field_history
|
||||
* и пересчёт месячной средней. Общая логика для автоматизаций и REST:
|
||||
* автоматизации вызывают с taskId и без актора (аудит от «Автоматизация»),
|
||||
* REST — с актором текущего пользователя. Возвращает распарсенное число.
|
||||
* Нечисловое значение — ошибка.
|
||||
*/
|
||||
async recordUserFieldHistoryValue(
|
||||
userId: number,
|
||||
field: UserProfileField,
|
||||
value: unknown,
|
||||
organizationId: number,
|
||||
options?: {
|
||||
taskId?: number | null;
|
||||
auditActor?: { changedBy: number | null; changedByName: string };
|
||||
}
|
||||
): Promise<number> {
|
||||
const num = typeof value === 'number' ? value : parseFloat(String(value).replace(',', '.').trim());
|
||||
if (!Number.isFinite(num)) {
|
||||
throw new Error(`Поле "${field.name}" имеет тип history-number: значение "${String(value)}" не является числом`);
|
||||
}
|
||||
await db.insert(userFieldHistory).values({
|
||||
organizationId,
|
||||
userId,
|
||||
fieldId: field.id,
|
||||
value: String(num),
|
||||
taskId: options?.taskId ?? null,
|
||||
});
|
||||
await this.recomputeUserFieldMonthlyAverage(userId, field.id, organizationId, options?.auditActor);
|
||||
return num;
|
||||
}
|
||||
|
||||
/**
|
||||
* Пересчитывает среднее за текущий календарный месяц по истории history-number поля.
|
||||
* Среднее пишется в поле, указанное в options.avgFieldCode самого history-number поля
|
||||
* (например «Оценка» → «Средняя оценка»); если avgFieldCode не задан — в само history-number поле.
|
||||
* Если в месяце нет записей — целевое поле очищается (null). Возвращает новое среднее.
|
||||
* auditActor — кто инициировал пересчёт (по умолчанию «Автоматизация»).
|
||||
*/
|
||||
async recomputeUserFieldMonthlyAverage(userId: number, fieldId: number, organizationId: number) {
|
||||
async recomputeUserFieldMonthlyAverage(
|
||||
userId: number,
|
||||
fieldId: number,
|
||||
organizationId: number,
|
||||
auditActor?: { changedBy: number | null; changedByName: string }
|
||||
) {
|
||||
const [field] = await db.select().from(userProfileFields).where(and(
|
||||
eq(userProfileFields.id, fieldId),
|
||||
eq(userProfileFields.organizationId, organizationId),
|
||||
@@ -239,9 +265,9 @@ export class UserProfileStorage extends UsersStorage {
|
||||
fieldId: targetFieldId,
|
||||
oldValue: existing.value ?? null,
|
||||
newValue: avg != null ? String(avg) : null,
|
||||
changedBy: null,
|
||||
changedByName: 'Автоматизация',
|
||||
metadata: { source: 'automation', reason: 'monthly-average-recalc', historyFieldId: fieldId },
|
||||
changedBy: auditActor?.changedBy ?? null,
|
||||
changedByName: auditActor?.changedByName ?? 'Автоматизация',
|
||||
metadata: { source: auditActor ? 'web' : 'automation', reason: 'monthly-average-recalc', historyFieldId: fieldId },
|
||||
});
|
||||
}
|
||||
} else if (avg != null) {
|
||||
|
||||
Reference in New Issue
Block a user