diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx index fa27c87..54e022f 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx @@ -55,6 +55,7 @@ const RecruitingHireForm: React.FC = ({ requestType, context, // после первого сохранения. const [requestId, setRequestId] = useState(editId); const [requestStatus, setRequestStatus] = useState(undefined); + const [requestCode, setRequestCode] = useState(undefined); const submit = useRequestSubmit(); const readOnly = !isEditableStatus(requestStatus); @@ -73,6 +74,7 @@ const RecruitingHireForm: React.FC = ({ requestType, context, if (alive) { setData(loaded.data); setRequestStatus(loaded.status); + setRequestCode(loaded.code || undefined); setExistingAttachments(atts); } }) @@ -152,7 +154,7 @@ const RecruitingHireForm: React.FC = ({ requestType, context, }; const save = (status: string): (() => Promise) => async (): Promise => { - const id = await saveHireRequest( + const { id, code } = await saveHireRequest( requestId, data, toApprovers(matrix.rows), @@ -161,6 +163,9 @@ const RecruitingHireForm: React.FC = ({ requestType, context, ); setRequestId(id); setRequestStatus(status); + if (code) { + setRequestCode(code); + } if (files.length > 0) { await uploadAttachments(DATA_LIST_TITLE, id, files); // файлы — после создания элемента setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало @@ -218,7 +223,7 @@ const RecruitingHireForm: React.FC = ({ requestType, context, requestId || requestStatus ? ( <> {requestStatus && } - {requestId && } + {requestCode && } ) : undefined } diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx index 8097e2f..329ec9f 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx @@ -41,6 +41,7 @@ const NOOP_SET: SetField = () => undefined; const RecruitingHireView: React.FC = ({ context, itemId }) => { const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); + const [code, setCode] = useState(''); const [attachments, setAttachments] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -89,6 +90,7 @@ const RecruitingHireView: React.FC = ({ context, itemI if (alive) { setData(loaded.data); setStatus(loaded.status); + setCode(loaded.code); setAttachments(files); setError(null); setLoading(false); @@ -140,7 +142,7 @@ const RecruitingHireView: React.FC = ({ context, itemI
: undefined} > @@ -177,6 +179,7 @@ const RecruitingHireView: React.FC = ({ context, itemI approval.searchUsers(q)} onDecided={(newStatus) => { setStatus(newStatus); setDecisionSeq((n) => n + 1); @@ -187,6 +190,16 @@ const RecruitingHireView: React.FC = ({ context, itemI approvalListName: APPROVAL_LIST_TITLE, }).catch(() => undefined); }} + onDelegated={() => { + // Шаг переназначен: активной стала строка делегата — обновляем + // матрицу и уведомляем нового активного участника. + setDecisionSeq((n) => n + 1); + notifyActiveStep(context, approval, itemId, { + requestTypeId: HIRE_REQUEST_TYPE_ID, + dataListName: DATA_LIST_TITLE, + approvalListName: APPROVAL_LIST_TITLE, + }).catch(() => undefined); + }} />
diff --git a/src/webparts/requestPortal/forms/RecruitingHire/approval/provisioning.json b/src/webparts/requestPortal/forms/RecruitingHire/approval/provisioning.json index 90b6141..5473480 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/approval/provisioning.json +++ b/src/webparts/requestPortal/forms/RecruitingHire/approval/provisioning.json @@ -8,6 +8,7 @@ { "internalName": "Comment", "title": "Комментарий", "type": "Note" }, { "internalName": "StepOrder", "title": "Порядок", "type": "Number" }, { "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" }, - { "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] } + { "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] }, + { "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" } ] } diff --git a/src/webparts/requestPortal/forms/RecruitingHire/load.ts b/src/webparts/requestPortal/forms/RecruitingHire/load.ts index 8a69434..3c4640e 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/load.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/load.ts @@ -13,6 +13,8 @@ const SCALAR_FIELDS: (keyof IFormData)[] = [ export interface ILoadedRequest { data: IFormData; status: string; + /** Уникальный код заявки; '' — если запись создана до появления поля. */ + code: string; } /** Прочитать заявку «на прием» по id: модель формы + статус (для режима чтения). */ @@ -21,7 +23,7 @@ export async function loadHireRequest(itemId: number): Promise { const item = (await sp.web.lists .getByTitle(LIST_TITLE) .items.getById(itemId) - .select(...SCALAR_FIELDS, 'RequestStatus')()) as Record; + .select(...SCALAR_FIELDS, 'RequestStatus', 'RequestUniqueCode')()) as Record; const data: IFormData = { ...EMPTY_DATA }; const writable = data as unknown as Record; @@ -37,5 +39,5 @@ export async function loadHireRequest(itemId: number): Promise { } }); - return { data, status: (item.RequestStatus as string) || '' }; + return { data, status: (item.RequestStatus as string) || '', code: (item.RequestUniqueCode as string) || '' }; } diff --git a/src/webparts/requestPortal/forms/RecruitingHire/submit.ts b/src/webparts/requestPortal/forms/RecruitingHire/submit.ts index 2a4b257..99cffe0 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/submit.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/submit.ts @@ -1,5 +1,6 @@ import { getSP } from '../../shared/pnp/pnpjs'; import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval'; +import { buildRequestUniqueCode } from '../../shared/requestCode'; import { IFormData } from './model'; export const DATA_LIST_TITLE = 'Прием на работу — данные'; @@ -37,12 +38,18 @@ function buildItem(data: IFormData, status: string): Record { return item; } +export interface ISaveResult { + id: number; + /** Задан только при создании новой заявки (существующий код не меняется). */ + code?: string; +} + /** * Сохранить заявку «на прием»: контент + маршрут (согласующие + исполнители) со статусом. * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. * status = «Черновик» (сохранение) или «На согласовании» (отправка). * executors может быть пустым — тогда после согласования заявка станет «Согласовано». - * Возвращает Id заявки. + * Возвращает Id заявки (+ код при создании). */ export async function saveHireRequest( existingId: number | undefined, @@ -50,21 +57,23 @@ export async function saveHireRequest( approvers: IApproverToWrite[], executors: IApproverToWrite[], status: string -): Promise { +): Promise { const list = getSP().web.lists.getByTitle(DATA_LIST_TITLE); const item = buildItem(data, status); let id: number; + let code: string | undefined; 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; - // Уникальный код заявки: пока = id (id известен только после создания). - await list.items.getById(id).update({ RequestUniqueCode: String(id) }); + // Код известен только после создания элемента (нужен его Id). + code = buildRequestUniqueCode(HIRE_REQUEST_TYPE_ID, id, new Date()); + await list.items.getById(id).update({ RequestUniqueCode: code }); } await writeRoute(hireApprovalService(), id, approvers, executors, status); - return id; + return { id, code }; } diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx index b2e9c63..131c783 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx @@ -15,6 +15,7 @@ const RecruitingSelectionForm: React.FC = ({ requestType, con const [data, setData] = useState(EMPTY_DATA); const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); + const [savedCode, setSavedCode] = useState(undefined); const [error, setError] = useState(null); const set: SetField = (key, value) => { @@ -35,8 +36,9 @@ const RecruitingSelectionForm: React.FC = ({ requestType, con setError(null); setSaving(true); try { - await submitRecruitingSelection(data); + const code = await submitRecruitingSelection(data); setSaved(true); + setSavedCode(code); if (onSaved) { onSaved(); } @@ -57,7 +59,9 @@ const RecruitingSelectionForm: React.FC = ({ requestType, con - {saved && Заявка сохранена в SharePoint.} + {saved && ( + Заявка сохранена в SharePoint. Код заявки: №{savedCode}. + )} {error && {error}} diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts b/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts index 9e3fb9f..b8c475b 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts +++ b/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts @@ -1,7 +1,10 @@ import { getSP } from '../../shared/pnp/pnpjs'; +import { buildRequestUniqueCode } from '../../shared/requestCode'; import { IFormData } from './model'; const LIST_TITLE = 'Заявка на подбор — данные'; +// Тип заявки во внешнем approval-route API (GET /api/request-types → id 4). +const RECRUITING_SELECTION_REQUEST_TYPE_ID = 4; // Поля-люди (один человек): логин → ensureUser → Id в колонку Id. const PERSON_FIELDS: (keyof IFormData)[] = ['Responsible', 'ProposedMentor', 'DirectManager']; @@ -16,8 +19,8 @@ async function resolveUserId(login: string): Promise { return ensured.Id; } -/** Записать заявку «на подбор» в список данных SharePoint. */ -export async function submitRecruitingSelection(data: IFormData): Promise { +/** Записать заявку «на подбор» в список данных SharePoint. Возвращает уникальный код заявки. */ +export async function submitRecruitingSelection(data: IFormData): Promise { const sp = getSP(); const item: Record = {}; @@ -56,6 +59,8 @@ export async function submitRecruitingSelection(data: IFormData): Promise const added = await sp.web.lists.getByTitle(LIST_TITLE).items.add(item); const id = (added as { Id: number }).Id; - // Уникальный код заявки: пока = id (id известен только после создания). - await sp.web.lists.getByTitle(LIST_TITLE).items.getById(id).update({ RequestUniqueCode: String(id) }); + // Код известен только после создания элемента (нужен его Id). + const code = buildRequestUniqueCode(RECRUITING_SELECTION_REQUEST_TYPE_ID, id, new Date()); + await sp.web.lists.getByTitle(LIST_TITLE).items.getById(id).update({ RequestUniqueCode: code }); + return code; } diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx index 8158c37..58271d1 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx @@ -54,6 +54,7 @@ const WorkplaceSetupForm: React.FC = ({ requestType, context, // открытии на редактирование (editId) — сразу из загруженной записи. const [requestId, setRequestId] = useState(editId); const [requestStatus, setRequestStatus] = useState(undefined); + const [requestCode, setRequestCode] = useState(undefined); const submit = useRequestSubmit(); // Редактировать можно новую/черновик/возвращённую/отклонённую; иначе только чтение. @@ -74,6 +75,7 @@ const WorkplaceSetupForm: React.FC = ({ requestType, context, if (alive) { setData(loaded.data); setRequestStatus(loaded.status); + setRequestCode(loaded.code || undefined); setExistingAttachments(atts); } }) @@ -120,12 +122,13 @@ const WorkplaceSetupForm: React.FC = ({ requestType, context, ? approval .getMatrixForRequest(editId) .then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution)) - : getPlannedMatrix(context, WORKPLACE_REQUEST_TYPE_ID, userEmail, approval, 'Approval'), - [context, userEmail, approval, editId] + : getPlannedMatrix(context, WORKPLACE_REQUEST_TYPE_ID, userEmail, approval, 'Approval', data.DepartmentId), + [context, userEmail, approval, editId, data.DepartmentId] ); const matrix = useMatrixEditor( loadMatrix, - `${userEmail}-${editId ?? 'new'}`, + // Отдел влияет на резолв маршрута — смена отдела у НОВОЙ заявки должна перепланировать матрицу. + `${userEmail}-${editId ?? 'new'}-${data.DepartmentId}`, !readOnly, true, (login) => approval.getJobTitle(login) @@ -138,12 +141,12 @@ const WorkplaceSetupForm: React.FC = ({ requestType, context, ? approval .getMatrixForRequest(editId) .then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution)) - : getPlannedMatrix(context, WORKPLACE_REQUEST_TYPE_ID, userEmail, approval, 'Execution'), - [context, userEmail, approval, editId] + : getPlannedMatrix(context, WORKPLACE_REQUEST_TYPE_ID, userEmail, approval, 'Execution', data.DepartmentId), + [context, userEmail, approval, editId, data.DepartmentId] ); const executors = useMatrixEditor( loadExecutors, - `${userEmail}-${editId ?? 'new'}`, + `${userEmail}-${editId ?? 'new'}-${data.DepartmentId}`, false, true, (login) => approval.getJobTitle(login) @@ -174,7 +177,7 @@ const WorkplaceSetupForm: React.FC = ({ requestType, context, }; const save = (status: string): (() => Promise) => async (): Promise => { - const id = await saveWorkplaceRequest( + const { id, code } = await saveWorkplaceRequest( requestId, data, toApprovers(matrix.rows), @@ -184,6 +187,9 @@ const WorkplaceSetupForm: React.FC = ({ requestType, context, ); setRequestId(id); setRequestStatus(status); + if (code) { + setRequestCode(code); + } if (files.length > 0) { await uploadAttachments(DATA_LIST_TITLE, id, files); setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало @@ -241,7 +247,7 @@ const WorkplaceSetupForm: React.FC = ({ requestType, context, requestId || requestStatus ? ( <> {requestStatus && } - {requestId && } + {requestCode && } ) : undefined } diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx index 15ca635..a5c56fc 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx @@ -50,6 +50,7 @@ type SaveState = 'idle' | 'saving' | 'saved' | 'error'; const WorkplaceSetupView: React.FC = ({ context, itemId }) => { const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); + const [code, setCode] = useState(''); const [attachments, setAttachments] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -101,6 +102,7 @@ const WorkplaceSetupView: React.FC = ({ context, itemI if (alive) { setData(loaded.data); setStatus(loaded.status); + setCode(loaded.code); setAttachments(files); setError(null); setLoading(false); @@ -202,7 +204,7 @@ const WorkplaceSetupView: React.FC = ({ context, itemI
: undefined} > @@ -236,6 +238,7 @@ const WorkplaceSetupView: React.FC = ({ context, itemI approval.searchUsers(q)} onBeforeDecide={canEditFields ? saveReviewFields : undefined} onDecided={(newStatus) => { setStatus(newStatus); @@ -247,6 +250,16 @@ const WorkplaceSetupView: React.FC = ({ context, itemI approvalListName: APPROVAL_LIST_TITLE, }).catch(() => undefined); }} + onDelegated={() => { + // Шаг переназначен: активной стала строка делегата — обновляем + // матрицу и уведомляем нового активного участника. + setDecisionSeq((n) => n + 1); + notifyActiveStep(context, approval, itemId, { + requestTypeId: WORKPLACE_REQUEST_TYPE_ID, + dataListName: DATA_LIST_TITLE, + approvalListName: APPROVAL_LIST_TITLE, + }).catch(() => undefined); + }} />
diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/approval/provisioning.json b/src/webparts/requestPortal/forms/WorkplaceSetup/approval/provisioning.json index 2ab403f..09fd21e 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/approval/provisioning.json +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/approval/provisioning.json @@ -8,6 +8,7 @@ { "internalName": "Comment", "title": "Комментарий", "type": "Note" }, { "internalName": "StepOrder", "title": "Порядок", "type": "Number" }, { "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" }, - { "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] } + { "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] }, + { "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" } ] } diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/load.ts b/src/webparts/requestPortal/forms/WorkplaceSetup/load.ts index 0ac02aa..2cf9af3 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/load.ts +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/load.ts @@ -4,13 +4,15 @@ import { IFormData, EMPTY_DATA } from './model'; const LIST_TITLE = 'Организация рабочего места — данные'; const SCALAR_FIELDS: (keyof IFormData)[] = [ - 'FullName', 'NameLatin', 'Phone', 'Position', 'Department', 'WorkKind', 'Location', 'StartDate', + 'FullName', 'NameLatin', 'Phone', 'Position', 'Department', 'DepartmentId', 'WorkKind', 'Location', 'StartDate', 'Software', 'AccessRights' ]; export interface ILoadedRequest { data: IFormData; status: string; + /** Уникальный код заявки; '' — если запись создана до появления поля. */ + code: string; } /** Прочитать заявку по id: модель формы + статус (для режима чтения). */ @@ -18,7 +20,7 @@ export async function loadWorkplaceRequest(itemId: number): Promise; + .select(...SCALAR_FIELDS, 'NeedsEquipment', 'RequestStatus', 'RequestUniqueCode')()) as Record; const data: IFormData = { ...EMPTY_DATA }; const writable = data as unknown as Record; @@ -36,5 +38,5 @@ export async function loadWorkplaceRequest(itemId: number): Promise = ({ data, set }) => ( +export const EmployeeSection: React.FC = ({ data, set, context }) => ( set('FullName', v)} /> @@ -17,7 +18,15 @@ export const EmployeeSection: React.FC = ({ data, set }) => ( set('Position', v)} /> - set('Department', v)} /> + { + set('Department', name); + set('DepartmentId', id); + }} + /> set('WorkKind', v)} /> diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/submit.ts b/src/webparts/requestPortal/forms/WorkplaceSetup/submit.ts index 7c904a7..60e1f95 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/submit.ts +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/submit.ts @@ -1,5 +1,6 @@ import { getSP } from '../../shared/pnp/pnpjs'; import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval'; +import { buildRequestUniqueCode } from '../../shared/requestCode'; import { IFormData } from './model'; export const DATA_LIST_TITLE = 'Организация рабочего места — данные'; @@ -51,11 +52,17 @@ function buildItem(data: IFormData, status: string, sourceHireId?: number): Reco return item; } +export interface ISaveResult { + id: number; + /** Задан только при создании новой заявки (существующий код не меняется). */ + code?: string; +} + /** * Сохранить заявку «орг. рабочего места»: контент + маршрут (согласующие + исполнители). * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. * Исполнители пишутся сразу — фаза исполнения стартует автоматически после согласования. - * sourceHireId — если заявка создана на основе «прием». Возвращает Id заявки. + * sourceHireId — если заявка создана на основе «прием». Возвращает Id заявки (+ код при создании). */ export async function saveWorkplaceRequest( existingId: number | undefined, @@ -64,21 +71,23 @@ export async function saveWorkplaceRequest( executors: IApproverToWrite[], status: string, sourceHireId?: number -): Promise { +): Promise { const list = getSP().web.lists.getByTitle(DATA_LIST_TITLE); const item = buildItem(data, status, sourceHireId); let id: number; + let code: string | undefined; 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; - // Уникальный код заявки: пока = id (id известен только после создания). - await list.items.getById(id).update({ RequestUniqueCode: String(id) }); + // Код известен только после создания элемента (нужен его Id). + code = buildRequestUniqueCode(WORKPLACE_REQUEST_TYPE_ID, id, new Date()); + await list.items.getById(id).update({ RequestUniqueCode: code }); } await writeRoute(workplaceApprovalService(), id, approvers, executors, status); - return id; + return { id, code }; } diff --git a/src/webparts/requestPortal/shared/approval/approvalList.service.ts b/src/webparts/requestPortal/shared/approval/approvalList.service.ts index d77b7de..09533ad 100644 --- a/src/webparts/requestPortal/shared/approval/approvalList.service.ts +++ b/src/webparts/requestPortal/shared/approval/approvalList.service.ts @@ -31,6 +31,7 @@ const FIELDS = { // скрытое системное поле SharePoint (не видно в UI списка). allowReassign: 'AllowReassignToOtherApprovers', // «Можно перепоручать» (Да/Нет) phase: 'Phase', // «Фаза» (Choice): Согласование | Исполнение + delegatedBy: 'DelegatedBy', // «Кем переназначено» (User) → DelegatedById }; // Согласующий для записи в список. @@ -146,12 +147,16 @@ export class ApprovalListService { } // Добавляет одну строку согласования с заданным Order (для делегата). + // delegatedById — Id пользователя, который выполнил переназначение (не строки + // из маршрута — у них делегата нет). Он сохраняет право переназначить эту + // строку ещё раз, если делегат недоступен (см. ApproverService.delegate). public async addApproverRow( requestId: number, approver: IApproverToWrite, order: number, status: string, - phase: string = APPROVAL_PHASE.approval + phase: string = APPROVAL_PHASE.approval, + delegatedById?: number ): Promise { const approverId = await this.resolveUserId(approver.email); await this.list().items.add({ @@ -162,6 +167,7 @@ export class ApprovalListService { [FIELDS.order]: order, [FIELDS.allowReassign]: approver.allowReassign, [FIELDS.phase]: phase, + ...(delegatedById ? { [`${FIELDS.delegatedBy}Id`]: delegatedById } : {}), }); } @@ -169,12 +175,21 @@ export class ApprovalListService { public async getForRequest(requestId: number): Promise { const rows = (await this.list() .items.filter(`${FIELDS.ticket}Id eq ${requestId}`) - .select('Id', FIELDS.status, FIELDS.order, FIELDS.allowReassign, FIELDS.phase, `${FIELDS.approver}/Id`) - .expand(FIELDS.approver) + .select( + 'Id', + FIELDS.status, + FIELDS.order, + FIELDS.allowReassign, + FIELDS.phase, + `${FIELDS.approver}/Id`, + `${FIELDS.delegatedBy}/Id` + ) + .expand(FIELDS.approver, FIELDS.delegatedBy) .orderBy(FIELDS.order, true)()) as Array>; return rows.map((r) => { const approver = r[FIELDS.approver] as { Id?: number } | undefined; + const delegatedBy = r[FIELDS.delegatedBy] as { Id?: number } | undefined; return { id: r.Id as number, status: (r[FIELDS.status] as string) || '', @@ -183,6 +198,7 @@ export class ApprovalListService { allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false, // Пустая фаза (старые строки без колонки) трактуется как «Согласование». phase: (r[FIELDS.phase] as string) || APPROVAL_PHASE.approval, + delegatedById: delegatedBy?.Id ?? 0, }; }); } diff --git a/src/webparts/requestPortal/shared/approval/approvalRoute.service.ts b/src/webparts/requestPortal/shared/approval/approvalRoute.service.ts index e76fefc..31cdf28 100644 --- a/src/webparts/requestPortal/shared/approval/approvalRoute.service.ts +++ b/src/webparts/requestPortal/shared/approval/approvalRoute.service.ts @@ -34,7 +34,8 @@ export async function resolveRoute( context: WebPartContext, requestTypeId: number, userEmail: string, - status: ApprovalStatus = 'Approval' + status: ApprovalStatus = 'Approval', + departmentId: string | null = null ): Promise { const c = await client(context); const url = `${APPROVAL_API_BASE_URL}/api/template-routes/resolve`; @@ -50,7 +51,8 @@ export async function resolveRoute( userEmail, projectId: null, status, - fieldValues: [] + fieldValues: [], + departmentId: departmentId || null }) }); if (!response.ok) { diff --git a/src/webparts/requestPortal/shared/approval/approvalSubmission.service.ts b/src/webparts/requestPortal/shared/approval/approvalSubmission.service.ts index b39efad..b06b3b2 100644 --- a/src/webparts/requestPortal/shared/approval/approvalSubmission.service.ts +++ b/src/webparts/requestPortal/shared/approval/approvalSubmission.service.ts @@ -8,15 +8,17 @@ import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses'; // Планируемый маршрут для НОВОЙ заявки: resolve по email инициатора → строки матрицы. // status выбирает фазу: 'Approval' (согласующие, по умолчанию) или 'Execution' // (исполнители). Имена подтягиваются по executorEmail (бэкенд отдаёт email + должность). +// departmentId — Id отдела из справочника (влияет на маршрут, если он различается по отделу). export async function getPlannedMatrix( context: WebPartContext, requestTypeId: number, userEmail: string, approval: ApprovalListService, - status: ApprovalStatus = 'Approval' + status: ApprovalStatus = 'Approval', + departmentId?: string | null ): Promise { if (!userEmail) return []; - const route = await resolveRoute(context, requestTypeId, userEmail, status); + const route = await resolveRoute(context, requestTypeId, userEmail, status, departmentId); const phase = status === 'Execution' ? APPROVAL_PHASE.execution : APPROVAL_PHASE.approval; const ordered = route.steps.slice().sort((a, b) => a.orderNo - b.orderNo); return Promise.all( diff --git a/src/webparts/requestPortal/shared/approval/approverDecision.service.ts b/src/webparts/requestPortal/shared/approval/approverDecision.service.ts index 78d1c89..19dfc3e 100644 --- a/src/webparts/requestPortal/shared/approval/approverDecision.service.ts +++ b/src/webparts/requestPortal/shared/approval/approverDecision.service.ts @@ -11,6 +11,13 @@ import { emitRequestsChanged } from '../requestEvents'; // approve/return/reject — согласующий; execute — исполнитель отметил «Выполнено». export type ApproverDecision = 'approve' | 'return' | 'reject' | 'execute'; +// Активная строка текущего пользователя: либо он сам назначен на неё +// (isDelegator=false), либо это его делегат недоступен (в отпуске и т.п.) и он, +// как делегировавший, вправе переназначить шаг ещё раз (isDelegator=true). +export interface IPendingRow extends IApprovalRow { + isDelegator: boolean; +} + // Решение → статус его строки в списке согласования. const ROW_STATUS: Record = { approve: REQUEST_STATUS.approved, @@ -35,7 +42,12 @@ export class ApproverService { // Очередь уже проставлена на запись (см. createSteps/advanceQueue) — активна // ровно одна строка со статусом «На согласовании» на фазу, искать «первую по // порядку» не нужно. - public async getPendingRowForCurrentUser(requestId: number): Promise { + // + // Если пользователь сам не назначен на активную строку, но именно он когда-то + // делегировал её текущему держателю (delegatedById) — строка тоже считается + // «его», но с isDelegator=true: DecisionPanel в этом случае даёт только + // переназначить (не решать за делегата), см. её использование там. + public async getPendingRowForCurrentUser(requestId: number): Promise { const rows = await this.approval.getForRequest(requestId); const userId = await this.approval.getCurrentUserId(); // Активные строки текущего шага: согласующий («На согласовании») или, когда @@ -45,11 +57,17 @@ export class ApproverService { const active = rows.filter( (r) => r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution ); - return active.filter((r) => r.approverId === userId)[0]; + const own = active.filter((r) => r.approverId === userId)[0]; + if (own) return { ...own, isDelegator: false }; + const delegated = active.filter((r) => r.delegatedById === userId)[0]; + return delegated ? { ...delegated, isDelegator: true } : undefined; } // Делегирование: согласующий добавляет делеганта ПЕРЕД собой (Order — между - // предыдущей строкой и своей). Делегат согласует раньше и сам не делегирует. + // предыдущей строкой и своей). Делегат согласует раньше и сам не делегирует + // (allowReassign всегда false у новой строки) — зато делегировавший (текущий + // пользователь) сохраняет право переназначить эту строку ещё раз, если делегат + // тоже окажется недоступен (см. getPendingRowForCurrentUser/isDelegator). public async delegate( requestId: number, currentRowId: number, @@ -62,6 +80,7 @@ export class ApproverService { const prevOrder = before.length > 0 ? before[before.length - 1].order : current.order - 1; const newOrder = (prevOrder + current.order) / 2; const position = await this.approval.getJobTitle(user.loginName); + const delegatedById = await this.approval.getCurrentUserId(); // Делегат наследует фазу той строки, вместо которой согласует (согласование/исполнение). // Обе строки — делегат и прежний согласующий — уходят в «Ждёт очереди», // activateNextQueued затем активирует делегата (у него меньший order). @@ -70,7 +89,8 @@ export class ApproverService { { email: user.email, position, allowReassign: false }, newOrder, REQUEST_STATUS.queued, - current.phase + current.phase, + delegatedById ); await this.approval.setStatus(current.id, REQUEST_STATUS.queued); await this.approval.activateNextQueued(requestId); diff --git a/src/webparts/requestPortal/shared/approval/index.ts b/src/webparts/requestPortal/shared/approval/index.ts index b1d0016..9d258b6 100644 --- a/src/webparts/requestPortal/shared/approval/index.ts +++ b/src/webparts/requestPortal/shared/approval/index.ts @@ -22,7 +22,7 @@ export { getRequestTypes, resolveRoute } from './approvalRoute.service'; export { ApprovalListService } from './approvalList.service'; export type { IApproverToWrite } from './approvalList.service'; export { ApproverService } from './approverDecision.service'; -export type { ApproverDecision } from './approverDecision.service'; +export type { ApproverDecision, IPendingRow } from './approverDecision.service'; export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service'; export { notifyActiveStep } from './notify.service'; export type { INotifyTarget } from './notify.service'; diff --git a/src/webparts/requestPortal/shared/approval/models/IApprovalRow.ts b/src/webparts/requestPortal/shared/approval/models/IApprovalRow.ts index e1eb57c..69d6d8a 100644 --- a/src/webparts/requestPortal/shared/approval/models/IApprovalRow.ts +++ b/src/webparts/requestPortal/shared/approval/models/IApprovalRow.ts @@ -6,4 +6,8 @@ export interface IApprovalRow { order: number; // колонка Order (очередь согласования) allowReassign: boolean; // AllowReassignToOtherApprovers phase: string; // Phase: «Согласование» | «Исполнение» (см. APPROVAL_PHASE) + /** Id пользователя сайта, который делегировал ЭТУ строку (DelegatedBy). 0 — строка не делегирована + * (пришла из маршрута напрямую). Позволяет делегировавшему переназначить шаг ещё раз, + * если делегат недоступен (например, в отпуске) — см. ApproverService.getPendingRowForCurrentUser. */ + delegatedById: number; } diff --git a/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.module.scss b/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.module.scss index 8144f76..827c7ad 100644 --- a/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.module.scss +++ b/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.module.scss @@ -24,6 +24,13 @@ margin-top: 16px; } +// Блок переназначения (делегирования) — отделён от кнопок решения тонкой линией. +.delegate { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid $gray-200; +} + .state { margin-top: 16px; padding: 24px; diff --git a/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.tsx b/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.tsx index c21503e..26f0848 100644 --- a/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.tsx +++ b/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.tsx @@ -2,8 +2,10 @@ import * as React from 'react'; import { useState, useEffect } from 'react'; import styles from './DecisionPanel.module.scss'; import { Button, Alert, FormField, TextArea, Icon } from '../../ui'; -import { ApproverService, ApproverDecision } from '../approverDecision.service'; +import { ApproverService, ApproverDecision, IPendingRow } from '../approverDecision.service'; import { APPROVAL_PHASE } from '../models/statuses'; +import { ISiteUser } from '../models/ISiteUser'; +import { UserPicker } from './UserPicker'; interface IDecisionPanelProps { service: ApproverService; @@ -14,22 +16,31 @@ interface IDecisionPanelProps { /** Выполнить ПЕРЕД применением решения (напр. сохранить поля, заполненные * согласующим). Если бросит — решение не применяется, показывается ошибка. */ onBeforeDecide?: () => Promise; + /** Поиск пользователей — нужен для переназначения (делегирования). Без него + * кнопка «Переназначить» не показывается. */ + search?: (query: string) => Promise; + /** Вызывается после успешного переназначения (обновить экран/матрицу). */ + onDelegated?: () => void; } -// Панель решения согласующего: проверяет, его ли сейчас очередь, и если да — -// показывает комментарий и кнопки Согласовать / Вернуть / Отклонить. +// Панель решения текущего шага: согласующий (Согласовать/Вернуть/Отклонить) или +// исполнитель («Выполнено»). Если у шага разрешено переназначение (allowReassign) +// — можно передать шаг другому сотруднику (делегат встаёт перед текущим). export const DecisionPanel: React.FC = ({ service, requestId, onDecided, onBeforeDecide, + search, + onDelegated, }) => { const [checking, setChecking] = useState(true); - const [rowId, setRowId] = useState(undefined); - const [phase, setPhase] = useState(APPROVAL_PHASE.approval); + const [pendingRow, setPendingRow] = useState(undefined); const [comment, setComment] = useState(''); const [submitting, setSubmitting] = useState(false); const [resultStatus, setResultStatus] = useState(undefined); + const [delegated, setDelegated] = useState(false); + const [showDelegate, setShowDelegate] = useState(false); const [error, setError] = useState(null); useEffect(() => { @@ -39,16 +50,13 @@ export const DecisionPanel: React.FC = ({ .getPendingRowForCurrentUser(requestId) .then((row) => { if (alive) { - setRowId(row?.id); - if (row) { - setPhase(row.phase); - } + setPendingRow(row); setChecking(false); } }) .catch((e) => { if (alive) { - setError(`Не удалось проверить очередь согласования: ${(e as Error).message}`); + setError(`Не удалось проверить очередь: ${(e as Error).message}`); setChecking(false); } }); @@ -58,7 +66,7 @@ export const DecisionPanel: React.FC = ({ }, [requestId, service]); async function decide(decision: ApproverDecision): Promise { - if (rowId === undefined) { + if (!pendingRow) { return; } if ((decision === 'return' || decision === 'reject') && comment.trim().length === 0) { @@ -68,11 +76,10 @@ export const DecisionPanel: React.FC = ({ setError(null); setSubmitting(true); try { - // Сохраняем правки согласующего (если есть) ДО применения решения. if (onBeforeDecide) { await onBeforeDecide(); } - const status = await service.decide(requestId, rowId, decision, comment.trim()); + const status = await service.decide(requestId, pendingRow.id, decision, comment.trim()); setResultStatus(status); setSubmitting(false); if (onDecided) { @@ -84,14 +91,46 @@ export const DecisionPanel: React.FC = ({ } } + async function handleDelegate(user: ISiteUser): Promise { + if (!pendingRow) { + return; + } + setError(null); + setSubmitting(true); + try { + await service.delegate(requestId, pendingRow.id, user); + setDelegated(true); + setSubmitting(false); + if (onDelegated) { + onDelegated(); + } + } catch (e) { + setError(`Не удалось переназначить: ${(e as Error).message}`); + setSubmitting(false); + } + } + + const phase = pendingRow?.phase ?? APPROVAL_PHASE.approval; const isExecution = phase === APPROVAL_PHASE.execution; + // Делегировавший переназначает вне зависимости от allowReassign строки — это + // его собственное право (создал шаг — может его поправить), а не право делегата. + const canDelegate = !!search && !!pendingRow && (pendingRow.isDelegator || pendingRow.allowReassign); + const isDelegator = !!pendingRow?.isDelegator; // Пока проверяем очередь или если действий нет (не твоя очередь / заявка // обработана / тебя нет в маршруте) — панель не показываем вовсе. - if (checking || rowId === undefined) { + if (checking || !pendingRow) { return null; } + if (delegated) { + return ( +
+ Шаг переназначен — заявка передана выбранному сотруднику. +
+ ); + } + if (resultStatus) { return (
@@ -103,9 +142,42 @@ export const DecisionPanel: React.FC = ({ ); } - // Фаза исполнения: одно действие «Выполнено» (без обязательного комментария), - // строка помечается как «Согласовано» → computeRequestStatus переведёт заявку в - // «Выполнение»/«Закрыта». + // Блок переназначения — общий для согласующего и исполнителя (когда разрешено). + const delegateBlock = canDelegate ? ( +
+ {showDelegate ? ( + + + + ) : ( + + )} +
+ ) : null; + + // Строка сейчас у делегата, а не у самого пользователя — решать за делегата + // нельзя, только переназначить (если он, например, недоступен — в отпуске). + if (isDelegator) { + return ( +
+
{isExecution ? 'Исполнение' : 'Ваше решение'}
+ + Этот шаг сейчас у сотрудника, которому вы его переназначили. Если он недоступен — + переназначьте шаг ещё раз. + + {error && {error}} + {delegateBlock} +
+ ); + } + if (isExecution) { return (
@@ -125,6 +197,7 @@ export const DecisionPanel: React.FC = ({ Выполнено
+ {delegateBlock}
); } @@ -155,6 +228,7 @@ export const DecisionPanel: React.FC = ({ Отклонить + {delegateBlock} ); }; diff --git a/src/webparts/requestPortal/shared/config.ts b/src/webparts/requestPortal/shared/config.ts index 1bd0980..98ab180 100644 --- a/src/webparts/requestPortal/shared/config.ts +++ b/src/webparts/requestPortal/shared/config.ts @@ -18,6 +18,13 @@ export const DATA_SITE_URL = 'https://tnsukz.sharepoint.com/sites/requests'; export const APPROVAL_API_CLIENT_ID = '9232d9e2-9807-4753-808f-07250e5474b0'; export const APPROVAL_API_BASE_URL = 'https://api.tnsu.kz/approval-route'; +/** + * Справочники (Dictionary API) — тот же gateway api.tnsu.kz и тот же AAD-app + * (APPROVAL_API_CLIENT_ID), но другой путь/сервис. Используется, например, для + * GET /Dictionary/Departments. + */ +export const DICTIONARY_API_BASE_URL = 'https://api.tnsu.kz'; + /** * Power Automate: поток оповещения согласующего/исполнителя. * Триггер «When an HTTP request is received» защищён Entra («любой пользователь diff --git a/src/webparts/requestPortal/shared/dictionary/departments.service.ts b/src/webparts/requestPortal/shared/dictionary/departments.service.ts new file mode 100644 index 0000000..7259e42 --- /dev/null +++ b/src/webparts/requestPortal/shared/dictionary/departments.service.ts @@ -0,0 +1,32 @@ +import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http'; +import { WebPartContext } from '@microsoft/sp-webpart-base'; +import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL } from '../config'; +import { IDepartment } from './models/IDepartment'; + +// GET /Dictionary/Departments — справочник отделов по всем компаниям, ответ — +// плоский массив (без обёртки {data: ...}, в отличие от approval-route API). +async function fetchDepartments(context: WebPartContext): Promise { + const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID); + const response: HttpClientResponse = await client.get( + `${DICTIONARY_API_BASE_URL}/Dictionary/Departments`, + AadHttpClient.configurations.v1 + ); + if (!response.ok) { + throw new Error(`Dictionary/Departments: ${response.status} ${response.statusText}`); + } + return response.json(); +} + +// Справочник большой (сотни записей) и в рамках сессии не меняется — грузим +// один раз и отдаём тот же промис повторным вызовам (иначе каждое открытие +// формы/пикера заново тянуло бы весь список). +let cache: Promise | undefined; +export function getDepartments(context: WebPartContext): Promise { + if (!cache) { + cache = fetchDepartments(context).catch((e) => { + cache = undefined; + throw e; + }); + } + return cache; +} diff --git a/src/webparts/requestPortal/shared/dictionary/index.ts b/src/webparts/requestPortal/shared/dictionary/index.ts new file mode 100644 index 0000000..e214f95 --- /dev/null +++ b/src/webparts/requestPortal/shared/dictionary/index.ts @@ -0,0 +1,4 @@ +export { getDepartments } from './departments.service'; +export type { IDepartment } from './models/IDepartment'; +export { DepartmentPicker } from './ui/DepartmentPicker'; +export type { IDepartmentPickerProps } from './ui/DepartmentPicker'; diff --git a/src/webparts/requestPortal/shared/dictionary/models/IDepartment.ts b/src/webparts/requestPortal/shared/dictionary/models/IDepartment.ts new file mode 100644 index 0000000..85832e1 --- /dev/null +++ b/src/webparts/requestPortal/shared/dictionary/models/IDepartment.ts @@ -0,0 +1,7 @@ +// GET /Dictionary/Departments (api.tnsu.kz) → IDepartment[] +export interface IDepartment { + Id: string; + Name: string; + CompanyId: string; + Company: string; +} diff --git a/src/webparts/requestPortal/shared/dictionary/ui/DepartmentPicker.module.scss b/src/webparts/requestPortal/shared/dictionary/ui/DepartmentPicker.module.scss new file mode 100644 index 0000000..399e6ff --- /dev/null +++ b/src/webparts/requestPortal/shared/dictionary/ui/DepartmentPicker.module.scss @@ -0,0 +1,63 @@ +@import '../../../../../styles/design-tokens'; + +.wrap { + position: relative; +} + +.input { + box-sizing: border-box; + width: 100%; + padding: 11px 13px; + border: 1px solid $gray-300; + border-radius: $r-sm; + background: $white; + font-family: $font-sans; + font-size: 13.5px; + color: $gray-900; + transition: .18s; + + @include input-focus-gold; + @include readonly-field(':read-only'); + + &::placeholder { + color: $gray-400; + } +} + +.list { + position: absolute; + z-index: 20; + left: 0; + right: 0; + margin-top: $sp-1; + max-height: 260px; + overflow-y: auto; + background: $white; + border: 1px solid $gray-200; + border-radius: $r-sm; + box-shadow: $sh-2; +} + +.item { + display: flex; + flex-direction: column; + gap: 2px; + width: 100%; + padding: 9px 12px; + border: 0; + background: transparent; + text-align: left; + font-family: $font-sans; + font-size: $fs-sm; + color: $gray-900; + cursor: pointer; + + &:hover { + background: $navy-50; + } +} + +.company { + font-size: $fs-caption; + color: $gray-500; +} diff --git a/src/webparts/requestPortal/shared/dictionary/ui/DepartmentPicker.tsx b/src/webparts/requestPortal/shared/dictionary/ui/DepartmentPicker.tsx new file mode 100644 index 0000000..2ad7456 --- /dev/null +++ b/src/webparts/requestPortal/shared/dictionary/ui/DepartmentPicker.tsx @@ -0,0 +1,91 @@ +import * as React from 'react'; +import type { WebPartContext } from '@microsoft/sp-webpart-base'; +import styles from './DepartmentPicker.module.scss'; +import { useFormReadOnly } from '../../ui/FormMode'; +import { getDepartments } from '../departments.service'; +import { IDepartment } from '../models/IDepartment'; + +export interface IDepartmentPickerProps { + context: WebPartContext; + id?: string; + /** Текущее название отдела (то, что видно в поле). */ + value: string; + /** name — введённый/выбранный текст; id — Id из справочника ('' если текст введён вручную и не выбран из списка). */ + onChange: (name: string, id: string) => void; + placeholder?: string; + /** Только чтение (режим согласующего). */ + readOnly?: boolean; +} + +const MAX_SUGGESTIONS = 30; + +/** Поле «Отдел / участок» со справочником (Dictionary/Departments): ввод текста фильтрует список, выбор проставляет Id. */ +export const DepartmentPicker: React.FC = ({ + context, + id, + value, + onChange, + placeholder, + readOnly +}) => { + const contextReadOnly = useFormReadOnly(); + const effectiveReadOnly = readOnly ?? contextReadOnly; + + const [departments, setDepartments] = React.useState([]); + const [open, setOpen] = React.useState(false); + + React.useEffect(() => { + let alive = true; + getDepartments(context) + .then((list) => { + if (alive) setDepartments(list); + }) + .catch(() => undefined); + return () => { + alive = false; + }; + }, [context]); + + const suggestions = React.useMemo(() => { + const query = value.trim().toLowerCase(); + if (!query) return []; + return departments.filter((d) => d.Name.toLowerCase().indexOf(query) !== -1).slice(0, MAX_SUGGESTIONS); + }, [departments, value]); + + const handlePick = (dept: IDepartment): void => { + onChange(dept.Name, dept.Id); + setOpen(false); + }; + + return ( +
+ onChange(e.target.value, '')} + onFocus={() => setOpen(true)} + onBlur={() => setOpen(false)} + /> + {!effectiveReadOnly && open && suggestions.length > 0 && ( +
+ {suggestions.map((d) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/src/webparts/requestPortal/shared/requestCode.ts b/src/webparts/requestPortal/shared/requestCode.ts new file mode 100644 index 0000000..5432026 --- /dev/null +++ b/src/webparts/requestPortal/shared/requestCode.ts @@ -0,0 +1,11 @@ +/** + * Уникальный код заявки: {requestTypeId}-{YY}{MM}-{itemId}. + * requestTypeId — id типа заявки во внешнем approval-route API (GET /api/request-types). + * YY/MM — год и месяц подачи, itemId — Id элемента в списке данных. Пример: 1-2608-12. + */ +export function buildRequestUniqueCode(requestTypeId: number, itemId: number, submittedAt: Date): string { + const yy = String(submittedAt.getFullYear()).slice(-2); + const month = submittedAt.getMonth() + 1; + const mm = month < 10 ? `0${month}` : String(month); + return `${requestTypeId}-${yy}${mm}-${itemId}`; +} diff --git a/src/webparts/requestPortal/shared/ui/RequestNumber/RequestNumber.tsx b/src/webparts/requestPortal/shared/ui/RequestNumber/RequestNumber.tsx index 67fcebc..1886ff1 100644 --- a/src/webparts/requestPortal/shared/ui/RequestNumber/RequestNumber.tsx +++ b/src/webparts/requestPortal/shared/ui/RequestNumber/RequestNumber.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import styles from './RequestNumber.module.scss'; -/** Номер заявки (Id элемента списка) — плашка в правом углу шапки формы. */ -export const RequestNumber: React.FC<{ id: number }> = ({ id }) => ( - №{id} +/** Уникальный код заявки ({requestTypeId}-{YY}{MM}-{itemId}) — плашка в правом углу шапки формы. */ +export const RequestNumber: React.FC<{ code: string }> = ({ code }) => ( + №{code} );