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:
2026-09-06 18:47:05 +03:00
parent ddc2bd9491
commit a60a75eda0
6 changed files with 2041 additions and 914 deletions

View File

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

View File

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