From ba4e3feee5147e8f51694b841548afc17d63bf82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Tue, 4 Aug 2026 17:00:04 +0500 Subject: [PATCH] feat: implemented form lifecycle hool and state machine --- src/styles/_design-tokens.scss | 14 ++ .../RecruitingHire/RecruitingHireForm.tsx | 153 +++++++++--------- .../RecruitingHire/RecruitingHireView.tsx | 12 +- .../forms/RecruitingHire/load.ts | 13 +- .../forms/RecruitingHire/submit.ts | 23 ++- .../WorkplaceSetup/WorkplaceSetupForm.tsx | 145 +++++++++-------- .../WorkplaceSetup/WorkplaceSetupView.tsx | 12 +- .../forms/WorkplaceSetup/load.ts | 13 +- .../forms/WorkplaceSetup/prefill.ts | 2 +- .../forms/WorkplaceSetup/submit.ts | 27 ++-- .../requestPortal/shared/approval/index.ts | 3 + .../shared/approval/models/statuses.ts | 14 ++ .../approval/ui/StatusBadge.module.scss | 48 ++++++ .../shared/approval/ui/StatusBadge.tsx | 19 +++ .../shared/ui/FormCard/FormCard.module.scss | 14 ++ .../shared/ui/FormCard/FormCard.tsx | 13 +- .../RequestNumber/RequestNumber.module.scss | 16 ++ .../shared/ui/RequestNumber/RequestNumber.tsx | 7 + .../shared/ui/Select/Select.module.scss | 1 + .../shared/ui/SubmitActions/SubmitActions.tsx | 50 ++++++ .../shared/ui/TextArea/TextArea.module.scss | 1 + .../shared/ui/TextInput/TextInput.module.scss | 1 + .../shared/ui/Toggle/Toggle.module.scss | 10 ++ src/webparts/requestPortal/shared/ui/index.ts | 4 + .../shared/ui/useRequestSubmit.ts | 60 +++++++ 25 files changed, 504 insertions(+), 171 deletions(-) create mode 100644 src/webparts/requestPortal/shared/approval/ui/StatusBadge.module.scss create mode 100644 src/webparts/requestPortal/shared/approval/ui/StatusBadge.tsx create mode 100644 src/webparts/requestPortal/shared/ui/RequestNumber/RequestNumber.module.scss create mode 100644 src/webparts/requestPortal/shared/ui/RequestNumber/RequestNumber.tsx create mode 100644 src/webparts/requestPortal/shared/ui/SubmitActions/SubmitActions.tsx create mode 100644 src/webparts/requestPortal/shared/ui/useRequestSubmit.ts diff --git a/src/styles/_design-tokens.scss b/src/styles/_design-tokens.scss index e5762bc..2ee5a19 100644 --- a/src/styles/_design-tokens.scss +++ b/src/styles/_design-tokens.scss @@ -125,6 +125,20 @@ $sp-7: 28px; $sp-8: 32px; } } +// Вид поля «только чтение» (режим согласующего / отправленная заявка): +// приглушённый фон, серый текст, без золотого фокуса — визуально «неактивно». +// $sel — псевдокласс состояния поля (:read-only для input/textarea, :disabled для select). +@mixin readonly-field($sel) { + &#{$sel}, + &#{$sel}:focus { + background: $gray-50; + color: $gray-500; + border-color: $gray-200; + box-shadow: none; + cursor: default; + } +} + @mixin hairline { height: 1px; background: $grad-hairline; diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx index 1e2f8e4..ece4b12 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx @@ -1,12 +1,25 @@ import * as React from 'react'; import { useState, useMemo, useCallback } from 'react'; import styles from './RecruitingHireForm.module.scss'; -import { Button, Alert, FormCard, FormActions, FileDropzone, CollapseSection, FormField, Icon } from '../../shared/ui'; +import { + Alert, + FormCard, + FormModeProvider, + FileDropzone, + CollapseSection, + FormField, + Icon, + RequestNumber, + SubmitActions, + useRequestSubmit +} from '../../shared/ui'; import { ApprovalMatrix, + StatusBadge, useMatrixEditor, getPlannedMatrix, REQUEST_STATUS, + isEditableStatus, IApproverToWrite } from '../../shared/approval'; import { uploadAttachments } from '../../shared/attachments/attachments.service'; @@ -18,21 +31,16 @@ import { CandidateSection } from './sections/CandidateSection'; import { PpeSection } from './sections/PpeSection'; import { ConditionsSection } from './sections/ConditionsSection'; -type ActionStatus = 'idle' | 'saving' | 'success' | 'error'; -type ActionKind = 'draft' | 'submit'; - const RecruitingHireForm: React.FC = ({ requestType, context, onCancel, onSaved }) => { const [data, setData] = useState(EMPTY_DATA); - const [actionStatus, setActionStatus] = useState('idle'); - const [actionKind, setActionKind] = useState(undefined); - const [status, setStatus] = useState(undefined); - const [error, setError] = useState(null); const [files, setFiles] = useState([]); + const [requestId, setRequestId] = useState(undefined); + const [requestStatus, setRequestStatus] = useState(undefined); + const submit = useRequestSubmit(); - const set: SetField = (key, value) => { - setData((prev) => ({ ...prev, [key]: value })); - setActionStatus('idle'); - }; + const readOnly = !isEditableStatus(requestStatus); + + const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); // Маршрут согласования (планируемый) резолвится по email инициатора — текущего // пользователя (кандидата ещё нет в системе). @@ -42,7 +50,7 @@ const RecruitingHireForm: React.FC = ({ requestType, context, () => getPlannedMatrix(context, HIRE_REQUEST_TYPE_ID, userEmail, approval, 'Approval'), [context, userEmail, approval] ); - const matrix = useMatrixEditor(loadMatrix, userEmail, true, true, (login) => + const matrix = useMatrixEditor(loadMatrix, userEmail, !readOnly, true, (login) => approval.getJobTitle(login) ); @@ -55,41 +63,36 @@ const RecruitingHireForm: React.FC = ({ requestType, context, approval.getJobTitle(login) ); - const saving = actionStatus === 'saving'; const isValid = data.CandidateName.trim().length > 0 && data.Position.trim().length > 0; + const validate = (): string | undefined => + isValid ? undefined : 'Заполните обязательные поля: Ф.И.О. кандидата, Должность.'; const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] => rows.map((r) => ({ email: r.email, position: r.position, allowReassign: r.allowReassign })); - async function run(kind: ActionKind, requestStatus: string): Promise { - if (!isValid) { - setError('Заполните обязательные поля: Ф.И.О. кандидата, Должность.'); - return; - } - setError(null); - setActionKind(kind); - setActionStatus('saving'); - try { - const id = await saveHireRequest( - data, - toApprovers(matrix.rows), - toApprovers(executors.rows), - requestStatus - ); + const save = (status: string): (() => Promise) => async (): Promise => { + const id = await saveHireRequest( + requestId, + data, + toApprovers(matrix.rows), + toApprovers(executors.rows), + status + ); + setRequestId(id); + setRequestStatus(status); + if (files.length > 0) { await uploadAttachments(DATA_LIST_TITLE, id, files); // файлы — после создания элемента - setStatus(requestStatus); - setActionStatus('success'); - if (onSaved) { - onSaved(); - } - } catch (e) { - setActionStatus('error'); - setError(`Ошибка сохранения: ${(e as Error).message}`); + setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало } - } + if (onSaved) { + onSaved(); + } + }; - const handleSaveDraft = (): Promise => run('draft', REQUEST_STATUS.draft); - const handleSubmit = (): Promise => run('submit', REQUEST_STATUS.onApproval); + const handleSaveDraft = (): Promise => + submit.run('draft', save(REQUEST_STATUS.draft), validate); + const handleSubmit = (): Promise => + submit.run('submit', save(REQUEST_STATUS.onApproval), validate); const sectionProps = { data, set, context }; @@ -100,32 +103,39 @@ const RecruitingHireForm: React.FC = ({ requestType, context, К списку заявок - - {status && ( -
- {status} -
- )} - - {actionStatus === 'success' && ( + + {requestStatus && } + {requestId && } + + ) : undefined + } + > + {submit.savedKind && !submit.error && ( - {actionKind === 'draft' - ? 'Черновик сохранён.' - : 'Заявка отправлена на согласование.'} + {submit.savedKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'} )} - {error && {error}} + {submit.error && {submit.error}} - - - - + + + + + + - - - - - + {!readOnly && ( + + + + + + )} approval.searchUsers(q)} /> @@ -139,19 +149,14 @@ const RecruitingHireForm: React.FC = ({ requestType, context, /> )} - - - - - + ); diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx index bde125d..240da28 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useMemo } from 'react'; import type { WebPartContext } from '@microsoft/sp-webpart-base'; import styles from './RecruitingHireView.module.scss'; import { Alert, FormCard, FormModeProvider, CollapseSection, FormField, AttachmentList } from '../../shared/ui'; -import { ApproverService, DecisionPanel } from '../../shared/approval'; +import { ApproverService, DecisionPanel, StatusBadge } from '../../shared/approval'; import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service'; import { IFormData, EMPTY_DATA, SetField } from './model'; import { loadHireRequest } from './load'; @@ -26,6 +26,7 @@ const NOOP_SET: SetField = () => undefined; // через — поля сами становятся неизменяемыми. const RecruitingHireView: React.FC = ({ context, itemId }) => { const [data, setData] = useState(EMPTY_DATA); + const [status, setStatus] = useState(''); const [attachments, setAttachments] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -42,7 +43,8 @@ const RecruitingHireView: React.FC = ({ context, itemI Promise.all([loadHireRequest(itemId), getAttachments(DATA_LIST_TITLE, itemId)]) .then(([loaded, files]) => { if (alive) { - setData(loaded); + setData(loaded.data); + setStatus(loaded.status); setAttachments(files); setError(null); setLoading(false); @@ -74,7 +76,11 @@ const RecruitingHireView: React.FC = ({ context, itemI return (
- + : undefined} + > diff --git a/src/webparts/requestPortal/forms/RecruitingHire/load.ts b/src/webparts/requestPortal/forms/RecruitingHire/load.ts index c37f106..b8d21fb 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/load.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/load.ts @@ -10,13 +10,18 @@ const SCALAR_FIELDS: (keyof IFormData)[] = [ 'SalaryAfterProbation', 'WorkSchedule', 'EmploymentForm' ]; -/** Прочитать заявку «на прием» по id и разложить в модель формы (для режима чтения). */ -export async function loadHireRequest(itemId: number): Promise { +export interface ILoadedRequest { + data: IFormData; + status: string; +} + +/** Прочитать заявку «на прием» по id: модель формы + статус (для режима чтения). */ +export async function loadHireRequest(itemId: number): Promise { const sp = getSP(); const item = (await sp.web.lists .getByTitle(LIST_TITLE) .items.getById(itemId) - .select(...SCALAR_FIELDS)()) as Record; + .select(...SCALAR_FIELDS, 'Status')()) as Record; const data: IFormData = { ...EMPTY_DATA }; const writable = data as unknown as Record; @@ -32,5 +37,5 @@ export async function loadHireRequest(itemId: number): Promise { } }); - return data; + return { data, status: (item.Status as string) || '' }; } diff --git a/src/webparts/requestPortal/forms/RecruitingHire/submit.ts b/src/webparts/requestPortal/forms/RecruitingHire/submit.ts index 1e77743..6197257 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/submit.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/submit.ts @@ -21,11 +21,10 @@ function buildTitle(data: IFormData): string { return ['Прием', data.CandidateName, data.Position].filter(Boolean).join(' — '); } -/** Записать контент заявки со статусом. Возвращает Id созданного элемента. */ -async function writeContent(data: IFormData, status: string): Promise { +/** Поля элемента списка данных из модели формы (текст/выбор/даты/числа). */ +function buildItem(data: IFormData, status: string): Record { const item: Record = { Title: buildTitle(data), Status: status }; - // Текст/выбор/даты/числа. (Object.keys(data) as (keyof IFormData)[]).forEach((key) => { const value = data[key]; if (NUMBER_FIELDS.indexOf(key) !== -1) { @@ -35,23 +34,35 @@ async function writeContent(data: IFormData, status: string): Promise { } }); - const added = await getSP().web.lists.getByTitle(DATA_LIST_TITLE).items.add(item); - return (added as { Id: number }).Id; + return item; } /** * Сохранить заявку «на прием»: контент + маршрут (согласующие + исполнители) со статусом. + * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. * status = «Черновик» (сохранение) или «На согласовании» (отправка). * executors может быть пустым — тогда после согласования заявка станет «Согласовано». * Возвращает Id заявки. */ export async function saveHireRequest( + existingId: number | undefined, data: IFormData, approvers: IApproverToWrite[], executors: IApproverToWrite[], status: string ): Promise { - const id = await writeContent(data, status); + const list = getSP().web.lists.getByTitle(DATA_LIST_TITLE); + const item = buildItem(data, status); + + let id: number; + if (existingId) { + await list.items.getById(existingId).update(item); + id = existingId; + } else { + const added = await list.items.add(item); + id = (added as { Id: number }).Id; + } + await writeRoute(hireApprovalService(), id, approvers, executors, status); return id; } diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx index 768c2b4..da4110d 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx @@ -1,12 +1,25 @@ import * as React from 'react'; import { useState, useMemo, useCallback, useEffect } from 'react'; import styles from './WorkplaceSetupForm.module.scss'; -import { Button, Alert, FormCard, FormActions, FileDropzone, CollapseSection, FormField, Icon } from '../../shared/ui'; +import { + Alert, + FormCard, + FormModeProvider, + FileDropzone, + CollapseSection, + FormField, + Icon, + RequestNumber, + SubmitActions, + useRequestSubmit +} from '../../shared/ui'; import { ApprovalMatrix, + StatusBadge, useMatrixEditor, getPlannedMatrix, REQUEST_STATUS, + isEditableStatus, IApproverToWrite } from '../../shared/approval'; import { uploadAttachments } from '../../shared/attachments/attachments.service'; @@ -22,20 +35,18 @@ import { loadPrefillFromHire } from './prefill'; import { EmployeeSection } from './sections/EmployeeSection'; import { EquipmentSection } from './sections/EquipmentSection'; -type ActionStatus = 'idle' | 'saving' | 'success' | 'error'; -type ActionKind = 'draft' | 'submit'; - const WorkplaceSetupForm: React.FC = ({ requestType, context, onCancel, onSaved, sourceId }) => { const [data, setData] = useState(EMPTY_DATA); - const [actionStatus, setActionStatus] = useState('idle'); - const [actionKind, setActionKind] = useState(undefined); - const [error, setError] = useState(null); const [files, setFiles] = useState([]); + // Id и статус текущей заявки: заполняются после первого сохранения. + const [requestId, setRequestId] = useState(undefined); + const [requestStatus, setRequestStatus] = useState(undefined); + const submit = useRequestSubmit(); - const set: SetField = (key, value) => { - setData((prev) => ({ ...prev, [key]: value })); - setActionStatus('idle'); - }; + // Редактировать можно новую/черновик/возвращённую/отклонённую; иначе только чтение. + const readOnly = !isEditableStatus(requestStatus); + + const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); // Предзаполнение из «Заявки на прием», если форма открыта «на основе» (rpsource). useEffect(() => { @@ -51,7 +62,7 @@ const WorkplaceSetupForm: React.FC = ({ requestType, context, }) .catch((e) => { if (alive) { - setError(`Не удалось предзаполнить из заявки на прием №${sourceId}: ${(e as Error).message}`); + submit.setError(`Не удалось предзаполнить из заявки на прием №${sourceId}: ${(e as Error).message}`); } }); return () => { @@ -65,7 +76,7 @@ const WorkplaceSetupForm: React.FC = ({ requestType, context, () => getPlannedMatrix(context, WORKPLACE_REQUEST_TYPE_ID, userEmail, approval, 'Approval'), [context, userEmail, approval] ); - const matrix = useMatrixEditor(loadMatrix, userEmail, true, true, (login) => approval.getJobTitle(login)); + const matrix = useMatrixEditor(loadMatrix, userEmail, !readOnly, true, (login) => approval.getJobTitle(login)); // Фаза исполнения: исполнители из маршрута (status='Execution'), только для чтения. const loadExecutors = useCallback( @@ -76,41 +87,37 @@ const WorkplaceSetupForm: React.FC = ({ requestType, context, approval.getJobTitle(login) ); - const saving = actionStatus === 'saving'; const isValid = data.FullName.trim().length > 0 && data.Position.trim().length > 0; + const validate = (): string | undefined => + isValid ? undefined : 'Заполните обязательные поля: Ф.И.О., Должность.'; const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] => rows.map((r) => ({ email: r.email, position: r.position, allowReassign: r.allowReassign })); - async function run(kind: ActionKind, requestStatus: string): Promise { - if (!isValid) { - setError('Заполните обязательные поля: Ф.И.О., Должность.'); - return; - } - setError(null); - setActionKind(kind); - setActionStatus('saving'); - try { - const id = await saveWorkplaceRequest( - data, - toApprovers(matrix.rows), - toApprovers(executors.rows), - requestStatus, - sourceId - ); + const save = (status: string): (() => Promise) => async (): Promise => { + const id = await saveWorkplaceRequest( + requestId, + data, + toApprovers(matrix.rows), + toApprovers(executors.rows), + status, + sourceId + ); + setRequestId(id); + setRequestStatus(status); + if (files.length > 0) { await uploadAttachments(DATA_LIST_TITLE, id, files); - setActionStatus('success'); - if (onSaved) { - onSaved(); - } - } catch (e) { - setActionStatus('error'); - setError(`Ошибка сохранения: ${(e as Error).message}`); + setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало } - } + if (onSaved) { + onSaved(); + } + }; - const handleSaveDraft = (): Promise => run('draft', REQUEST_STATUS.draft); - const handleSubmit = (): Promise => run('submit', REQUEST_STATUS.onApproval); + const handleSaveDraft = (): Promise => + submit.run('draft', save(REQUEST_STATUS.draft), validate); + const handleSubmit = (): Promise => + submit.run('submit', save(REQUEST_STATUS.onApproval), validate); const sectionProps = { data, set, context }; @@ -121,22 +128,37 @@ const WorkplaceSetupForm: React.FC = ({ requestType, context, К списку заявок - - {actionStatus === 'success' && ( + + {requestStatus && } + {requestId && } + + ) : undefined + } + > + {submit.savedKind && !submit.error && ( - {actionKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'} + {submit.savedKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'} )} - {error && {error}} + {submit.error && {submit.error}} - - + + + + - - - - - + {!readOnly && ( + + + + + + )} approval.searchUsers(q)} /> @@ -150,19 +172,14 @@ const WorkplaceSetupForm: React.FC = ({ requestType, context, /> )} - - - - - +
); diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx index 748c7e9..ffb9179 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useMemo } from 'react'; import type { WebPartContext } from '@microsoft/sp-webpart-base'; import styles from './WorkplaceSetupView.module.scss'; import { Alert, FormCard, FormModeProvider, CollapseSection, FormField, AttachmentList } from '../../shared/ui'; -import { ApproverService, DecisionPanel } from '../../shared/approval'; +import { ApproverService, DecisionPanel, StatusBadge } from '../../shared/approval'; import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service'; import { IFormData, EMPTY_DATA, SetField } from './model'; import { loadWorkplaceRequest } from './load'; @@ -21,6 +21,7 @@ const NOOP_SET: SetField = () => undefined; // Заявка «орг. рабочего места» только для просмотра (режим согласующего). const WorkplaceSetupView: React.FC = ({ context, itemId }) => { const [data, setData] = useState(EMPTY_DATA); + const [status, setStatus] = useState(''); const [attachments, setAttachments] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -36,7 +37,8 @@ const WorkplaceSetupView: React.FC = ({ context, itemI Promise.all([loadWorkplaceRequest(itemId), getAttachments(DATA_LIST_TITLE, itemId)]) .then(([loaded, files]) => { if (alive) { - setData(loaded); + setData(loaded.data); + setStatus(loaded.status); setAttachments(files); setError(null); setLoading(false); @@ -68,7 +70,11 @@ const WorkplaceSetupView: React.FC = ({ context, itemI return (
- + : undefined} + > diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/load.ts b/src/webparts/requestPortal/forms/WorkplaceSetup/load.ts index 8eb291e..0505621 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/load.ts +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/load.ts @@ -8,12 +8,17 @@ const SCALAR_FIELDS: (keyof IFormData)[] = [ 'Software', 'AccessRights' ]; -/** Прочитать заявку по id и разложить в модель формы (для режима чтения). */ -export async function loadWorkplaceRequest(itemId: number): Promise { +export interface ILoadedRequest { + data: IFormData; + status: string; +} + +/** Прочитать заявку по id: модель формы + статус (для режима чтения). */ +export async function loadWorkplaceRequest(itemId: number): Promise { const item = (await getSP() .web.lists.getByTitle(LIST_TITLE) .items.getById(itemId) - .select(...SCALAR_FIELDS, 'NeedsEquipment')()) as Record; + .select(...SCALAR_FIELDS, 'NeedsEquipment', 'Status')()) as Record; const data: IFormData = { ...EMPTY_DATA }; const writable = data as unknown as Record; @@ -31,5 +36,5 @@ export async function loadWorkplaceRequest(itemId: number): Promise { data.NeedsEquipment = item.NeedsEquipment === true; - return data; + return { data, status: (item.Status as string) || '' }; } diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/prefill.ts b/src/webparts/requestPortal/forms/WorkplaceSetup/prefill.ts index 334e8b9..5d64a0d 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/prefill.ts +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/prefill.ts @@ -6,7 +6,7 @@ import { IFormData, EMPTY_DATA } from './model'; * Копируются согласованные поля; остальное остаётся пустым для ручного заполнения. */ export async function loadPrefillFromHire(hireId: number): Promise { - const hire = await loadHireRequest(hireId); + const { data: hire } = await loadHireRequest(hireId); return { ...EMPTY_DATA, FullName: hire.CandidateName, diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/submit.ts b/src/webparts/requestPortal/forms/WorkplaceSetup/submit.ts index d1eda5f..7d735d8 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/submit.ts +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/submit.ts @@ -17,11 +17,8 @@ function buildTitle(data: IFormData): string { return ['Раб. место', data.FullName, data.Position].filter(Boolean).join(' — '); } -/** - * Записать контент заявки. sourceHireId — id исходной «Заявки на прием» - * (записывается в lookup-колонку SourceHire для прослеживаемости). Возвращает Id. - */ -async function writeContent(data: IFormData, status: string, sourceHireId?: number): Promise { +/** Поля элемента списка данных из модели формы. */ +function buildItem(data: IFormData, status: string, sourceHireId?: number): Record { const item: Record = { Title: buildTitle(data), Status: status }; (Object.keys(data) as (keyof IFormData)[]).forEach((key) => { @@ -37,23 +34,35 @@ async function writeContent(data: IFormData, status: string, sourceHireId?: numb item.SourceHireId = sourceHireId; // lookup → «Прием на работу — данные» } - const added = await getSP().web.lists.getByTitle(DATA_LIST_TITLE).items.add(item); - return (added as { Id: number }).Id; + return item; } /** * Сохранить заявку «орг. рабочего места»: контент + маршрут (согласующие + исполнители). + * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. * Исполнители пишутся сразу — фаза исполнения стартует автоматически после согласования. - * sourceHireId — если заявка создана на основе «прием». + * sourceHireId — если заявка создана на основе «прием». Возвращает Id заявки. */ export async function saveWorkplaceRequest( + existingId: number | undefined, data: IFormData, approvers: IApproverToWrite[], executors: IApproverToWrite[], status: string, sourceHireId?: number ): Promise { - const id = await writeContent(data, status, sourceHireId); + const list = getSP().web.lists.getByTitle(DATA_LIST_TITLE); + const item = buildItem(data, status, sourceHireId); + + let id: number; + if (existingId) { + await list.items.getById(existingId).update(item); + id = existingId; + } else { + const added = await list.items.add(item); + id = (added as { Id: number }).Id; + } + await writeRoute(workplaceApprovalService(), id, approvers, executors, status); return id; } diff --git a/src/webparts/requestPortal/shared/approval/index.ts b/src/webparts/requestPortal/shared/approval/index.ts index d1a76c7..9695f26 100644 --- a/src/webparts/requestPortal/shared/approval/index.ts +++ b/src/webparts/requestPortal/shared/approval/index.ts @@ -4,6 +4,8 @@ export { REQUEST_STATUS_CHOICES, APPROVAL_PHASE, APPROVAL_PHASE_CHOICES, + EDITABLE_STATUSES, + isEditableStatus, } from './models/statuses'; export type { ISiteUser } from './models/ISiteUser'; export type { IMatrixRow } from './models/IMatrixRow'; @@ -26,3 +28,4 @@ export type { IUseMatrixEditor } from './useMatrixEditor'; export { ApprovalMatrix } from './ui/ApprovalMatrix'; export { UserPicker } from './ui/UserPicker'; export { DecisionPanel } from './ui/DecisionPanel'; +export { StatusBadge } from './ui/StatusBadge'; diff --git a/src/webparts/requestPortal/shared/approval/models/statuses.ts b/src/webparts/requestPortal/shared/approval/models/statuses.ts index 735c04c..ef2e4cf 100644 --- a/src/webparts/requestPortal/shared/approval/models/statuses.ts +++ b/src/webparts/requestPortal/shared/approval/models/statuses.ts @@ -13,6 +13,20 @@ export const REQUEST_STATUS = { closed: 'Закрыта', } as const; +// Статусы, при которых заявку ещё можно править (форма редактируема). +// Всё остальное (На согласовании / Согласовано / Выполнение / Закрыта) — только чтение. +// «Отклонено» здесь по решению 2026-08-04: отклонённую можно доработать и переотправить. +export const EDITABLE_STATUSES: string[] = [ + REQUEST_STATUS.draft, + REQUEST_STATUS.returned, + REQUEST_STATUS.rejected, +]; + +// Новая (без статуса) или заявка в редактируемом статусе → форму можно менять. +export function isEditableStatus(status: string | undefined): boolean { + return !status || EDITABLE_STATUSES.indexOf(status) !== -1; +} + // Полный набор значений для Choice-колонки при провижининге. export const REQUEST_STATUS_CHOICES: string[] = [ REQUEST_STATUS.draft, diff --git a/src/webparts/requestPortal/shared/approval/ui/StatusBadge.module.scss b/src/webparts/requestPortal/shared/approval/ui/StatusBadge.module.scss new file mode 100644 index 0000000..cb87155 --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/ui/StatusBadge.module.scss @@ -0,0 +1,48 @@ +@import '../../../../../styles/design-tokens'; + +.badge { + display: inline-flex; + align-items: center; + padding: 5px 12px; + border-radius: $r-sm; + font-family: $font-sans; + font-size: 12.5px; + font-weight: 600; + letter-spacing: .2px; + white-space: nowrap; +} + +.draft { + background: $gray-100; + color: $gray-700; +} + +.onApproval { + background: $blue-50; + color: $blue-600; +} + +.approved { + background: $green-50; + color: $green-600; +} + +.rejected { + background: $red-50; + color: $red-500; +} + +.returned { + background: $gold-50; + color: $amber-700; +} + +.execution { + background: $navy-50; + color: $navy-800; +} + +.closed { + background: $gray-100; + color: $gray-500; +} diff --git a/src/webparts/requestPortal/shared/approval/ui/StatusBadge.tsx b/src/webparts/requestPortal/shared/approval/ui/StatusBadge.tsx new file mode 100644 index 0000000..bd874aa --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/ui/StatusBadge.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; +import styles from './StatusBadge.module.scss'; +import { REQUEST_STATUS } from '../models/statuses'; + +// Цвет бейджа по статусу заявки. Неизвестный статус → нейтральный (как черновик). +const VARIANT: Record = { + [REQUEST_STATUS.draft]: styles.draft, + [REQUEST_STATUS.onApproval]: styles.onApproval, + [REQUEST_STATUS.approved]: styles.approved, + [REQUEST_STATUS.rejected]: styles.rejected, + [REQUEST_STATUS.returned]: styles.returned, + [REQUEST_STATUS.execution]: styles.execution, + [REQUEST_STATUS.closed]: styles.closed, +}; + +/** Цветная плашка статуса заявки (шапка формы / просмотра). */ +export const StatusBadge: React.FC<{ status: string }> = ({ status }) => ( + {status} +); diff --git a/src/webparts/requestPortal/shared/ui/FormCard/FormCard.module.scss b/src/webparts/requestPortal/shared/ui/FormCard/FormCard.module.scss index 1b74bc3..5f17def 100644 --- a/src/webparts/requestPortal/shared/ui/FormCard/FormCard.module.scss +++ b/src/webparts/requestPortal/shared/ui/FormCard/FormCard.module.scss @@ -8,6 +8,13 @@ padding: 32px; } +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + .title { font-family: $font-serif; font-size: $fs-form-title; @@ -15,6 +22,13 @@ color: $navy-900; } +.headerRight { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 8px; +} + .subtitle { font-size: 12px; color: $gray-400; diff --git a/src/webparts/requestPortal/shared/ui/FormCard/FormCard.tsx b/src/webparts/requestPortal/shared/ui/FormCard/FormCard.tsx index b14ce53..996cac0 100644 --- a/src/webparts/requestPortal/shared/ui/FormCard/FormCard.tsx +++ b/src/webparts/requestPortal/shared/ui/FormCard/FormCard.tsx @@ -4,15 +4,22 @@ import styles from './FormCard.module.scss'; interface IFormCardProps { title: string; subtitle?: string; + /** Контент в правом верхнем углу шапки (например, номер заявки). */ + headerRight?: React.ReactNode; children: React.ReactNode; } /** Белая карточка формы: заголовок (Lora) + золотой hairline + содержимое. */ -export const FormCard: React.FC = ({ title, subtitle, children }) => { +export const FormCard: React.FC = ({ title, subtitle, headerRight, children }) => { return (
-
{title}
- {subtitle &&
{subtitle}
} +
+
+
{title}
+ {subtitle &&
{subtitle}
} +
+ {headerRight &&
{headerRight}
} +

{children}
diff --git a/src/webparts/requestPortal/shared/ui/RequestNumber/RequestNumber.module.scss b/src/webparts/requestPortal/shared/ui/RequestNumber/RequestNumber.module.scss new file mode 100644 index 0000000..f37d77d --- /dev/null +++ b/src/webparts/requestPortal/shared/ui/RequestNumber/RequestNumber.module.scss @@ -0,0 +1,16 @@ +@import '../../../../../styles/design-tokens'; + +.badge { + display: inline-flex; + align-items: center; + padding: 5px 12px; + border: 1px solid $gray-200; + border-radius: $r-sm; + background: $navy-50; + color: $navy-800; + font-family: $font-sans; + font-size: 13px; + font-weight: 600; + letter-spacing: .3px; + white-space: nowrap; +} diff --git a/src/webparts/requestPortal/shared/ui/RequestNumber/RequestNumber.tsx b/src/webparts/requestPortal/shared/ui/RequestNumber/RequestNumber.tsx new file mode 100644 index 0000000..67fcebc --- /dev/null +++ b/src/webparts/requestPortal/shared/ui/RequestNumber/RequestNumber.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; +import styles from './RequestNumber.module.scss'; + +/** Номер заявки (Id элемента списка) — плашка в правом углу шапки формы. */ +export const RequestNumber: React.FC<{ id: number }> = ({ id }) => ( + №{id} +); diff --git a/src/webparts/requestPortal/shared/ui/Select/Select.module.scss b/src/webparts/requestPortal/shared/ui/Select/Select.module.scss index 49d77c5..647e71b 100644 --- a/src/webparts/requestPortal/shared/ui/Select/Select.module.scss +++ b/src/webparts/requestPortal/shared/ui/Select/Select.module.scss @@ -16,4 +16,5 @@ transition: .18s; @include input-focus-gold; + @include readonly-field(':disabled'); } diff --git a/src/webparts/requestPortal/shared/ui/SubmitActions/SubmitActions.tsx b/src/webparts/requestPortal/shared/ui/SubmitActions/SubmitActions.tsx new file mode 100644 index 0000000..b2bf50b --- /dev/null +++ b/src/webparts/requestPortal/shared/ui/SubmitActions/SubmitActions.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import { Button } from '../Button/Button'; +import { Icon } from '../Icon/Icon'; +import { FormActions } from '../FormActions/FormActions'; +import { IRequestSubmit } from '../useRequestSubmit'; + +export interface ISubmitActionsProps { + submit: IRequestSubmit; + isValid: boolean; + /** Заявка в нередактируемом статусе (На согласовании/…): сохранять нельзя. */ + readOnly: boolean; + onCancel: () => void; + onSaveDraft: () => void; + onSubmit: () => void; + /** Подпись основной кнопки. По умолчанию — «Отправить на согласование». */ + submitLabel?: string; +} + +// Единый ряд кнопок форм подачи: Отмена / Сохранить черновик / Отправить. +// Логика доступности — здесь, в одном месте, чтобы все формы вели себя одинаково: +// • сохранять/отправлять нельзя, пока идёт сохранение или заявка уже read-only; +// • «Отмена» доступна всегда (возврат к списку), кроме момента сохранения. +export const SubmitActions: React.FC = ({ + submit, + isValid, + readOnly, + onCancel, + onSaveDraft, + onSubmit, + submitLabel = 'Отправить на согласование', +}) => { + const { saving, kind } = submit; + const locked = saving || readOnly; + + return ( + + + + + + ); +}; diff --git a/src/webparts/requestPortal/shared/ui/TextArea/TextArea.module.scss b/src/webparts/requestPortal/shared/ui/TextArea/TextArea.module.scss index ab94e0d..2fd1ae5 100644 --- a/src/webparts/requestPortal/shared/ui/TextArea/TextArea.module.scss +++ b/src/webparts/requestPortal/shared/ui/TextArea/TextArea.module.scss @@ -13,6 +13,7 @@ transition: .18s; @include input-focus-gold; + @include readonly-field(':read-only'); &::placeholder { color: $gray-400; diff --git a/src/webparts/requestPortal/shared/ui/TextInput/TextInput.module.scss b/src/webparts/requestPortal/shared/ui/TextInput/TextInput.module.scss index 98def27..adfa790 100644 --- a/src/webparts/requestPortal/shared/ui/TextInput/TextInput.module.scss +++ b/src/webparts/requestPortal/shared/ui/TextInput/TextInput.module.scss @@ -11,6 +11,7 @@ transition: .18s; @include input-focus-gold; + @include readonly-field(':read-only'); &::placeholder { color: $gray-400; diff --git a/src/webparts/requestPortal/shared/ui/Toggle/Toggle.module.scss b/src/webparts/requestPortal/shared/ui/Toggle/Toggle.module.scss index 2e3a781..2789034 100644 --- a/src/webparts/requestPortal/shared/ui/Toggle/Toggle.module.scss +++ b/src/webparts/requestPortal/shared/ui/Toggle/Toggle.module.scss @@ -49,6 +49,16 @@ box-shadow: 0 0 0 3px rgba(212, 160, 30, .25); } +// Только чтение: приглушаем переключатель и подпись. +.input:disabled + .track { + opacity: .5; + cursor: default; +} + +.input:disabled ~ .text { + color: $gray-400; +} + .text { font-size: 13px; color: $gray-700; diff --git a/src/webparts/requestPortal/shared/ui/index.ts b/src/webparts/requestPortal/shared/ui/index.ts index 0271ff8..57e1d45 100644 --- a/src/webparts/requestPortal/shared/ui/index.ts +++ b/src/webparts/requestPortal/shared/ui/index.ts @@ -19,3 +19,7 @@ export { PeopleField } from './PeopleField/PeopleField'; export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti'; export { FormModeProvider, useFormReadOnly } from './FormMode'; export { Icon } from './Icon/Icon'; +export { SubmitActions } from './SubmitActions/SubmitActions'; +export { RequestNumber } from './RequestNumber/RequestNumber'; +export { useRequestSubmit } from './useRequestSubmit'; +export type { IRequestSubmit, SubmitKind } from './useRequestSubmit'; diff --git a/src/webparts/requestPortal/shared/ui/useRequestSubmit.ts b/src/webparts/requestPortal/shared/ui/useRequestSubmit.ts new file mode 100644 index 0000000..7ab07ba --- /dev/null +++ b/src/webparts/requestPortal/shared/ui/useRequestSubmit.ts @@ -0,0 +1,60 @@ +import { useState } from 'react'; + +// Общая машина сохранения заявки для ВСЕХ форм портала. +// Отвечает ТОЛЬКО за транзиентное состояние операции (идёт сохранение / ошибка / +// что сохранили). Блокировка формы (readOnly) считается из СТАТУСА заявки на +// стороне формы через isEditableStatus — не здесь: так одинаково блокируются +// и «после отправки», и «открыли уже отправленную». +export type SubmitKind = 'draft' | 'submit'; + +export interface IRequestSubmit { + saving: boolean; // идёт сохранение + kind: SubmitKind | undefined; // текущее/последнее действие (для подписи кнопки) + savedKind: SubmitKind | undefined; // последнее УСПЕШНОЕ сохранение (для плашки) + error: string | undefined; + /** + * Запустить сохранение. validate (опц.) возвращает текст ошибки или undefined. + * Повторный вызов во время сохранения игнорируется (защита от дабл-клика). + */ + run: ( + kind: SubmitKind, + save: () => Promise, + validate?: () => string | undefined + ) => Promise; + setError: (message: string | undefined) => void; +} + +export function useRequestSubmit(): IRequestSubmit { + const [saving, setSaving] = useState(false); + const [kind, setKind] = useState(undefined); + const [savedKind, setSavedKind] = useState(undefined); + const [error, setError] = useState(undefined); + + const run = async ( + k: SubmitKind, + save: () => Promise, + validate?: () => string | undefined + ): Promise => { + if (saving) { + return; + } + setKind(k); + const validationError = validate ? validate() : undefined; + if (validationError) { + setError(validationError); + return; + } + setError(undefined); + setSaving(true); + try { + await save(); + setSavedKind(k); + } catch (e) { + setError(`Ошибка сохранения: ${(e as Error).message}`); + } finally { + setSaving(false); + } + }; + + return { saving, kind, savedKind, error, run, setError }; +}