2 Commits

Author SHA1 Message Date
Тимур Городецкий 06044bbce9 refactor: категория IT переименована в IT/1C
Tests / frontend-test (push) Successful in 1m17s
Название вкладки и подпись в шапке форм категории — чтобы было видно,
что заявки в отдел 1С лежат здесь же.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 14:37:43 +05:00
Тимур Городецкий e9b0df4822 feat: варианты полей заявки 1С из колонок SP + вид работ «Маршруты в ДО»
«Виды работ», «Организация» и «База 1С» теперь читаются из колонок списка
(общий хук shared/fieldChoices), а не из констант: новый пункт добавляется
в параметрах столбца без правки кода и деплоя. Пилот — на форме 1С, при
успехе переводим остальные формы. В стартовые варианты provisioning.json
добавлен «Маршруты в ДО».

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 14:37:42 +05:00
9 changed files with 165 additions and 91 deletions
@@ -6,7 +6,7 @@ import { ICategory } from '../models/types';
*/
export const CATEGORIES: ICategory[] = [
{ id: 'recruiting', title: 'Отдел рекрутинга', icon: 'people' },
{ id: 'it', title: 'IT', icon: 'monitor' },
{ id: 'it', title: 'IT/1C', icon: 'monitor' },
{ id: 'accounting', title: 'Бухгалтерия', icon: 'calculator' },
{ id: 'hr', title: 'Отдел кадров', icon: 'clipboard' },
{ id: 'warehouse', title: 'Склад / Снабжение', icon: 'building' }
@@ -368,7 +368,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
<FormCard
title={requestType.title}
subtitle="Категория: IT"
subtitle="Категория: IT/1C"
belowSubtitle={
<InitiatorLine
listTitle={editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID)}
@@ -1,45 +1,15 @@
// Варианты Choice-полей — см. forms/Ticket1C/provisioning.json (единый источник правды).
export const WORK_TYPE_OPTIONS: string[] = [
'Создание пользователя в 1С',
'Расширение прав доступа / предоставление доступа к документам других пользователей',
'Открытие даты запрета',
'Прочее',
'Не работает 1С',
'Блокировка пользователя',
];
export const ORGANIZATION_OPTIONS: string[] = [
'AFL Engineering',
'CL MANAGEMENT',
'KazPromService',
'QAZGEOMAX',
'TANSU Construction',
'АСТЕЛ-К',
'ГеоТрек',
'Монтажник-Т',
'ОСН Беркут',
'CT Global',
];
export const DB1C_OPTIONS: string[] = [
'1С: Зарплата и управление персоналом (ЗУП)',
'1С: Документооборот (ДО)',
'1С: Автотранспортное предприятие (АТП)',
'1С: БСО TANSU Construction',
'1С: Бухгалтерия AFL Engineering',
'1С: Бухгалтерия CL MANAGEMENT',
'1С: Бухгалтерия KazPromService',
'1С: Бухгалтерия QAZGEOMAX',
'1С: Бухгалтерия АСТЕЛ-К',
'1С: Бухгалтерия ГеоТрек',
'1С: Бухгалтерия Монтажник-Т',
'1С: Бухгалтерия ОСН Беркут',
'1С: Бухгалтерия CT Global',
];
// Варианты «Видов работ», «Организации» и «Базы 1С» в коде не хранятся — форма
// читает их из колонок списка SP (см. shared/fieldChoices). Чтобы добавить пункт,
// допишите его в параметрах столбца списка. Варианты в provisioning.json —
// только стартовые, для создания списка с нуля.
export const QUESTION_MAX = 1000;
// Пользовательские тексты состояний загрузки вариантов полей.
export const CHOICES_LOADING_PLACEHOLDER = 'Загрузка вариантов…';
export const CHOICES_EMPTY_PLACEHOLDER = 'Нет вариантов — обратитесь к администратору';
export const CHOICES_ERROR_PREFIX = 'Не удалось загрузить варианты полей';
// Значения из property pane старой web part (см. живую страницу SharePoint).
export const PLANNER_PLAN_ID = '168WqDV8CEKkW3cL6olCRZgACwYa';
export const PLANNER_BUCKET_NAME = 'Входящие неотсортированные';
@@ -11,7 +11,8 @@
"Открытие даты запрета",
"Прочее",
"Не работает 1С",
"Блокировка пользователя"
"Блокировка пользователя",
"Маршруты в ДО"
] },
{ "internalName": "Organization", "title": "Организация", "type": "Choice", "required": true, "choices": [
"AFL Engineering",
@@ -1,52 +1,76 @@
import * as React from 'react';
import { CollapseSection, FormField, MultiChoiceField, Select, TextArea, TextInput, ISelectOption } from '../../../shared/ui';
import { ISectionProps } from '../model';
import { WORK_TYPE_OPTIONS, ORGANIZATION_OPTIONS, DB1C_OPTIONS, QUESTION_MAX } from '../config';
import { Alert, CollapseSection, FormField, MultiChoiceField, Select, TextArea, TextInput, ISelectOption } from '../../../shared/ui';
import { useFieldChoices, IFieldChoicesState } from '../../../shared/fieldChoices';
import { dataListTitle } from '../../../shared/listTitles';
import { FORM_ID, ISectionProps } from '../model';
import {
QUESTION_MAX,
CHOICES_LOADING_PLACEHOLDER,
CHOICES_EMPTY_PLACEHOLDER,
CHOICES_ERROR_PREFIX
} from '../config';
const toOptions = (values: string[]): ISelectOption[] => values.map((v) => ({ value: v, label: v }));
function choicesPlaceholder(state: IFieldChoicesState, readyText: string): string {
if (state.isLoading) return CHOICES_LOADING_PLACEHOLDER;
if (state.options.length === 0) return CHOICES_EMPTY_PLACEHOLDER;
return readyText;
}
const WORK_TYPE_SELECT_OPTIONS = toOptions(WORK_TYPE_OPTIONS);
const ORGANIZATION_SELECT_OPTIONS = toOptions(ORGANIZATION_OPTIONS);
const DB1C_SELECT_OPTIONS = toOptions(DB1C_OPTIONS);
// Сохранённое значение могли убрать из вариантов колонки — без этого Select
// показал бы пустое поле, и заявитель не увидел бы, что было выбрано.
function withCurrentValue(options: ISelectOption[], value: string): ISelectOption[] {
if (!value || options.some((o) => o.value === value)) return options;
return [{ value, label: value }, ...options];
}
export const RequestSection: React.FC<ISectionProps> = ({ data, set }) => (
<CollapseSection title="Заявка" icon="clipboard">
<FormField label="Наименование задачи" htmlFor="TaskName" required full>
<TextInput id="TaskName" value={data.TaskName} onChange={(v) => set('TaskName', v)} />
</FormField>
<FormField label="Виды работ" htmlFor="WorkType" required>
<Select
id="WorkType"
value={data.WorkType}
onChange={(v) => set('WorkType', v)}
options={WORK_TYPE_SELECT_OPTIONS}
placeholder="— выберите —"
/>
</FormField>
<FormField label="Организация" htmlFor="Organization" required>
<Select
id="Organization"
value={data.Organization}
onChange={(v) => set('Organization', v)}
options={ORGANIZATION_SELECT_OPTIONS}
placeholder="— выберите —"
/>
</FormField>
<FormField label="База 1С" fieldKey="DB1C" required>
<MultiChoiceField
value={data.DB1C}
onChange={(v) => set('DB1C', v)}
options={DB1C_SELECT_OPTIONS}
placeholder="Выберите одну или несколько баз"
/>
</FormField>
<FormField label={`Вопрос (${data.Question.length}/${QUESTION_MAX})`} htmlFor="Question" required full>
<TextArea
id="Question"
value={data.Question}
onChange={(v) => set('Question', v.slice(0, QUESTION_MAX))}
rows={6}
/>
</FormField>
</CollapseSection>
);
export const RequestSection: React.FC<ISectionProps> = ({ data, set }) => {
// Варианты — из текущей партиции: туда пишутся новые заявки.
const listTitle = dataListTitle(FORM_ID);
const workTypes = useFieldChoices(listTitle, 'WorkType');
const organizations = useFieldChoices(listTitle, 'Organization');
const databases = useFieldChoices(listTitle, 'DB1C');
const choicesError = workTypes.error || organizations.error || databases.error;
return (
<CollapseSection title="Заявка" icon="clipboard">
{choicesError && <Alert variant="err">{`${CHOICES_ERROR_PREFIX}: ${choicesError}`}</Alert>}
<FormField label="Наименование задачи" htmlFor="TaskName" required full>
<TextInput id="TaskName" value={data.TaskName} onChange={(v) => set('TaskName', v)} />
</FormField>
<FormField label="Виды работ" htmlFor="WorkType" required>
<Select
id="WorkType"
value={data.WorkType}
onChange={(v) => set('WorkType', v)}
options={withCurrentValue(workTypes.options, data.WorkType)}
placeholder={choicesPlaceholder(workTypes, '— выберите —')}
/>
</FormField>
<FormField label="Организация" htmlFor="Organization" required>
<Select
id="Organization"
value={data.Organization}
onChange={(v) => set('Organization', v)}
options={withCurrentValue(organizations.options, data.Organization)}
placeholder={choicesPlaceholder(organizations, '— выберите —')}
/>
</FormField>
<FormField label="База 1С" fieldKey="DB1C" required>
<MultiChoiceField
value={data.DB1C}
onChange={(v) => set('DB1C', v)}
options={databases.options}
placeholder={choicesPlaceholder(databases, 'Выберите одну или несколько баз')}
/>
</FormField>
<FormField label={`Вопрос (${data.Question.length}/${QUESTION_MAX})`} htmlFor="Question" required full>
<TextArea
id="Question"
value={data.Question}
onChange={(v) => set('Question', v.slice(0, QUESTION_MAX))}
rows={6}
/>
</FormField>
</CollapseSection>
);
};
@@ -345,7 +345,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
<FormCard
title={requestType.title}
subtitle="Категория: IT"
subtitle="Категория: IT/1C"
belowSubtitle={
<InitiatorLine
listTitle={editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID)}
@@ -0,0 +1,40 @@
import '@pnp/sp/fields';
import { getSP } from '../pnp/pnpjs';
/** Ответ SP по колонке Choice: массив в nometadata, { results } — в verbose-формате OData. */
interface IChoiceFieldResponse {
Choices?: string[] | { results?: string[] };
}
// Кеш на время жизни страницы: варианты администратор меняет редко, а форму за
// сессию открывают по нескольку раз — повторно ходить в SP незачем. Хранится
// промис, чтобы параллельные вызовы (три поля одной формы) не дублировали запрос.
const cache = new Map<string, Promise<string[]>>();
/**
* Варианты колонки Choice/MultiChoice прямо из списка SharePoint.
*
* Колонка списка — единственный источник вариантов: чтобы добавить пункт,
* достаточно дописать его в параметрах столбца, без правки кода и деплоя.
*/
export function getFieldChoices(listTitle: string, internalName: string): Promise<string[]> {
const key = `${listTitle}|${internalName}`.toLowerCase();
const cached = cache.get(key);
if (cached) return cached;
const request = loadFieldChoices(listTitle, internalName);
// Сбой не кешируем — при следующем открытии формы запрос уйдёт заново.
request.catch(() => cache.delete(key));
cache.set(key, request);
return request;
}
async function loadFieldChoices(listTitle: string, internalName: string): Promise<string[]> {
const field = (await getSP()
.web.lists.getByTitle(listTitle)
.fields.getByInternalNameOrTitle(internalName)
.select('Choices')()) as IChoiceFieldResponse;
const choices = Array.isArray(field.Choices) ? field.Choices : field.Choices?.results || [];
return choices.filter((choice) => choice.trim() !== '');
}
@@ -0,0 +1,3 @@
export { getFieldChoices } from './fieldChoices.service';
export { useFieldChoices } from './useFieldChoices';
export type { IFieldChoicesState } from './useFieldChoices';
@@ -0,0 +1,36 @@
import * as React from 'react';
import { ISelectOption } from '@tnsu/ui-kit';
import { friendlyErrorMessage } from '../diagnostics';
import { getFieldChoices } from './fieldChoices.service';
export interface IFieldChoicesState {
/** Варианты колонки в формате Select/MultiChoiceField; пусто, пока грузится или при ошибке. */
options: ISelectOption[];
isLoading: boolean;
/** Текст ошибки загрузки; undefined — ошибки нет. */
error?: string;
}
const LOADING_STATE: IFieldChoicesState = { options: [], isLoading: true };
/** Варианты колонки Choice списка SharePoint — см. getFieldChoices. */
export function useFieldChoices(listTitle: string, internalName: string): IFieldChoicesState {
const [state, setState] = React.useState<IFieldChoicesState>(LOADING_STATE);
React.useEffect(() => {
let alive = true;
setState(LOADING_STATE);
getFieldChoices(listTitle, internalName)
.then((choices) => {
if (alive) setState({ options: choices.map((c) => ({ value: c, label: c })), isLoading: false });
})
.catch((e) => {
if (alive) setState({ options: [], isLoading: false, error: friendlyErrorMessage(e) });
});
return () => {
alive = false;
};
}, [listTitle, internalName]);
return state;
}