feat: раздел «Мебель» в организации рабочего места
Мультивыбор (стол, кресло, тумбочка, канцелярия, другое) с полем своего значения при «Другое». Необязательный; руководитель на своём шаге правит его наравне с ПО и доступами. Хранится в WorkplaceFurnitureJson/WorkplaceFurnitureOther и уходит в маршрут (fieldValues), чтобы шаг исполнения «по мебели» мог назначаться условием в админке. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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: так в «Организации рабочего места» работает Ербол).
|
||||
|
||||
Reference in New Issue
Block a user