Поля профиля с 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:
@@ -334,26 +334,33 @@ function HistoryTab({ auditLog }: { auditLog: AuditLogEntry[] }) {
|
|||||||
|
|
||||||
// Поле history-number: обычный ввод значения + кнопка с графиком истории оценок.
|
// Поле history-number: обычный ввод значения + кнопка с графиком истории оценок.
|
||||||
// График доступен всегда (просмотр), редактирование значения — как у остальных полей.
|
// График доступен всегда (просмотр), редактирование значения — как у остальных полей.
|
||||||
|
// readOnly (options.readOnly): ввод недоступен — только значение текстом и график.
|
||||||
function HistoryNumberFieldInput({
|
function HistoryNumberFieldInput({
|
||||||
field,
|
field,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
userId,
|
userId,
|
||||||
|
readOnly,
|
||||||
}: {
|
}: {
|
||||||
field: UserProfileField;
|
field: UserProfileField;
|
||||||
value: any;
|
value: any;
|
||||||
onChange: (val: any) => void;
|
onChange: (val: any) => void;
|
||||||
userId: number;
|
userId: number;
|
||||||
|
readOnly?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [historyOpen, setHistoryOpen] = useState(false);
|
const [historyOpen, setHistoryOpen] = useState(false);
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
{readOnly ? (
|
||||||
value={value ?? ''}
|
<span className="text-sm leading-6">{value ?? '—'}</span>
|
||||||
onChange={(e) => onChange(e.target.value)}
|
) : (
|
||||||
placeholder={field.placeholder}
|
<Input
|
||||||
required={field.isRequired}
|
value={value ?? ''}
|
||||||
/>
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
required={field.isRequired}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -388,6 +395,22 @@ function UserProfileFieldInput({
|
|||||||
users: Array<{ id: number; firstName?: string; lastName?: string; middleName?: string; email: string }>;
|
users: Array<{ id: number; firstName?: string; lastName?: string; middleName?: string; email: string }>;
|
||||||
userId: number;
|
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) {
|
switch (field.type) {
|
||||||
case 'text':
|
case 'text':
|
||||||
case 'contract-number':
|
case 'contract-number':
|
||||||
@@ -399,15 +422,6 @@ function UserProfileFieldInput({
|
|||||||
required={field.isRequired}
|
required={field.isRequired}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'history-number':
|
|
||||||
return (
|
|
||||||
<HistoryNumberFieldInput
|
|
||||||
field={field}
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
userId={userId}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'number':
|
case 'number':
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
@@ -894,7 +908,10 @@ export default function UserProfilePage({ userId: userIdProp }: { userId?: numbe
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onSaveCustomValues = (tabFields: UserProfileField[]) => {
|
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,
|
fieldId: f.id,
|
||||||
value: customValues[f.id] ?? null,
|
value: customValues[f.id] ?? null,
|
||||||
}));
|
}));
|
||||||
@@ -1311,7 +1328,7 @@ export default function UserProfilePage({ userId: userIdProp }: { userId?: numbe
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{canEdit && (
|
{canEdit && tabFields.some((f) => !f.options?.readOnly) && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => onSaveCustomValues(tabFields)}
|
onClick={() => onSaveCustomValues(tabFields)}
|
||||||
disabled={saveCustomValuesMutation.isPending}
|
disabled={saveCustomValuesMutation.isPending}
|
||||||
|
|||||||
@@ -6,6 +6,16 @@ import { isValidFieldType } from "@shared/field-types";
|
|||||||
import { canManageUser } from "../utils/user-access";
|
import { canManageUser } from "../utils/user-access";
|
||||||
|
|
||||||
export function registerUserProfileRoutes(router: Router): void {
|
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 ===
|
// === USER PROFILE TABS ===
|
||||||
router.get('/api/user-profile/tabs',
|
router.get('/api/user-profile/tabs',
|
||||||
authenticateToken,
|
authenticateToken,
|
||||||
@@ -278,6 +288,10 @@ export function registerUserProfileRoutes(router: Router): void {
|
|||||||
const changedBy = req.user!.id;
|
const changedBy = req.user!.id;
|
||||||
const createdValues = [];
|
const createdValues = [];
|
||||||
for (const fv of fieldValues) {
|
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);
|
const existing = await storage.getUserProfileFieldValue(userId, fv.fieldId);
|
||||||
if (fv.value === null || fv.value === undefined) {
|
if (fv.value === null || fv.value === undefined) {
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -327,6 +341,10 @@ export function registerUserProfileRoutes(router: Router): void {
|
|||||||
|
|
||||||
const changedBy = req.user!.id;
|
const changedBy = req.user!.id;
|
||||||
const { value } = req.body;
|
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);
|
const existing = await storage.getUserProfileFieldValue(userId, fieldId);
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|||||||
Reference in New Issue
Block a user