Заявка заказывалась свободным текстом, без привязки к реальным рецептам завода. Теперь БСУ выбирается из API bsu.tnsu.kz (BsuId/BsuName в шапке), а класс смеси в позиции — из рецептов этого БСУ (formulaId + снимок состава, т.к. на заводе рецепт могут поменять). Состав показывается плашками. Если API не ответил, отправку не блокируем — ручной ввод. Новые колонки BsuId/BsuName нужно провизионировать до деплоя. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -43,7 +43,8 @@ import {
|
|||||||
dataListTitle,
|
dataListTitle,
|
||||||
dataListTitleForListId
|
dataListTitleForListId
|
||||||
} from '../../shared/listTitles';
|
} from '../../shared/listTitles';
|
||||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
import { FORM_ID, IFormData, EMPTY_DATA, SetField, withoutFormula } from './model';
|
||||||
|
import { useBsuCatalog } from './bsu/useBsuCatalog';
|
||||||
import { saveConcreteRequest, concreteRequestApprovalService } from './submit';
|
import { saveConcreteRequest, concreteRequestApprovalService } from './submit';
|
||||||
import { loadConcreteRequest } from './load';
|
import { loadConcreteRequest } from './load';
|
||||||
import { HeaderSection } from './sections/HeaderSection';
|
import { HeaderSection } from './sections/HeaderSection';
|
||||||
@@ -167,6 +168,19 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
|
|||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Справочник БСУ нужен только при заполнении — в режиме чтения показываем сохранённые названия.
|
||||||
|
const bsu = useBsuCatalog(context, data.BsuId, !readOnly);
|
||||||
|
|
||||||
|
// Рецепты позиций принадлежат конкретному заводу — при смене БСУ сбрасываем их.
|
||||||
|
const handleBsuChange = (bsuId: string, bsuName: string): void => {
|
||||||
|
setData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
BsuId: bsuId,
|
||||||
|
BsuName: bsuName,
|
||||||
|
Items: bsuId === prev.BsuId ? prev.Items : prev.Items.map(withoutFormula)
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
// Маршрут согласования зависит от начальника участка И проекта (в админке
|
// Маршрут согласования зависит от начальника участка И проекта (в админке
|
||||||
// approval-route для этого типа заявки настроено условие ByProject) —
|
// approval-route для этого типа заявки настроено условие ByProject) —
|
||||||
// planned resolve без ProjectId 404-ит, поэтому матрица не готова, пока
|
// planned resolve без ProjectId 404-ит, поэтому матрица не готова, пока
|
||||||
@@ -205,10 +219,10 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const hasItems = data.Items.some((i) => i.mixClass.trim().length > 0 && i.volume.trim().length > 0);
|
const hasItems = data.Items.some((i) => i.mixClass.trim().length > 0 && i.volume.trim().length > 0);
|
||||||
const isValid = !!(data.RequestDate && data.SiteChiefEmail && data.Project.trim() && hasItems);
|
const isValid = !!(data.RequestDate && data.SiteChiefEmail && data.Project.trim() && data.BsuName.trim() && hasItems);
|
||||||
const validate = (): string | undefined => {
|
const validate = (): string | undefined => {
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
return 'Заполните обязательные поля: дата поставки, начальник участка, проект и хотя бы одну позицию (класс смеси + количество).';
|
return 'Заполните обязательные поля: дата поставки, начальник участка, проект, БСУ и хотя бы одну позицию (класс смеси + количество).';
|
||||||
}
|
}
|
||||||
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
||||||
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
||||||
@@ -303,7 +317,9 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
|
|||||||
set,
|
set,
|
||||||
context,
|
context,
|
||||||
onSiteChiefChange: handleSiteChiefChange,
|
onSiteChiefChange: handleSiteChiefChange,
|
||||||
onForemanChange: handleForemanChange
|
onForemanChange: handleForemanChange,
|
||||||
|
bsu: readOnly ? undefined : bsu,
|
||||||
|
onBsuChange: handleBsuChange
|
||||||
};
|
};
|
||||||
|
|
||||||
// «Удалить черновик» — только у сохранённой заявки в статусе «Черновик».
|
// «Удалить черновик» — только у сохранённой заявки в статусе «Черновик».
|
||||||
|
|||||||
@@ -51,11 +51,20 @@
|
|||||||
здесь другой, поэтому у формы свой редактор. Карточки (`FormGrid`/`FormField`),
|
здесь другой, поэтому у формы свой редактор. Карточки (`FormGrid`/`FormField`),
|
||||||
а не строки плоской таблицы — адаптивнее на мобильных, чем строка с
|
а не строки плоской таблицы — адаптивнее на мобильных, чем строка с
|
||||||
горизонтальным скроллом.
|
горизонтальным скроллом.
|
||||||
|
- **БСУ и рецепты** (`bsu/`, API `https://bsu.tnsu.kz/api/v1`, токен того же
|
||||||
|
AAD-приложения, что у approval-route): в шапке выбирается БСУ (`GET /bsu` →
|
||||||
|
`BsuId`/`BsuName`, обязателен), в позиции — рецепт этого БСУ
|
||||||
|
(`GET /bsu/{id}/formulas`): в позицию пишутся `formulaId`, название в
|
||||||
|
`mixClass` и снимок состава `ingredients` (на заводе рецепт могут поменять).
|
||||||
|
Смена БСУ сбрасывает выбранные рецепты (`withoutFormula`). API не ответил —
|
||||||
|
отправку **не блокируем**: предупреждение и ручной ввод (название БСУ /
|
||||||
|
класс смеси текстом, как до справочника). В просмотре API не вызывается —
|
||||||
|
показываются сохранённые названия.
|
||||||
- **Заголовок элемента** (`Title`): `Заявка на бетон — {ФИО начальника участка} — дд.мм.гггг`.
|
- **Заголовок элемента** (`Title`): `Заявка на бетон — {ФИО начальника участка} — дд.мм.гггг`.
|
||||||
|
|
||||||
## Секции формы
|
## Секции формы
|
||||||
HeaderSection (дата поставки · начальник участка · организация (read-only) ·
|
HeaderSection (дата поставки · начальник участка · организация (read-only) ·
|
||||||
проект · мастер участка) + ItemsEditor (табличная часть поставок)
|
проект · БСУ · мастер участка) + ItemsEditor (табличная часть поставок)
|
||||||
|
|
||||||
## Точки входа
|
## Точки входа
|
||||||
- rptype: `wh-concrete-request`
|
- rptype: `wh-concrete-request`
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/** БСУ (бетонно-смесительный узел) — ответ GET /bsu. */
|
||||||
|
export interface IBsu {
|
||||||
|
id: string;
|
||||||
|
/** Бэкенд может отдать null — подпись в списке строит bsuLabel. */
|
||||||
|
name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Рецепт (формула) смеси конкретного БСУ — ответ GET /bsu/{id}/formulas. */
|
||||||
|
export interface IBsuFormula {
|
||||||
|
id: number;
|
||||||
|
/** Марка/класс («В25», «pp M100»). Бывает пустой строкой. */
|
||||||
|
name: string;
|
||||||
|
/** Состав одной строкой: «Бункер 1: 670; Цемент 1: 400; Вода: 146; …». */
|
||||||
|
ingredients: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { BSU_API_BASE_URL, BSU_API_CLIENT_ID } from '../../../shared/config';
|
||||||
|
import { IBsu, IBsuFormula } from './IBsu';
|
||||||
|
|
||||||
|
async function getJson<T>(context: WebPartContext, path: string): Promise<T> {
|
||||||
|
const client = await context.aadHttpClientFactory.getClient(BSU_API_CLIENT_ID);
|
||||||
|
const response: HttpClientResponse = await client.get(`${BSU_API_BASE_URL}${path}`, AadHttpClient.configurations.v1);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`БСУ ${path}: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Список БСУ в рамках сессии не меняется — грузим один раз (как projects.service).
|
||||||
|
let bsuCache: Promise<IBsu[]> | undefined;
|
||||||
|
export function getBsuList(context: WebPartContext): Promise<IBsu[]> {
|
||||||
|
if (!bsuCache) {
|
||||||
|
bsuCache = getJson<IBsu[]>(context, '/bsu').catch((e) => {
|
||||||
|
bsuCache = undefined;
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return bsuCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Рецепты выбранного БСУ. Не кэшируем: БСУ переключают редко, а рецепты на заводе могут правиться. */
|
||||||
|
export function getBsuFormulas(context: WebPartContext, bsuId: string): Promise<IBsuFormula[]> {
|
||||||
|
return getJson<IBsuFormula[]>(context, `/bsu/${encodeURIComponent(bsuId)}/formulas`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Подпись БСУ в списке: имени может не быть — тогда хотя бы начало id, чтобы пункты различались. */
|
||||||
|
export function bsuLabel(bsu: IBsu): string {
|
||||||
|
return bsu.name?.trim() || `БСУ ${bsu.id.slice(0, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Подпись рецепта: пустое имя встречается (id 13) — показываем номер. */
|
||||||
|
export function formulaLabel(formula: IBsuFormula): string {
|
||||||
|
return formula.name.trim() || `Рецепт №${formula.id}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { friendlyErrorMessage } from '../../../shared/diagnostics';
|
||||||
|
import { getBsuFormulas, getBsuList } from './bsu.service';
|
||||||
|
import { IBsu, IBsuFormula } from './IBsu';
|
||||||
|
|
||||||
|
/** Список из API с явными состояниями загрузки и ошибки. */
|
||||||
|
export interface IRemoteList<T> {
|
||||||
|
items: T[];
|
||||||
|
loading: boolean;
|
||||||
|
/** '' — ошибки нет. */
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Справочник БСУ для формы: список заводов и рецепты выбранного. */
|
||||||
|
export interface IBsuCatalog {
|
||||||
|
bsu: IRemoteList<IBsu>;
|
||||||
|
formulas: IRemoteList<IBsuFormula>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyList<T>(): IRemoteList<T> {
|
||||||
|
return { items: [], loading: false, error: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** load = null — грузить нечего (выключено или не выбран БСУ). load должен быть стабильным (useCallback). */
|
||||||
|
function useRemoteList<T>(load: (() => Promise<T[]>) | null): IRemoteList<T> {
|
||||||
|
const [state, setState] = useState<IRemoteList<T>>(emptyList);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!load) {
|
||||||
|
setState(emptyList());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let alive = true;
|
||||||
|
setState({ items: [], loading: true, error: '' });
|
||||||
|
load()
|
||||||
|
.then((items) => {
|
||||||
|
if (alive) setState({ items, loading: false, error: '' });
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (alive) setState({ items: [], loading: false, error: friendlyErrorMessage(e) });
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Заводы и рецепты выбранного завода. enabled = false (заявка только для чтения) —
|
||||||
|
* ничего не грузим: в просмотре показываются сохранённые в заявке названия.
|
||||||
|
*/
|
||||||
|
export function useBsuCatalog(context: WebPartContext, bsuId: string, enabled: boolean): IBsuCatalog {
|
||||||
|
const loadBsu = useCallback(() => getBsuList(context), [context]);
|
||||||
|
const loadFormulas = useCallback(() => getBsuFormulas(context, bsuId), [context, bsuId]);
|
||||||
|
|
||||||
|
const bsu = useRemoteList(enabled ? loadBsu : null);
|
||||||
|
const formulas = useRemoteList(enabled && bsuId ? loadFormulas : null);
|
||||||
|
return { bsu, formulas };
|
||||||
|
}
|
||||||
@@ -45,6 +45,8 @@ export async function loadConcreteRequest(itemId: number, listId?: string): Prom
|
|||||||
'Organization',
|
'Organization',
|
||||||
'Project',
|
'Project',
|
||||||
'ProjectId',
|
'ProjectId',
|
||||||
|
'BsuId',
|
||||||
|
'BsuName',
|
||||||
'ItemsJson',
|
'ItemsJson',
|
||||||
'RequestStatus',
|
'RequestStatus',
|
||||||
'RequestUniqueCode'
|
'RequestUniqueCode'
|
||||||
@@ -62,6 +64,8 @@ export async function loadConcreteRequest(itemId: number, listId?: string): Prom
|
|||||||
Organization: (item.Organization as string) || '',
|
Organization: (item.Organization as string) || '',
|
||||||
Project: (item.Project as string) || '',
|
Project: (item.Project as string) || '',
|
||||||
ProjectId: (item.ProjectId as string) || '',
|
ProjectId: (item.ProjectId as string) || '',
|
||||||
|
BsuId: (item.BsuId as string) || '',
|
||||||
|
BsuName: (item.BsuName as string) || '',
|
||||||
Items: parseItems(item.ItemsJson)
|
Items: parseItems(item.ItemsJson)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import { ISelectOption } from '../../shared/ui';
|
import { ISelectOption } from '../../shared/ui';
|
||||||
import { RequestTypeId } from '../../models/types';
|
import { RequestTypeId } from '../../models/types';
|
||||||
|
import { IBsuCatalog } from './bsu/useBsuCatalog';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
|
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
|
||||||
@@ -18,8 +19,18 @@ export const FORM_ID: RequestTypeId = 'wh-concrete-request';
|
|||||||
* согласования (ByProject, см. IFormData).
|
* согласования (ByProject, см. IFormData).
|
||||||
*/
|
*/
|
||||||
export interface IConcreteItem {
|
export interface IConcreteItem {
|
||||||
/** Класс/марка смеси и краткая характеристика (напр. «C20/25 F75 W6»). */
|
/**
|
||||||
|
* Класс/марка смеси. Выбран рецепт БСУ — название рецепта; API БСУ недоступен
|
||||||
|
* или заявка старая — свободный текст (напр. «C20/25 F75 W6»).
|
||||||
|
*/
|
||||||
mixClass: string;
|
mixClass: string;
|
||||||
|
/**
|
||||||
|
* id рецепта БСУ (GET /bsu/{id}/formulas). Нет — рецепт введён вручную или
|
||||||
|
* заявка подана до справочника БСУ, поэтому поле необязательное.
|
||||||
|
*/
|
||||||
|
formulaId?: number;
|
||||||
|
/** Состав рецепта на момент подачи — снимок: на заводе рецепт могут поменять. */
|
||||||
|
ingredients?: string;
|
||||||
/** Количество, м³. */
|
/** Количество, м³. */
|
||||||
volume: string;
|
volume: string;
|
||||||
/** Подвижность смеси — см. WORKABILITY_OPTIONS. */
|
/** Подвижность смеси — см. WORKABILITY_OPTIONS. */
|
||||||
@@ -38,6 +49,12 @@ export const EMPTY_CONCRETE_ITEM: IConcreteItem = {
|
|||||||
note: ''
|
note: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Позиция без рецепта БСУ. Рецепт, введённый вручную (без formulaId), не трогаем. */
|
||||||
|
export function withoutFormula(item: IConcreteItem): IConcreteItem {
|
||||||
|
if (item.formulaId === undefined) return item;
|
||||||
|
return { ...item, formulaId: undefined, mixClass: '', ingredients: '' };
|
||||||
|
}
|
||||||
|
|
||||||
/** Подвижность бетонной смеси (ГОСТ 7473) — фиксированный набор, чтобы не плодить опечатки. */
|
/** Подвижность бетонной смеси (ГОСТ 7473) — фиксированный набор, чтобы не плодить опечатки. */
|
||||||
export const WORKABILITY_OPTIONS: ISelectOption[] = [
|
export const WORKABILITY_OPTIONS: ISelectOption[] = [
|
||||||
{ value: 'П1', label: 'П1' },
|
{ value: 'П1', label: 'П1' },
|
||||||
@@ -90,6 +107,10 @@ export interface IFormData {
|
|||||||
Project: string;
|
Project: string;
|
||||||
/** oid проекта из справочника; '' — введён вручную без выбора. */
|
/** oid проекта из справочника; '' — введён вручную без выбора. */
|
||||||
ProjectId: string;
|
ProjectId: string;
|
||||||
|
/** uuid БСУ (бетонного завода) из API БСУ; '' — введён вручную (API недоступен). */
|
||||||
|
BsuId: string;
|
||||||
|
/** Название БСУ — для просмотра и печати без обращения к API. */
|
||||||
|
BsuName: string;
|
||||||
/** Табличная часть — позиции поставки (сериализуется в ItemsJson). */
|
/** Табличная часть — позиции поставки (сериализуется в ItemsJson). */
|
||||||
Items: IConcreteItem[];
|
Items: IConcreteItem[];
|
||||||
}
|
}
|
||||||
@@ -108,6 +129,10 @@ export interface ISectionProps {
|
|||||||
onSiteChiefChange?: (login: string) => void;
|
onSiteChiefChange?: (login: string) => void;
|
||||||
/** Выбор мастера участка подтягивает его ФИО. Не передан (просмотр) — правка через set. */
|
/** Выбор мастера участка подтягивает его ФИО. Не передан (просмотр) — правка через set. */
|
||||||
onForemanChange?: (login: string) => void;
|
onForemanChange?: (login: string) => void;
|
||||||
|
/** Справочник БСУ. Не передан (просмотр) — БСУ и рецепты показываются сохранённым текстом. */
|
||||||
|
bsu?: IBsuCatalog;
|
||||||
|
/** Смена БСУ сбрасывает выбранные в позициях рецепты (они от прежнего завода). */
|
||||||
|
onBsuChange?: (bsuId: string, bsuName: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SITE_CHIEF_PLACEHOLDER = 'Начните вводить Ф.И.О. начальника участка…';
|
export const SITE_CHIEF_PLACEHOLDER = 'Начните вводить Ф.И.О. начальника участка…';
|
||||||
@@ -122,5 +147,7 @@ export const EMPTY_DATA: IFormData = {
|
|||||||
Organization: '',
|
Organization: '',
|
||||||
Project: '',
|
Project: '',
|
||||||
ProjectId: '',
|
ProjectId: '',
|
||||||
|
BsuId: '',
|
||||||
|
BsuName: '',
|
||||||
Items: []
|
Items: []
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
|
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
|
||||||
{ "internalName": "Project", "title": "Проект", "type": "Text", "required": true },
|
{ "internalName": "Project", "title": "Проект", "type": "Text", "required": true },
|
||||||
{ "internalName": "ProjectId", "title": "Id проекта (справочник)", "type": "Text" },
|
{ "internalName": "ProjectId", "title": "Id проекта (справочник)", "type": "Text" },
|
||||||
|
{ "internalName": "BsuId", "title": "Id БСУ (API БСУ)", "type": "Text" },
|
||||||
|
{ "internalName": "BsuName", "title": "БСУ", "type": "Text" },
|
||||||
|
|
||||||
{ "internalName": "ItemsJson", "title": "Позиции поставки (JSON)", "type": "Note" }
|
{ "internalName": "ItemsJson", "title": "Позиции поставки (JSON)", "type": "Note" }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Alert, FormField, Select, TextInput } from '../../../shared/ui';
|
||||||
|
import { IBsuCatalog } from '../bsu/useBsuCatalog';
|
||||||
|
import { bsuLabel } from '../bsu/bsu.service';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
bsuId: string;
|
||||||
|
bsuName: string;
|
||||||
|
/** Не передан (просмотр) — показываем сохранённое название. */
|
||||||
|
catalog?: IBsuCatalog;
|
||||||
|
onChange: (bsuId: string, bsuName: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LABEL = 'БСУ (бетонный завод)';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выбор БСУ из API. Если API не ответил — отправку не блокируем: предупреждение
|
||||||
|
* и ручной ввод названия (BsuId остаётся пустым).
|
||||||
|
*/
|
||||||
|
export const BsuField: React.FC<Props> = ({ bsuId, bsuName, catalog, onChange }) => {
|
||||||
|
if (!catalog || catalog.bsu.error) {
|
||||||
|
return (
|
||||||
|
<FormField label={LABEL} htmlFor="BsuName" required>
|
||||||
|
{catalog && (
|
||||||
|
<Alert variant="warn">
|
||||||
|
Не удалось загрузить список БСУ ({catalog.bsu.error}). Введите название вручную.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<TextInput id="BsuName" value={bsuName} onChange={(v) => onChange('', v)} placeholder="Название БСУ" />
|
||||||
|
</FormField>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = catalog.bsu.items.map((b) => ({ value: b.id, label: bsuLabel(b) }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormField label={LABEL} htmlFor="BsuId" required>
|
||||||
|
<Select
|
||||||
|
id="BsuId"
|
||||||
|
value={bsuId}
|
||||||
|
onChange={(id) => {
|
||||||
|
const picked = catalog.bsu.items.find((b) => b.id === id);
|
||||||
|
onChange(id, picked ? bsuLabel(picked) : '');
|
||||||
|
}}
|
||||||
|
options={options}
|
||||||
|
placeholder={catalog.bsu.loading ? 'Загрузка…' : 'Выберите БСУ'}
|
||||||
|
readOnly={catalog.bsu.loading}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,15 +2,18 @@ import * as React from 'react';
|
|||||||
import { FormGrid, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui';
|
import { FormGrid, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui';
|
||||||
import { ProjectPicker } from '../../../shared/dictionary';
|
import { ProjectPicker } from '../../../shared/dictionary';
|
||||||
import { ISectionProps, SITE_CHIEF_PLACEHOLDER, FOREMAN_PLACEHOLDER } from '../model';
|
import { ISectionProps, SITE_CHIEF_PLACEHOLDER, FOREMAN_PLACEHOLDER } from '../model';
|
||||||
|
import { BsuField } from './BsuField';
|
||||||
|
|
||||||
/** Шапка заявки на бетон: дата поставки, начальник участка (составил), мастер участка. */
|
/** Шапка заявки на бетон: дата поставки, начальник участка (составил), проект, БСУ, мастер участка. */
|
||||||
export const HeaderSection: React.FC<ISectionProps> = ({
|
export const HeaderSection: React.FC<ISectionProps> = ({
|
||||||
data,
|
data,
|
||||||
set,
|
set,
|
||||||
context,
|
context,
|
||||||
printOrgIndex,
|
printOrgIndex,
|
||||||
onSiteChiefChange,
|
onSiteChiefChange,
|
||||||
onForemanChange
|
onForemanChange,
|
||||||
|
bsu,
|
||||||
|
onBsuChange
|
||||||
}) => (
|
}) => (
|
||||||
<FormGrid>
|
<FormGrid>
|
||||||
<FormField label="Дата поставки" htmlFor="RequestDate" required>
|
<FormField label="Дата поставки" htmlFor="RequestDate" required>
|
||||||
@@ -37,6 +40,18 @@ export const HeaderSection: React.FC<ISectionProps> = ({
|
|||||||
placeholder="Начните вводить название или код проекта…"
|
placeholder="Начните вводить название или код проекта…"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
<BsuField
|
||||||
|
bsuId={data.BsuId}
|
||||||
|
bsuName={data.BsuName}
|
||||||
|
catalog={bsu}
|
||||||
|
onChange={
|
||||||
|
onBsuChange ??
|
||||||
|
((id, name) => {
|
||||||
|
set('BsuId', id);
|
||||||
|
set('BsuName', name);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
<FormField label="Мастер участка" htmlFor="ForemanEmail" full>
|
<FormField label="Мастер участка" htmlFor="ForemanEmail" full>
|
||||||
<PeopleField
|
<PeopleField
|
||||||
context={context}
|
context={context}
|
||||||
|
|||||||
@@ -49,6 +49,61 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Состав рецепта БСУ под полем «Класс смеси» (RecipeIngredients).
|
||||||
|
.recipe {
|
||||||
|
margin-top: $sp-2;
|
||||||
|
padding: $sp-3;
|
||||||
|
border: 1px solid $navy-100;
|
||||||
|
border-left: 3px solid $gold-500;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $white;
|
||||||
|
font-family: $font-sans;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipeLabel {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: $sp-2;
|
||||||
|
font-size: $fs-label;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $navy-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipeList {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: $sp-2;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipeChip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: $sp-2;
|
||||||
|
padding: $sp-1 $sp-3;
|
||||||
|
border-radius: $r-full;
|
||||||
|
background: $navy-50;
|
||||||
|
border: 1px solid $navy-100;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipeName {
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipeAmount {
|
||||||
|
font-weight: 600;
|
||||||
|
color: $gray-900;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipeRaw {
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-700;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
padding: $sp-3 0;
|
padding: $sp-3 0;
|
||||||
font-family: $font-sans;
|
font-family: $font-sans;
|
||||||
|
|||||||
@@ -2,22 +2,25 @@ import * as React from 'react';
|
|||||||
import styles from './ItemsEditor.module.scss';
|
import styles from './ItemsEditor.module.scss';
|
||||||
import { Button, Icon, TextInput, Select, FormGrid, FormField, useFormReadOnly } from '../../../shared/ui';
|
import { Button, Icon, TextInput, Select, FormGrid, FormField, useFormReadOnly } from '../../../shared/ui';
|
||||||
import { EMPTY_CONCRETE_ITEM, IConcreteItem, WORKABILITY_OPTIONS } from '../model';
|
import { EMPTY_CONCRETE_ITEM, IConcreteItem, WORKABILITY_OPTIONS } from '../model';
|
||||||
|
import { IRecipePicker, MixClassField } from './MixClassField';
|
||||||
|
|
||||||
export interface IItemsEditorProps {
|
export interface IItemsEditorProps {
|
||||||
value: IConcreteItem[];
|
value: IConcreteItem[];
|
||||||
onChange: (rows: IConcreteItem[]) => void;
|
onChange: (rows: IConcreteItem[]) => void;
|
||||||
|
/** Рецепты БСУ; не передан — класс смеси вводится текстом (см. MixClassField). */
|
||||||
|
recipePicker?: IRecipePicker;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Табличная часть заявки на бетон: класс смеси, объём, подвижность, время и
|
* Табличная часть заявки на бетон: класс смеси (рецепт БСУ), объём, подвижность,
|
||||||
* примечание — карточка на каждую поставку. Проект/объект сюда не входит: он
|
* время и примечание — карточка на каждую поставку. Проект/объект сюда не входит: он
|
||||||
* один на всю заявку и выбирается в шапке (см. HeaderSection, IFormData.Project) —
|
* один на всю заявку и выбирается в шапке (см. HeaderSection, IFormData.Project) —
|
||||||
* от него зависит маршрут согласования, поэтому поле не дублируется по позициям.
|
* от него зависит маршрут согласования, поэтому поле не дублируется по позициям.
|
||||||
*
|
*
|
||||||
* НЕ плоская таблица-простыня (как shared/ui/ItemsTable): карточки с обычным
|
* НЕ плоская таблица-простыня (как shared/ui/ItemsTable): карточки с обычным
|
||||||
* FormGrid адаптивнее узкой строки с горизонтальным скроллом на мобильных.
|
* FormGrid адаптивнее узкой строки с горизонтальным скроллом на мобильных.
|
||||||
*/
|
*/
|
||||||
export const ItemsEditor: React.FC<IItemsEditorProps> = ({ value, onChange }) => {
|
export const ItemsEditor: React.FC<IItemsEditorProps> = ({ value, onChange, recipePicker }) => {
|
||||||
const readOnly = useFormReadOnly();
|
const readOnly = useFormReadOnly();
|
||||||
|
|
||||||
const setRow = (index: number, patch: Partial<IConcreteItem>): void => {
|
const setRow = (index: number, patch: Partial<IConcreteItem>): void => {
|
||||||
@@ -43,13 +46,8 @@ export const ItemsEditor: React.FC<IItemsEditorProps> = ({ value, onChange }) =>
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<FormGrid>
|
<FormGrid>
|
||||||
<FormField label="Класс смеси и хар-ка" htmlFor={`mixClass-${i}`} required full>
|
<FormField label="Класс смеси (рецепт)" htmlFor={`mixClass-${i}`} required full>
|
||||||
<TextInput
|
<MixClassField id={`mixClass-${i}`} row={row} picker={recipePicker} onChange={(patch) => setRow(i, patch)} />
|
||||||
id={`mixClass-${i}`}
|
|
||||||
value={row.mixClass}
|
|
||||||
onChange={(v) => setRow(i, { mixClass: v })}
|
|
||||||
placeholder="C20/25 F75 W6"
|
|
||||||
/>
|
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Количество, м³" htmlFor={`volume-${i}`} required>
|
<FormField label="Количество, м³" htmlFor={`volume-${i}`} required>
|
||||||
<TextInput id={`volume-${i}`} value={row.volume} onChange={(v) => setRow(i, { volume: v })} placeholder="0" />
|
<TextInput id={`volume-${i}`} value={row.volume} onChange={(v) => setRow(i, { volume: v })} placeholder="0" />
|
||||||
|
|||||||
@@ -1,11 +1,35 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { CollapseSection } from '../../../shared/ui';
|
import styles from './ItemsEditor.module.scss';
|
||||||
|
import { Alert, CollapseSection } from '../../../shared/ui';
|
||||||
import { ItemsEditor } from './ItemsEditor';
|
import { ItemsEditor } from './ItemsEditor';
|
||||||
|
import { IRecipePicker } from './MixClassField';
|
||||||
import { ISectionProps } from '../model';
|
import { ISectionProps } from '../model';
|
||||||
|
import { IBsuCatalog } from '../bsu/useBsuCatalog';
|
||||||
|
|
||||||
/** Табличная часть заявки на бетон: класс смеси, объём, подвижность, время, примечание. */
|
/**
|
||||||
export const ItemsSection: React.FC<ISectionProps> = ({ data, set }) => (
|
* Рецепты из справочника — только когда API БСУ отвечает. Нет справочника
|
||||||
|
* (просмотр) или API упал — undefined: класс смеси вводится текстом, заявку
|
||||||
|
* подать можно.
|
||||||
|
*/
|
||||||
|
function toRecipePicker(bsuId: string, catalog?: IBsuCatalog): IRecipePicker | undefined {
|
||||||
|
if (!catalog || catalog.bsu.error || catalog.formulas.error) return undefined;
|
||||||
|
return { formulas: catalog.formulas.items, loading: catalog.formulas.loading, bsuChosen: !!bsuId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Табличная часть заявки на бетон: класс смеси (рецепт БСУ), объём, подвижность, время, примечание. */
|
||||||
|
export const ItemsSection: React.FC<ISectionProps> = ({ data, set, bsu }) => (
|
||||||
<CollapseSection title="Позиции поставки" icon="clipboard">
|
<CollapseSection title="Позиции поставки" icon="clipboard">
|
||||||
<ItemsEditor value={data.Items} onChange={(rows) => set('Items', rows)} />
|
{bsu?.formulas.error && (
|
||||||
|
<div className={styles.full}>
|
||||||
|
<Alert variant="warn">
|
||||||
|
Не удалось загрузить рецепты БСУ ({bsu.formulas.error}). Укажите класс смеси вручную.
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ItemsEditor
|
||||||
|
value={data.Items}
|
||||||
|
onChange={(rows) => set('Items', rows)}
|
||||||
|
recipePicker={toRecipePicker(data.BsuId, bsu)}
|
||||||
|
/>
|
||||||
</CollapseSection>
|
</CollapseSection>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Select, TextInput } from '../../../shared/ui';
|
||||||
|
import { RecipeIngredients } from './RecipeIngredients';
|
||||||
|
import { IConcreteItem } from '../model';
|
||||||
|
import { IBsuFormula } from '../bsu/IBsu';
|
||||||
|
import { formulaLabel } from '../bsu/bsu.service';
|
||||||
|
|
||||||
|
/** Рецепты выбранного БСУ для выпадающего списка. */
|
||||||
|
export interface IRecipePicker {
|
||||||
|
formulas: IBsuFormula[];
|
||||||
|
loading: boolean;
|
||||||
|
/** БСУ ещё не выбран — рецептов взять неоткуда. */
|
||||||
|
bsuChosen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
row: IConcreteItem;
|
||||||
|
/** Не передан — ручной ввод (API БСУ недоступен) или просмотр сохранённой заявки. */
|
||||||
|
picker?: IRecipePicker;
|
||||||
|
onChange: (patch: Partial<IConcreteItem>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickerPlaceholder(picker: IRecipePicker): string {
|
||||||
|
if (!picker.bsuChosen) return 'Сначала выберите БСУ';
|
||||||
|
if (picker.loading) return 'Загрузка рецептов…';
|
||||||
|
if (picker.formulas.length === 0) return 'У этого БСУ нет рецептов';
|
||||||
|
return 'Выберите рецепт';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Класс смеси: рецепт из справочника БСУ, а без справочника — свободный текст. Под полем — состав рецепта. */
|
||||||
|
export const MixClassField: React.FC<Props> = ({ id, row, picker, onChange }) => {
|
||||||
|
const handlePick = (value: string): void => {
|
||||||
|
const formula = picker?.formulas.find((f) => String(f.id) === value);
|
||||||
|
onChange(
|
||||||
|
formula
|
||||||
|
? { formulaId: formula.id, mixClass: formulaLabel(formula), ingredients: formula.ingredients }
|
||||||
|
: { formulaId: undefined, mixClass: '', ingredients: '' }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{picker ? (
|
||||||
|
<Select
|
||||||
|
id={id}
|
||||||
|
value={row.formulaId === undefined ? '' : String(row.formulaId)}
|
||||||
|
onChange={handlePick}
|
||||||
|
options={picker.formulas.map((f) => ({ value: String(f.id), label: formulaLabel(f) }))}
|
||||||
|
placeholder={pickerPlaceholder(picker)}
|
||||||
|
readOnly={!picker.bsuChosen || picker.loading}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TextInput
|
||||||
|
id={id}
|
||||||
|
value={row.mixClass}
|
||||||
|
onChange={(v) => onChange({ mixClass: v, formulaId: undefined, ingredients: '' })}
|
||||||
|
placeholder="C20/25 F75 W6"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{row.ingredients && <RecipeIngredients value={row.ingredients} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './ItemsEditor.module.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Состав строкой из API БСУ: «Бункер 1: 670; Цемент 1: 400; Вода: 146». */
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IIngredient {
|
||||||
|
name: string;
|
||||||
|
amount: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Бункер 1: 670; Вода: 146» → [{Бункер 1, 670}, {Вода, 146}]. Хоть одна часть не по формату — null. */
|
||||||
|
function parseIngredients(value: string): IIngredient[] | null {
|
||||||
|
const parts = value.split(';').map((p) => p.trim()).filter((p) => p.length > 0);
|
||||||
|
const result: IIngredient[] = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
const colon = part.lastIndexOf(':');
|
||||||
|
if (colon <= 0) return null;
|
||||||
|
result.push({ name: part.slice(0, colon).trim(), amount: part.slice(colon + 1).trim() });
|
||||||
|
}
|
||||||
|
return result.length > 0 ? result : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Состав рецепта плашками «компонент — количество»; формат не распознан — исходная строка. */
|
||||||
|
export const RecipeIngredients: React.FC<Props> = ({ value }) => {
|
||||||
|
const ingredients = parseIngredients(value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.recipe}>
|
||||||
|
<span className={styles.recipeLabel}>Состав рецепта</span>
|
||||||
|
{ingredients ? (
|
||||||
|
<ul className={styles.recipeList}>
|
||||||
|
{ingredients.map((ing, i) => (
|
||||||
|
<li key={i} className={styles.recipeChip}>
|
||||||
|
<span className={styles.recipeName}>{ing.name}</span>
|
||||||
|
<span className={styles.recipeAmount}>{ing.amount}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<span className={styles.recipeRaw}>{value}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -52,6 +52,15 @@ export function getApprovalApiBaseUrl(): string {
|
|||||||
return _approvalApiBaseUrl;
|
return _approvalApiBaseUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API БСУ (бетонно-смесительные узлы и их рецепты) — справочник для «Заявки на бетон».
|
||||||
|
* Токен — того же AAD-приложения, что у approval-route и ЭДО (проверено по aud рабочего
|
||||||
|
* токена). Если у БСУ появится своё приложение — поменять только эту константу и объявить
|
||||||
|
* доступ в package-solution.json (webApiPermissionRequests).
|
||||||
|
*/
|
||||||
|
export const BSU_API_CLIENT_ID = APPROVAL_API_CLIENT_ID;
|
||||||
|
export const BSU_API_BASE_URL = 'https://bsu.tnsu.kz/api/v1';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ЭДО (TANSU ЭДО) — подписание документов. Токен берётся для того же AAD-приложения, что и
|
* ЭДО (TANSU ЭДО) — подписание документов. Токен берётся для того же AAD-приложения, что и
|
||||||
* у approval-route и справочников: бэкенд ЭДО принимает токены сотрудников этого приложения
|
* у approval-route и справочников: бэкенд ЭДО принимает токены сотрудников этого приложения
|
||||||
|
|||||||
Reference in New Issue
Block a user