Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/18
This commit was merged in pull request #18.
This commit is contained in:
@@ -11,7 +11,24 @@ module.exports = {
|
|||||||
'ecmaVersion': 2018,
|
'ecmaVersion': 2018,
|
||||||
'sourceType': 'module'
|
'sourceType': 'module'
|
||||||
},
|
},
|
||||||
|
plugins: ['react-hooks'],
|
||||||
rules: {
|
rules: {
|
||||||
|
// RATIONALE: Хук, вызванный не на каждом рендере (например, useMemo
|
||||||
|
// ПОСЛЕ раннего `return` для loading/error), ломает порядок
|
||||||
|
// хуков и валит компонент в рантайме с React #310 «rendered
|
||||||
|
// more hooks than during the previous render» — ошибка,
|
||||||
|
// которую видно только при открытии конкретного экрана.
|
||||||
|
// Правило ловит это на сборке. eslint-plugin-react-hooks
|
||||||
|
// давно в зависимостях, а в коде есть eslint-disable для
|
||||||
|
// exhaustive-deps — то есть правила подразумевались, но
|
||||||
|
// включить их в конфиг забыли (поймали на ServiceRequestView).
|
||||||
|
'react-hooks/rules-of-hooks': 2,
|
||||||
|
// Зависимости эффектов — ВЫКЛЮЧЕНО намеренно. Правило полезное, но на
|
||||||
|
// текущем коде даёт 16 предупреждений (в основном намеренно опущенные
|
||||||
|
// onCodeLoaded/submit во всех формах). Шум такого объёма в каждой сборке
|
||||||
|
// приучает не читать варнинги вовсе — включать имеет смысл вместе с
|
||||||
|
// разбором этих мест, отдельной задачей, а не заодно с фиксом падения.
|
||||||
|
'react-hooks/exhaustive-deps': 0,
|
||||||
// Prevent usage of the JavaScript null value, while allowing code to access existing APIs that may require null. https://www.npmjs.com/package/@rushstack/eslint-plugin
|
// Prevent usage of the JavaScript null value, while allowing code to access existing APIs that may require null. https://www.npmjs.com/package/@rushstack/eslint-plugin
|
||||||
'@rushstack/no-new-null': 1,
|
'@rushstack/no-new-null': 1,
|
||||||
// Require Jest module mocking APIs to be called before any other statements in their code block. https://www.npmjs.com/package/@rushstack/eslint-plugin
|
// Require Jest module mocking APIs to be called before any other statements in their code block. https://www.npmjs.com/package/@rushstack/eslint-plugin
|
||||||
@@ -290,6 +307,22 @@ module.exports = {
|
|||||||
// Rationale to disable: !!{}
|
// Rationale to disable: !!{}
|
||||||
'no-extra-boolean-cast': 0,
|
'no-extra-boolean-cast': 0,
|
||||||
// ====================================================================
|
// ====================================================================
|
||||||
|
// eslint-plugin-react-hooks (плагин подключён профилем
|
||||||
|
// @microsoft/eslint-config-spfx/lib/profiles/react, но правил он не задаёт)
|
||||||
|
// ====================================================================
|
||||||
|
// RATIONALE: Хуки нельзя звать условно/в циклах — иначе React
|
||||||
|
// перепутает состояние между рендерами.
|
||||||
|
'react-hooks/rules-of-hooks': 2,
|
||||||
|
// RATIONALE: Неполный массив зависимостей useEffect/useCallback/
|
||||||
|
// useMemo — это устаревшее замыкание: эффект продолжает
|
||||||
|
// видеть старые пропсы и state. Держим предупреждением:
|
||||||
|
// там, где отступление осознанное (например, эффект
|
||||||
|
// должен сработать только на смену id, а не на каждое
|
||||||
|
// изменение колбэка), рядом стоит
|
||||||
|
// // eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
// с объяснением причины.
|
||||||
|
'react-hooks/exhaustive-deps': 1,
|
||||||
|
// ====================================================================
|
||||||
// @microsoft/eslint-plugin-spfx
|
// @microsoft/eslint-plugin-spfx
|
||||||
// ====================================================================
|
// ====================================================================
|
||||||
'@microsoft/spfx/no-require-ensure': 2,
|
'@microsoft/spfx/no-require-ensure': 2,
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
| [`clearance-form`](../src/webparts/requestPortal/forms/Clearance/README.md) | Обходной лист | Отдел кадров | да | нет | да | подаётся HR за увольняемого (People-picker) либо создаётся сам по согласованному «Заявлению на увольнение»; условие «IsManager» резолвится по должности из самой заявки; да/нет-поля (парковка, СКУД); 5 независимых сумм к списанию компактной сеткой (ОС, СИЗ, подотчётная сумма, НМА, ТМЦ — обучение не входит, у него своё поле) — вводит любой активный согласующий, блокируется навсегда после первого ввода; заполнил хоть одну — стандартный комментарий решения (в блоке «Ваше решение») становится обязательным на любое решение, не только возврат/отклонение | ✅ |
|
| [`clearance-form`](../src/webparts/requestPortal/forms/Clearance/README.md) | Обходной лист | Отдел кадров | да | нет | да | подаётся HR за увольняемого (People-picker) либо создаётся сам по согласованному «Заявлению на увольнение»; условие «IsManager» резолвится по должности из самой заявки; да/нет-поля (парковка, СКУД); 5 независимых сумм к списанию компактной сеткой (ОС, СИЗ, подотчётная сумма, НМА, ТМЦ — обучение не входит, у него своё поле) — вводит любой активный согласующий, блокируется навсегда после первого ввода; заполнил хоть одну — стандартный комментарий решения (в блоке «Ваше решение») становится обязательным на любое решение, не только возврат/отклонение | ✅ |
|
||||||
| [`hr-dismissal`](../src/webparts/requestPortal/forms/Dismissal/README.md) | Заявление на увольнение | Отдел кадров | да | нет | да | совместители — таблица компаний с удалением строки (снимает и согласующего из матрицы); должность/организация из справочника; **после согласования сам создаёт обходной лист**; печать заблокирована до согласования обходного листа; «Лицо, принимающее ОС/НМА» — заполняет только непосредственный руководитель | ✅ |
|
| [`hr-dismissal`](../src/webparts/requestPortal/forms/Dismissal/README.md) | Заявление на увольнение | Отдел кадров | да | нет | да | совместители — таблица компаний с удалением строки (снимает и согласующего из матрицы); должность/организация из справочника; **после согласования сам создаёт обходной лист**; печать заблокирована до согласования обходного листа; «Лицо, принимающее ОС/НМА» — заполняет только непосредственный руководитель | ✅ |
|
||||||
| `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) |
|
| `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) |
|
||||||
|
| [`wh-service-request`](../src/webparts/requestPortal/forms/ServiceRequest/README.md) | Заявка на услуги | Склад / Снабжение | да | нет | да | маршрут по проекту (руководитель проекта/ПТО); табличные части «Приложения» и «Смета» (JSON-колонки); сумма по смете считается на лету (qty × цена), итог дублируется числовой колонкой для отчётов | ✅ |
|
||||||
|
|
||||||
Легенда: **Согл.** — есть фаза согласования; **Исп.** — есть фаза исполнения;
|
Легенда: **Согл.** — есть фаза согласования; **Исп.** — есть фаза исполнения;
|
||||||
**Просмотр** — есть режим согласующего (`<Форма>View`).
|
**Просмотр** — есть режим согласующего (`<Форма>View`).
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ export const REQUEST_TYPES: IRequestType[] = [
|
|||||||
{ id: 'hr-transfer', categoryId: 'hr', title: 'Заявка на перемещение сотрудников', icon: 'people' },
|
{ id: 'hr-transfer', categoryId: 'hr', title: 'Заявка на перемещение сотрудников', icon: 'people' },
|
||||||
|
|
||||||
// Склад / Снабжение
|
// Склад / Снабжение
|
||||||
{ id: 'wh-tmc-transfer', categoryId: 'warehouse', title: 'СЗ на перемещение ТМЦ', icon: 'send' }
|
{ id: 'wh-tmc-transfer', categoryId: 'warehouse', title: 'СЗ на перемещение ТМЦ', icon: 'send' },
|
||||||
|
{ id: 'wh-service-request', categoryId: 'warehouse', title: 'Заявка на услуги', icon: 'document' }
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Заявки, относящиеся к указанной категории. */
|
/** Заявки, относящиеся к указанной категории. */
|
||||||
|
|||||||
@@ -477,7 +477,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
unsubscribe();
|
unsubscribe();
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
};
|
};
|
||||||
}, [context, types, view, statusFilter, debouncedQuery, page]);
|
}, [context, types, view, statusFilter, debouncedQuery, page, dataKey]);
|
||||||
|
|
||||||
// Все ветки отдают уже нарезанную страницу — просто показываем как есть.
|
// Все ветки отдают уже нарезанную страницу — просто показываем как есть.
|
||||||
const q = debouncedQuery.trim();
|
const q = debouncedQuery.trim();
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
|||||||
import { saveClearanceRequest, clearanceApprovalService } from './submit';
|
import { saveClearanceRequest, clearanceApprovalService } from './submit';
|
||||||
import { loadClearanceRequest } from './load';
|
import { loadClearanceRequest } from './load';
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
const ClearanceForm: React.FC<IRequestFormProps> = ({
|
const ClearanceForm: React.FC<IRequestFormProps> = ({
|
||||||
requestType,
|
requestType,
|
||||||
@@ -53,6 +54,14 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({
|
|||||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||||
const submit = useRequestSubmit();
|
const submit = useRequestSubmit();
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
|
||||||
|
// стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при
|
||||||
|
// каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
|
// submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене
|
||||||
|
// saving/error), а setError стабилен — его и берём.
|
||||||
|
const { setError: setSubmitError } = submit;
|
||||||
|
|
||||||
const readOnly = !isEditableStatus(requestStatus);
|
const readOnly = !isEditableStatus(requestStatus);
|
||||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||||
@@ -78,19 +87,18 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setRequestStatus(loaded.status);
|
setRequestStatus(loaded.status);
|
||||||
setRequestCode(loaded.code || undefined);
|
setRequestCode(loaded.code || undefined);
|
||||||
if (loaded.code) onCodeLoaded?.(loaded.code);
|
if (loaded.code) notifyCode(loaded.code);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [editId, editListId, notifyCode, setSubmitError]);
|
||||||
}, [editId, editListId]);
|
|
||||||
|
|
||||||
// Новая заявка — дата расторжения по умолчанию сегодняшняя (типичный случай:
|
// Новая заявка — дата расторжения по умолчанию сегодняшняя (типичный случай:
|
||||||
// обходной лист оформляют в день увольнения). При открытии на редактирование
|
// обходной лист оформляют в день увольнения). При открытии на редактирование
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
} from './submit';
|
} from './submit';
|
||||||
import { canEditHrRecordsFields, canEditParkingField } from './approverRights';
|
import { canEditHrRecordsFields, canEditParkingField } from './approverRights';
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
interface IClearanceViewProps {
|
interface IClearanceViewProps {
|
||||||
context: WebPartContext;
|
context: WebPartContext;
|
||||||
@@ -49,6 +50,11 @@ type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
|||||||
// заполняет профильный отдел на своём шаге: КДП — карта СКУД и сумма обучения,
|
// заполняет профильный отдел на своём шаге: КДП — карта СКУД и сумма обучения,
|
||||||
// отдел подбора — парковочная карта (см. approverRights.ts).
|
// отдел подбора — парковочная карта (см. approverRights.ts).
|
||||||
const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
|
||||||
|
// идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы
|
||||||
|
// при каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
@@ -115,7 +121,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
onCodeLoaded?.(loaded.code);
|
notifyCode(loaded.code);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setLockedWriteOffs(
|
setLockedWriteOffs(
|
||||||
@@ -132,7 +138,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [itemId, listId]);
|
}, [itemId, listId, notifyCode]);
|
||||||
|
|
||||||
// Права на отметки — по должности ТЕКУЩЕГО активного шага пользователя.
|
// Права на отметки — по должности ТЕКУЩЕГО активного шага пользователя.
|
||||||
// isDelegator намеренно не даёт прав: делегировавший не решает за делегата
|
// isDelegator намеренно не даёт прав: делегировавший не решает за делегата
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField, MIN_NOTICE_MONTHS, IOrganizat
|
|||||||
import { saveDismissalRequest, dismissalApprovalService } from './submit';
|
import { saveDismissalRequest, dismissalApprovalService } from './submit';
|
||||||
import { loadDismissalRequest } from './load';
|
import { loadDismissalRequest } from './load';
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
const DismissalForm: React.FC<IRequestFormProps> = ({
|
const DismissalForm: React.FC<IRequestFormProps> = ({
|
||||||
requestType,
|
requestType,
|
||||||
@@ -53,6 +54,14 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
|||||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||||
const submit = useRequestSubmit();
|
const submit = useRequestSubmit();
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
|
||||||
|
// стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при
|
||||||
|
// каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
|
// submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене
|
||||||
|
// saving/error), а setError стабилен — его и берём.
|
||||||
|
const { setError: setSubmitError } = submit;
|
||||||
|
|
||||||
const readOnly = !isEditableStatus(requestStatus);
|
const readOnly = !isEditableStatus(requestStatus);
|
||||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||||
@@ -82,19 +91,18 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setRequestStatus(loaded.status);
|
setRequestStatus(loaded.status);
|
||||||
setRequestCode(loaded.code || undefined);
|
setRequestCode(loaded.code || undefined);
|
||||||
if (loaded.code) onCodeLoaded?.(loaded.code);
|
if (loaded.code) notifyCode(loaded.code);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [editId, editListId, notifyCode, setSubmitError]);
|
||||||
}, [editId, editListId]);
|
|
||||||
|
|
||||||
/** Employee/ByEmail → таблица организаций (см. IOrganizationRecord). */
|
/** Employee/ByEmail → таблица организаций (см. IOrganizationRecord). */
|
||||||
const toOrganization = (records: IEmployeeOrganization[]): IOrganizationRecord[] =>
|
const toOrganization = (records: IEmployeeOrganization[]): IOrganizationRecord[] =>
|
||||||
@@ -124,8 +132,9 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// context — единственный экземпляр WebPartContext на всё время жизни
|
||||||
}, [editId]);
|
// веб-части, так что перезапуска эффекта эта зависимость не добавляет.
|
||||||
|
}, [editId, context]);
|
||||||
|
|
||||||
// Выбор другого сотрудника подтягивает его таблицу организаций.
|
// Выбор другого сотрудника подтягивает его таблицу организаций.
|
||||||
const handleEmployeeChange = (login: string): void => {
|
const handleEmployeeChange = (login: string): void => {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { dismissalApprovalService, updateAssetsAcceptor } from './submit';
|
|||||||
import { loadClearanceRequest } from '../Clearance/load';
|
import { loadClearanceRequest } from '../Clearance/load';
|
||||||
import { ensureClearanceForDismissal } from './createClearance';
|
import { ensureClearanceForDismissal } from './createClearance';
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Статусы обходного листа, при которых его СОГЛАСОВАНИЕ уже пройдено целиком
|
* Статусы обходного листа, при которых его СОГЛАСОВАНИЕ уже пройдено целиком
|
||||||
@@ -56,6 +57,11 @@ interface IDismissalViewProps {
|
|||||||
// Переиспользует ту же секцию, что и форма подачи; редактирование выключено через
|
// Переиспользует ту же секцию, что и форма подачи; редактирование выключено через
|
||||||
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
|
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
|
||||||
const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
|
||||||
|
// идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы
|
||||||
|
// при каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
@@ -94,7 +100,7 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
onCodeLoaded?.(loaded.code);
|
notifyCode(loaded.code);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -108,7 +114,7 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [itemId, listId]);
|
}, [itemId, listId, notifyCode]);
|
||||||
|
|
||||||
// Печать заявления запрещена до согласования ПАРНОГО обходного листа: сначала
|
// Печать заявления запрещена до согласования ПАРНОГО обходного листа: сначала
|
||||||
// должны отработать/подтвердиться все связанные с увольнением отметки (карты,
|
// должны отработать/подтвердиться все связанные с увольнением отметки (карты,
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import { CandidateSection } from './sections/CandidateSection';
|
|||||||
import { PpeSection } from './sections/PpeSection';
|
import { PpeSection } from './sections/PpeSection';
|
||||||
import { ConditionsSection } from './sections/ConditionsSection';
|
import { ConditionsSection } from './sections/ConditionsSection';
|
||||||
import { AgencySection } from './sections/AgencySection';
|
import { AgencySection } from './sections/AgencySection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
||||||
requestType,
|
requestType,
|
||||||
@@ -83,6 +84,14 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
|||||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||||
const submit = useRequestSubmit();
|
const submit = useRequestSubmit();
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
|
||||||
|
// стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при
|
||||||
|
// каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
|
// submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене
|
||||||
|
// saving/error), а setError стабилен — его и берём.
|
||||||
|
const { setError: setSubmitError } = submit;
|
||||||
|
|
||||||
const readOnly = !isEditableStatus(requestStatus);
|
const readOnly = !isEditableStatus(requestStatus);
|
||||||
|
|
||||||
@@ -101,19 +110,19 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setRequestStatus(loaded.status);
|
setRequestStatus(loaded.status);
|
||||||
setRequestCode(loaded.code || undefined);
|
setRequestCode(loaded.code || undefined);
|
||||||
if (loaded.code) onCodeLoaded?.(loaded.code);
|
if (loaded.code) notifyCode(loaded.code);
|
||||||
setExistingAttachments(atts);
|
setExistingAttachments(atts);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [editId, editListId]);
|
}, [editId, editListId, notifyCode, setSubmitError]);
|
||||||
|
|
||||||
// Маршрут согласования (планируемый) резолвится по email инициатора — текущего
|
// Маршрут согласования (планируемый) резолвится по email инициатора — текущего
|
||||||
// пользователя (кандидата ещё нет в системе). У заявки, открытой на
|
// пользователя (кандидата ещё нет в системе). У заявки, открытой на
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { CandidateSection } from './sections/CandidateSection';
|
|||||||
import { PpeSection } from './sections/PpeSection';
|
import { PpeSection } from './sections/PpeSection';
|
||||||
import { ConditionsSection } from './sections/ConditionsSection';
|
import { ConditionsSection } from './sections/ConditionsSection';
|
||||||
import { AgencySection } from './sections/AgencySection';
|
import { AgencySection } from './sections/AgencySection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
interface IRecruitingHireViewProps {
|
interface IRecruitingHireViewProps {
|
||||||
context: WebPartContext;
|
context: WebPartContext;
|
||||||
@@ -56,6 +57,11 @@ const NOOP_SET: SetField = () => undefined;
|
|||||||
// Переиспользует те же секции, что и форма подачи; редактирование выключено
|
// Переиспользует те же секции, что и форма подачи; редактирование выключено
|
||||||
// через <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
|
// через <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
|
||||||
const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
|
||||||
|
// идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы
|
||||||
|
// при каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
@@ -116,7 +122,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
onCodeLoaded?.(loaded.code);
|
notifyCode(loaded.code);
|
||||||
setAttachments(files);
|
setAttachments(files);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -131,7 +137,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [itemId, listId]);
|
}, [itemId, listId, notifyCode]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className={styles.state}>Загрузка заявки…</div>;
|
return <div className={styles.state}>Загрузка заявки…</div>;
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import { DepartmentSection } from './sections/DepartmentSection';
|
|||||||
import { VacancySection } from './sections/VacancySection';
|
import { VacancySection } from './sections/VacancySection';
|
||||||
import { HealthTripsSection } from './sections/HealthTripsSection';
|
import { HealthTripsSection } from './sections/HealthTripsSection';
|
||||||
import { ConditionsSection } from './sections/ConditionsSection';
|
import { ConditionsSection } from './sections/ConditionsSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
|
const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
|
||||||
requestType,
|
requestType,
|
||||||
@@ -69,6 +70,14 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
|
|||||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||||
const submit = useRequestSubmit();
|
const submit = useRequestSubmit();
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
|
||||||
|
// стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при
|
||||||
|
// каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
|
// submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене
|
||||||
|
// saving/error), а setError стабилен — его и берём.
|
||||||
|
const { setError: setSubmitError } = submit;
|
||||||
|
|
||||||
const readOnly = !isEditableStatus(requestStatus);
|
const readOnly = !isEditableStatus(requestStatus);
|
||||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||||
@@ -86,20 +95,19 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setRequestStatus(loaded.status);
|
setRequestStatus(loaded.status);
|
||||||
setRequestCode(loaded.code || undefined);
|
setRequestCode(loaded.code || undefined);
|
||||||
if (loaded.code) onCodeLoaded?.(loaded.code);
|
if (loaded.code) notifyCode(loaded.code);
|
||||||
setExistingAttachments(atts);
|
setExistingAttachments(atts);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [editId, editListId, notifyCode, setSubmitError]);
|
||||||
}, [editId, editListId]);
|
|
||||||
|
|
||||||
// Компании ответственного (Employee/ByEmail.company/companyId) → предзаполнение
|
// Компании ответственного (Employee/ByEmail.company/companyId) → предзаполнение
|
||||||
// «1.6 Организация» (Companies/CompanyPickerMulti). companyId из Employee/ByEmail
|
// «1.6 Организация» (Companies/CompanyPickerMulti). companyId из Employee/ByEmail
|
||||||
@@ -132,8 +140,9 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// context — единственный экземпляр WebPartContext на всё время жизни
|
||||||
}, [editId]);
|
// веб-части, так что перезапуска эффекта эта зависимость не добавляет.
|
||||||
|
}, [editId, context]);
|
||||||
|
|
||||||
// Выбор другого ответственного подтягивает его подразделение и организацию.
|
// Выбор другого ответственного подтягивает его подразделение и организацию.
|
||||||
const handleResponsibleChange = (login: string): void => {
|
const handleResponsibleChange = (login: string): void => {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { DepartmentSection } from './sections/DepartmentSection';
|
|||||||
import { VacancySection } from './sections/VacancySection';
|
import { VacancySection } from './sections/VacancySection';
|
||||||
import { HealthTripsSection } from './sections/HealthTripsSection';
|
import { HealthTripsSection } from './sections/HealthTripsSection';
|
||||||
import { ConditionsSection } from './sections/ConditionsSection';
|
import { ConditionsSection } from './sections/ConditionsSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
interface IRecruitingSelectionViewProps {
|
interface IRecruitingSelectionViewProps {
|
||||||
context: WebPartContext;
|
context: WebPartContext;
|
||||||
@@ -47,6 +48,11 @@ const NOOP_SET: SetField = () => undefined;
|
|||||||
// Заявка «на подбор» только для просмотра (режим согласующего). Переиспользует те же
|
// Заявка «на подбор» только для просмотра (режим согласующего). Переиспользует те же
|
||||||
// секции, что и форма подачи; редактирование выключено через <FormModeProvider readOnly>.
|
// секции, что и форма подачи; редактирование выключено через <FormModeProvider readOnly>.
|
||||||
const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
|
||||||
|
// идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы
|
||||||
|
// при каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
@@ -80,7 +86,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
onCodeLoaded?.(loaded.code);
|
notifyCode(loaded.code);
|
||||||
setAttachments(files);
|
setAttachments(files);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -95,7 +101,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [itemId, listId]);
|
}, [itemId, listId, notifyCode]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className={styles.state}>Загрузка заявки…</div>;
|
return <div className={styles.state}>Загрузка заявки…</div>;
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { CandidateSection } from './sections/CandidateSection';
|
|||||||
import { VacancySection } from './sections/VacancySection';
|
import { VacancySection } from './sections/VacancySection';
|
||||||
import { CandidateInfoSection } from './sections/CandidateInfoSection';
|
import { CandidateInfoSection } from './sections/CandidateInfoSection';
|
||||||
import { AssessmentSection } from './sections/AssessmentSection';
|
import { AssessmentSection } from './sections/AssessmentSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
const ReferralForm: React.FC<IRequestFormProps> = ({
|
const ReferralForm: React.FC<IRequestFormProps> = ({
|
||||||
requestType,
|
requestType,
|
||||||
@@ -58,6 +59,14 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
|
|||||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||||
const submit = useRequestSubmit();
|
const submit = useRequestSubmit();
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
|
||||||
|
// стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при
|
||||||
|
// каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
|
// submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене
|
||||||
|
// saving/error), а setError стабилен — его и берём.
|
||||||
|
const { setError: setSubmitError } = submit;
|
||||||
|
|
||||||
const readOnly = !isEditableStatus(requestStatus);
|
const readOnly = !isEditableStatus(requestStatus);
|
||||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||||
@@ -83,19 +92,18 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setRequestStatus(loaded.status);
|
setRequestStatus(loaded.status);
|
||||||
setRequestCode(loaded.code || undefined);
|
setRequestCode(loaded.code || undefined);
|
||||||
if (loaded.code) onCodeLoaded?.(loaded.code);
|
if (loaded.code) notifyCode(loaded.code);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [editId, editListId, notifyCode, setSubmitError]);
|
||||||
}, [editId, editListId]);
|
|
||||||
|
|
||||||
// Новая заявка: рекомендатель — сам подающий (подставляем его; подразделение —
|
// Новая заявка: рекомендатель — сам подающий (подставляем его; подразделение —
|
||||||
// из профиля SharePoint SPS-Department, как в «Обходном листе»; должность и
|
// из профиля SharePoint SPS-Department, как в «Обходном листе»; должность и
|
||||||
@@ -136,8 +144,10 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// context — единственный экземпляр WebPartContext на всё время жизни
|
||||||
}, [editId]);
|
// веб-части, approval мемоизирован по editListId: перезапуска эффекта эти
|
||||||
|
// зависимости не добавляют.
|
||||||
|
}, [editId, context, approval]);
|
||||||
|
|
||||||
// Выбор другого рекомендателя подтягивает его подразделение из профиля,
|
// Выбор другого рекомендателя подтягивает его подразделение из профиля,
|
||||||
// должность и организации — из Employee/ByEmail. Оба поля (Position/
|
// должность и организации — из Employee/ByEmail. Оба поля (Position/
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { CandidateSection } from './sections/CandidateSection';
|
|||||||
import { VacancySection } from './sections/VacancySection';
|
import { VacancySection } from './sections/VacancySection';
|
||||||
import { CandidateInfoSection } from './sections/CandidateInfoSection';
|
import { CandidateInfoSection } from './sections/CandidateInfoSection';
|
||||||
import { AssessmentSection } from './sections/AssessmentSection';
|
import { AssessmentSection } from './sections/AssessmentSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
interface IReferralViewProps {
|
interface IReferralViewProps {
|
||||||
context: WebPartContext;
|
context: WebPartContext;
|
||||||
@@ -48,6 +49,11 @@ const NOOP_SET: SetField = () => undefined;
|
|||||||
// те же секции, что и форма подачи; редактирование выключено через
|
// те же секции, что и форма подачи; редактирование выключено через
|
||||||
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
|
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
|
||||||
const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
|
||||||
|
// идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы
|
||||||
|
// при каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
@@ -101,7 +107,7 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
onCodeLoaded?.(loaded.code);
|
notifyCode(loaded.code);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -115,7 +121,7 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [itemId, listId]);
|
}, [itemId, listId, notifyCode]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className={styles.state}>Загрузка заявки…</div>;
|
return <div className={styles.state}>Загрузка заявки…</div>;
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Заявка на услуги (`wh-service-request`)
|
||||||
|
|
||||||
|
**Назначение:** электронный аналог бумажной Service Requisition Form ПТО — заявка на договор/услуги/работы (субподряд, поставка, аренда) по проекту.
|
||||||
|
**Категория:** Склад / Снабжение
|
||||||
|
**Списки SP:** «Заявка на услуги — данные», «Заявка на услуги — согласование»
|
||||||
|
**RequestTypeId (внешний API маршрутов):** резолвится в рантайме по `pageUrl` (см. `docs/forms.md`)
|
||||||
|
|
||||||
|
## Маршрут
|
||||||
|
- Фазы: согласование (без фазы исполнения — договор дальше ведётся вне портала)
|
||||||
|
- Особенности: маршрут зависит от выбранного проекта (`ProjectId`, oid из `ProjectPicker`) — пока проект не выбран, `resolveRoute` отвечает 404, и матрица не резолвится (см. `matrixReady` в `ServiceRequestForm.tsx`, тот же приём, что в `TmcTransferForm`)
|
||||||
|
|
||||||
|
## Особое поведение
|
||||||
|
- Подача за другого: заявитель — `PeopleField`, по умолчанию текущий пользователь, можно выбрать любого — маршрут резолвится на ВЫБРАННОГО (см. `handleRequesterChange`).
|
||||||
|
- Табличная часть «Приложения» (`Annexes`) — описание документа + номер приложения + сам файл, прикреплённый прямо к строке (`fileName`, хранится как встроенное вложение элемента списка — см. shared/attachments). Отдельного общего блока «Вложения» у формы нет: каждый файл привязан к своей строке.
|
||||||
|
- Табличная часть «Смета» (`Costs`) — список групп (разделов работ), внутри каждой свои позиции; название группы необязательно. Сумма по позиции, по группе и общий итог считаются на лету (`qty × rate`, см. `cost.ts`), не хранятся в структуре — правка количества/цены не может их рассинхронизировать.
|
||||||
|
- Под «Объём работ» (текстовое поле) — детализация по позициям в ТОМ ЖЕ формате, что и смета: группы/позиции/цена/сумма (`WorkVolumes`, см. `CostGroupsEditor.tsx` — общий редактор, переиспользуется и в CostsSection, и здесь). Дополняет текст итоговыми цифрами, не заменяет его.
|
||||||
|
- `Costs`/`WorkVolumes` сериализуются в JSON-колонки (`EstimateJson`/`WorkVolumesJson`) ОБЪЕКТОМ `{ groups, total }` — готовый итог кладётся прямо в JSON рядом с группами (см. `serializeGroups`/`parseGroupedJson` в `cost.ts`), а не отдельной числовой колонкой: он и так пересчитывается из groups при каждом сохранении, отдельная колонка была бы лишней схемой ради того же значения. Сама форма читает только `groups` — total из хранилища не доверяет, всегда считает заново (`costTotal`); `total` в JSON нужен тем, кто читает колонку напрямую (отчёты, sp-query), не поднимая форму.
|
||||||
|
- `Annexes` сериализуется в `AnnexesJson` просто массивом (без обёртки total — там считать нечего) — тот же приём, что в `TmcTransfer` (`ItemsJson`).
|
||||||
|
- Продолжительность работ под датами «начало/окончание» — производная величина (считается на лету), в SharePoint не пишется.
|
||||||
|
- Фазы исполнения нет: `saveServiceRequest` пишет маршрут с пустым списком исполнителей.
|
||||||
|
|
||||||
|
## Секции формы
|
||||||
|
HeaderSection, ScopeSection, AnnexesSection, CostsSection, RemarksSection
|
||||||
|
|
||||||
|
## Точки входа
|
||||||
|
- rptype: `wh-service-request`
|
||||||
|
- Режим согласующего (просмотр): есть (`ServiceRequestView`)
|
||||||
|
- «Создать на основе»: —
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-2;
|
||||||
|
margin-bottom: $sp-4;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: $navy-700;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color .18s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $gold-600;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||||
|
import styles from './ServiceRequestForm.module.scss';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
FormCard,
|
||||||
|
InitiatorLine,
|
||||||
|
FormModeProvider,
|
||||||
|
Icon,
|
||||||
|
RequestNumber,
|
||||||
|
SubmitActions,
|
||||||
|
useRequestSubmit
|
||||||
|
} from '../../shared/ui';
|
||||||
|
import {
|
||||||
|
ApprovalMatrix,
|
||||||
|
StatusBadge,
|
||||||
|
useMatrixEditor,
|
||||||
|
getPlannedMatrix,
|
||||||
|
notifyActiveStep,
|
||||||
|
useRequestTypeId,
|
||||||
|
REQUEST_STATUS,
|
||||||
|
isEditableStatus,
|
||||||
|
IApproverToWrite,
|
||||||
|
unassignedStepMessage,
|
||||||
|
resolveWriteListId
|
||||||
|
} from '../../shared/approval';
|
||||||
|
import {
|
||||||
|
uploadAttachments,
|
||||||
|
getAttachments,
|
||||||
|
deleteAttachment,
|
||||||
|
IAttachmentInfo
|
||||||
|
} from '../../shared/attachments/attachments.service';
|
||||||
|
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||||
|
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
|
||||||
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
|
import { IRequestFormProps } from '../../models/types';
|
||||||
|
import {
|
||||||
|
approvalListTitle,
|
||||||
|
approvalListTitleForListId,
|
||||||
|
dataListTitle,
|
||||||
|
dataListTitleForListId
|
||||||
|
} from '../../shared/listTitles';
|
||||||
|
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||||
|
import { hasAnyCostItem } from './cost';
|
||||||
|
import { saveServiceRequest, serviceRequestApprovalService } from './submit';
|
||||||
|
import { loadServiceRequest } from './load';
|
||||||
|
import { HeaderSection } from './sections/HeaderSection';
|
||||||
|
import { ScopeSection } from './sections/ScopeSection';
|
||||||
|
import { AnnexesSection } from './sections/AnnexesSection';
|
||||||
|
import { CostsSection } from './sections/CostsSection';
|
||||||
|
import { RemarksSection } from './sections/RemarksSection';
|
||||||
|
|
||||||
|
const ServiceRequestForm: React.FC<IRequestFormProps> = ({
|
||||||
|
requestType,
|
||||||
|
context,
|
||||||
|
onCancel,
|
||||||
|
onSaved,
|
||||||
|
editId,
|
||||||
|
editListId,
|
||||||
|
onCodeLoaded
|
||||||
|
}) => {
|
||||||
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
|
// Файлы, выбранные для строк «Приложения», ещё не загруженные на сервер (нужен
|
||||||
|
// id элемента — см. save()). Ключ — fileName (= выбранное имя строки), не индекс
|
||||||
|
// строки: индекс «плывёт» при удалении строк, а имя однозначно и переживает это.
|
||||||
|
const [annexFiles, setAnnexFiles] = useState<Record<string, File>>({});
|
||||||
|
const [existingAttachments, setExistingAttachments] = useState<IAttachmentInfo[]>([]);
|
||||||
|
const [requestId, setRequestId] = useState<number | undefined>(editId);
|
||||||
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
|
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||||
|
const submit = useRequestSubmit();
|
||||||
|
|
||||||
|
const readOnly = !isEditableStatus(requestStatus);
|
||||||
|
|
||||||
|
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||||
|
|
||||||
|
// Новая заявка — предзаполняем заявителя текущим пользователем и дату сегодняшней.
|
||||||
|
useEffect(() => {
|
||||||
|
if (editId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const user = context.pageContext.user;
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const requesterEmail = effectiveUserEmail(context);
|
||||||
|
setData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
// email — через effectiveUserEmail (учитывает отладочную подмену личности);
|
||||||
|
// displayName подменить нечем, справочник ФИО тут не читается — при
|
||||||
|
// подмене имя останется настоящим, для отладки это неважно.
|
||||||
|
RequesterEmail: requesterEmail,
|
||||||
|
RequesterName: user.displayName || '',
|
||||||
|
RequestDate: prev.RequestDate || today
|
||||||
|
}));
|
||||||
|
// Организации/должность заявителя (Employee/ByEmail) — как в TmcTransfer.
|
||||||
|
let alive = true;
|
||||||
|
getEmployeeOrganizations(context, requesterEmail)
|
||||||
|
.then((records) => {
|
||||||
|
if (alive) {
|
||||||
|
setData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
Organization: formatEmployeeOrganizations(records),
|
||||||
|
RequesterJobTitle: prev.RequesterJobTitle || records[0]?.position || ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [editId, context]);
|
||||||
|
|
||||||
|
// Выбор другого заявителя подтягивает его ФИО, должность и организации.
|
||||||
|
const handleRequesterChange = (login: string): void => {
|
||||||
|
setData((prev) => ({ ...prev, RequesterEmail: login }));
|
||||||
|
if (!login) {
|
||||||
|
setData((prev) => ({ ...prev, Organization: '' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
getEmployeeOrganizations(context, login)
|
||||||
|
.then((records) =>
|
||||||
|
setData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
RequesterName: records[0]?.fio || prev.RequesterName,
|
||||||
|
RequesterJobTitle: records[0]?.position || prev.RequesterJobTitle,
|
||||||
|
Organization: formatEmployeeOrganizations(records)
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
.catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Открыта СВОЯ заявка на редактирование — подгружаем содержимое, статус и вложения.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let alive = true;
|
||||||
|
Promise.all([loadServiceRequest(editId, editListId), getAttachments(editListId || dataListTitle(FORM_ID), editId)])
|
||||||
|
.then(([loaded, atts]) => {
|
||||||
|
if (alive) {
|
||||||
|
setData(loaded.data);
|
||||||
|
setRequestStatus(loaded.status);
|
||||||
|
setRequestCode(loaded.code || undefined);
|
||||||
|
if (loaded.code) onCodeLoaded?.(loaded.code);
|
||||||
|
setExistingAttachments(atts);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (alive) {
|
||||||
|
submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [editId, editListId]);
|
||||||
|
|
||||||
|
// editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
|
||||||
|
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
|
||||||
|
// serviceRequestApprovalService можно звать не опасаясь исключения.
|
||||||
|
const approval = useMemo(() => serviceRequestApprovalService(editListId), [editListId]);
|
||||||
|
// Маршрут резолвится по e-mail ВЫБРАННОГО заявителя (не обязательно текущего
|
||||||
|
// пользователя) — подача за другого, как в TmcTransfer.
|
||||||
|
const userEmail = data.RequesterEmail;
|
||||||
|
const requestTypeIdState = useRequestTypeId(context, requestType.id);
|
||||||
|
const requestTypeId = requestTypeIdState.id;
|
||||||
|
// Маршрут зависит от проекта (см. approval-route: согласующие — руководитель
|
||||||
|
// проекта, ПТО объекта) — пока проект не выбран, resolve вернёт 404.
|
||||||
|
const projectId = data.ProjectId || null;
|
||||||
|
const matrixReady = !!editId || (!!userEmail && !!projectId && !!requestTypeId);
|
||||||
|
const loadMatrix = useCallback(
|
||||||
|
() =>
|
||||||
|
editId
|
||||||
|
? approval.getMatrixForRequest(editId)
|
||||||
|
: getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, undefined, projectId),
|
||||||
|
[context, userEmail, approval, editId, projectId, requestTypeId]
|
||||||
|
);
|
||||||
|
const matrix = useMatrixEditor(loadMatrix, `${userEmail}-${editId ?? 'new'}-${projectId}`, !readOnly, matrixReady, (login) =>
|
||||||
|
approval.getJobTitle(login)
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasCosts = hasAnyCostItem(data.Costs);
|
||||||
|
const isValid =
|
||||||
|
!!data.RequesterEmail &&
|
||||||
|
data.ProjectName.trim().length > 0 &&
|
||||||
|
data.ContractKind.trim().length > 0 &&
|
||||||
|
data.ContractAction.trim().length > 0 &&
|
||||||
|
data.ActivityName.trim().length > 0 &&
|
||||||
|
data.ScopeOfWork.trim().length > 0 &&
|
||||||
|
hasCosts;
|
||||||
|
const validate = (): string | undefined => {
|
||||||
|
if (!isValid) {
|
||||||
|
return 'Заполните обязательные поля: заявитель, проект, предмет заявки, действие с договором, наименование и объём работ, хотя бы одну строку сметы.';
|
||||||
|
}
|
||||||
|
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
||||||
|
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
|
||||||
|
rows.map((r) => ({
|
||||||
|
email: r.email,
|
||||||
|
position: r.position,
|
||||||
|
allowReassign: r.allowReassign,
|
||||||
|
order: r.order
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Ссылки на скачивание для уже загруженных файлов строк «Приложения».
|
||||||
|
const attachmentUrls = useMemo(() => {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
existingAttachments.forEach((a) => {
|
||||||
|
map[a.fileName] = a.url;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [existingAttachments]);
|
||||||
|
|
||||||
|
// Выбрали файл для строки приложения — сохраняем в памяти до save() (нужен id
|
||||||
|
// элемента, см. saveServiceRequest). Если у строки уже был загруженный файл —
|
||||||
|
// сразу открепляем старый, иначе на элементе повиснет лишнее вложение.
|
||||||
|
const handlePickAnnexFile = (rowIndex: number, file: File): void => {
|
||||||
|
const prevFileName = data.Annexes[rowIndex]?.fileName;
|
||||||
|
if (prevFileName && requestId !== undefined && existingAttachments.some((a) => a.fileName === prevFileName)) {
|
||||||
|
deleteAttachment(editListId || dataListTitle(FORM_ID), requestId, prevFileName)
|
||||||
|
.then(() => setExistingAttachments((prev) => prev.filter((a) => a.fileName !== prevFileName)))
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
setData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
Annexes: prev.Annexes.map((row, i) => (i === rowIndex ? { ...row, fileName: file.name } : row))
|
||||||
|
}));
|
||||||
|
setAnnexFiles((prev) => ({ ...prev, [file.name]: file }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Открепить файл строки: если он уже на сервере — удаляем вложение сразу,
|
||||||
|
// иначе просто забываем ещё не загруженный выбор.
|
||||||
|
const handleRemoveAnnexFile = (rowIndex: number): void => {
|
||||||
|
const fileName = data.Annexes[rowIndex]?.fileName;
|
||||||
|
setData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
Annexes: prev.Annexes.map((row, i) => (i === rowIndex ? { ...row, fileName: '' } : row))
|
||||||
|
}));
|
||||||
|
if (!fileName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAnnexFiles((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[fileName];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
if (requestId !== undefined && existingAttachments.some((a) => a.fileName === fileName)) {
|
||||||
|
deleteAttachment(editListId || dataListTitle(FORM_ID), requestId, fileName)
|
||||||
|
.then(() => setExistingAttachments((prev) => prev.filter((a) => a.fileName !== fileName)))
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
|
||||||
|
// validate()/validateSubmit() уже гарантировали requestTypeId к моменту вызова
|
||||||
|
// (см. useRequestSubmit.run — валидирует ДО save), но TS этого через границу
|
||||||
|
// замыкания не видит — локальный const и явная проверка снимают both проблемы.
|
||||||
|
const rtId = requestTypeId;
|
||||||
|
if (!rtId) {
|
||||||
|
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
||||||
|
}
|
||||||
|
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
|
||||||
|
const { id, code } = await saveServiceRequest(requestId, rtId, data, toApprovers(matrix.rows), status, writeListId);
|
||||||
|
setRequestId(id);
|
||||||
|
setRequestStatus(status);
|
||||||
|
if (code) {
|
||||||
|
setRequestCode(code);
|
||||||
|
}
|
||||||
|
// Файлы строк «Приложения» — только те, чья строка ещё существует (строку
|
||||||
|
// могли удалить, не открепляя файл явно — см. AnnexesSection.removeRow) и
|
||||||
|
// чьё имя всё ещё совпадает (замену уже отправляет handlePickAnnexFile).
|
||||||
|
const annexFileNames = new Set(data.Annexes.map((r) => r.fileName).filter(Boolean));
|
||||||
|
const pendingAnnexFiles = Object.entries(annexFiles)
|
||||||
|
.filter(([fileName]) => annexFileNames.has(fileName))
|
||||||
|
.map(([, file]) => file);
|
||||||
|
if (pendingAnnexFiles.length > 0) {
|
||||||
|
await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, pendingAnnexFiles);
|
||||||
|
setAnnexFiles({});
|
||||||
|
setExistingAttachments(await getAttachments(writeListId || dataListTitle(FORM_ID), id));
|
||||||
|
}
|
||||||
|
if (onSaved) {
|
||||||
|
onSaved();
|
||||||
|
}
|
||||||
|
if (status === REQUEST_STATUS.onApproval) {
|
||||||
|
await notifyActiveStep(context, approval, id, {
|
||||||
|
requestTypeId: rtId,
|
||||||
|
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
||||||
|
approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateSubmit = (): string | undefined => {
|
||||||
|
const formError = validate();
|
||||||
|
if (formError) return formError;
|
||||||
|
if (matrix.error) {
|
||||||
|
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
|
||||||
|
}
|
||||||
|
const unassignedError = unassignedStepMessage(
|
||||||
|
matrix.rows,
|
||||||
|
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
||||||
|
);
|
||||||
|
if (unassignedError) return unassignedError;
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
||||||
|
const handleSubmit = (): Promise<void> => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit);
|
||||||
|
|
||||||
|
const sectionProps = {
|
||||||
|
data,
|
||||||
|
set,
|
||||||
|
context,
|
||||||
|
onRequesterChange: handleRequesterChange,
|
||||||
|
attachmentUrls,
|
||||||
|
onPickAnnexFile: handlePickAnnexFile,
|
||||||
|
onRemoveAnnexFile: handleRemoveAnnexFile
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button type="button" className={styles.back} onClick={onCancel}>
|
||||||
|
<Icon name="arrow-left" size="sm" />
|
||||||
|
<span>К списку заявок</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<FormCard
|
||||||
|
title={requestType.title}
|
||||||
|
subtitle="Категория: Склад / Снабжение"
|
||||||
|
belowSubtitle={
|
||||||
|
<InitiatorLine
|
||||||
|
listTitle={editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID)}
|
||||||
|
itemId={requestId}
|
||||||
|
fallback={{ name: data.RequesterName, email: data.RequesterEmail }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
headerRight={
|
||||||
|
requestId || requestStatus ? (
|
||||||
|
<>
|
||||||
|
{requestStatus && <StatusBadge status={requestStatus} />}
|
||||||
|
{requestCode && <RequestNumber code={requestCode} />}
|
||||||
|
</>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{submit.savedKind && !submit.error && (
|
||||||
|
<Alert variant="ok">
|
||||||
|
{submit.savedKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
||||||
|
|
||||||
|
<FormModeProvider readOnly={readOnly}>
|
||||||
|
<HeaderSection {...sectionProps} />
|
||||||
|
<ScopeSection {...sectionProps} />
|
||||||
|
<AnnexesSection {...sectionProps} />
|
||||||
|
<CostsSection {...sectionProps} />
|
||||||
|
<RemarksSection {...sectionProps} />
|
||||||
|
</FormModeProvider>
|
||||||
|
|
||||||
|
<ApprovalMatrix
|
||||||
|
editor={matrix}
|
||||||
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
waiting={!matrixReady}
|
||||||
|
waitingText="Укажите заявителя и проект, чтобы построить маршрут согласования."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SubmitActions
|
||||||
|
submit={submit}
|
||||||
|
isValid={isValid}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onSaveDraft={handleSaveDraft}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
</FormCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ServiceRequestForm;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.state {
|
||||||
|
padding: $sp-8;
|
||||||
|
color: $gray-500;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: $sp-3;
|
||||||
|
margin-bottom: $sp-4;
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import styles from './ServiceRequestView.module.scss';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
FormCard,
|
||||||
|
InitiatorLine,
|
||||||
|
FormModeProvider,
|
||||||
|
PrintSplitButton,
|
||||||
|
OrgLetterhead,
|
||||||
|
Icon
|
||||||
|
} from '../../shared/ui';
|
||||||
|
import { navigate } from '../../shared/routing';
|
||||||
|
import {
|
||||||
|
ApproverService,
|
||||||
|
DecisionPanel,
|
||||||
|
StatusBadge,
|
||||||
|
ApprovalMatrix,
|
||||||
|
useMatrixEditor,
|
||||||
|
notifyActiveStep,
|
||||||
|
useRequestTypeId,
|
||||||
|
isEditableStatus
|
||||||
|
} from '../../shared/approval';
|
||||||
|
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
||||||
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
|
import {
|
||||||
|
approvalListTitle,
|
||||||
|
approvalListTitleForListId,
|
||||||
|
dataListTitle,
|
||||||
|
dataListTitleForListId
|
||||||
|
} from '../../shared/listTitles';
|
||||||
|
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||||
|
import { loadServiceRequest } from './load';
|
||||||
|
import { serviceRequestApprovalService } from './submit';
|
||||||
|
import { HeaderSection } from './sections/HeaderSection';
|
||||||
|
import { ScopeSection } from './sections/ScopeSection';
|
||||||
|
import { AnnexesSection } from './sections/AnnexesSection';
|
||||||
|
import { CostsSection } from './sections/CostsSection';
|
||||||
|
import { RemarksSection } from './sections/RemarksSection';
|
||||||
|
|
||||||
|
interface IServiceRequestViewProps {
|
||||||
|
context: WebPartContext;
|
||||||
|
itemId: number;
|
||||||
|
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
|
||||||
|
formKey: string;
|
||||||
|
/** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */
|
||||||
|
listId?: string;
|
||||||
|
/** Уведомление о загруженном уникальном коде заявки — см. IApprovalViewProps.onCodeLoaded. */
|
||||||
|
onCodeLoaded?: (code: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заявка «Заявка на услуги» только для просмотра (режим согласующего).
|
||||||
|
const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
|
const [status, setStatus] = useState<string>('');
|
||||||
|
const [code, setCode] = useState<string>('');
|
||||||
|
const [attachments, setAttachments] = useState<IAttachmentInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
// Какая из организаций заявителя (см. OrganizationField) идёт в печать —
|
||||||
|
// выбор в тулбаре через PrintSplitButton, по умолчанию первая.
|
||||||
|
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
|
||||||
|
|
||||||
|
const { id: requestTypeId } = useRequestTypeId(context, formKey);
|
||||||
|
// В просмотре форма read-only, но секции ждут set — даём no-op.
|
||||||
|
const set: SetField = () => undefined;
|
||||||
|
|
||||||
|
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
|
||||||
|
// serviceRequestApprovalService можно звать не опасаясь исключения.
|
||||||
|
const approval = useMemo(() => serviceRequestApprovalService(listId), [listId]);
|
||||||
|
// Ref списка данных для ApproverService.updateRequestStatus: listId (url) для
|
||||||
|
// архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef).
|
||||||
|
const dataListRef = listId || dataListTitle(FORM_ID);
|
||||||
|
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
|
||||||
|
|
||||||
|
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||||
|
const loadApprovers = React.useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]);
|
||||||
|
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
|
||||||
|
approval.getJobTitle(l)
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
setLoading(true);
|
||||||
|
Promise.all([loadServiceRequest(itemId, listId), getAttachments(listId || dataListTitle(FORM_ID), itemId)])
|
||||||
|
.then(([loaded, files]) => {
|
||||||
|
if (alive) {
|
||||||
|
setData(loaded.data);
|
||||||
|
setStatus(loaded.status);
|
||||||
|
setCode(loaded.code);
|
||||||
|
onCodeLoaded?.(loaded.code);
|
||||||
|
setAttachments(files);
|
||||||
|
setError(null);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (alive) {
|
||||||
|
setError(`Не удалось загрузить заявку №${itemId}: ${friendlyErrorMessage(e)}`);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [itemId, listId]);
|
||||||
|
|
||||||
|
// Ссылки на скачивание для файлов строк «Приложения» (см. AnnexesSection).
|
||||||
|
// Считаем ДО ранних return'ов ниже: хук, вызванный только на части рендеров,
|
||||||
|
// ломает порядок хуков (React #310 — «rendered more hooks than during the
|
||||||
|
// previous render»), потому что на первом рендере (loading) его бы не было.
|
||||||
|
const attachmentUrls = useMemo(() => {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
attachments.forEach((a) => {
|
||||||
|
map[a.fileName] = a.url;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [attachments]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className={styles.state}>Загрузка заявки…</div>;
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className={styles.state}>
|
||||||
|
<Alert variant="err">{error}</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionProps = { data, set, context, printOrgIndex, attachmentUrls };
|
||||||
|
|
||||||
|
const notify = (): void => {
|
||||||
|
if (!requestTypeId) return;
|
||||||
|
notifyActiveStep(context, approval, itemId, {
|
||||||
|
requestTypeId,
|
||||||
|
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||||
|
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||||
|
}).catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={`${styles.toolbar} rp-no-print`}>
|
||||||
|
{isEditableStatus(status) && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => navigate({ mode: 'edit', nav: 'new', formKey, itemId, listId })}
|
||||||
|
>
|
||||||
|
<Icon name="edit" size="sm" />
|
||||||
|
Редактировать
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rp-print-root">
|
||||||
|
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
||||||
|
<FormCard
|
||||||
|
title="Заявка на услуги"
|
||||||
|
subtitle={`№${code || itemId}`}
|
||||||
|
belowSubtitle={
|
||||||
|
<InitiatorLine
|
||||||
|
listTitle={listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID)}
|
||||||
|
itemId={itemId}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||||
|
>
|
||||||
|
<FormModeProvider readOnly>
|
||||||
|
<HeaderSection {...sectionProps} />
|
||||||
|
<ScopeSection {...sectionProps} />
|
||||||
|
<AnnexesSection {...sectionProps} />
|
||||||
|
<CostsSection {...sectionProps} />
|
||||||
|
<RemarksSection {...sectionProps} />
|
||||||
|
</FormModeProvider>
|
||||||
|
</FormCard>
|
||||||
|
|
||||||
|
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rp-no-print">
|
||||||
|
<DecisionPanel
|
||||||
|
service={approverService}
|
||||||
|
requestId={itemId}
|
||||||
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
onDecided={(newStatus) => {
|
||||||
|
setStatus(newStatus);
|
||||||
|
setDecisionSeq((n) => n + 1);
|
||||||
|
notify();
|
||||||
|
}}
|
||||||
|
onDelegated={() => {
|
||||||
|
setDecisionSeq((n) => n + 1);
|
||||||
|
notify();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ServiceRequestView;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"dataListTitle": "Заявка на услуги — согласование",
|
||||||
|
"columns": [
|
||||||
|
{ "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" },
|
||||||
|
{ "internalName": "ApprovalStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "Ждёт очереди", "На согласовании", "На исполнении", "Согласовано", "Выполнено", "Отклонено", "Возвращена на доработку"] },
|
||||||
|
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Заявка на услуги — данные", "lookupShowField": "Title" },
|
||||||
|
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
|
||||||
|
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
||||||
|
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
|
||||||
|
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
|
||||||
|
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] },
|
||||||
|
{ "internalName": "SLAhours", "title": "SLA, часов", "type": "Number" },
|
||||||
|
{ "internalName": "AskOverdueReason", "title": "Опрос причины просрочки", "type": "Boolean" },
|
||||||
|
{ "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { ICostGroup, ICostRow } from './model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Число из поля ввода: пустое/нечисловое — 0. Пробелы убираем (их вставляет
|
||||||
|
* копипаста из Excel), запятую приводим к точке — в русской раскладке
|
||||||
|
* десятичный разделитель именно запятая.
|
||||||
|
*/
|
||||||
|
function toNumber(raw: string): number {
|
||||||
|
const normalized = raw.replace(/\s/g, '').replace(',', '.');
|
||||||
|
const value = Number(normalized);
|
||||||
|
return Number.isFinite(value) ? value : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сумма по строке сметы без НДС: количество × цена. */
|
||||||
|
export function rowAmount(row: ICostRow): number {
|
||||||
|
return toNumber(row.qty) * toNumber(row.rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сумма по группе сметы без НДС. */
|
||||||
|
export function groupAmount(group: ICostGroup): number {
|
||||||
|
return group.items.reduce((sum, row) => sum + rowAmount(row), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Итог по всей смете без НДС (сумма по всем группам). */
|
||||||
|
export function costTotal(groups: ICostGroup[]): number {
|
||||||
|
return groups.reduce((sum, group) => sum + groupAmount(group), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Есть хотя бы одна заполненная позиция хоть в одной группе — минимум для отправки. */
|
||||||
|
export function hasAnyCostItem(groups: ICostGroup[]): boolean {
|
||||||
|
return groups.some((group) => group.items.some((row) => row.description.trim().length > 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форма JSON-колонки для групп (EstimateJson / WorkVolumesJson): группы +
|
||||||
|
* готовый итог. Итог кладём РЯДОМ с группами в тот же JSON, а не отдельной
|
||||||
|
* числовой колонкой SharePoint — он и так пересчитывается из groups при
|
||||||
|
* каждом сохранении (см. serializeGroups), отдельная колонка была бы просто
|
||||||
|
* ещё одним местом, которое нужно провижинить и держать в курсе схемы.
|
||||||
|
*/
|
||||||
|
export interface IGroupedJson {
|
||||||
|
groups: ICostGroup[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Собрать JSON-колонку: группы как есть + пересчитанный итог. */
|
||||||
|
export function serializeGroups(groups: ICostGroup[]): IGroupedJson {
|
||||||
|
return { groups, total: costTotal(groups) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Разобрать JSON-колонку групп. Итог из хранилища НЕ используется формой —
|
||||||
|
* она всегда считает его заново из groups (см. costTotal); в разобранном
|
||||||
|
* объекте total пригождается только тем, кто читает эту колонку напрямую
|
||||||
|
* (отчёты, sp-query), не поднимая форму.
|
||||||
|
*/
|
||||||
|
export function parseGroupedJson(raw: unknown): ICostGroup[] {
|
||||||
|
if (typeof raw !== 'string' || raw.trim() === '') return [];
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
if (Array.isArray(parsed)) return parsed as ICostGroup[]; // старый формат (просто массив), до появления total
|
||||||
|
if (parsed && typeof parsed === 'object' && Array.isArray((parsed as IGroupedJson).groups)) {
|
||||||
|
return (parsed as IGroupedJson).groups;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сумма для показа: «1 234 567,89». Без символа валюты — валюта подписана в
|
||||||
|
* заголовке колонки (тенге), как в бумажной форме.
|
||||||
|
*/
|
||||||
|
export function formatAmount(value: number): string {
|
||||||
|
return new Intl.NumberFormat('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { getSP } from '../../shared/pnp/pnpjs';
|
||||||
|
import { dataListTitle } from '../../shared/listTitles';
|
||||||
|
import { parseGroupedJson } from './cost';
|
||||||
|
import { FORM_ID, IFormData, EMPTY_DATA, EMPTY_ANNEX_ROW, IAnnexRow } from './model';
|
||||||
|
|
||||||
|
export interface ILoadedRequest {
|
||||||
|
data: IFormData;
|
||||||
|
status: string;
|
||||||
|
/** Уникальный код заявки; '' — если запись создана до появления поля. */
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Колонки списка данных, из которых собирается модель формы. */
|
||||||
|
const SELECT_FIELDS = [
|
||||||
|
'Id',
|
||||||
|
'RequesterEmail',
|
||||||
|
'RequesterName',
|
||||||
|
'RequesterJobTitle',
|
||||||
|
'Organization',
|
||||||
|
'Department',
|
||||||
|
'DepartmentId',
|
||||||
|
'RequestDate',
|
||||||
|
'Revision',
|
||||||
|
'ProjectName',
|
||||||
|
'ProjectId',
|
||||||
|
'ProjectManager',
|
||||||
|
'Customer',
|
||||||
|
'ActivityId',
|
||||||
|
'ActivityName',
|
||||||
|
'ContractKind',
|
||||||
|
'ContractAction',
|
||||||
|
'ScopeOfWork',
|
||||||
|
'SuggestedSuppliers',
|
||||||
|
'EstimatedStartDate',
|
||||||
|
'EstimatedFinishDate',
|
||||||
|
'GeneralRemarks',
|
||||||
|
'AnnexesJson',
|
||||||
|
'WorkVolumesJson',
|
||||||
|
'EstimateJson',
|
||||||
|
'RequestStatus',
|
||||||
|
'RequestUniqueCode'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Безопасный разбор JSON-колонки табличной части: пустой массив при пустом
|
||||||
|
* значении или битом JSON — заявка должна открываться даже с испорченной
|
||||||
|
* таблицей, иначе её нельзя было бы починить через форму.
|
||||||
|
*/
|
||||||
|
function parseRows<T>(raw: unknown): T[] {
|
||||||
|
if (typeof raw !== 'string' || raw.trim() === '') return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? (parsed as T[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ISO-дата из SharePoint → YYYY-MM-DD для input type=date. */
|
||||||
|
function toDateInput(raw: unknown): string {
|
||||||
|
return typeof raw === 'string' ? raw.slice(0, 10) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Прочитать заявку «Заявка на услуги» по id: модель формы + статус + код.
|
||||||
|
*
|
||||||
|
* listId — партиция, где лежит ИМЕННО эта заявка (см. shared/approval/
|
||||||
|
* listPartitions.ts). Не задан — читаем из ТЕКУЩЕЙ партиции (старые ссылки
|
||||||
|
* без параметра).
|
||||||
|
*/
|
||||||
|
export async function loadServiceRequest(itemId: number, listId?: string): Promise<ILoadedRequest> {
|
||||||
|
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||||
|
const item = (await list.items.getById(itemId).select(...SELECT_FIELDS)()) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const data: IFormData = {
|
||||||
|
...EMPTY_DATA,
|
||||||
|
RequesterEmail: (item.RequesterEmail as string) || '',
|
||||||
|
RequesterName: (item.RequesterName as string) || '',
|
||||||
|
RequesterJobTitle: (item.RequesterJobTitle as string) || '',
|
||||||
|
Organization: (item.Organization as string) || '',
|
||||||
|
Department: (item.Department as string) || '',
|
||||||
|
DepartmentId: (item.DepartmentId as string) || '',
|
||||||
|
RequestDate: toDateInput(item.RequestDate),
|
||||||
|
Revision: (item.Revision as string) || '',
|
||||||
|
ProjectName: (item.ProjectName as string) || '',
|
||||||
|
ProjectId: (item.ProjectId as string) || '',
|
||||||
|
ProjectManager: (item.ProjectManager as string) || '',
|
||||||
|
Customer: (item.Customer as string) || '',
|
||||||
|
ActivityId: (item.ActivityId as string) || '',
|
||||||
|
ActivityName: (item.ActivityName as string) || '',
|
||||||
|
ContractKind: (item.ContractKind as string) || '',
|
||||||
|
ContractAction: (item.ContractAction as string) || '',
|
||||||
|
ScopeOfWork: (item.ScopeOfWork as string) || '',
|
||||||
|
WorkVolumes: parseGroupedJson(item.WorkVolumesJson),
|
||||||
|
SuggestedSuppliers: (item.SuggestedSuppliers as string) || '',
|
||||||
|
EstimatedStartDate: toDateInput(item.EstimatedStartDate),
|
||||||
|
EstimatedFinishDate: toDateInput(item.EstimatedFinishDate),
|
||||||
|
GeneralRemarks: (item.GeneralRemarks as string) || '',
|
||||||
|
// fileName — через spread с дефолтом: заявки, сохранённые до появления
|
||||||
|
// прикреплённых файлов у строки приложения, не имеют его в JSON вовсе.
|
||||||
|
Annexes: parseRows<IAnnexRow>(item.AnnexesJson).map((row) => ({ ...EMPTY_ANNEX_ROW, ...row })),
|
||||||
|
Costs: parseGroupedJson(item.EstimateJson)
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
status: (item.RequestStatus as string) || '',
|
||||||
|
code: (item.RequestUniqueCode as string) || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { ISelectOption } from '../../shared/ui';
|
||||||
|
import { RequestTypeId } from '../../models/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
|
||||||
|
* SharePoint выводятся из него, см. shared/listTitles.ts.
|
||||||
|
*/
|
||||||
|
export const FORM_ID: RequestTypeId = 'wh-service-request';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Строка таблицы приложений: документ, который заявитель прикладывает к
|
||||||
|
* заявке (чертежи, ТЗ, ведомости объёмов, перечень материалов…).
|
||||||
|
*/
|
||||||
|
export interface IAnnexRow {
|
||||||
|
/** Описание документа. */
|
||||||
|
description: string;
|
||||||
|
/** Номер приложения / шифр проекта (напр. «ASL-1812-2025-5-КЖ»). */
|
||||||
|
annexNo: string;
|
||||||
|
/**
|
||||||
|
* Имя реально прикреплённого файла — привязывает строку к одному из вложений
|
||||||
|
* элемента списка (см. shared/attachments). '' — файл ещё не выбран.
|
||||||
|
* Отдельного списка/библиотеки под это не заводим: вложения — встроенная
|
||||||
|
* возможность любого списка SharePoint, форма использует её напрямую, без
|
||||||
|
* отдельного общего блока «Вложения» — каждый файл привязан к своей строке.
|
||||||
|
*/
|
||||||
|
fileName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Строка сметы. Сумму по строке НЕ храним — она считается из qty × rate
|
||||||
|
* (см. cost.ts): иначе при правке количества сумма молча расходилась бы.
|
||||||
|
*/
|
||||||
|
export interface ICostRow {
|
||||||
|
/** Наименование позиции. */
|
||||||
|
description: string;
|
||||||
|
/** Единица измерения. */
|
||||||
|
unit: string;
|
||||||
|
/** Количество. */
|
||||||
|
qty: string;
|
||||||
|
/** Цена за единицу без НДС, тенге. */
|
||||||
|
rate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Группа сметы — раздел работ (напр. «Бетонная подготовка», «Монолитные
|
||||||
|
* фундаменты»), внутри которого свои позиции. У бумажной формы такого деления
|
||||||
|
* нет (там одна плоская таблица), но реальные сметы почти всегда идут по
|
||||||
|
* разделам — группировка нужна, чтобы длинная смета читалась. Название группы
|
||||||
|
* необязательно (можно оставить одну безымянную «Группа 1» на всю смету).
|
||||||
|
*/
|
||||||
|
export interface ICostGroup {
|
||||||
|
/** Название раздела сметы; '' — группа без названия. */
|
||||||
|
name: string;
|
||||||
|
items: ICostRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_ANNEX_ROW: IAnnexRow = { description: '', annexNo: '', fileName: '' };
|
||||||
|
/** Пустая строка сметы — количество/цена вводятся руками, без значений по умолчанию. */
|
||||||
|
export const EMPTY_COST_ROW: ICostRow = { description: '', unit: '', qty: '', rate: '' };
|
||||||
|
/** Новая группа сметы — сразу с одной пустой позицией, чтобы не жать «Добавить позицию» отдельно. */
|
||||||
|
export const EMPTY_COST_GROUP: ICostGroup = { name: '', items: [{ ...EMPTY_COST_ROW }] };
|
||||||
|
|
||||||
|
/** Предмет заявки (чем закрывается потребность). */
|
||||||
|
export const CONTRACT_KIND_OPTIONS: ISelectOption[] = [
|
||||||
|
{ value: 'Субподряд', label: 'Субподряд' },
|
||||||
|
{ value: 'Услуги', label: 'Услуги' },
|
||||||
|
{ value: 'Поставка', label: 'Поставка' },
|
||||||
|
{ value: 'Аренда', label: 'Аренда' },
|
||||||
|
{ value: 'Прочее', label: 'Прочее' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Что делаем с договором — в бумажной форме это ряд взаимоисключающих галочек. */
|
||||||
|
export const CONTRACT_ACTION_OPTIONS: ISelectOption[] = [
|
||||||
|
{ value: 'Новый договор', label: 'Новый договор' },
|
||||||
|
{ value: 'Доп. соглашение', label: 'Доп. соглашение' },
|
||||||
|
{ value: 'Возобновление договора', label: 'Возобновление договора' },
|
||||||
|
{ value: 'Пролонгация договора', label: 'Пролонгация договора' },
|
||||||
|
{ value: 'Расторжение договора', label: 'Расторжение договора' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Данные формы «Заявка на услуги» (бумажный аналог — Service Requisition Form
|
||||||
|
* ПТО). Ключи = internalName колонок SP-списка «Заявка на услуги — данные».
|
||||||
|
* Табличные части (Annexes / Costs) в колонки не разворачиваются, а
|
||||||
|
* сериализуются в JSON-колонки AnnexesJson / EstimateJson (см. submit.ts / load.ts).
|
||||||
|
*/
|
||||||
|
export interface IFormData {
|
||||||
|
// --- Заявитель ---
|
||||||
|
/** Email заявителя; по нему резолвится маршрут согласования (можно подать за другого). */
|
||||||
|
RequesterEmail: string;
|
||||||
|
/** Ф.И.О. заявителя — для печати; подтягивается из Employee/ByEmail. */
|
||||||
|
RequesterName: string;
|
||||||
|
/** Должность заявителя (из справочника, можно поправить — справочник не всегда точен). */
|
||||||
|
RequesterJobTitle: string;
|
||||||
|
/** Организации заявителя (`\n`-joined «Компания — должность»), read-only. */
|
||||||
|
Organization: string;
|
||||||
|
/** Отдел (справочник Dictionary/Departments) — на маршрут не влияет. */
|
||||||
|
Department: string;
|
||||||
|
/** Id отдела из справочника; '' — если введён вручную. */
|
||||||
|
DepartmentId: string;
|
||||||
|
/** Дата заявки (YYYY-MM-DD). */
|
||||||
|
RequestDate: string;
|
||||||
|
/** Ревизия заявки: бумажная форма живёт ревизиями (00, U01, U02…). */
|
||||||
|
Revision: string;
|
||||||
|
|
||||||
|
// --- Проект и работы ---
|
||||||
|
ProjectName: string;
|
||||||
|
/** oid проекта ('' — если введён вручную); по нему бэкенд резолвит руководителя проекта. */
|
||||||
|
ProjectId: string;
|
||||||
|
/** Снимок email руководителя проекта — для разбора маршрута постфактум. */
|
||||||
|
ProjectManager: string;
|
||||||
|
/** Заказчик проекта (подставляется из справочника проектов, можно поправить). */
|
||||||
|
Customer: string;
|
||||||
|
/** Идентификация работ / Activity ID (напр. «КОД СМР 2.3»). */
|
||||||
|
ActivityId: string;
|
||||||
|
/** Наименование работ. */
|
||||||
|
ActivityName: string;
|
||||||
|
/** Предмет заявки — см. CONTRACT_KIND_OPTIONS. */
|
||||||
|
ContractKind: string;
|
||||||
|
/** Действие с договором — см. CONTRACT_ACTION_OPTIONS. */
|
||||||
|
ContractAction: string;
|
||||||
|
|
||||||
|
// --- Описание требований ---
|
||||||
|
/** Объём работ — основное содержание заявки, длинный текст. */
|
||||||
|
ScopeOfWork: string;
|
||||||
|
/**
|
||||||
|
* Детализация объёмов работ по разделам (группам) — тот же формат
|
||||||
|
* группа/позиция/цена/сумма, что и у сметы (см. CostGroupsEditor,
|
||||||
|
* сериализуется в WorkVolumesJson). Дополняет текстовое описание
|
||||||
|
* ScopeOfWork итоговыми цифрами по каждой позиции, а не заменяет его.
|
||||||
|
*/
|
||||||
|
WorkVolumes: ICostGroup[];
|
||||||
|
/** Предлагаемые поставщики / требования к субподрядчику. */
|
||||||
|
SuggestedSuppliers: string;
|
||||||
|
EstimatedStartDate: string;
|
||||||
|
EstimatedFinishDate: string;
|
||||||
|
|
||||||
|
/** Общие замечания. */
|
||||||
|
GeneralRemarks: string;
|
||||||
|
|
||||||
|
// --- Табличные части ---
|
||||||
|
/** Приложения к заявке (сериализуются в AnnexesJson). */
|
||||||
|
Annexes: IAnnexRow[];
|
||||||
|
/** Сметная стоимость по группам (сериализуется в EstimateJson). */
|
||||||
|
Costs: ICostGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Обновление одного поля формы с сохранением типа значения. */
|
||||||
|
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
|
||||||
|
|
||||||
|
/** Пропсы каждой секции формы. */
|
||||||
|
export interface ISectionProps {
|
||||||
|
data: IFormData;
|
||||||
|
set: SetField;
|
||||||
|
context: WebPartContext;
|
||||||
|
/** Индекс организации для печати (см. shared/ui/PrintSplitButton и OrganizationField.printIndex). */
|
||||||
|
printOrgIndex?: number;
|
||||||
|
/**
|
||||||
|
* Выбор заявителя подтягивает его ФИО, должность и организации — поэтому у
|
||||||
|
* поля отдельный обработчик. Не передан (режим чтения) — правка через set.
|
||||||
|
*/
|
||||||
|
onRequesterChange?: (login: string) => void;
|
||||||
|
/**
|
||||||
|
* Только для AnnexesSection: ссылки на скачивание уже прикреплённых файлов
|
||||||
|
* (fileName → ServerRelativeUrl). Не передан — ссылки не показываются
|
||||||
|
* (напр. новая заявка до первого сохранения, там вложений ещё нет).
|
||||||
|
*/
|
||||||
|
attachmentUrls?: Record<string, string>;
|
||||||
|
/**
|
||||||
|
* Только для AnnexesSection: выбрать файл для строки приложения — форма
|
||||||
|
* держит его в памяти (см. ServiceRequestForm.annexFiles) и реально
|
||||||
|
* загружает при сохранении (нужен id элемента). Не передан — прикрепление
|
||||||
|
* недоступно (режим просмотра).
|
||||||
|
*/
|
||||||
|
onPickAnnexFile?: (rowIndex: number, file: File) => void;
|
||||||
|
/**
|
||||||
|
* Только для AnnexesSection: открепить файл строки. Если он уже загружен на
|
||||||
|
* сервер — форма сразу удаляет вложение элемента, иначе просто забывает
|
||||||
|
* выбор (ещё не сохранённый). Не передан — открепление недоступно.
|
||||||
|
*/
|
||||||
|
onRemoveAnnexFile?: (rowIndex: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ревизия по умолчанию — как в бумажной форме («Рев:00»). */
|
||||||
|
export const DEFAULT_REVISION = '00';
|
||||||
|
export const REQUESTER_PLACEHOLDER = 'Начните вводить Ф.И.О. заявителя…';
|
||||||
|
|
||||||
|
export const EMPTY_DATA: IFormData = {
|
||||||
|
RequesterEmail: '',
|
||||||
|
RequesterName: '',
|
||||||
|
RequesterJobTitle: '',
|
||||||
|
Organization: '',
|
||||||
|
Department: '',
|
||||||
|
DepartmentId: '',
|
||||||
|
RequestDate: '',
|
||||||
|
Revision: DEFAULT_REVISION,
|
||||||
|
ProjectName: '',
|
||||||
|
ProjectId: '',
|
||||||
|
ProjectManager: '',
|
||||||
|
Customer: '',
|
||||||
|
ActivityId: '',
|
||||||
|
ActivityName: '',
|
||||||
|
ContractKind: '',
|
||||||
|
ContractAction: '',
|
||||||
|
ScopeOfWork: '',
|
||||||
|
// Новая заявка сразу показывает одну пустую группу (не «пустое место с
|
||||||
|
// кнопкой») — так понятнее, что заполнять; загруженная заявка эти значения
|
||||||
|
// всё равно перезапишет (см. load.ts). Тот же приём — у Costs ниже.
|
||||||
|
WorkVolumes: [{ name: '', items: [{ ...EMPTY_COST_ROW }] }],
|
||||||
|
SuggestedSuppliers: '',
|
||||||
|
EstimatedStartDate: '',
|
||||||
|
EstimatedFinishDate: '',
|
||||||
|
GeneralRemarks: '',
|
||||||
|
Annexes: [],
|
||||||
|
Costs: [{ name: '', items: [{ ...EMPTY_COST_ROW }] }]
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"dataListTitle": "Заявка на услуги — данные",
|
||||||
|
"columns": [
|
||||||
|
{ "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" },
|
||||||
|
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
|
||||||
|
|
||||||
|
{ "internalName": "RequesterEmail", "title": "Заявитель (email)", "type": "Text" },
|
||||||
|
{ "internalName": "RequesterName", "title": "Заявитель (Ф.И.О.)", "type": "Text" },
|
||||||
|
{ "internalName": "RequesterJobTitle", "title": "Должность заявителя", "type": "Text" },
|
||||||
|
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
|
||||||
|
{ "internalName": "Department", "title": "Отдел", "type": "Text" },
|
||||||
|
{ "internalName": "DepartmentId", "title": "Id отдела (справочник)", "type": "Text" },
|
||||||
|
{ "internalName": "RequestDate", "title": "Дата заявки", "type": "DateTime", "dateOnly": true },
|
||||||
|
{ "internalName": "Revision", "title": "Ревизия", "type": "Text" },
|
||||||
|
|
||||||
|
{ "internalName": "ProjectName", "title": "Проект", "type": "Text" },
|
||||||
|
{ "internalName": "ProjectId", "title": "Id проекта", "type": "Text" },
|
||||||
|
{ "internalName": "ProjectManager", "title": "Руководитель проекта (email)", "type": "Text" },
|
||||||
|
{ "internalName": "Customer", "title": "Заказчик проекта", "type": "Text" },
|
||||||
|
{ "internalName": "ActivityId", "title": "Идентификация работ (Activity ID)", "type": "Text" },
|
||||||
|
{ "internalName": "ActivityName", "title": "Наименование работ", "type": "Note" },
|
||||||
|
{ "internalName": "ContractKind", "title": "Предмет заявки", "type": "Choice", "choices": ["Субподряд", "Услуги", "Поставка", "Аренда", "Прочее"] },
|
||||||
|
{ "internalName": "ContractAction", "title": "Действие с договором", "type": "Choice", "choices": ["Новый договор", "Доп. соглашение", "Возобновление договора", "Пролонгация договора", "Расторжение договора"] },
|
||||||
|
|
||||||
|
{ "internalName": "ScopeOfWork", "title": "Объём работ", "type": "Note" },
|
||||||
|
{ "internalName": "WorkVolumesJson", "title": "Объёмы работ по позициям (JSON, с итогом)", "type": "Note" },
|
||||||
|
{ "internalName": "SuggestedSuppliers", "title": "Предлагаемые поставщики", "type": "Note" },
|
||||||
|
{ "internalName": "EstimatedStartDate", "title": "Предполагаемая дата начала", "type": "DateTime", "dateOnly": true },
|
||||||
|
{ "internalName": "EstimatedFinishDate", "title": "Предполагаемая дата окончания", "type": "DateTime", "dateOnly": true },
|
||||||
|
{ "internalName": "GeneralRemarks", "title": "Общие замечания", "type": "Note" },
|
||||||
|
|
||||||
|
{ "internalName": "AnnexesJson", "title": "Приложения (JSON)", "type": "Note" },
|
||||||
|
{ "internalName": "EstimateJson", "title": "Сметная стоимость (JSON, с итогом)", "type": "Note" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
@import '../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Тело CollapseSection — грид 1fr 1fr; таблица должна занимать обе колонки.
|
||||||
|
.full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrap {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-900;
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
padding: 0;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
padding: 7px 9px;
|
||||||
|
background: $navy-50;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: $fs-caption;
|
||||||
|
color: $gray-700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
width: 36px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annexNo {
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file {
|
||||||
|
width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.act {
|
||||||
|
width: 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Скрытый нативный <input type=file> — открывается кликом по кнопке .attach.
|
||||||
|
.fileInput {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attach {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 5px 9px;
|
||||||
|
border: 1px dashed $gray-300;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: transparent;
|
||||||
|
color: $gray-700;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-caption;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: .18s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $gold-400;
|
||||||
|
color: $gold-600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileName {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-2;
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
a,
|
||||||
|
span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: $navy-700;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $gold-600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeFile {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 4px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: transparent;
|
||||||
|
color: $gray-500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: .18s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $red-50;
|
||||||
|
color: $red-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.noFile {
|
||||||
|
color: $gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 5px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: transparent;
|
||||||
|
color: $gray-500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: .18s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $red-50;
|
||||||
|
color: $red-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 14px !important;
|
||||||
|
text-align: center;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-2;
|
||||||
|
margin-top: $sp-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// См. CostGroupsEditor: на бумаге прокрутки нет, уехавшее вбок теряется.
|
||||||
|
@media print {
|
||||||
|
.tableWrap {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './AnnexesSection.module.scss';
|
||||||
|
import { Button, CollapseSection, Icon, TextInput, useFormReadOnly } from '../../../shared/ui';
|
||||||
|
import { EMPTY_ANNEX_ROW, IAnnexRow, ISectionProps } from '../model';
|
||||||
|
|
||||||
|
/** Файл строки: ссылка на скачивание, если уже загружен, иначе — прикрепить/открепить. */
|
||||||
|
const AnnexFileCell: React.FC<{
|
||||||
|
row: IAnnexRow;
|
||||||
|
readOnly: boolean;
|
||||||
|
url?: string;
|
||||||
|
onPick?: (file: File) => void;
|
||||||
|
onRemove?: () => void;
|
||||||
|
}> = ({ row, readOnly, url, onPick, onRemove }) => {
|
||||||
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
if (!row.fileName) {
|
||||||
|
if (readOnly || !onPick) {
|
||||||
|
return <span className={styles.noFile}>—</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button type="button" className={styles.attach} onClick={() => inputRef.current?.click()}>
|
||||||
|
<Icon name="upload" size="sm" />
|
||||||
|
Прикрепить
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
className={styles.fileInput}
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) onPick(file);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={styles.fileName}>
|
||||||
|
{url ? (
|
||||||
|
<a href={url} target="_blank" rel="noopener noreferrer" title={row.fileName}>
|
||||||
|
{row.fileName}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span title={row.fileName}>{row.fileName}</span>
|
||||||
|
)}
|
||||||
|
{!readOnly && onRemove && (
|
||||||
|
<button type="button" className={styles.removeFile} title="Открепить файл" onClick={onRemove}>
|
||||||
|
<Icon name="close" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Приложения к заявке (проектные чертежи, ТЗ, ведомости объёмов, перечень
|
||||||
|
* материалов…) — описание документа, номер приложения и сам файл. Файл
|
||||||
|
* физически хранится как вложение элемента списка (та же встроенная
|
||||||
|
* возможность SharePoint, что раньше использовал общий блок «Вложения» —
|
||||||
|
* его убрали, каждый файл теперь привязан к своей строке напрямую, отдельная
|
||||||
|
* библиотека под это не нужна).
|
||||||
|
*/
|
||||||
|
export const AnnexesSection: React.FC<ISectionProps> = ({
|
||||||
|
data,
|
||||||
|
set,
|
||||||
|
attachmentUrls,
|
||||||
|
onPickAnnexFile,
|
||||||
|
onRemoveAnnexFile
|
||||||
|
}) => {
|
||||||
|
const readOnly = useFormReadOnly();
|
||||||
|
|
||||||
|
const setRow = (index: number, patch: Partial<IAnnexRow>): void => {
|
||||||
|
set(
|
||||||
|
'Annexes',
|
||||||
|
data.Annexes.map((row, i) => (i === index ? { ...row, ...patch } : row))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addRow = (): void => set('Annexes', [...data.Annexes, { ...EMPTY_ANNEX_ROW }]);
|
||||||
|
const removeRow = (index: number): void => {
|
||||||
|
// Строка с файлом (уже загруженным или ещё нет) — сначала открепляем через
|
||||||
|
// родителя (см. ServiceRequestForm.handleRemoveAnnexFile), иначе загруженное
|
||||||
|
// вложение останется висеть на элементе без ссылающейся на него строки.
|
||||||
|
if (data.Annexes[index]?.fileName) {
|
||||||
|
onRemoveAnnexFile?.(index);
|
||||||
|
}
|
||||||
|
set('Annexes', data.Annexes.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapseSection title="Приложения" icon="document">
|
||||||
|
<div className={styles.full}>
|
||||||
|
<div className={styles.tableWrap}>
|
||||||
|
<table className={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className={styles.num}>#</th>
|
||||||
|
<th>Описание документа</th>
|
||||||
|
<th className={styles.annexNo}>Приложение №</th>
|
||||||
|
<th className={styles.file}>Файл</th>
|
||||||
|
{!readOnly && <th className={styles.act} aria-label="Действия" />}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.Annexes.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={readOnly ? 4 : 5} className={styles.empty}>
|
||||||
|
Приложения не добавлены
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
data.Annexes.map((row, i) =>
|
||||||
|
readOnly ? (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className={styles.num}>{i + 1}</td>
|
||||||
|
<td>{row.description}</td>
|
||||||
|
<td className={styles.annexNo}>{row.annexNo}</td>
|
||||||
|
<td className={styles.file}>
|
||||||
|
<AnnexFileCell row={row} readOnly url={attachmentUrls?.[row.fileName]} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className={styles.num}>{i + 1}</td>
|
||||||
|
<td>
|
||||||
|
<TextInput
|
||||||
|
value={row.description}
|
||||||
|
onChange={(v) => setRow(i, { description: v })}
|
||||||
|
ariaLabel="Описание документа"
|
||||||
|
borderless
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className={styles.annexNo}>
|
||||||
|
<TextInput
|
||||||
|
value={row.annexNo}
|
||||||
|
onChange={(v) => setRow(i, { annexNo: v })}
|
||||||
|
ariaLabel="Приложение №"
|
||||||
|
borderless
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className={styles.file}>
|
||||||
|
<AnnexFileCell
|
||||||
|
row={row}
|
||||||
|
readOnly={false}
|
||||||
|
url={attachmentUrls?.[row.fileName]}
|
||||||
|
onPick={onPickAnnexFile ? (file) => onPickAnnexFile(i, file) : undefined}
|
||||||
|
onRemove={onRemoveAnnexFile ? () => onRemoveAnnexFile(i) : undefined}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className={styles.act}>
|
||||||
|
<button type="button" className={styles.remove} title="Удалить строку" onClick={() => removeRow(i)}>
|
||||||
|
<Icon name="trash" size="sm" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!readOnly && (
|
||||||
|
<div className={styles.toolbar}>
|
||||||
|
<Button variant="secondary" size="sm" onClick={addRow}>
|
||||||
|
<Icon name="plus" size="sm" />
|
||||||
|
Добавить документ
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CollapseSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
@import '../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Тело CollapseSection — грид 1fr 1fr; смета должна занимать обе колонки.
|
||||||
|
.full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $sp-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-md;
|
||||||
|
background: $gray-50;
|
||||||
|
padding: $sp-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupHead {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-3;
|
||||||
|
margin-bottom: $sp-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupLabel {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupHead input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeGroup {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 6px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: transparent;
|
||||||
|
color: $gray-500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: .18s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $red-50;
|
||||||
|
color: $red-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Строка целиком в фикс-колонках (ед.изм/кол-во/цена/сумма) физически не
|
||||||
|
// влезает в узкую панель — форма открывается в Drawer сбоку, а не на всю
|
||||||
|
// страницу, и его ширина не связана с шириной окна браузера (в отличие от
|
||||||
|
// брейкпоинтов CollapseSection/FormGrid, которые следят за viewport, а не за
|
||||||
|
// шириной своего контейнера). Вместо того чтобы сжимать поля до
|
||||||
|
// нечитаемости, даём строке её естественную минимальную ширину и скроллим
|
||||||
|
// горизонтально контейнером — тот же приём, что раньше был у плоской таблицы
|
||||||
|
// (см. AnnexesSection .tableWrap), а не «резиновая» вёрстка, которая сама себя
|
||||||
|
// сжать до бесконечности не умеет (grid-колонки по умолчанию не сжимаются
|
||||||
|
// уже своего содержимого).
|
||||||
|
.rowsWrap {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(160px, 1fr) 84px 96px 128px 128px 30px;
|
||||||
|
gap: $sp-2;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 640px;
|
||||||
|
|
||||||
|
& + & {
|
||||||
|
margin-top: $sp-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.colHead {
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .03em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cellStatic {
|
||||||
|
padding: 9px 12px;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cellAmount {
|
||||||
|
padding: 9px 12px;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-500;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeItem {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 6px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: transparent;
|
||||||
|
color: $gray-500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: .18s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $red-50;
|
||||||
|
color: $red-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: $sp-3 0;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-500;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupFoot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $sp-3;
|
||||||
|
margin-top: $sp-3;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupTotal {
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total {
|
||||||
|
align-self: flex-end;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-body;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $navy-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Печать ----
|
||||||
|
// На бумаге прокрутки нет: «уехавшее вбок» просто потерялось бы, а сам
|
||||||
|
// контейнер печатается вместе с полосой прокрутки (видно на печати заявки).
|
||||||
|
// Отпускаем ширину — колонки строки суммарно уже A4, так что она укладывается
|
||||||
|
// в лист целиком.
|
||||||
|
//
|
||||||
|
// Блок ДОЛЖЕН стоять в конце файла: @media print не повышает специфичность, и
|
||||||
|
// базовое `.row { min-width }` выше перебило бы это правило, стой оно раньше.
|
||||||
|
@media print {
|
||||||
|
.rowsWrap {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './CostGroupsEditor.module.scss';
|
||||||
|
import { Button, Icon, TextInput, useFormReadOnly } from '../../../shared/ui';
|
||||||
|
import { costTotal, formatAmount, groupAmount, rowAmount } from '../cost';
|
||||||
|
import { EMPTY_COST_GROUP, EMPTY_COST_ROW, ICostGroup, ICostRow } from '../model';
|
||||||
|
|
||||||
|
interface IGroupProps {
|
||||||
|
group: ICostGroup;
|
||||||
|
index: number;
|
||||||
|
readOnly: boolean;
|
||||||
|
onChangeName: (name: string) => void;
|
||||||
|
onChangeItem: (itemIndex: number, patch: Partial<ICostRow>) => void;
|
||||||
|
onAddItem: () => void;
|
||||||
|
onRemoveItem: (itemIndex: number) => void;
|
||||||
|
onRemoveGroup: () => void;
|
||||||
|
canRemoveGroup: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Один раздел (группа): название + таблица позиций внутри неё. */
|
||||||
|
const CostGroup: React.FC<IGroupProps> = ({
|
||||||
|
group,
|
||||||
|
index,
|
||||||
|
readOnly,
|
||||||
|
onChangeName,
|
||||||
|
onChangeItem,
|
||||||
|
onAddItem,
|
||||||
|
onRemoveItem,
|
||||||
|
onRemoveGroup,
|
||||||
|
canRemoveGroup
|
||||||
|
}) => (
|
||||||
|
<div className={styles.group}>
|
||||||
|
<div className={styles.groupHead}>
|
||||||
|
<span className={styles.groupLabel}>Группа {index + 1}</span>
|
||||||
|
<TextInput
|
||||||
|
value={group.name}
|
||||||
|
onChange={onChangeName}
|
||||||
|
placeholder="Наименование группы"
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
{!readOnly && canRemoveGroup && (
|
||||||
|
<button type="button" className={styles.removeGroup} title="Удалить группу" onClick={onRemoveGroup}>
|
||||||
|
<Icon name="trash" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.rowsWrap}>
|
||||||
|
<div className={styles.row} role="row">
|
||||||
|
<span className={styles.colHead}>Наименование позиции</span>
|
||||||
|
<span className={styles.colHead}>Ед. изм.</span>
|
||||||
|
<span className={styles.colHead}>Количество</span>
|
||||||
|
<span className={styles.colHead}>Цена</span>
|
||||||
|
<span className={styles.colHead}>Сумма</span>
|
||||||
|
{!readOnly && <span className={styles.colHead} aria-hidden />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{group.items.length === 0 ? (
|
||||||
|
<div className={styles.empty}>Позиции не добавлены</div>
|
||||||
|
) : (
|
||||||
|
group.items.map((item, i) =>
|
||||||
|
readOnly ? (
|
||||||
|
<div className={styles.row} key={i} role="row">
|
||||||
|
<span className={styles.cellStatic}>{item.description || '—'}</span>
|
||||||
|
<span className={styles.cellStatic}>{item.unit || '—'}</span>
|
||||||
|
<span className={styles.cellStatic}>{item.qty || '—'}</span>
|
||||||
|
<span className={styles.cellStatic}>{item.rate || '—'}</span>
|
||||||
|
<span className={styles.cellAmount}>{formatAmount(rowAmount(item))}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.row} key={i} role="row">
|
||||||
|
<TextInput
|
||||||
|
value={item.description}
|
||||||
|
onChange={(v) => onChangeItem(i, { description: v })}
|
||||||
|
placeholder="Наименование"
|
||||||
|
ariaLabel="Наименование позиции"
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
value={item.unit}
|
||||||
|
onChange={(v) => onChangeItem(i, { unit: v })}
|
||||||
|
placeholder="шт."
|
||||||
|
ariaLabel="Единица измерения"
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
value={item.qty}
|
||||||
|
onChange={(v) => onChangeItem(i, { qty: v })}
|
||||||
|
placeholder="0"
|
||||||
|
ariaLabel="Количество"
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
value={item.rate}
|
||||||
|
onChange={(v) => onChangeItem(i, { rate: v })}
|
||||||
|
placeholder="0"
|
||||||
|
ariaLabel="Цена за единицу"
|
||||||
|
/>
|
||||||
|
<span className={styles.cellAmount}>{item.qty || item.rate ? formatAmount(rowAmount(item)) : 'авто'}</span>
|
||||||
|
<button type="button" className={styles.removeItem} title="Удалить позицию" onClick={() => onRemoveItem(i)}>
|
||||||
|
<Icon name="trash" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.groupFoot}>
|
||||||
|
{!readOnly && (
|
||||||
|
<Button variant="secondary" size="sm" onClick={onAddItem}>
|
||||||
|
<Icon name="plus" size="sm" />
|
||||||
|
Добавить позицию
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<span className={styles.groupTotal}>Сумма по группе: {formatAmount(groupAmount(group))}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ICostGroupsEditorProps {
|
||||||
|
groups: ICostGroup[];
|
||||||
|
onChange: (groups: ICostGroup[]) => void;
|
||||||
|
/** Подпись итоговой строки. По умолчанию — «Итого без НДС». */
|
||||||
|
totalLabel?: string;
|
||||||
|
/** Текст при пустом списке групп в режиме просмотра. */
|
||||||
|
emptyReadOnlyText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Редактор таблицы «группы → позиции с ценой и количеством»: используется и
|
||||||
|
* для сметной стоимости (CostsSection), и для детализации объёмов работ
|
||||||
|
* (ScopeSection) — один и тот же формат, разные поля модели формы. Сумма по
|
||||||
|
* позиции, по группе и общий итог — производные (qty × цена), считаются на
|
||||||
|
* лету и НЕ хранятся построчно: правка количества/цены не может их
|
||||||
|
* рассинхронизировать (см. cost.ts).
|
||||||
|
*/
|
||||||
|
export const CostGroupsEditor: React.FC<ICostGroupsEditorProps> = ({
|
||||||
|
groups,
|
||||||
|
onChange,
|
||||||
|
totalLabel = 'Итого без НДС',
|
||||||
|
emptyReadOnlyText = 'Не заполнено'
|
||||||
|
}) => {
|
||||||
|
const readOnly = useFormReadOnly();
|
||||||
|
|
||||||
|
const setGroup = (index: number, patch: Partial<ICostGroup>): void => {
|
||||||
|
onChange(groups.map((group, i) => (i === index ? { ...group, ...patch } : group)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setItem = (groupIndex: number, itemIndex: number, patch: Partial<ICostRow>): void => {
|
||||||
|
setGroup(groupIndex, {
|
||||||
|
items: groups[groupIndex].items.map((row, i) => (i === itemIndex ? { ...row, ...patch } : row))
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addItem = (groupIndex: number): void =>
|
||||||
|
setGroup(groupIndex, { items: [...groups[groupIndex].items, { ...EMPTY_COST_ROW }] });
|
||||||
|
|
||||||
|
const removeItem = (groupIndex: number, itemIndex: number): void =>
|
||||||
|
setGroup(groupIndex, { items: groups[groupIndex].items.filter((_, i) => i !== itemIndex) });
|
||||||
|
|
||||||
|
const addGroup = (): void => onChange([...groups, { ...EMPTY_COST_GROUP, items: [{ ...EMPTY_COST_ROW }] }]);
|
||||||
|
|
||||||
|
const removeGroup = (groupIndex: number): void => onChange(groups.filter((_, i) => i !== groupIndex));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.full}>
|
||||||
|
{groups.map((group, i) => (
|
||||||
|
<CostGroup
|
||||||
|
key={i}
|
||||||
|
group={group}
|
||||||
|
index={i}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onChangeName={(name) => setGroup(i, { name })}
|
||||||
|
onChangeItem={(itemIndex, patch) => setItem(i, itemIndex, patch)}
|
||||||
|
onAddItem={() => addItem(i)}
|
||||||
|
onRemoveItem={(itemIndex) => removeItem(i, itemIndex)}
|
||||||
|
onRemoveGroup={() => removeGroup(i)}
|
||||||
|
canRemoveGroup={groups.length > 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{groups.length === 0 && readOnly && <div className={styles.empty}>{emptyReadOnlyText}</div>}
|
||||||
|
|
||||||
|
{!readOnly && (
|
||||||
|
<Button variant="secondary" size="sm" onClick={addGroup}>
|
||||||
|
<Icon name="plus" size="sm" />
|
||||||
|
Добавить группу
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groups.length > 0 && (
|
||||||
|
<div className={styles.total}>
|
||||||
|
{totalLabel}: {formatAmount(costTotal(groups))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { CollapseSection } from '../../../shared/ui';
|
||||||
|
import { CostGroupsEditor } from './CostGroupsEditor';
|
||||||
|
import { ISectionProps } from '../model';
|
||||||
|
|
||||||
|
/** Сметная стоимость услуг-работ по разделам (группам) — см. CostGroupsEditor. */
|
||||||
|
export const CostsSection: React.FC<ISectionProps> = ({ data, set }) => (
|
||||||
|
<CollapseSection title="Сметная стоимость услуг-работ" icon="calculator">
|
||||||
|
<CostGroupsEditor
|
||||||
|
groups={data.Costs}
|
||||||
|
onChange={(groups) => set('Costs', groups)}
|
||||||
|
emptyReadOnlyText="Смета не заполнена"
|
||||||
|
/>
|
||||||
|
</CollapseSection>
|
||||||
|
);
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import {
|
||||||
|
FormGrid,
|
||||||
|
FormField,
|
||||||
|
TextInput,
|
||||||
|
TextArea,
|
||||||
|
Select,
|
||||||
|
PeopleField,
|
||||||
|
OrganizationField
|
||||||
|
} from '../../../shared/ui';
|
||||||
|
import { DepartmentPicker, ProjectPicker } from '../../../shared/dictionary';
|
||||||
|
import {
|
||||||
|
ISectionProps,
|
||||||
|
REQUESTER_PLACEHOLDER,
|
||||||
|
CONTRACT_KIND_OPTIONS,
|
||||||
|
CONTRACT_ACTION_OPTIONS
|
||||||
|
} from '../model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Шапка заявки: кто подаёт, по какому проекту и какие работы заказываем.
|
||||||
|
* Заказчик подставляется из справочника проектов, но остаётся редактируемым —
|
||||||
|
* в справочнике он не всегда совпадает со стороной договора.
|
||||||
|
*/
|
||||||
|
export const HeaderSection: React.FC<ISectionProps> = ({
|
||||||
|
data,
|
||||||
|
set,
|
||||||
|
context,
|
||||||
|
printOrgIndex,
|
||||||
|
onRequesterChange
|
||||||
|
}) => (
|
||||||
|
<FormGrid>
|
||||||
|
<FormField label="Заявитель" htmlFor="RequesterEmail" required full>
|
||||||
|
<PeopleField
|
||||||
|
context={context}
|
||||||
|
value={data.RequesterEmail}
|
||||||
|
onChange={onRequesterChange ?? ((v) => set('RequesterEmail', v))}
|
||||||
|
placeholder={REQUESTER_PLACEHOLDER}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<OrganizationField value={data.Organization} printIndex={printOrgIndex} />
|
||||||
|
|
||||||
|
<FormField label="Должность заявителя" htmlFor="RequesterJobTitle">
|
||||||
|
<TextInput
|
||||||
|
id="RequesterJobTitle"
|
||||||
|
value={data.RequesterJobTitle}
|
||||||
|
onChange={(v) => set('RequesterJobTitle', v)}
|
||||||
|
placeholder="Напр. вед. инженер ПТО"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Отдел" htmlFor="Department">
|
||||||
|
<DepartmentPicker
|
||||||
|
id="Department"
|
||||||
|
context={context}
|
||||||
|
value={data.Department}
|
||||||
|
onChange={(name, id) => {
|
||||||
|
set('Department', name);
|
||||||
|
set('DepartmentId', id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Дата заявки" htmlFor="RequestDate">
|
||||||
|
<TextInput
|
||||||
|
id="RequestDate"
|
||||||
|
type="date"
|
||||||
|
value={data.RequestDate}
|
||||||
|
onChange={(v) => set('RequestDate', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Ревизия" htmlFor="Revision">
|
||||||
|
<TextInput
|
||||||
|
id="Revision"
|
||||||
|
value={data.Revision}
|
||||||
|
onChange={(v) => set('Revision', v)}
|
||||||
|
placeholder="00"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Проект" htmlFor="ProjectName" required>
|
||||||
|
<ProjectPicker
|
||||||
|
id="ProjectName"
|
||||||
|
context={context}
|
||||||
|
value={data.ProjectName}
|
||||||
|
placeholder="Начните вводить название проекта…"
|
||||||
|
onChange={(name, id, project) => {
|
||||||
|
set('ProjectName', name);
|
||||||
|
set('ProjectId', id);
|
||||||
|
set('ProjectManager', project ? project.projectManager || '' : '');
|
||||||
|
if (project && project.customerName) {
|
||||||
|
set('Customer', project.customerName);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Заказчик проекта" htmlFor="Customer">
|
||||||
|
<TextInput id="Customer" value={data.Customer} onChange={(v) => set('Customer', v)} />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Идентификация работ (Activity ID)" htmlFor="ActivityId">
|
||||||
|
<TextInput
|
||||||
|
id="ActivityId"
|
||||||
|
value={data.ActivityId}
|
||||||
|
onChange={(v) => set('ActivityId', v)}
|
||||||
|
placeholder="Напр. КОД СМР 2.3"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Предмет заявки" htmlFor="ContractKind" required>
|
||||||
|
<Select
|
||||||
|
id="ContractKind"
|
||||||
|
value={data.ContractKind}
|
||||||
|
onChange={(v) => set('ContractKind', v)}
|
||||||
|
options={CONTRACT_KIND_OPTIONS}
|
||||||
|
placeholder="Выберите…"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Действие с договором" htmlFor="ContractAction" required>
|
||||||
|
<Select
|
||||||
|
id="ContractAction"
|
||||||
|
value={data.ContractAction}
|
||||||
|
onChange={(v) => set('ContractAction', v)}
|
||||||
|
options={CONTRACT_ACTION_OPTIONS}
|
||||||
|
placeholder="Выберите…"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Наименование работ" htmlFor="ActivityName" required full>
|
||||||
|
<TextArea
|
||||||
|
id="ActivityName"
|
||||||
|
value={data.ActivityName}
|
||||||
|
onChange={(v) => set('ActivityName', v)}
|
||||||
|
rows={2}
|
||||||
|
placeholder="Напр. строительно-монтажные работы по устройству монолитных конструкций (раздел КЖ)"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</FormGrid>
|
||||||
|
);
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { CollapseSection, FormField, TextArea } from '../../../shared/ui';
|
||||||
|
import { ISectionProps } from '../model';
|
||||||
|
|
||||||
|
/** Общие замечания — отдельный блок бумажной формы, не относится к объёму работ. */
|
||||||
|
export const RemarksSection: React.FC<ISectionProps> = ({ data, set }) => (
|
||||||
|
<CollapseSection title="Общие замечания" icon="info">
|
||||||
|
<FormField label="Общие замечания" htmlFor="GeneralRemarks" full>
|
||||||
|
<TextArea
|
||||||
|
id="GeneralRemarks"
|
||||||
|
value={data.GeneralRemarks}
|
||||||
|
onChange={(v) => set('GeneralRemarks', v)}
|
||||||
|
rows={4}
|
||||||
|
placeholder="Особые условия, ссылки на нормативы, порядок приёмки скрытых работ…"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</CollapseSection>
|
||||||
|
);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
@import '../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Продолжительность — производная от дат, а не поле ввода: показываем строкой
|
||||||
|
// под датами, на всю ширину сетки формы.
|
||||||
|
.duration {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-caption;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Блок детализации объёмов работ (CostGroupsEditor) — своя подпись сверху
|
||||||
|
// (не через FormField: там подпись сцеплена с htmlFor одного контрола, а
|
||||||
|
// здесь под ней целый редактор групп, не один инпут).
|
||||||
|
.volumesBlock {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volumesLabel {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: $sp-2;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-label;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './ScopeSection.module.scss';
|
||||||
|
import { CollapseSection, FormField, TextInput, TextArea } from '../../../shared/ui';
|
||||||
|
import { CostGroupsEditor } from './CostGroupsEditor';
|
||||||
|
import { ISectionProps } from '../model';
|
||||||
|
|
||||||
|
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Продолжительность в календарных днях по датам «с» и «по» (включительно, как
|
||||||
|
* считают сроки в графике работ). Не поле модели, а производная величина —
|
||||||
|
* поэтому считается на лету и в SharePoint не пишется.
|
||||||
|
*/
|
||||||
|
function durationDays(start: string, finish: string): number | undefined {
|
||||||
|
if (!start || !finish) return undefined;
|
||||||
|
const days = (Date.parse(finish) - Date.parse(start)) / MS_PER_DAY;
|
||||||
|
if (!Number.isFinite(days) || days < 0) return undefined;
|
||||||
|
return Math.round(days) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Описание требований: объём работ, поставщики, сроки. */
|
||||||
|
export const ScopeSection: React.FC<ISectionProps> = ({ data, set }) => {
|
||||||
|
const days = durationDays(data.EstimatedStartDate, data.EstimatedFinishDate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapseSection title="Описание требований" icon="helmet">
|
||||||
|
<FormField label="Объём работ" htmlFor="ScopeOfWork" required full>
|
||||||
|
<TextArea
|
||||||
|
id="ScopeOfWork"
|
||||||
|
value={data.ScopeOfWork}
|
||||||
|
onChange={(v) => set('ScopeOfWork', v)}
|
||||||
|
rows={12}
|
||||||
|
placeholder="Состав и объёмы работ, ссылки на рабочую документацию, требования к исполнительной документации…"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className={styles.volumesBlock}>
|
||||||
|
<span className={styles.volumesLabel}>Итого объёмы работ</span>
|
||||||
|
<CostGroupsEditor
|
||||||
|
groups={data.WorkVolumes}
|
||||||
|
onChange={(groups) => set('WorkVolumes', groups)}
|
||||||
|
totalLabel="Итого"
|
||||||
|
emptyReadOnlyText="Объёмы работ не заполнены"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField label="Предлагаемые поставщики" htmlFor="SuggestedSuppliers" full>
|
||||||
|
<TextArea
|
||||||
|
id="SuggestedSuppliers"
|
||||||
|
value={data.SuggestedSuppliers}
|
||||||
|
onChange={(v) => set('SuggestedSuppliers', v)}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Требования к подрядчику и/или конкретные организации из утверждённого перечня"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Предполагаемая дата начала" htmlFor="EstimatedStartDate">
|
||||||
|
<TextInput
|
||||||
|
id="EstimatedStartDate"
|
||||||
|
type="date"
|
||||||
|
value={data.EstimatedStartDate}
|
||||||
|
onChange={(v) => set('EstimatedStartDate', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Предполагаемая дата окончания" htmlFor="EstimatedFinishDate">
|
||||||
|
<TextInput
|
||||||
|
id="EstimatedFinishDate"
|
||||||
|
type="date"
|
||||||
|
value={data.EstimatedFinishDate}
|
||||||
|
onChange={(v) => set('EstimatedFinishDate', v)}
|
||||||
|
min={data.EstimatedStartDate || undefined}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
{days !== undefined && <div className={styles.duration}>Продолжительность: {days} кал. дн.</div>}
|
||||||
|
</CollapseSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
||||||
|
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
|
||||||
|
import { serializeGroups } from './cost';
|
||||||
|
import { FORM_ID, IFormData } from './model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
|
||||||
|
* approvalServiceFor (см. shared/approval/approvalServiceFactory.ts).
|
||||||
|
*/
|
||||||
|
export function serviceRequestApprovalService(listId?: string): ApprovalListService {
|
||||||
|
return approvalServiceFor(FORM_ID, listId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Осмысленный заголовок элемента (системная колонка Title). */
|
||||||
|
function buildTitle(data: IFormData): string {
|
||||||
|
const subject = data.ActivityName.trim() || data.ContractKind || 'услуги';
|
||||||
|
return ['Заявка на услуги', [subject, data.ProjectName].filter(Boolean).join(' — ')]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(': ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поля элемента списка данных из модели формы. Табличные части уходят в
|
||||||
|
* JSON-колонки; у групп (Costs/WorkVolumes) в тот же JSON кладётся и готовый
|
||||||
|
* итог — см. serializeGroups в cost.ts.
|
||||||
|
*/
|
||||||
|
function buildItem(data: IFormData, status: string): Record<string, unknown> {
|
||||||
|
const item: Record<string, unknown> = { Title: buildTitle(data), RequestStatus: status };
|
||||||
|
|
||||||
|
(Object.keys(data) as (keyof IFormData)[]).forEach((key) => {
|
||||||
|
if (key === 'Annexes' || key === 'Costs' || key === 'WorkVolumes') return; // ниже, отдельными колонками
|
||||||
|
const value = data[key];
|
||||||
|
item[key] = value === '' ? null : value;
|
||||||
|
});
|
||||||
|
|
||||||
|
item.AnnexesJson = data.Annexes.length > 0 ? JSON.stringify(data.Annexes) : null;
|
||||||
|
item.WorkVolumesJson = data.WorkVolumes.length > 0 ? JSON.stringify(serializeGroups(data.WorkVolumes)) : null;
|
||||||
|
item.EstimateJson = data.Costs.length > 0 ? JSON.stringify(serializeGroups(data.Costs)) : null;
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ISaveResult = ISaveItemResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохранить «Заявку на услуги»: контент + маршрут согласования.
|
||||||
|
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём
|
||||||
|
* новую. Возвращает Id заявки (+ уникальный код при создании).
|
||||||
|
*
|
||||||
|
* listId — партиция, в которую пишем: для обновления — партиция ЭТОЙ заявки,
|
||||||
|
* для новой — ТЕКУЩАЯ партиция, резолвленная вызывающей стороной
|
||||||
|
* (см. resolveWriteListId).
|
||||||
|
*/
|
||||||
|
export async function saveServiceRequest(
|
||||||
|
existingId: number | undefined,
|
||||||
|
requestTypeId: number,
|
||||||
|
data: IFormData,
|
||||||
|
approvers: IApproverToWrite[],
|
||||||
|
status: string,
|
||||||
|
listId?: string
|
||||||
|
): Promise<ISaveResult> {
|
||||||
|
const item = buildItem(data, status);
|
||||||
|
|
||||||
|
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
||||||
|
// Фазы исполнения у заявки на услуги нет — договор дальше ведут вне портала,
|
||||||
|
// поэтому исполнителей не пишем (пустой массив).
|
||||||
|
await writeRoute(serviceRequestApprovalService(listId), result.id, approvers, [], status);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ import { getApplicantProfile } from './services/profile.service';
|
|||||||
import { PLANNER_PLAN_ID, PLANNER_BUCKET_NAME } from './config';
|
import { PLANNER_PLAN_ID, PLANNER_BUCKET_NAME } from './config';
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
import { ContactSection } from './sections/ContactSection';
|
import { ContactSection } from './sections/ContactSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
/** Текст карточки Planner из данных заявки (соответствует старому buildPlannerNotes). */
|
/** Текст карточки Planner из данных заявки (соответствует старому buildPlannerNotes). */
|
||||||
function buildPlannerNotes(data: IFormData, code: string): string {
|
function buildPlannerNotes(data: IFormData, code: string): string {
|
||||||
@@ -86,6 +87,14 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||||
const submit = useRequestSubmit();
|
const submit = useRequestSubmit();
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
|
||||||
|
// стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при
|
||||||
|
// каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
|
// submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене
|
||||||
|
// saving/error), а setError стабилен — его и берём.
|
||||||
|
const { setError: setSubmitError } = submit;
|
||||||
|
|
||||||
const readOnly = !isEditableStatus(requestStatus);
|
const readOnly = !isEditableStatus(requestStatus);
|
||||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||||
@@ -102,20 +111,19 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setRequestStatus(loaded.status);
|
setRequestStatus(loaded.status);
|
||||||
setRequestCode(loaded.code || undefined);
|
setRequestCode(loaded.code || undefined);
|
||||||
if (loaded.code) onCodeLoaded?.(loaded.code);
|
if (loaded.code) notifyCode(loaded.code);
|
||||||
setExistingAttachments(atts);
|
setExistingAttachments(atts);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [editId, editListId, notifyCode, setSubmitError]);
|
||||||
}, [editId, editListId]);
|
|
||||||
|
|
||||||
// Предзаполнение контактов заявителя текущим пользователем — только для новой заявки.
|
// Предзаполнение контактов заявителя текущим пользователем — только для новой заявки.
|
||||||
// Телефон/отдел/должность в pageContext нет — берём из Graph (/me), с фолбэком.
|
// Телефон/отдел/должность в pageContext нет — берём из Graph (/me), с фолбэком.
|
||||||
@@ -140,8 +148,9 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// context — единственный экземпляр WebPartContext на всё время жизни
|
||||||
}, [editId]);
|
// веб-части, так что перезапуска эффекта эта зависимость не добавляет.
|
||||||
|
}, [editId, context]);
|
||||||
|
|
||||||
// editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
|
// editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
|
||||||
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
|
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { PlannerTicketService } from './services/plannerTicket.service';
|
|||||||
import { PLANNER_PLAN_ID, PLANNER_BUCKET_NAME, PLANNER_REJECTED_BUCKET_NAME } from './config';
|
import { PLANNER_PLAN_ID, PLANNER_BUCKET_NAME, PLANNER_REJECTED_BUCKET_NAME } from './config';
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
import { ContactSection } from './sections/ContactSection';
|
import { ContactSection } from './sections/ContactSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
interface ITicket1CViewProps {
|
interface ITicket1CViewProps {
|
||||||
context: WebPartContext;
|
context: WebPartContext;
|
||||||
@@ -83,6 +84,11 @@ function buildPlannerNotes(
|
|||||||
|
|
||||||
// Заявка в отдел 1С только для просмотра (режим согласующего).
|
// Заявка в отдел 1С только для просмотра (режим согласующего).
|
||||||
const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
|
||||||
|
// идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы
|
||||||
|
// при каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
@@ -118,7 +124,7 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
onCodeLoaded?.(loaded.code);
|
notifyCode(loaded.code);
|
||||||
setAttachments(files);
|
setAttachments(files);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -133,7 +139,7 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [itemId, listId]);
|
}, [itemId, listId, notifyCode]);
|
||||||
|
|
||||||
// Передать согласованную/отклонённую заявку в Planner. PlannerTaskId — защита
|
// Передать согласованную/отклонённую заявку в Planner. PlannerTaskId — защита
|
||||||
// от повторного создания карточки, если решение применяется из нескольких
|
// от повторного создания карточки, если решение применяется из нескольких
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import { saveTmcTransferRequest, tmcTransferApprovalService } from './submit';
|
|||||||
import { loadTmcTransferRequest } from './load';
|
import { loadTmcTransferRequest } from './load';
|
||||||
import { HeaderSection } from './sections/HeaderSection';
|
import { HeaderSection } from './sections/HeaderSection';
|
||||||
import { ItemsSection } from './sections/ItemsSection';
|
import { ItemsSection } from './sections/ItemsSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
const TmcTransferForm: React.FC<IRequestFormProps> = ({
|
const TmcTransferForm: React.FC<IRequestFormProps> = ({
|
||||||
requestType,
|
requestType,
|
||||||
@@ -67,6 +68,14 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
|
|||||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||||
const submit = useRequestSubmit();
|
const submit = useRequestSubmit();
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
|
||||||
|
// стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при
|
||||||
|
// каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
|
// submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене
|
||||||
|
// saving/error), а setError стабилен — его и берём.
|
||||||
|
const { setError: setSubmitError } = submit;
|
||||||
|
|
||||||
const readOnly = !isEditableStatus(requestStatus);
|
const readOnly = !isEditableStatus(requestStatus);
|
||||||
|
|
||||||
@@ -135,19 +144,19 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setRequestStatus(loaded.status);
|
setRequestStatus(loaded.status);
|
||||||
setRequestCode(loaded.code || undefined);
|
setRequestCode(loaded.code || undefined);
|
||||||
if (loaded.code) onCodeLoaded?.(loaded.code);
|
if (loaded.code) notifyCode(loaded.code);
|
||||||
setExistingAttachments(atts);
|
setExistingAttachments(atts);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [editId, editListId]);
|
}, [editId, editListId, notifyCode, setSubmitError]);
|
||||||
|
|
||||||
// editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
|
// editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
|
||||||
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
|
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { loadTmcTransferRequest } from './load';
|
|||||||
import { tmcTransferApprovalService } from './submit';
|
import { tmcTransferApprovalService } from './submit';
|
||||||
import { HeaderSection } from './sections/HeaderSection';
|
import { HeaderSection } from './sections/HeaderSection';
|
||||||
import { ItemsSection } from './sections/ItemsSection';
|
import { ItemsSection } from './sections/ItemsSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
interface ITmcTransferViewProps {
|
interface ITmcTransferViewProps {
|
||||||
context: WebPartContext;
|
context: WebPartContext;
|
||||||
@@ -54,6 +55,11 @@ interface ITmcTransferViewProps {
|
|||||||
|
|
||||||
// Заявка «СЗ на перемещение ТМЦ» только для просмотра (режим согласующего).
|
// Заявка «СЗ на перемещение ТМЦ» только для просмотра (режим согласующего).
|
||||||
const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
|
||||||
|
// идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы
|
||||||
|
// при каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
@@ -107,7 +113,7 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
onCodeLoaded?.(loaded.code);
|
notifyCode(loaded.code);
|
||||||
setAttachments(files);
|
setAttachments(files);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -122,7 +128,7 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [itemId, listId]);
|
}, [itemId, listId, notifyCode]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className={styles.state}>Загрузка заявки…</div>;
|
return <div className={styles.state}>Загрузка заявки…</div>;
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
|
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
|
||||||
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
|
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
|
||||||
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] },
|
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] },
|
||||||
|
{ "internalName": "SLAhours", "title": "SLA, часов", "type": "Number" },
|
||||||
|
{ "internalName": "AskOverdueReason", "title": "Опрос причины просрочки", "type": "Boolean" },
|
||||||
{ "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
|
{ "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField, MIN_NOTICE_DAYS } from './mod
|
|||||||
import { saveUnpaidLeaveRequest, unpaidLeaveApprovalService } from './submit';
|
import { saveUnpaidLeaveRequest, unpaidLeaveApprovalService } from './submit';
|
||||||
import { loadUnpaidLeaveRequest } from './load';
|
import { loadUnpaidLeaveRequest } from './load';
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
||||||
requestType,
|
requestType,
|
||||||
@@ -53,6 +54,14 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
|||||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||||
const submit = useRequestSubmit();
|
const submit = useRequestSubmit();
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
|
||||||
|
// стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при
|
||||||
|
// каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
|
// submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене
|
||||||
|
// saving/error), а setError стабилен — его и берём.
|
||||||
|
const { setError: setSubmitError } = submit;
|
||||||
|
|
||||||
const readOnly = !isEditableStatus(requestStatus);
|
const readOnly = !isEditableStatus(requestStatus);
|
||||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||||
@@ -78,19 +87,18 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setRequestStatus(loaded.status);
|
setRequestStatus(loaded.status);
|
||||||
setRequestCode(loaded.code || undefined);
|
setRequestCode(loaded.code || undefined);
|
||||||
if (loaded.code) onCodeLoaded?.(loaded.code);
|
if (loaded.code) notifyCode(loaded.code);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [editId, editListId, notifyCode, setSubmitError]);
|
||||||
}, [editId, editListId]);
|
|
||||||
|
|
||||||
// Новая заявка — по умолчанию на себя: подставляем текущего пользователя,
|
// Новая заявка — по умолчанию на себя: подставляем текущего пользователя,
|
||||||
// его должность и организации (Employee/ByEmail — см. shared/dictionary/
|
// его должность и организации (Employee/ByEmail — см. shared/dictionary/
|
||||||
@@ -116,8 +124,9 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// context — единственный экземпляр WebPartContext на всё время жизни
|
||||||
}, [editId]);
|
// веб-части, так что перезапуска эффекта эта зависимость не добавляет.
|
||||||
|
}, [editId, context]);
|
||||||
|
|
||||||
// Выбор другого сотрудника подтягивает его должность и организации.
|
// Выбор другого сотрудника подтягивает его должность и организации.
|
||||||
const handleEmployeeChange = (login: string): void => {
|
const handleEmployeeChange = (login: string): void => {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
|||||||
import { loadUnpaidLeaveRequest } from './load';
|
import { loadUnpaidLeaveRequest } from './load';
|
||||||
import { unpaidLeaveApprovalService } from './submit';
|
import { unpaidLeaveApprovalService } from './submit';
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
interface IUnpaidLeaveViewProps {
|
interface IUnpaidLeaveViewProps {
|
||||||
context: WebPartContext;
|
context: WebPartContext;
|
||||||
@@ -42,6 +43,11 @@ const NOOP_SET: SetField = () => undefined;
|
|||||||
// ту же секцию, что и форма подачи; редактирование выключено через
|
// ту же секцию, что и форма подачи; редактирование выключено через
|
||||||
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
|
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
|
||||||
const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
|
||||||
|
// идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы
|
||||||
|
// при каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
@@ -77,7 +83,7 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
onCodeLoaded?.(loaded.code);
|
notifyCode(loaded.code);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -91,7 +97,7 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [itemId, listId]);
|
}, [itemId, listId, notifyCode]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className={styles.state}>Загрузка заявки…</div>;
|
return <div className={styles.state}>Загрузка заявки…</div>;
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField, MIN_NOTICE_DAYS } from './mod
|
|||||||
import { saveVacationRequest, vacationApprovalService } from './submit';
|
import { saveVacationRequest, vacationApprovalService } from './submit';
|
||||||
import { loadVacationRequest } from './load';
|
import { loadVacationRequest } from './load';
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
const VacationForm: React.FC<IRequestFormProps> = ({
|
const VacationForm: React.FC<IRequestFormProps> = ({
|
||||||
requestType,
|
requestType,
|
||||||
@@ -53,6 +54,14 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||||
const submit = useRequestSubmit();
|
const submit = useRequestSubmit();
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
|
||||||
|
// стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при
|
||||||
|
// каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
|
// submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене
|
||||||
|
// saving/error), а setError стабилен — его и берём.
|
||||||
|
const { setError: setSubmitError } = submit;
|
||||||
|
|
||||||
const readOnly = !isEditableStatus(requestStatus);
|
const readOnly = !isEditableStatus(requestStatus);
|
||||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||||
@@ -78,19 +87,18 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setRequestStatus(loaded.status);
|
setRequestStatus(loaded.status);
|
||||||
setRequestCode(loaded.code || undefined);
|
setRequestCode(loaded.code || undefined);
|
||||||
if (loaded.code) onCodeLoaded?.(loaded.code);
|
if (loaded.code) notifyCode(loaded.code);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [editId, editListId, notifyCode, setSubmitError]);
|
||||||
}, [editId, editListId]);
|
|
||||||
|
|
||||||
// Новая заявка — по умолчанию на себя: подставляем текущего пользователя,
|
// Новая заявка — по умолчанию на себя: подставляем текущего пользователя,
|
||||||
// его должность и организации (Employee/ByEmail — см. shared/dictionary/
|
// его должность и организации (Employee/ByEmail — см. shared/dictionary/
|
||||||
@@ -116,8 +124,9 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// context — единственный экземпляр WebPartContext на всё время жизни
|
||||||
}, [editId]);
|
// веб-части, так что перезапуска эффекта эта зависимость не добавляет.
|
||||||
|
}, [editId, context]);
|
||||||
|
|
||||||
// Выбор другого сотрудника подтягивает его должность и организации (как в
|
// Выбор другого сотрудника подтягивает его должность и организации (как в
|
||||||
// исходной pto-tracker, но источник — Employee/ByEmail вместо SPS-JobTitle).
|
// исходной pto-tracker, но источник — Employee/ByEmail вместо SPS-JobTitle).
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
|||||||
import { loadVacationRequest } from './load';
|
import { loadVacationRequest } from './load';
|
||||||
import { vacationApprovalService } from './submit';
|
import { vacationApprovalService } from './submit';
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
interface IVacationViewProps {
|
interface IVacationViewProps {
|
||||||
context: WebPartContext;
|
context: WebPartContext;
|
||||||
@@ -42,6 +43,11 @@ const NOOP_SET: SetField = () => undefined;
|
|||||||
// же секцию, что и форма подачи; редактирование выключено через
|
// же секцию, что и форма подачи; редактирование выключено через
|
||||||
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
|
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
|
||||||
const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
|
||||||
|
// идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы
|
||||||
|
// при каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
@@ -77,7 +83,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
onCodeLoaded?.(loaded.code);
|
notifyCode(loaded.code);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -91,7 +97,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [itemId, listId]);
|
}, [itemId, listId, notifyCode]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className={styles.state}>Загрузка заявки…</div>;
|
return <div className={styles.state}>Загрузка заявки…</div>;
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import { loadPrefillFromHire } from './prefill';
|
|||||||
import { loadWorkplaceRequest } from './load';
|
import { loadWorkplaceRequest } from './load';
|
||||||
import { EmployeeSection } from './sections/EmployeeSection';
|
import { EmployeeSection } from './sections/EmployeeSection';
|
||||||
import { EquipmentSection } from './sections/EquipmentSection';
|
import { EquipmentSection } from './sections/EquipmentSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
||||||
requestType,
|
requestType,
|
||||||
@@ -71,6 +72,14 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
|||||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||||
const submit = useRequestSubmit();
|
const submit = useRequestSubmit();
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
|
||||||
|
// стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при
|
||||||
|
// каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
|
// submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене
|
||||||
|
// saving/error), а setError стабилен — его и берём.
|
||||||
|
const { setError: setSubmitError } = submit;
|
||||||
|
|
||||||
// Редактировать можно новую/черновик/возвращённую/отклонённую; иначе только чтение.
|
// Редактировать можно новую/черновик/возвращённую/отклонённую; иначе только чтение.
|
||||||
const readOnly = !isEditableStatus(requestStatus);
|
const readOnly = !isEditableStatus(requestStatus);
|
||||||
@@ -91,19 +100,19 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setRequestStatus(loaded.status);
|
setRequestStatus(loaded.status);
|
||||||
setRequestCode(loaded.code || undefined);
|
setRequestCode(loaded.code || undefined);
|
||||||
if (loaded.code) onCodeLoaded?.(loaded.code);
|
if (loaded.code) notifyCode(loaded.code);
|
||||||
setExistingAttachments(atts);
|
setExistingAttachments(atts);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [editId, editListId]);
|
}, [editId, editListId, notifyCode, setSubmitError]);
|
||||||
|
|
||||||
// Предзаполнение из «Заявки на прием», если форма открыта «на основе» (rpsource).
|
// Предзаполнение из «Заявки на прием», если форма открыта «на основе» (rpsource).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -122,13 +131,13 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
|||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
submit.setError(`Не удалось предзаполнить из заявки на прием №${sourceId}: ${(e as Error).message}`);
|
setSubmitError(`Не удалось предзаполнить из заявки на прием №${sourceId}: ${(e as Error).message}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [sourceId]);
|
}, [sourceId, setSubmitError]);
|
||||||
|
|
||||||
// editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
|
// editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
|
||||||
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
|
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
|
||||||
@@ -156,8 +165,9 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// context — единственный экземпляр WebPartContext на всё время жизни
|
||||||
}, [editId, userEmail]);
|
// веб-части, так что перезапуска эффекта эта зависимость не добавляет.
|
||||||
|
}, [editId, userEmail, context]);
|
||||||
|
|
||||||
// pageUrl формы (requestType.id совпадает с ним по конвенции портала, см.
|
// pageUrl формы (requestType.id совпадает с ним по конвенции портала, см.
|
||||||
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
|
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { loadWorkplaceRequest } from './load';
|
|||||||
import { workplaceApprovalService, updateReviewFields } from './submit';
|
import { workplaceApprovalService, updateReviewFields } from './submit';
|
||||||
import { EmployeeSection } from './sections/EmployeeSection';
|
import { EmployeeSection } from './sections/EmployeeSection';
|
||||||
import { EquipmentSection } from './sections/EquipmentSection';
|
import { EquipmentSection } from './sections/EquipmentSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
interface IWorkplaceSetupViewProps {
|
interface IWorkplaceSetupViewProps {
|
||||||
context: WebPartContext;
|
context: WebPartContext;
|
||||||
@@ -58,6 +59,11 @@ type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
|||||||
// Исключение: первый согласующий шаг (по маршруту — непосредственный руководитель)
|
// Исключение: первый согласующий шаг (по маршруту — непосредственный руководитель)
|
||||||
// на своём этапе может заполнить «Необходимое ПО» и «Доступы» (инициатор их не знает).
|
// на своём этапе может заполнить «Необходимое ПО» и «Доступы» (инициатор их не знает).
|
||||||
const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
|
||||||
|
// идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы
|
||||||
|
// при каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
@@ -122,7 +128,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
onCodeLoaded?.(loaded.code);
|
notifyCode(loaded.code);
|
||||||
setAttachments(files);
|
setAttachments(files);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -137,7 +143,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [itemId, listId]);
|
}, [itemId, listId, notifyCode]);
|
||||||
|
|
||||||
// Правка «ПО»/«Доступы» доступна, когда сейчас очередь ПЕРВОГО шага согласования
|
// Правка «ПО»/«Доступы» доступна, когда сейчас очередь ПЕРВОГО шага согласования
|
||||||
// (по маршруту — непосредственного руководителя) и это текущий пользователь.
|
// (по маршруту — непосредственного руководителя) и это текущий пользователь.
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ const formRegistry: Record<string, React.LazyExoticComponent<FormComponent>> = {
|
|||||||
'wh-tmc-transfer': React.lazy(
|
'wh-tmc-transfer': React.lazy(
|
||||||
() => import(/* webpackChunkName: 'form-tmc-transfer' */ './TmcTransfer/TmcTransferForm')
|
() => import(/* webpackChunkName: 'form-tmc-transfer' */ './TmcTransfer/TmcTransferForm')
|
||||||
),
|
),
|
||||||
|
'wh-service-request': React.lazy(
|
||||||
|
() => import(/* webpackChunkName: 'form-service-request' */ './ServiceRequest/ServiceRequestForm')
|
||||||
|
),
|
||||||
'clearance-form': React.lazy(
|
'clearance-form': React.lazy(
|
||||||
() => import(/* webpackChunkName: 'form-clearance' */ './Clearance/ClearanceForm')
|
() => import(/* webpackChunkName: 'form-clearance' */ './Clearance/ClearanceForm')
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ const viewRegistry: Record<string, React.LazyExoticComponent<ViewComponent>> = {
|
|||||||
'wh-tmc-transfer': React.lazy(
|
'wh-tmc-transfer': React.lazy(
|
||||||
() => import(/* webpackChunkName: 'view-tmc-transfer' */ './TmcTransfer/TmcTransferView')
|
() => import(/* webpackChunkName: 'view-tmc-transfer' */ './TmcTransfer/TmcTransferView')
|
||||||
),
|
),
|
||||||
|
'wh-service-request': React.lazy(
|
||||||
|
() => import(/* webpackChunkName: 'view-service-request' */ './ServiceRequest/ServiceRequestView')
|
||||||
|
),
|
||||||
'clearance-form': React.lazy(
|
'clearance-form': React.lazy(
|
||||||
() => import(/* webpackChunkName: 'view-clearance' */ './Clearance/ClearanceView')
|
() => import(/* webpackChunkName: 'view-clearance' */ './Clearance/ClearanceView')
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import { resolveDebugUser } from '../shared/impersonation';
|
import { resolveDebugUser } from '../shared/impersonation';
|
||||||
|
import { useStableCallback } from '../shared/useStableCallback';
|
||||||
|
|
||||||
export interface IDebugUserFieldProps {
|
export interface IDebugUserFieldProps {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -22,6 +23,10 @@ const DEBOUNCE_MS = 400;
|
|||||||
* чтобы не гадать, кого именно подставили.
|
* чтобы не гадать, кого именно подставили.
|
||||||
*/
|
*/
|
||||||
export const DebugUserField: React.FC<IDebugUserFieldProps> = ({ label, description, context, initialQuery, onResolved }) => {
|
export const DebugUserField: React.FC<IDebugUserFieldProps> = ({ label, description, context, initialQuery, onResolved }) => {
|
||||||
|
// onResolved приходит из панели свойств и пересоздаётся на каждом её рендере:
|
||||||
|
// в зависимостях эффекта он перезапускал бы поиск, без него — эффект звал бы
|
||||||
|
// навсегда запомненную первую версию колбэка. Стабильная обёртка снимает оба.
|
||||||
|
const notifyResolved = useStableCallback(onResolved);
|
||||||
const [query, setQuery] = React.useState(initialQuery || '');
|
const [query, setQuery] = React.useState(initialQuery || '');
|
||||||
const [status, setStatus] = React.useState<Status>('idle');
|
const [status, setStatus] = React.useState<Status>('idle');
|
||||||
const [found, setFound] = React.useState<{ email: string; displayName: string } | undefined>(undefined);
|
const [found, setFound] = React.useState<{ email: string; displayName: string } | undefined>(undefined);
|
||||||
@@ -32,7 +37,7 @@ export const DebugUserField: React.FC<IDebugUserFieldProps> = ({ label, descript
|
|||||||
if (!text) {
|
if (!text) {
|
||||||
setStatus('idle');
|
setStatus('idle');
|
||||||
setFound(undefined);
|
setFound(undefined);
|
||||||
onResolved('', '', '');
|
notifyResolved('', '', '');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,25 +54,25 @@ export const DebugUserField: React.FC<IDebugUserFieldProps> = ({ label, descript
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (isEmail) {
|
if (isEmail) {
|
||||||
setFound(hit);
|
setFound(hit);
|
||||||
onResolved(text, text, hit ? hit.displayName : '');
|
notifyResolved(text, text, hit ? hit.displayName : '');
|
||||||
} else if (hit) {
|
} else if (hit) {
|
||||||
setStatus('found');
|
setStatus('found');
|
||||||
setFound(hit);
|
setFound(hit);
|
||||||
onResolved(text, hit.email, hit.displayName);
|
notifyResolved(text, hit.email, hit.displayName);
|
||||||
} else {
|
} else {
|
||||||
setStatus('notfound');
|
setStatus('notfound');
|
||||||
setFound(undefined);
|
setFound(undefined);
|
||||||
onResolved(text, '', '');
|
notifyResolved(text, '', '');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (isEmail) {
|
if (isEmail) {
|
||||||
onResolved(text, text, '');
|
notifyResolved(text, text, '');
|
||||||
} else {
|
} else {
|
||||||
setStatus('notfound');
|
setStatus('notfound');
|
||||||
setFound(undefined);
|
setFound(undefined);
|
||||||
onResolved(text, '', '');
|
notifyResolved(text, '', '');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, DEBOUNCE_MS);
|
}, DEBOUNCE_MS);
|
||||||
@@ -76,8 +81,7 @@ export const DebugUserField: React.FC<IDebugUserFieldProps> = ({ label, descript
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.clearTimeout(timer);
|
window.clearTimeout(timer);
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [query, context, notifyResolved]);
|
||||||
}, [query]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -128,6 +128,7 @@
|
|||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.matrix {
|
.matrix {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 480px; // колонки не сжимаются; при нехватке места .matrixScroll прокручивает
|
min-width: 480px; // колонки не сжимаются; при нехватке места .matrixScroll прокручивает
|
||||||
@@ -355,3 +356,16 @@
|
|||||||
padding-top: $sp-2 !important;
|
padding-top: $sp-2 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Печать ----
|
||||||
|
// Прокрутка на бумаге недоступна: неуместившееся вбок просто не попало бы на
|
||||||
|
// лист. Обе оси отпускаем — overflow-y здесь стоит только ради анимации
|
||||||
|
// перестановки строк (см. .matrixScroll), а в печати её нет.
|
||||||
|
//
|
||||||
|
// В конце файла намеренно: @media print не повышает специфичность, и любое
|
||||||
|
// базовое правило ниже перебило бы это (см. CostGroupsEditor.module.scss).
|
||||||
|
@media print {
|
||||||
|
.matrixScroll {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { friendlyErrorMessage } from '../diagnostics';
|
||||||
import { IMatrixRow } from './models/IMatrixRow';
|
import { IMatrixRow } from './models/IMatrixRow';
|
||||||
import { ISiteUser } from './models/ISiteUser';
|
import { ISiteUser } from './models/ISiteUser';
|
||||||
|
|
||||||
@@ -175,13 +176,22 @@ export function useMatrixEditor(
|
|||||||
})
|
})
|
||||||
.catch((err: Error) => {
|
.catch((err: Error) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setError(err.message);
|
// Через friendlyErrorMessage, а не err.message: PnPjs кладёт в message
|
||||||
|
// тело ответа SharePoint целиком, и кириллица в нём остаётся
|
||||||
|
// \u-escape-последовательностями — пользователь видел «Поле или
|
||||||
|
// свойство…» набором «Поле».
|
||||||
|
setError(friendlyErrorMessage(err));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
// load — стабильная ссылка от вызывающего (useCallback); перезагрузка по reloadKey/ready/retrySeq.
|
// load в зависимостях СОЗНАТЕЛЬНО нет: когда матрицу перечитывать —
|
||||||
|
// решает вызывающий через reloadKey (у него это, например, «выбранный
|
||||||
|
// сотрудник + id заявки»), а сам load пересоздаётся и от тех данных,
|
||||||
|
// которые перезагрузки не требуют. С load в массиве маршрут дёргался бы
|
||||||
|
// на каждое такое изменение. Устаревшего замыкания при этом нет: любой
|
||||||
|
// повторный запуск (reloadKey/ready/retrySeq) берёт свежий load.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [reloadKey, ready, retrySeq]);
|
}, [reloadKey, ready, retrySeq]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { friendlyErrorMessage } from '../diagnostics';
|
||||||
import { getRequestTypeIdByPageUrl } from './approvalRoute.service';
|
import { getRequestTypeIdByPageUrl } from './approvalRoute.service';
|
||||||
|
|
||||||
export interface IRequestTypeIdState {
|
export interface IRequestTypeIdState {
|
||||||
@@ -33,7 +34,9 @@ export function useRequestTypeId(context: WebPartContext, pageUrl: string): IReq
|
|||||||
})
|
})
|
||||||
.catch((e: Error) => {
|
.catch((e: Error) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
setError(e.message);
|
// См. useMatrixEditor: сырой message от PnPjs/SharePoint показывает
|
||||||
|
// кириллицу \u-escape-последовательностями.
|
||||||
|
setError(friendlyErrorMessage(e));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ const LIST_TITLE_BASE: Record<RequestTypeId, string> = {
|
|||||||
'recruiting-hire': 'Прием на работу',
|
'recruiting-hire': 'Прием на работу',
|
||||||
'recruiting-referral': 'Реферальная рекомендация',
|
'recruiting-referral': 'Реферальная рекомендация',
|
||||||
'recruiting-selection': 'Заявка на подбор',
|
'recruiting-selection': 'Заявка на подбор',
|
||||||
'wh-tmc-transfer': 'СЗ на перемещение ТМЦ'
|
'wh-tmc-transfer': 'СЗ на перемещение ТМЦ',
|
||||||
|
'wh-service-request': 'Заявка на услуги'
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.table {
|
.table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@@ -164,3 +165,11 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: $sp-2;
|
gap: $sp-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// На печати прокрутки не существует: то, что не поместилось по ширине, просто
|
||||||
|
// пропало бы с листа, а контейнер вышел бы с полосой прокрутки.
|
||||||
|
@media print {
|
||||||
|
.tableWrap {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
// Общая машина сохранения заявки для ВСЕХ форм портала.
|
// Общая машина сохранения заявки для ВСЕХ форм портала.
|
||||||
// Отвечает ТОЛЬКО за транзиентное состояние операции (идёт сохранение / ошибка /
|
// Отвечает ТОЛЬКО за транзиентное состояние операции (идёт сохранение / ошибка /
|
||||||
@@ -15,6 +15,7 @@ export interface IRequestSubmit {
|
|||||||
/**
|
/**
|
||||||
* Запустить сохранение. validate (опц.) возвращает текст ошибки или undefined.
|
* Запустить сохранение. validate (опц.) возвращает текст ошибки или undefined.
|
||||||
* Повторный вызов во время сохранения игнорируется (защита от дабл-клика).
|
* Повторный вызов во время сохранения игнорируется (защита от дабл-клика).
|
||||||
|
* Ссылка на функцию неизменна — её можно класть в зависимости хуков.
|
||||||
*/
|
*/
|
||||||
run: (
|
run: (
|
||||||
kind: SubmitKind,
|
kind: SubmitKind,
|
||||||
@@ -29,13 +30,17 @@ export function useRequestSubmit(): IRequestSubmit {
|
|||||||
const [kind, setKind] = useState<SubmitKind | undefined>(undefined);
|
const [kind, setKind] = useState<SubmitKind | undefined>(undefined);
|
||||||
const [savedKind, setSavedKind] = useState<SubmitKind | undefined>(undefined);
|
const [savedKind, setSavedKind] = useState<SubmitKind | undefined>(undefined);
|
||||||
const [error, setError] = useState<string | undefined>(undefined);
|
const [error, setError] = useState<string | undefined>(undefined);
|
||||||
|
// Тот же признак «идёт сохранение», но читаемый синхронно. Нужен, чтобы run
|
||||||
|
// не зависела от состояния и сохраняла неизменную ссылку: формы кладут её
|
||||||
|
// (и весь submit) в зависимости эффектов.
|
||||||
|
const savingRef = useRef<boolean>(false);
|
||||||
|
|
||||||
const run = async (
|
const run = useCallback(async (
|
||||||
k: SubmitKind,
|
k: SubmitKind,
|
||||||
save: () => Promise<void>,
|
save: () => Promise<void>,
|
||||||
validate?: () => string | undefined
|
validate?: () => string | undefined
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
if (saving) {
|
if (savingRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setKind(k);
|
setKind(k);
|
||||||
@@ -45,6 +50,7 @@ export function useRequestSubmit(): IRequestSubmit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
|
savingRef.current = true;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await save();
|
await save();
|
||||||
@@ -52,9 +58,20 @@ export function useRequestSubmit(): IRequestSubmit {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(`Ошибка сохранения: ${(e as Error).message}`);
|
setError(`Ошибка сохранения: ${(e as Error).message}`);
|
||||||
} finally {
|
} finally {
|
||||||
|
// require-atomic-updates ловит присваивание после await, но здесь это не
|
||||||
|
// гонка: savingRef — не производное состояние, а флаг «занято» с одним
|
||||||
|
// владельцем, и второй вызов run до этой строки просто не проходит.
|
||||||
|
// eslint-disable-next-line require-atomic-updates
|
||||||
|
savingRef.current = false;
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
return { saving, kind, savedKind, error, run, setError };
|
// Объект пересоздаётся только при РЕАЛЬНОЙ смене состояния операции, а не на
|
||||||
|
// каждом рендере формы: run и setError стабильны, поэтому эффекты, которым
|
||||||
|
// нужен только setError, не перезапускаются вовсе.
|
||||||
|
return useMemo(
|
||||||
|
() => ({ saving, kind, savedKind, error, run, setError }),
|
||||||
|
[saving, kind, savedKind, error, run]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { useStableCallback } from './useStableCallback';
|
||||||
|
|
||||||
// Задержка перед тем, как введённый текст уйдёт в запрос. Без неё поиск
|
// Задержка перед тем, как введённый текст уйдёт в запрос. Без неё поиск
|
||||||
// срабатывает на КАЖДОЕ нажатие клавиши: «согласование» — это 12 полных циклов
|
// срабатывает на КАЖДОЕ нажатие клавиши: «согласование» — это 12 полных циклов
|
||||||
@@ -57,15 +58,17 @@ export function useRoutedQuery(
|
|||||||
const [input, setInput] = React.useState<string>(routeQuery);
|
const [input, setInput] = React.useState<string>(routeQuery);
|
||||||
const debounced = useDebouncedQuery(input);
|
const debounced = useDebouncedQuery(input);
|
||||||
const pushed = React.useRef<string>(routeQuery);
|
const pushed = React.useRef<string>(routeQuery);
|
||||||
|
// onQueryChange родитель пересоздаёт на каждом рендере: в зависимостях он
|
||||||
|
// гонял бы эффект вхолостую, а без него эффект навсегда запомнил бы ПЕРВУЮ
|
||||||
|
// версию колбэка (а она замкнута на раздел, открытый при монтировании, — и
|
||||||
|
// запрос уезжал бы не в тот маршрут). Стабильная обёртка снимает и то и другое.
|
||||||
|
const pushQuery = useStableCallback(onQueryChange);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (debounced === pushed.current) return;
|
if (debounced === pushed.current) return;
|
||||||
pushed.current = debounced;
|
pushed.current = debounced;
|
||||||
onQueryChange(debounced);
|
pushQuery(debounced);
|
||||||
// onQueryChange пересоздаётся на каждый рендер родителя — в зависимостях
|
}, [debounced, pushQuery]);
|
||||||
// он гонял бы эффект вхолостую; важен только сам факт смены debounced.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [debounced]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (routeQuery === pushed.current) return;
|
if (routeQuery === pushed.current) return;
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useCallback, useLayoutEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обёртка вокруг колбэка-пропа с НЕИЗМЕННОЙ ссылкой.
|
||||||
|
*
|
||||||
|
* Зачем: колбэк, приходящий сверху пропом (`onCodeLoaded`, `onSaved` и т.п.),
|
||||||
|
* родитель почти всегда пересоздаёт на каждом рендере. Положить такой колбэк
|
||||||
|
* в зависимости useEffect нельзя — эффект начнёт перезапускаться на ровном
|
||||||
|
* месте (для форм портала это означает повторную загрузку заявки с сервера).
|
||||||
|
* Выкинуть его из зависимостей тоже нельзя — эффект зафиксирует ПЕРВУЮ версию
|
||||||
|
* колбэка и после смены пропа будет звать устаревшее замыкание.
|
||||||
|
*
|
||||||
|
* Хук снимает обе проблемы: наружу отдаётся одна и та же функция на всю жизнь
|
||||||
|
* компонента, а внутри она всегда вызывает АКТУАЛЬНУЮ версию колбэка. Такую
|
||||||
|
* обёртку можно честно указывать в зависимостях (см. react-hooks/exhaustive-deps).
|
||||||
|
*
|
||||||
|
* Аналог `useEffectEvent` из будущих версий React; ограничен колбэками без
|
||||||
|
* возвращаемого значения — именно такие у нас в пропах.
|
||||||
|
*
|
||||||
|
* ВАЖНО: вызывать её во время РЕНДЕРА нельзя — только из эффектов и
|
||||||
|
* обработчиков событий, иначе можно прочитать колбэк от прошлого рендера.
|
||||||
|
*/
|
||||||
|
export function useStableCallback<TArgs extends unknown[]>(
|
||||||
|
callback: ((...args: TArgs) => void) | undefined
|
||||||
|
): (...args: TArgs) => void {
|
||||||
|
const ref = useRef(callback);
|
||||||
|
// Обновляем ссылку до того, как отработают обычные (passive) эффекты, —
|
||||||
|
// так вызов из useEffect гарантированно попадает в свежий колбэк.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
ref.current = callback;
|
||||||
|
});
|
||||||
|
return useCallback((...args: TArgs) => ref.current?.(...args), []);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user