Поля профиля с options.readOnly: запрет редактирования «Оценки» и «Средней оценки»

- UI: read-only поля отображаются текстом (у history-number остаётся кнопка графика),
  исключены из сохранения; кнопка «Сохранить» скрыта, если все поля вкладки read-only
- Сервер: POST/PATCH field-values профиля отклоняют запись в поле с options.readOnly (400);
  автоматизации пишут через storage напрямую и не затрагиваются
- Данные: options.readOnly=true у полей «Оценка» и «Средняя оценка»
This commit is contained in:
2026-09-06 15:08:23 +03:00
parent b070f862c5
commit ddc2bd9491
2 changed files with 52 additions and 17 deletions

View File

@@ -334,26 +334,33 @@ function HistoryTab({ auditLog }: { auditLog: AuditLogEntry[] }) {
// Поле history-number: обычный ввод значения + кнопка с графиком истории оценок.
// График доступен всегда (просмотр), редактирование значения — как у остальных полей.
// readOnly (options.readOnly): ввод недоступен — только значение текстом и график.
function HistoryNumberFieldInput({
field,
value,
onChange,
userId,
readOnly,
}: {
field: UserProfileField;
value: any;
onChange: (val: any) => void;
userId: number;
readOnly?: boolean;
}) {
const [historyOpen, setHistoryOpen] = useState(false);
return (
<div className="flex items-center gap-2">
{readOnly ? (
<span className="text-sm leading-6">{value ?? '—'}</span>
) : (
<Input
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
required={field.isRequired}
/>
)}
<Button
type="button"
variant="ghost"
@@ -388,6 +395,22 @@ function UserProfileFieldInput({
users: Array<{ id: number; firstName?: string; lastName?: string; middleName?: string; email: string }>;
userId: number;
}) {
// Поля с options.readOnly — только отображение (редактирование запрещено на сервере)
const readOnly = !!field.options?.readOnly;
if (field.type === 'history-number') {
return (
<HistoryNumberFieldInput
field={field}
value={value}
onChange={onChange}
userId={userId}
readOnly={readOnly}
/>
);
}
if (readOnly) {
return <span className="text-sm leading-6">{value === null || value === undefined || value === '' ? '—' : String(value)}</span>;
}
switch (field.type) {
case 'text':
case 'contract-number':
@@ -399,15 +422,6 @@ function UserProfileFieldInput({
required={field.isRequired}
/>
);
case 'history-number':
return (
<HistoryNumberFieldInput
field={field}
value={value}
onChange={onChange}
userId={userId}
/>
);
case 'number':
return (
<Input
@@ -894,7 +908,10 @@ export default function UserProfilePage({ userId: userIdProp }: { userId?: numbe
};
const onSaveCustomValues = (tabFields: UserProfileField[]) => {
const values = tabFields.map((f) => ({
// Read-only поля (например «Оценка», «Средняя оценка») не сохраняем — сервер их отклонит
const editable = tabFields.filter((f) => !f.options?.readOnly);
if (editable.length === 0) return;
const values = editable.map((f) => ({
fieldId: f.id,
value: customValues[f.id] ?? null,
}));
@@ -1311,7 +1328,7 @@ export default function UserProfilePage({ userId: userIdProp }: { userId?: numbe
</div>
))}
</div>
{canEdit && (
{canEdit && tabFields.some((f) => !f.options?.readOnly) && (
<Button
onClick={() => onSaveCustomValues(tabFields)}
disabled={saveCustomValuesMutation.isPending}

View File

@@ -6,6 +6,16 @@ import { isValidFieldType } from "@shared/field-types";
import { canManageUser } from "../utils/user-access";
export function registerUserProfileRoutes(router: Router): void {
// Поле профиля с options.readOnly менять через API нельзя — только из автоматизаций (storage напрямую)
async function assertProfileFieldEditable(fieldId: number, organizationId: number): Promise<string | null> {
const field = await storage.getUserProfileFieldById(fieldId, organizationId);
if (!field) return `Поле профиля ${fieldId} не найдено`;
if ((field.options as { readOnly?: boolean } | null)?.readOnly) {
return `Поле «${field.name}» доступно только для чтения`;
}
return null;
}
// === USER PROFILE TABS ===
router.get('/api/user-profile/tabs',
authenticateToken,
@@ -278,6 +288,10 @@ export function registerUserProfileRoutes(router: Router): void {
const changedBy = req.user!.id;
const createdValues = [];
for (const fv of fieldValues) {
const readOnlyError = await assertProfileFieldEditable(fv.fieldId, req.organizationId!);
if (readOnlyError) {
return res.status(400).json({ success: false, error: readOnlyError });
}
const existing = await storage.getUserProfileFieldValue(userId, fv.fieldId);
if (fv.value === null || fv.value === undefined) {
if (existing) {
@@ -327,6 +341,10 @@ export function registerUserProfileRoutes(router: Router): void {
const changedBy = req.user!.id;
const { value } = req.body;
const readOnlyError = await assertProfileFieldEditable(fieldId, req.organizationId!);
if (readOnlyError) {
return res.status(400).json({ success: false, error: readOnlyError });
}
const existing = await storage.getUserProfileFieldValue(userId, fieldId);
if (value === null || value === undefined) {
if (existing) {