feat: раздел «Мебель» в организации рабочего места

Мультивыбор (стол, кресло, тумбочка, канцелярия, другое) с полем своего
значения при «Другое». Необязательный; руководитель на своём шаге правит его
наравне с ПО и доступами. Хранится в WorkplaceFurnitureJson/WorkplaceFurnitureOther
и уходит в маршрут (fieldValues), чтобы шаг исполнения «по мебели» мог
назначаться условием в админке.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-29 17:29:54 +05:00
parent 14ac640c50
commit 45fc06a6ac
11 changed files with 152 additions and 28 deletions
+1 -1
View File
@@ -26,7 +26,7 @@
| Править **все поля и вложения** заявки (добавлять и удалять файлы) на своём активном шаге согласования | Алтынай Бултурукова | email в коде (список по каждой форме) | Подбор, Приём (Реферальная рекомендация — нет) | `shared/approval/fullEditRights.ts` → `canEditAllFields` |
| То же: **все поля и вложения**, но **не только на своём шаге**: достаточно быть в маршруте (согласование или исполнение), пока заявка «На согласовании» или «Выполнение» | Ербол Шаймерденов | email в коде (список по каждой форме) + участие в маршруте | **Только** Организация рабочего места | `shared/approval/fullEditRights.ts` → `canEditAllFields` + `forms/WorkplaceSetup/useApproverRights.ts` |
| Заменять человека на шаге согласования (не удалять шаг) **до отправки**: новая заявка, черновик, доработка | Любой инициатор | право по статусу заявки (форма редактируема) | **Только** Организация рабочего места | `forms/WorkplaceSetup/WorkplaceSetupForm.tsx` (`canReplaceRow`) + `useMatrixEditor.replace` |
| Заполнять «Необходимое ПО» и «Доступы» на своём шаге согласования | Первый шаг маршрута согласования (по маршруту — непосредственный руководитель) | порядок шага в маршруте | Организация рабочего места | `forms/WorkplaceSetup/useApproverRights.ts` (`canEditReviewFields`) |
| Заполнять «Необходимое ПО», «Доступы» и «Мебель» на своём шаге согласования | Первый шаг маршрута согласования (по маршруту — непосредственный руководитель) | порядок шага в маршруте | Организация рабочего места | `forms/WorkplaceSetup/useApproverRights.ts` (`canEditReviewFields`) |
| Править оклад на ИС, оклад после ИС, «Отдел/Участок» на своём шаге согласования | Начальник расчётной группы (сейчас Ким Анна) | должность шага: «начальник/руководитель» + «расчет» | Заявка на приём | `forms/RecruitingHire/approverRights.ts` → `canEditPayrollFields` |
| Ставить дату приёма (2.2) | Активный исполнитель | роль в маршруте (шаг «Исполнение»), без проверки должности | Заявка на приём | `forms/RecruitingHire/RecruitingHireView.tsx` (`hireDateEditable`) |
| Править карту СКУД и сумму за обучение | Начальник отдела кадрового делопроизводства | должность шага: «начальник/руководитель» + «кадров» + «делопроизводств» | Обходной лист | `forms/Clearance/approverRights.ts` → `canEditHrRecordsFields` |
@@ -23,16 +23,21 @@
- **Полное право правки** (`shared/approval/fullEditRights.ts`, сейчас Ербол
Шаймерденов): все поля и вложения, если он есть в маршруте и заявка «На
согласовании»/«Выполнение» (`useApproverRights.ts`).
- **Поля «Необходимое ПО» и «Доступы» заполняет согласующий-руководитель** на своём
этапе (инициатор их обычно не знает). Доступны для правки, когда очередь дошла до
- **Поля «Необходимое ПО», «Доступы» и «Мебель» заполняет согласующий-руководитель**
на своём этапе (инициатор их обычно не знает). Доступны для правки, когда очередь дошла до
**первого** шага согласования и это текущий пользователь; сохраняются кнопкой
«Сохранить» и автоматически при «Согласовать» (`updateReviewFields`,
`DecisionPanel.onBeforeDecide`). Остальная форма при этом read-only.
- **Мебель** — мультивыбор (`FURNITURE_OPTIONS`); при «Другое» появляется поле
своего значения. В SP — `WorkplaceFurnitureJson` (JSON-массив) и
`WorkplaceFurnitureOther`. Необязательная. Уходит в маршрут как
`WorkplaceFurnitureJson` (пункты через запятую, `routeFields.ts`) — на условие
шага исполнения «исполнитель по мебели».
- **Маска телефона** (PhoneInput).
- Печать — общий механизм (без «Вложений»).
## Секции формы
HireRequestPicker (только в редактируемой форме) · EmployeeSection · EquipmentSection
HireRequestPicker (только в редактируемой форме) · EmployeeSection · EquipmentSection · FurnitureSection
## Точки входа
- rptype: `it-workplace-setup`
@@ -63,6 +63,7 @@ import { loadWorkplaceRequest } from './load';
import { hasRequiredFields, REQUIRED_FIELDS_MESSAGE } from './validation';
import { EmployeeSection } from './sections/EmployeeSection';
import { EquipmentSection } from './sections/EquipmentSection';
import { FurnitureSection } from './sections/FurnitureSection';
import { useStableCallback } from '../../shared/useStableCallback';
const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
@@ -392,6 +393,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
<FormModeProvider readOnly={readOnly}>
<EmployeeSection {...sectionProps} />
<EquipmentSection {...sectionProps} />
<FurnitureSection {...sectionProps} />
</FormModeProvider>
</FieldChangesProvider>
@@ -49,6 +49,7 @@ import { hasRequiredFields, REQUIRED_FIELDS_MESSAGE } from './validation';
import { useApproverRights } from './useApproverRights';
import { EmployeeSection } from './sections/EmployeeSection';
import { EquipmentSection } from './sections/EquipmentSection';
import { FurnitureSection } from './sections/FurnitureSection';
import { useStableCallback } from '../../shared/useStableCallback';
interface IWorkplaceSetupViewProps {
@@ -64,7 +65,7 @@ interface IWorkplaceSetupViewProps {
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
const REVIEW_FIELDS_HINT = 'Как согласующий вы можете заполнить «Необходимое ПО» и «Доступы».';
const REVIEW_FIELDS_HINT = 'Как согласующий вы можете заполнить «Необходимое ПО», «Доступы» и «Мебель».';
const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.';
// Заявка «орг. рабочего места» только для просмотра (режим согласующего).
@@ -183,8 +184,8 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
await attachments.uploadPending();
};
const saveReviewFields = (): Promise<void> =>
updateReviewFields(itemId, { Software: data.Software, AccessRights: data.AccessRights }, listId);
// updateReviewFields пишет только поля руководителя (ReviewFields), остальное из data игнорирует.
const saveReviewFields = (): Promise<void> => updateReviewFields(itemId, data, listId);
// Полное право шире: оно включает и «ПО»/«Доступы».
const saveFields = (): Promise<void> => (canEditAll ? saveAllFields() : saveReviewFields());
@@ -283,6 +284,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
<FormModeProvider readOnly={!canEditAll}>
<EmployeeSection {...sectionProps} />
<EquipmentSection {...sectionProps} approverEditable={canEditReviewFields} />
<FurnitureSection {...sectionProps} approverEditable={canEditReviewFields} />
</FormModeProvider>
</FieldChangesProvider>
@@ -3,18 +3,25 @@ import { dataListTitle } from '../../shared/listTitles';
import { spDateOnlyToIso } from '../../shared/dateUtils';
import { ICompany } from '../../shared/dictionary';
import { IFieldChange, parseFieldChanges } from '../../shared/fieldChanges/fieldChanges';
import { COMPANIES_JSON_FIELD, FORM_ID, IFormData, EMPTY_DATA } from './model';
import {
COMPANIES_JSON_FIELD,
FURNITURE_JSON_FIELD,
FURNITURE_OTHER_FIELD,
FORM_ID,
IFormData,
EMPTY_DATA
} from './model';
import { APPROVER_CHANGES_FIELD } from './changeTracking';
/**
* JSON выбранных компаний → массив. Пусто — заявка до мультивыбора (тогда
* организации есть только текстом в Organization) или битый JSON.
* JSON-колонка с массивом → массив. Пусто — колонка не заполнена (заявка создана
* до появления поля: компании до мультивыбора, мебель до 2026-10-29) или битый JSON.
*/
function parseCompanies(raw: unknown): ICompany[] {
function parseJsonArray<T>(raw: unknown): T[] {
if (typeof raw !== 'string' || raw.trim() === '') return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as ICompany[]) : [];
return Array.isArray(parsed) ? (parsed as T[]) : [];
} catch {
return [];
}
@@ -50,6 +57,8 @@ export async function loadWorkplaceRequest(itemId: number, listId?: string): Pro
...SCALAR_FIELDS,
'NeedsEquipment',
COMPANIES_JSON_FIELD,
FURNITURE_JSON_FIELD,
FURNITURE_OTHER_FIELD,
APPROVER_CHANGES_FIELD,
'RequestStatus',
'RequestUniqueCode'
@@ -71,7 +80,9 @@ export async function loadWorkplaceRequest(itemId: number, listId?: string): Pro
});
data.NeedsEquipment = item.NeedsEquipment === true;
data.Companies = parseCompanies(item[COMPANIES_JSON_FIELD]);
data.Companies = parseJsonArray<ICompany>(item[COMPANIES_JSON_FIELD]);
data.Furniture = parseJsonArray<string>(item[FURNITURE_JSON_FIELD]);
data.FurnitureOther = (item[FURNITURE_OTHER_FIELD] as string) || '';
return {
data,
@@ -1,5 +1,6 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { RequestTypeId } from '../../models/types';
import { ISelectOption } from '@tnsu/ui-kit';
import { ICompany } from '../../shared/dictionary';
/**
@@ -14,6 +15,22 @@ export const FORM_ID: RequestTypeId = 'it-workplace-setup';
*/
export const COMPANIES_JSON_FIELD = 'WorkplaceCompaniesJson';
/** Note-колонка с IFormData.Furniture в JSON. Префикс Workplace — по той же причине, что у компаний. */
export const FURNITURE_JSON_FIELD = 'WorkplaceFurnitureJson';
/** Text-колонка с IFormData.FurnitureOther. */
export const FURNITURE_OTHER_FIELD = 'WorkplaceFurnitureOther';
/** Значение-триггер «Другое» в «Мебели»: при нём показываем поле ручного ввода. */
export const FURNITURE_OTHER_VALUE = 'Другое';
export const FURNITURE_OPTIONS: ISelectOption[] = [
{ value: 'Стол', label: 'Стол' },
{ value: 'Кресло', label: 'Кресло' },
{ value: 'Тумбочка', label: 'Тумбочка' },
{ value: 'Канцелярские принадлежности', label: 'Канцелярские принадлежности' },
{ value: FURNITURE_OTHER_VALUE, label: FURNITURE_OTHER_VALUE }
];
/**
* Данные формы «Организация рабочего места». Ключи = internalName колонок SP-списка
* «Организация рабочего места — данные».
@@ -57,6 +74,11 @@ export interface IFormData {
NeedsEquipment: boolean;
Software: string;
AccessRights: string;
// Мебель (необязательно; заполняется вручную)
/** Выбранные пункты из FURNITURE_OPTIONS. В SP — JSON в колонке WorkplaceFurnitureJson. */
Furniture: string[];
/** Своё значение — только если выбрано «Другое», иначе ''. В SP — колонка WorkplaceFurnitureOther. */
FurnitureOther: string;
}
/**
@@ -96,5 +118,7 @@ export const EMPTY_DATA: IFormData = {
Organization: '',
NeedsEquipment: false,
Software: '',
AccessRights: ''
AccessRights: '',
Furniture: [],
FurnitureOther: ''
};
@@ -21,6 +21,8 @@
{ "internalName": "NeedsEquipment", "title": "Нужна ли техника", "type": "Boolean" },
{ "internalName": "Software", "title": "Необходимое ПО", "type": "Note" },
{ "internalName": "AccessRights", "title": "Доступы", "type": "Note" },
{ "internalName": "WorkplaceFurnitureJson", "title": "Мебель (JSON)", "type": "Note" },
{ "internalName": "WorkplaceFurnitureOther", "title": "Мебель (другое)", "type": "Text" },
{ "internalName": "WorkplaceApproverChangesJson", "title": "Изменения согласующего — рабочее место (JSON)", "type": "Note" },
{ "internalName": "SourceHire", "title": "Источник (заявка на прием)", "type": "Lookup", "lookupList": "Прием на работу — данные", "lookupShowField": "Title" }
@@ -1,5 +1,5 @@
import { IResolveFieldValue } from '../../shared/approval';
import { COMPANIES_JSON_FIELD, IFormData } from './model';
import { COMPANIES_JSON_FIELD, FURNITURE_JSON_FIELD, IFormData } from './model';
/**
* Значения полей заявки для условных шагов маршрута (fieldValues при resolve).
@@ -11,12 +11,18 @@ import { COMPANIES_JSON_FIELD, IFormData } from './model';
* строку (массив → 400), а «содержит» ищет подстроку. Тот же формат, что в
* заявках на приём и подбор (см. RecruitingHireForm).
*
* Маршрут планируется при подаче, поэтому условие по Software сработает,
* только если «Необходимое ПО» указал сам инициатор (руководитель заполняет
* его позже, на своём шаге).
* Мебель — «WorkplaceFurnitureJson содержит Стол» и т.п. (исполнитель по мебели):
* пункты одной строкой через запятую — «содержит» ищет подстроку, разделитель
* ему безразличен, а в диагностике админки так читается легче. Сортируем, чтобы
* порядок кликов не менял строку и не перепланировал матрицу впустую.
*
* Маршрут планируется при подаче, поэтому условия по Software и мебели при
* подаче сработают, только если их указал сам инициатор. Если их потом
* заполнит руководитель, недостающих исполнителей добавляет просмотр заявки
* при сохранении правок.
*/
export function routeFieldValues(
data: Pick<IFormData, 'Companies'>,
data: Pick<IFormData, 'Companies' | 'Furniture'>,
software: string
): IResolveFieldValue[] {
const fieldValues: IResolveFieldValue[] = [];
@@ -29,5 +35,9 @@ export function routeFieldValues(
if (softwareText) {
fieldValues.push({ fieldName: 'Software', value: softwareText });
}
const furnitureText = data.Furniture.slice().sort().join(', ');
if (furnitureText) {
fieldValues.push({ fieldName: FURNITURE_JSON_FIELD, value: furnitureText });
}
return fieldValues;
}
@@ -0,0 +1,45 @@
import * as React from 'react';
import { CollapseSection, FormField, FormModeProvider, MultiChoiceField, TextInput } from '../../../shared/ui';
import { FURNITURE_OPTIONS, FURNITURE_OTHER_VALUE, ISectionProps } from '../model';
interface IFurnitureSectionProps extends ISectionProps {
/** Согласующий-руководитель на своём шаге может отметить мебель, даже когда
* остальная форма read-only (как ПО и доступы в EquipmentSection). */
approverEditable?: boolean;
}
export const FurnitureSection: React.FC<IFurnitureSectionProps> = ({ data, set, approverEditable }) => {
const hasOther = data.Furniture.indexOf(FURNITURE_OTHER_VALUE) !== -1;
const handleFurnitureChange = (values: string[]): void => {
set('Furniture', values);
// Сняли «Другое» — своё значение больше не относится к заявке, не храним его.
if (values.indexOf(FURNITURE_OTHER_VALUE) === -1) set('FurnitureOther', '');
};
const fields = (
<>
<FormField label="Мебель" fieldKey="Furniture" full>
<MultiChoiceField
value={data.Furniture}
onChange={handleFurnitureChange}
options={FURNITURE_OPTIONS}
placeholder="Выберите, что нужно"
/>
</FormField>
{hasOther && (
<FormField label="Мебель (другое)" htmlFor="FurnitureOther" full>
<TextInput id="FurnitureOther" value={data.FurnitureOther} onChange={(v) => set('FurnitureOther', v)} />
</FormField>
)}
</>
);
return (
<CollapseSection title="Мебель" icon="building">
{/* MultiChoiceField берёт режим только из контекста (своего readOnly у него нет),
поэтому право руководителя даём вложенным провайдером, перекрывающим внешний. */}
{approverEditable ? <FormModeProvider readOnly={false}>{fields}</FormModeProvider> : fields}
</CollapseSection>
);
};
@@ -1,7 +1,14 @@
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList';
import { IFieldChange, serializeFieldChanges } from '../../shared/fieldChanges/fieldChanges';
import { COMPANIES_JSON_FIELD, FORM_ID, IFormData, organizationText } from './model';
import {
COMPANIES_JSON_FIELD,
FURNITURE_JSON_FIELD,
FURNITURE_OTHER_FIELD,
FORM_ID,
IFormData,
organizationText
} from './model';
import { APPROVER_CHANGES_FIELD } from './changeTracking';
/**
@@ -13,22 +20,30 @@ export function workplaceApprovalService(listId?: string): ApprovalListService {
return approvalServiceFor(FORM_ID, listId);
}
/** Поля, которые заполняет согласующий-руководитель на своём шаге (см. updateReviewFields). */
export type ReviewFields = Pick<IFormData, 'Software' | 'AccessRights' | 'Furniture' | 'FurnitureOther'>;
/** Колонки SP «Мебели»: массив — JSON-ом, пустые значения — null. */
function furnitureFields(data: Pick<IFormData, 'Furniture' | 'FurnitureOther'>): Record<string, unknown> {
return {
[FURNITURE_JSON_FIELD]: data.Furniture.length > 0 ? JSON.stringify(data.Furniture) : null,
[FURNITURE_OTHER_FIELD]: data.FurnitureOther === '' ? null : data.FurnitureOther
};
}
/**
* Обновить поля, которые заполняет согласующий-руководитель на этапе согласования
* (инициатор часто не знает нужное ПО и доступы). Пишет только эти два поля.
* (инициатор часто не знает нужное ПО, доступы и мебель). Пишет только эти поля.
*
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadWorkplaceRequest): элемент уже
* существует, поэтому читаем/пишем напрямую по url его списка, если он задан.
*/
export async function updateReviewFields(
id: number,
fields: { Software: string; AccessRights: string },
listId?: string
): Promise<void> {
export async function updateReviewFields(id: number, fields: ReviewFields, listId?: string): Promise<void> {
const list = resolveDataList(FORM_ID, listId);
await list.items.getById(id).update({
Software: fields.Software === '' ? null : fields.Software,
AccessRights: fields.AccessRights === '' ? null : fields.AccessRights,
...furnitureFields(fields)
});
}
@@ -63,6 +78,14 @@ function buildContentFields(data: IFormData): Record<string, unknown> {
item[COMPANIES_JSON_FIELD] = data.Companies.length > 0 ? JSON.stringify(data.Companies) : null;
return;
}
if (key === 'Furniture') {
Object.assign(item, furnitureFields(data));
return;
}
if (key === 'FurnitureOther') {
// Пишется вместе с Furniture (см. выше).
return;
}
if (key === 'Organization') {
// Текстовая копия выбранных компаний — для печати и списка в SP (см. organizationText).
const text = organizationText(data);
@@ -11,7 +11,7 @@ import { effectiveUserEmail } from '../../shared/impersonation';
import { FORM_ID } from './model';
export interface IApproverRights {
/** Первый шаг согласования (руководитель) может заполнить «Необходимое ПО» и «Доступы». */
/** Первый шаг согласования (руководитель) может заполнить «Необходимое ПО», «Доступы» и «Мебель». */
canEditReviewFields: boolean;
/** Участник маршрута из shared/approval/fullEditRights.ts правит все поля и вложения. */
canEditAll: boolean;
@@ -28,7 +28,7 @@ const FULL_EDIT_STATUSES: ReadonlyArray<string> = [REQUEST_STATUS.onApproval, RE
/**
* Что текущий пользователь может править в поданной заявке.
* - «ПО»/«Доступы» — только на СВОЁМ активном шаге согласования, если это первый шаг.
* - «ПО»/«Доступы»/«Мебель» — только на СВОЁМ активном шаге согласования, если это первый шаг.
* - Все поля — если пользователь есть в маршруте (согласование или исполнение)
* НЕЗАВИСИМО от того, чья сейчас очередь, пока заявка в работе (решение
* Тимура 2026-10-07: так в «Организации рабочего места» работает Ербол).