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

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