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