Merge remote-tracking branch 'origin/main' into rework-list
Tests / frontend-test (pull_request) Successful in 1m9s
Tests / frontend-test (pull_request) Successful in 1m9s
# Conflicts: # src/webparts/requestPortal/forms/RecruitingHire/load.ts # src/webparts/requestPortal/forms/WorkplaceSetup/load.ts
This commit is contained in:
@@ -55,6 +55,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
// после первого сохранения.
|
// после первого сохранения.
|
||||||
const [requestId, setRequestId] = useState<number | undefined>(editId);
|
const [requestId, setRequestId] = useState<number | undefined>(editId);
|
||||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
|
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||||
const submit = useRequestSubmit();
|
const submit = useRequestSubmit();
|
||||||
|
|
||||||
const readOnly = !isEditableStatus(requestStatus);
|
const readOnly = !isEditableStatus(requestStatus);
|
||||||
@@ -73,6 +74,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
if (alive) {
|
if (alive) {
|
||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setRequestStatus(loaded.status);
|
setRequestStatus(loaded.status);
|
||||||
|
setRequestCode(loaded.code || undefined);
|
||||||
setExistingAttachments(atts);
|
setExistingAttachments(atts);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -152,7 +154,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
};
|
};
|
||||||
|
|
||||||
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
|
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
|
||||||
const id = await saveHireRequest(
|
const { id, code } = await saveHireRequest(
|
||||||
requestId,
|
requestId,
|
||||||
data,
|
data,
|
||||||
toApprovers(matrix.rows),
|
toApprovers(matrix.rows),
|
||||||
@@ -161,6 +163,9 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
);
|
);
|
||||||
setRequestId(id);
|
setRequestId(id);
|
||||||
setRequestStatus(status);
|
setRequestStatus(status);
|
||||||
|
if (code) {
|
||||||
|
setRequestCode(code);
|
||||||
|
}
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
await uploadAttachments(DATA_LIST_TITLE, id, files); // файлы — после создания элемента
|
await uploadAttachments(DATA_LIST_TITLE, id, files); // файлы — после создания элемента
|
||||||
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
|
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
|
||||||
@@ -218,7 +223,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
requestId || requestStatus ? (
|
requestId || requestStatus ? (
|
||||||
<>
|
<>
|
||||||
{requestStatus && <StatusBadge status={requestStatus} />}
|
{requestStatus && <StatusBadge status={requestStatus} />}
|
||||||
{requestId && <RequestNumber id={requestId} />}
|
{requestCode && <RequestNumber code={requestCode} />}
|
||||||
</>
|
</>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const NOOP_SET: SetField = () => undefined;
|
|||||||
const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemId }) => {
|
const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemId }) => {
|
||||||
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 [attachments, setAttachments] = useState<IAttachmentInfo[]>([]);
|
const [attachments, setAttachments] = useState<IAttachmentInfo[]>([]);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -89,6 +90,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
if (alive) {
|
if (alive) {
|
||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
|
setCode(loaded.code);
|
||||||
setAttachments(files);
|
setAttachments(files);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -140,7 +142,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
<div className="rp-print-root">
|
<div className="rp-print-root">
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Заявка на прием"
|
title="Заявка на прием"
|
||||||
subtitle={`№${itemId}`}
|
subtitle={`№${code || itemId}`}
|
||||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||||
>
|
>
|
||||||
<FormModeProvider readOnly>
|
<FormModeProvider readOnly>
|
||||||
@@ -177,6 +179,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
<DecisionPanel
|
<DecisionPanel
|
||||||
service={approverService}
|
service={approverService}
|
||||||
requestId={itemId}
|
requestId={itemId}
|
||||||
|
search={(q) => approval.searchUsers(q)}
|
||||||
onDecided={(newStatus) => {
|
onDecided={(newStatus) => {
|
||||||
setStatus(newStatus);
|
setStatus(newStatus);
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
@@ -187,6 +190,16 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
approvalListName: APPROVAL_LIST_TITLE,
|
approvalListName: APPROVAL_LIST_TITLE,
|
||||||
}).catch(() => undefined);
|
}).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);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
||||||
{ "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": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ const SCALAR_FIELDS: (keyof IFormData)[] = [
|
|||||||
export interface ILoadedRequest {
|
export interface ILoadedRequest {
|
||||||
data: IFormData;
|
data: IFormData;
|
||||||
status: string;
|
status: string;
|
||||||
|
/** Уникальный код заявки; '' — если запись создана до появления поля. */
|
||||||
|
code: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Прочитать заявку «на прием» по id: модель формы + статус (для режима чтения). */
|
/** Прочитать заявку «на прием» по id: модель формы + статус (для режима чтения). */
|
||||||
@@ -21,7 +23,7 @@ export async function loadHireRequest(itemId: number): Promise<ILoadedRequest> {
|
|||||||
const item = (await sp.web.lists
|
const item = (await sp.web.lists
|
||||||
.getByTitle(LIST_TITLE)
|
.getByTitle(LIST_TITLE)
|
||||||
.items.getById(itemId)
|
.items.getById(itemId)
|
||||||
.select(...SCALAR_FIELDS, 'RequestStatus')()) as Record<string, unknown>;
|
.select(...SCALAR_FIELDS, 'RequestStatus', 'RequestUniqueCode')()) as Record<string, unknown>;
|
||||||
|
|
||||||
const data: IFormData = { ...EMPTY_DATA };
|
const data: IFormData = { ...EMPTY_DATA };
|
||||||
const writable = data as unknown as Record<string, string>;
|
const writable = data as unknown as Record<string, string>;
|
||||||
@@ -37,5 +39,5 @@ export async function loadHireRequest(itemId: number): Promise<ILoadedRequest> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { data, status: (item.RequestStatus as string) || '' };
|
return { data, status: (item.RequestStatus as string) || '', code: (item.RequestUniqueCode as string) || '' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getSP } from '../../shared/pnp/pnpjs';
|
import { getSP } from '../../shared/pnp/pnpjs';
|
||||||
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
|
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
|
||||||
|
import { buildRequestUniqueCode } from '../../shared/requestCode';
|
||||||
import { IFormData } from './model';
|
import { IFormData } from './model';
|
||||||
|
|
||||||
export const DATA_LIST_TITLE = 'Прием на работу — данные';
|
export const DATA_LIST_TITLE = 'Прием на работу — данные';
|
||||||
@@ -37,12 +38,18 @@ function buildItem(data: IFormData, status: string): Record<string, unknown> {
|
|||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ISaveResult {
|
||||||
|
id: number;
|
||||||
|
/** Задан только при создании новой заявки (существующий код не меняется). */
|
||||||
|
code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сохранить заявку «на прием»: контент + маршрут (согласующие + исполнители) со статусом.
|
* Сохранить заявку «на прием»: контент + маршрут (согласующие + исполнители) со статусом.
|
||||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||||
* status = «Черновик» (сохранение) или «На согласовании» (отправка).
|
* status = «Черновик» (сохранение) или «На согласовании» (отправка).
|
||||||
* executors может быть пустым — тогда после согласования заявка станет «Согласовано».
|
* executors может быть пустым — тогда после согласования заявка станет «Согласовано».
|
||||||
* Возвращает Id заявки.
|
* Возвращает Id заявки (+ код при создании).
|
||||||
*/
|
*/
|
||||||
export async function saveHireRequest(
|
export async function saveHireRequest(
|
||||||
existingId: number | undefined,
|
existingId: number | undefined,
|
||||||
@@ -50,21 +57,23 @@ export async function saveHireRequest(
|
|||||||
approvers: IApproverToWrite[],
|
approvers: IApproverToWrite[],
|
||||||
executors: IApproverToWrite[],
|
executors: IApproverToWrite[],
|
||||||
status: string
|
status: string
|
||||||
): Promise<number> {
|
): Promise<ISaveResult> {
|
||||||
const list = getSP().web.lists.getByTitle(DATA_LIST_TITLE);
|
const list = getSP().web.lists.getByTitle(DATA_LIST_TITLE);
|
||||||
const item = buildItem(data, status);
|
const item = buildItem(data, status);
|
||||||
|
|
||||||
let id: number;
|
let id: number;
|
||||||
|
let code: string | undefined;
|
||||||
if (existingId) {
|
if (existingId) {
|
||||||
await list.items.getById(existingId).update(item);
|
await list.items.getById(existingId).update(item);
|
||||||
id = existingId;
|
id = existingId;
|
||||||
} else {
|
} else {
|
||||||
const added = await list.items.add(item);
|
const added = await list.items.add(item);
|
||||||
id = (added as { Id: number }).Id;
|
id = (added as { Id: number }).Id;
|
||||||
// Уникальный код заявки: пока = id (id известен только после создания).
|
// Код известен только после создания элемента (нужен его Id).
|
||||||
await list.items.getById(id).update({ RequestUniqueCode: String(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);
|
await writeRoute(hireApprovalService(), id, approvers, executors, status);
|
||||||
return id;
|
return { id, code };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({ requestType, con
|
|||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [saving, setSaving] = useState<boolean>(false);
|
const [saving, setSaving] = useState<boolean>(false);
|
||||||
const [saved, setSaved] = useState<boolean>(false);
|
const [saved, setSaved] = useState<boolean>(false);
|
||||||
|
const [savedCode, setSavedCode] = useState<string | undefined>(undefined);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const set: SetField = (key, value) => {
|
const set: SetField = (key, value) => {
|
||||||
@@ -35,8 +36,9 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({ requestType, con
|
|||||||
setError(null);
|
setError(null);
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await submitRecruitingSelection(data);
|
const code = await submitRecruitingSelection(data);
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
|
setSavedCode(code);
|
||||||
if (onSaved) {
|
if (onSaved) {
|
||||||
onSaved();
|
onSaved();
|
||||||
}
|
}
|
||||||
@@ -57,7 +59,9 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({ requestType, con
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<FormCard title={requestType.title} subtitle="Категория: Отдел рекрутинга">
|
<FormCard title={requestType.title} subtitle="Категория: Отдел рекрутинга">
|
||||||
{saved && <Alert variant="ok">Заявка сохранена в SharePoint.</Alert>}
|
{saved && (
|
||||||
|
<Alert variant="ok">Заявка сохранена в SharePoint. Код заявки: №{savedCode}.</Alert>
|
||||||
|
)}
|
||||||
{error && <Alert variant="err">{error}</Alert>}
|
{error && <Alert variant="err">{error}</Alert>}
|
||||||
|
|
||||||
<ApplicationSection {...sectionProps} />
|
<ApplicationSection {...sectionProps} />
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { getSP } from '../../shared/pnp/pnpjs';
|
import { getSP } from '../../shared/pnp/pnpjs';
|
||||||
|
import { buildRequestUniqueCode } from '../../shared/requestCode';
|
||||||
import { IFormData } from './model';
|
import { IFormData } from './model';
|
||||||
|
|
||||||
const LIST_TITLE = 'Заявка на подбор — данные';
|
const LIST_TITLE = 'Заявка на подбор — данные';
|
||||||
|
// Тип заявки во внешнем approval-route API (GET /api/request-types → id 4).
|
||||||
|
const RECRUITING_SELECTION_REQUEST_TYPE_ID = 4;
|
||||||
|
|
||||||
// Поля-люди (один человек): логин → ensureUser → Id в колонку <Field>Id.
|
// Поля-люди (один человек): логин → ensureUser → Id в колонку <Field>Id.
|
||||||
const PERSON_FIELDS: (keyof IFormData)[] = ['Responsible', 'ProposedMentor', 'DirectManager'];
|
const PERSON_FIELDS: (keyof IFormData)[] = ['Responsible', 'ProposedMentor', 'DirectManager'];
|
||||||
@@ -16,8 +19,8 @@ async function resolveUserId(login: string): Promise<number> {
|
|||||||
return ensured.Id;
|
return ensured.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Записать заявку «на подбор» в список данных SharePoint. */
|
/** Записать заявку «на подбор» в список данных SharePoint. Возвращает уникальный код заявки. */
|
||||||
export async function submitRecruitingSelection(data: IFormData): Promise<void> {
|
export async function submitRecruitingSelection(data: IFormData): Promise<string> {
|
||||||
const sp = getSP();
|
const sp = getSP();
|
||||||
const item: Record<string, unknown> = {};
|
const item: Record<string, unknown> = {};
|
||||||
|
|
||||||
@@ -56,6 +59,8 @@ export async function submitRecruitingSelection(data: IFormData): Promise<void>
|
|||||||
|
|
||||||
const added = await sp.web.lists.getByTitle(LIST_TITLE).items.add(item);
|
const added = await sp.web.lists.getByTitle(LIST_TITLE).items.add(item);
|
||||||
const id = (added as { Id: number }).Id;
|
const id = (added as { Id: number }).Id;
|
||||||
// Уникальный код заявки: пока = id (id известен только после создания).
|
// Код известен только после создания элемента (нужен его Id).
|
||||||
await sp.web.lists.getByTitle(LIST_TITLE).items.getById(id).update({ RequestUniqueCode: String(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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
// открытии на редактирование (editId) — сразу из загруженной записи.
|
// открытии на редактирование (editId) — сразу из загруженной записи.
|
||||||
const [requestId, setRequestId] = useState<number | undefined>(editId);
|
const [requestId, setRequestId] = useState<number | undefined>(editId);
|
||||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
|
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||||
const submit = useRequestSubmit();
|
const submit = useRequestSubmit();
|
||||||
|
|
||||||
// Редактировать можно новую/черновик/возвращённую/отклонённую; иначе только чтение.
|
// Редактировать можно новую/черновик/возвращённую/отклонённую; иначе только чтение.
|
||||||
@@ -74,6 +75,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
if (alive) {
|
if (alive) {
|
||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setRequestStatus(loaded.status);
|
setRequestStatus(loaded.status);
|
||||||
|
setRequestCode(loaded.code || undefined);
|
||||||
setExistingAttachments(atts);
|
setExistingAttachments(atts);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -120,12 +122,13 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
? approval
|
? approval
|
||||||
.getMatrixForRequest(editId)
|
.getMatrixForRequest(editId)
|
||||||
.then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution))
|
.then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution))
|
||||||
: getPlannedMatrix(context, WORKPLACE_REQUEST_TYPE_ID, userEmail, approval, 'Approval'),
|
: getPlannedMatrix(context, WORKPLACE_REQUEST_TYPE_ID, userEmail, approval, 'Approval', data.DepartmentId),
|
||||||
[context, userEmail, approval, editId]
|
[context, userEmail, approval, editId, data.DepartmentId]
|
||||||
);
|
);
|
||||||
const matrix = useMatrixEditor(
|
const matrix = useMatrixEditor(
|
||||||
loadMatrix,
|
loadMatrix,
|
||||||
`${userEmail}-${editId ?? 'new'}`,
|
// Отдел влияет на резолв маршрута — смена отдела у НОВОЙ заявки должна перепланировать матрицу.
|
||||||
|
`${userEmail}-${editId ?? 'new'}-${data.DepartmentId}`,
|
||||||
!readOnly,
|
!readOnly,
|
||||||
true,
|
true,
|
||||||
(login) => approval.getJobTitle(login)
|
(login) => approval.getJobTitle(login)
|
||||||
@@ -138,12 +141,12 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
? approval
|
? approval
|
||||||
.getMatrixForRequest(editId)
|
.getMatrixForRequest(editId)
|
||||||
.then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution))
|
.then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution))
|
||||||
: getPlannedMatrix(context, WORKPLACE_REQUEST_TYPE_ID, userEmail, approval, 'Execution'),
|
: getPlannedMatrix(context, WORKPLACE_REQUEST_TYPE_ID, userEmail, approval, 'Execution', data.DepartmentId),
|
||||||
[context, userEmail, approval, editId]
|
[context, userEmail, approval, editId, data.DepartmentId]
|
||||||
);
|
);
|
||||||
const executors = useMatrixEditor(
|
const executors = useMatrixEditor(
|
||||||
loadExecutors,
|
loadExecutors,
|
||||||
`${userEmail}-${editId ?? 'new'}`,
|
`${userEmail}-${editId ?? 'new'}-${data.DepartmentId}`,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
(login) => approval.getJobTitle(login)
|
(login) => approval.getJobTitle(login)
|
||||||
@@ -174,7 +177,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
};
|
};
|
||||||
|
|
||||||
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
|
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
|
||||||
const id = await saveWorkplaceRequest(
|
const { id, code } = await saveWorkplaceRequest(
|
||||||
requestId,
|
requestId,
|
||||||
data,
|
data,
|
||||||
toApprovers(matrix.rows),
|
toApprovers(matrix.rows),
|
||||||
@@ -184,6 +187,9 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
);
|
);
|
||||||
setRequestId(id);
|
setRequestId(id);
|
||||||
setRequestStatus(status);
|
setRequestStatus(status);
|
||||||
|
if (code) {
|
||||||
|
setRequestCode(code);
|
||||||
|
}
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
await uploadAttachments(DATA_LIST_TITLE, id, files);
|
await uploadAttachments(DATA_LIST_TITLE, id, files);
|
||||||
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
|
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
|
||||||
@@ -241,7 +247,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
requestId || requestStatus ? (
|
requestId || requestStatus ? (
|
||||||
<>
|
<>
|
||||||
{requestStatus && <StatusBadge status={requestStatus} />}
|
{requestStatus && <StatusBadge status={requestStatus} />}
|
||||||
{requestId && <RequestNumber id={requestId} />}
|
{requestCode && <RequestNumber code={requestCode} />}
|
||||||
</>
|
</>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
|||||||
const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemId }) => {
|
const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemId }) => {
|
||||||
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 [attachments, setAttachments] = useState<IAttachmentInfo[]>([]);
|
const [attachments, setAttachments] = useState<IAttachmentInfo[]>([]);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -101,6 +102,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
if (alive) {
|
if (alive) {
|
||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
|
setCode(loaded.code);
|
||||||
setAttachments(files);
|
setAttachments(files);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -202,7 +204,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
<div className="rp-print-root">
|
<div className="rp-print-root">
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Организация рабочего места"
|
title="Организация рабочего места"
|
||||||
subtitle={`№${itemId}`}
|
subtitle={`№${code || itemId}`}
|
||||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||||
>
|
>
|
||||||
<FormModeProvider readOnly>
|
<FormModeProvider readOnly>
|
||||||
@@ -236,6 +238,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
<DecisionPanel
|
<DecisionPanel
|
||||||
service={approverService}
|
service={approverService}
|
||||||
requestId={itemId}
|
requestId={itemId}
|
||||||
|
search={(q) => approval.searchUsers(q)}
|
||||||
onBeforeDecide={canEditFields ? saveReviewFields : undefined}
|
onBeforeDecide={canEditFields ? saveReviewFields : undefined}
|
||||||
onDecided={(newStatus) => {
|
onDecided={(newStatus) => {
|
||||||
setStatus(newStatus);
|
setStatus(newStatus);
|
||||||
@@ -247,6 +250,16 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
approvalListName: APPROVAL_LIST_TITLE,
|
approvalListName: APPROVAL_LIST_TITLE,
|
||||||
}).catch(() => undefined);
|
}).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);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
||||||
{ "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": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import { IFormData, EMPTY_DATA } from './model';
|
|||||||
const LIST_TITLE = 'Организация рабочего места — данные';
|
const LIST_TITLE = 'Организация рабочего места — данные';
|
||||||
|
|
||||||
const SCALAR_FIELDS: (keyof IFormData)[] = [
|
const SCALAR_FIELDS: (keyof IFormData)[] = [
|
||||||
'FullName', 'NameLatin', 'Phone', 'Position', 'Department', 'WorkKind', 'Location', 'StartDate',
|
'FullName', 'NameLatin', 'Phone', 'Position', 'Department', 'DepartmentId', 'WorkKind', 'Location', 'StartDate',
|
||||||
'Software', 'AccessRights'
|
'Software', 'AccessRights'
|
||||||
];
|
];
|
||||||
|
|
||||||
export interface ILoadedRequest {
|
export interface ILoadedRequest {
|
||||||
data: IFormData;
|
data: IFormData;
|
||||||
status: string;
|
status: string;
|
||||||
|
/** Уникальный код заявки; '' — если запись создана до появления поля. */
|
||||||
|
code: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Прочитать заявку по id: модель формы + статус (для режима чтения). */
|
/** Прочитать заявку по id: модель формы + статус (для режима чтения). */
|
||||||
@@ -18,7 +20,7 @@ export async function loadWorkplaceRequest(itemId: number): Promise<ILoadedReque
|
|||||||
const item = (await getSP()
|
const item = (await getSP()
|
||||||
.web.lists.getByTitle(LIST_TITLE)
|
.web.lists.getByTitle(LIST_TITLE)
|
||||||
.items.getById(itemId)
|
.items.getById(itemId)
|
||||||
.select(...SCALAR_FIELDS, 'NeedsEquipment', 'RequestStatus')()) as Record<string, unknown>;
|
.select(...SCALAR_FIELDS, 'NeedsEquipment', 'RequestStatus', 'RequestUniqueCode')()) as Record<string, unknown>;
|
||||||
|
|
||||||
const data: IFormData = { ...EMPTY_DATA };
|
const data: IFormData = { ...EMPTY_DATA };
|
||||||
const writable = data as unknown as Record<string, string>;
|
const writable = data as unknown as Record<string, string>;
|
||||||
@@ -36,5 +38,5 @@ export async function loadWorkplaceRequest(itemId: number): Promise<ILoadedReque
|
|||||||
|
|
||||||
data.NeedsEquipment = item.NeedsEquipment === true;
|
data.NeedsEquipment = item.NeedsEquipment === true;
|
||||||
|
|
||||||
return { data, status: (item.RequestStatus as string) || '' };
|
return { data, status: (item.RequestStatus as string) || '', code: (item.RequestUniqueCode as string) || '' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export interface IFormData {
|
|||||||
Phone: string;
|
Phone: string;
|
||||||
Position: string;
|
Position: string;
|
||||||
Department: string;
|
Department: string;
|
||||||
|
/** Id отдела из справочника (Dictionary/Departments); '' — если введён вручную без выбора из списка. */
|
||||||
|
DepartmentId: string;
|
||||||
WorkKind: string;
|
WorkKind: string;
|
||||||
Location: string;
|
Location: string;
|
||||||
StartDate: string;
|
StartDate: string;
|
||||||
@@ -37,6 +39,7 @@ export const EMPTY_DATA: IFormData = {
|
|||||||
Phone: '',
|
Phone: '',
|
||||||
Position: '',
|
Position: '',
|
||||||
Department: '',
|
Department: '',
|
||||||
|
DepartmentId: '',
|
||||||
WorkKind: '',
|
WorkKind: '',
|
||||||
Location: '',
|
Location: '',
|
||||||
StartDate: '',
|
StartDate: '',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
{ "internalName": "Phone", "title": "Телефон", "type": "Text" },
|
{ "internalName": "Phone", "title": "Телефон", "type": "Text" },
|
||||||
{ "internalName": "Position", "title": "Должность", "type": "Text", "required": true },
|
{ "internalName": "Position", "title": "Должность", "type": "Text", "required": true },
|
||||||
{ "internalName": "Department", "title": "Отдел / участок", "type": "Text" },
|
{ "internalName": "Department", "title": "Отдел / участок", "type": "Text" },
|
||||||
|
{ "internalName": "DepartmentId", "title": "Id отдела (справочник)", "type": "Text" },
|
||||||
{ "internalName": "WorkKind", "title": "Вид работ", "type": "Text" },
|
{ "internalName": "WorkKind", "title": "Вид работ", "type": "Text" },
|
||||||
{ "internalName": "Location", "title": "Местоположение", "type": "Text" },
|
{ "internalName": "Location", "title": "Местоположение", "type": "Text" },
|
||||||
{ "internalName": "StartDate", "title": "Дата приёма", "type": "DateTime", "dateOnly": true },
|
{ "internalName": "StartDate", "title": "Дата приёма", "type": "DateTime", "dateOnly": true },
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { CollapseSection, FormField, TextInput, PhoneInput } from '../../../shared/ui';
|
import { CollapseSection, FormField, TextInput, PhoneInput } from '../../../shared/ui';
|
||||||
|
import { DepartmentPicker } from '../../../shared/dictionary';
|
||||||
import { ISectionProps } from '../model';
|
import { ISectionProps } from '../model';
|
||||||
|
|
||||||
export const EmployeeSection: React.FC<ISectionProps> = ({ data, set }) => (
|
export const EmployeeSection: React.FC<ISectionProps> = ({ data, set, context }) => (
|
||||||
<CollapseSection title="Сотрудник" icon="people">
|
<CollapseSection title="Сотрудник" icon="people">
|
||||||
<FormField label="Ф.И.О." htmlFor="FullName" required full>
|
<FormField label="Ф.И.О." htmlFor="FullName" required full>
|
||||||
<TextInput id="FullName" value={data.FullName} onChange={(v) => set('FullName', v)} />
|
<TextInput id="FullName" value={data.FullName} onChange={(v) => set('FullName', v)} />
|
||||||
@@ -17,7 +18,15 @@ export const EmployeeSection: React.FC<ISectionProps> = ({ data, set }) => (
|
|||||||
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
|
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Отдел / участок" htmlFor="Department">
|
<FormField label="Отдел / участок" htmlFor="Department">
|
||||||
<TextInput id="Department" value={data.Department} onChange={(v) => set('Department', v)} />
|
<DepartmentPicker
|
||||||
|
id="Department"
|
||||||
|
context={context}
|
||||||
|
value={data.Department}
|
||||||
|
onChange={(name, id) => {
|
||||||
|
set('Department', name);
|
||||||
|
set('DepartmentId', id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Вид работ" htmlFor="WorkKind">
|
<FormField label="Вид работ" htmlFor="WorkKind">
|
||||||
<TextInput id="WorkKind" value={data.WorkKind} onChange={(v) => set('WorkKind', v)} />
|
<TextInput id="WorkKind" value={data.WorkKind} onChange={(v) => set('WorkKind', v)} />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getSP } from '../../shared/pnp/pnpjs';
|
import { getSP } from '../../shared/pnp/pnpjs';
|
||||||
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
|
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
|
||||||
|
import { buildRequestUniqueCode } from '../../shared/requestCode';
|
||||||
import { IFormData } from './model';
|
import { IFormData } from './model';
|
||||||
|
|
||||||
export const DATA_LIST_TITLE = 'Организация рабочего места — данные';
|
export const DATA_LIST_TITLE = 'Организация рабочего места — данные';
|
||||||
@@ -51,11 +52,17 @@ function buildItem(data: IFormData, status: string, sourceHireId?: number): Reco
|
|||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ISaveResult {
|
||||||
|
id: number;
|
||||||
|
/** Задан только при создании новой заявки (существующий код не меняется). */
|
||||||
|
code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сохранить заявку «орг. рабочего места»: контент + маршрут (согласующие + исполнители).
|
* Сохранить заявку «орг. рабочего места»: контент + маршрут (согласующие + исполнители).
|
||||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||||
* Исполнители пишутся сразу — фаза исполнения стартует автоматически после согласования.
|
* Исполнители пишутся сразу — фаза исполнения стартует автоматически после согласования.
|
||||||
* sourceHireId — если заявка создана на основе «прием». Возвращает Id заявки.
|
* sourceHireId — если заявка создана на основе «прием». Возвращает Id заявки (+ код при создании).
|
||||||
*/
|
*/
|
||||||
export async function saveWorkplaceRequest(
|
export async function saveWorkplaceRequest(
|
||||||
existingId: number | undefined,
|
existingId: number | undefined,
|
||||||
@@ -64,21 +71,23 @@ export async function saveWorkplaceRequest(
|
|||||||
executors: IApproverToWrite[],
|
executors: IApproverToWrite[],
|
||||||
status: string,
|
status: string,
|
||||||
sourceHireId?: number
|
sourceHireId?: number
|
||||||
): Promise<number> {
|
): Promise<ISaveResult> {
|
||||||
const list = getSP().web.lists.getByTitle(DATA_LIST_TITLE);
|
const list = getSP().web.lists.getByTitle(DATA_LIST_TITLE);
|
||||||
const item = buildItem(data, status, sourceHireId);
|
const item = buildItem(data, status, sourceHireId);
|
||||||
|
|
||||||
let id: number;
|
let id: number;
|
||||||
|
let code: string | undefined;
|
||||||
if (existingId) {
|
if (existingId) {
|
||||||
await list.items.getById(existingId).update(item);
|
await list.items.getById(existingId).update(item);
|
||||||
id = existingId;
|
id = existingId;
|
||||||
} else {
|
} else {
|
||||||
const added = await list.items.add(item);
|
const added = await list.items.add(item);
|
||||||
id = (added as { Id: number }).Id;
|
id = (added as { Id: number }).Id;
|
||||||
// Уникальный код заявки: пока = id (id известен только после создания).
|
// Код известен только после создания элемента (нужен его Id).
|
||||||
await list.items.getById(id).update({ RequestUniqueCode: String(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);
|
await writeRoute(workplaceApprovalService(), id, approvers, executors, status);
|
||||||
return id;
|
return { id, code };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const FIELDS = {
|
|||||||
// скрытое системное поле SharePoint (не видно в UI списка).
|
// скрытое системное поле SharePoint (не видно в UI списка).
|
||||||
allowReassign: 'AllowReassignToOtherApprovers', // «Можно перепоручать» (Да/Нет)
|
allowReassign: 'AllowReassignToOtherApprovers', // «Можно перепоручать» (Да/Нет)
|
||||||
phase: 'Phase', // «Фаза» (Choice): Согласование | Исполнение
|
phase: 'Phase', // «Фаза» (Choice): Согласование | Исполнение
|
||||||
|
delegatedBy: 'DelegatedBy', // «Кем переназначено» (User) → DelegatedById
|
||||||
};
|
};
|
||||||
|
|
||||||
// Согласующий для записи в список.
|
// Согласующий для записи в список.
|
||||||
@@ -146,12 +147,16 @@ export class ApprovalListService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Добавляет одну строку согласования с заданным Order (для делегата).
|
// Добавляет одну строку согласования с заданным Order (для делегата).
|
||||||
|
// delegatedById — Id пользователя, который выполнил переназначение (не строки
|
||||||
|
// из маршрута — у них делегата нет). Он сохраняет право переназначить эту
|
||||||
|
// строку ещё раз, если делегат недоступен (см. ApproverService.delegate).
|
||||||
public async addApproverRow(
|
public async addApproverRow(
|
||||||
requestId: number,
|
requestId: number,
|
||||||
approver: IApproverToWrite,
|
approver: IApproverToWrite,
|
||||||
order: number,
|
order: number,
|
||||||
status: string,
|
status: string,
|
||||||
phase: string = APPROVAL_PHASE.approval
|
phase: string = APPROVAL_PHASE.approval,
|
||||||
|
delegatedById?: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const approverId = await this.resolveUserId(approver.email);
|
const approverId = await this.resolveUserId(approver.email);
|
||||||
await this.list().items.add({
|
await this.list().items.add({
|
||||||
@@ -162,6 +167,7 @@ export class ApprovalListService {
|
|||||||
[FIELDS.order]: order,
|
[FIELDS.order]: order,
|
||||||
[FIELDS.allowReassign]: approver.allowReassign,
|
[FIELDS.allowReassign]: approver.allowReassign,
|
||||||
[FIELDS.phase]: phase,
|
[FIELDS.phase]: phase,
|
||||||
|
...(delegatedById ? { [`${FIELDS.delegatedBy}Id`]: delegatedById } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,12 +175,21 @@ export class ApprovalListService {
|
|||||||
public async getForRequest(requestId: number): Promise<IApprovalRow[]> {
|
public async getForRequest(requestId: number): Promise<IApprovalRow[]> {
|
||||||
const rows = (await this.list()
|
const rows = (await this.list()
|
||||||
.items.filter(`${FIELDS.ticket}Id eq ${requestId}`)
|
.items.filter(`${FIELDS.ticket}Id eq ${requestId}`)
|
||||||
.select('Id', FIELDS.status, FIELDS.order, FIELDS.allowReassign, FIELDS.phase, `${FIELDS.approver}/Id`)
|
.select(
|
||||||
.expand(FIELDS.approver)
|
'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<Record<string, unknown>>;
|
.orderBy(FIELDS.order, true)()) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
return rows.map((r) => {
|
return rows.map((r) => {
|
||||||
const approver = r[FIELDS.approver] as { Id?: number } | undefined;
|
const approver = r[FIELDS.approver] as { Id?: number } | undefined;
|
||||||
|
const delegatedBy = r[FIELDS.delegatedBy] as { Id?: number } | undefined;
|
||||||
return {
|
return {
|
||||||
id: r.Id as number,
|
id: r.Id as number,
|
||||||
status: (r[FIELDS.status] as string) || '',
|
status: (r[FIELDS.status] as string) || '',
|
||||||
@@ -183,6 +198,7 @@ export class ApprovalListService {
|
|||||||
allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false,
|
allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false,
|
||||||
// Пустая фаза (старые строки без колонки) трактуется как «Согласование».
|
// Пустая фаза (старые строки без колонки) трактуется как «Согласование».
|
||||||
phase: (r[FIELDS.phase] as string) || APPROVAL_PHASE.approval,
|
phase: (r[FIELDS.phase] as string) || APPROVAL_PHASE.approval,
|
||||||
|
delegatedById: delegatedBy?.Id ?? 0,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ export async function resolveRoute(
|
|||||||
context: WebPartContext,
|
context: WebPartContext,
|
||||||
requestTypeId: number,
|
requestTypeId: number,
|
||||||
userEmail: string,
|
userEmail: string,
|
||||||
status: ApprovalStatus = 'Approval'
|
status: ApprovalStatus = 'Approval',
|
||||||
|
departmentId: string | null = null
|
||||||
): Promise<IResolvedRoute> {
|
): Promise<IResolvedRoute> {
|
||||||
const c = await client(context);
|
const c = await client(context);
|
||||||
const url = `${APPROVAL_API_BASE_URL}/api/template-routes/resolve`;
|
const url = `${APPROVAL_API_BASE_URL}/api/template-routes/resolve`;
|
||||||
@@ -50,7 +51,8 @@ export async function resolveRoute(
|
|||||||
userEmail,
|
userEmail,
|
||||||
projectId: null,
|
projectId: null,
|
||||||
status,
|
status,
|
||||||
fieldValues: []
|
fieldValues: [],
|
||||||
|
departmentId: departmentId || null
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -8,15 +8,17 @@ import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses';
|
|||||||
// Планируемый маршрут для НОВОЙ заявки: resolve по email инициатора → строки матрицы.
|
// Планируемый маршрут для НОВОЙ заявки: resolve по email инициатора → строки матрицы.
|
||||||
// status выбирает фазу: 'Approval' (согласующие, по умолчанию) или 'Execution'
|
// status выбирает фазу: 'Approval' (согласующие, по умолчанию) или 'Execution'
|
||||||
// (исполнители). Имена подтягиваются по executorEmail (бэкенд отдаёт email + должность).
|
// (исполнители). Имена подтягиваются по executorEmail (бэкенд отдаёт email + должность).
|
||||||
|
// departmentId — Id отдела из справочника (влияет на маршрут, если он различается по отделу).
|
||||||
export async function getPlannedMatrix(
|
export async function getPlannedMatrix(
|
||||||
context: WebPartContext,
|
context: WebPartContext,
|
||||||
requestTypeId: number,
|
requestTypeId: number,
|
||||||
userEmail: string,
|
userEmail: string,
|
||||||
approval: ApprovalListService,
|
approval: ApprovalListService,
|
||||||
status: ApprovalStatus = 'Approval'
|
status: ApprovalStatus = 'Approval',
|
||||||
|
departmentId?: string | null
|
||||||
): Promise<IMatrixRow[]> {
|
): Promise<IMatrixRow[]> {
|
||||||
if (!userEmail) return [];
|
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 phase = status === 'Execution' ? APPROVAL_PHASE.execution : APPROVAL_PHASE.approval;
|
||||||
const ordered = route.steps.slice().sort((a, b) => a.orderNo - b.orderNo);
|
const ordered = route.steps.slice().sort((a, b) => a.orderNo - b.orderNo);
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ import { emitRequestsChanged } from '../requestEvents';
|
|||||||
// approve/return/reject — согласующий; execute — исполнитель отметил «Выполнено».
|
// approve/return/reject — согласующий; execute — исполнитель отметил «Выполнено».
|
||||||
export type ApproverDecision = '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<ApproverDecision, string> = {
|
const ROW_STATUS: Record<ApproverDecision, string> = {
|
||||||
approve: REQUEST_STATUS.approved,
|
approve: REQUEST_STATUS.approved,
|
||||||
@@ -35,7 +42,12 @@ export class ApproverService {
|
|||||||
// Очередь уже проставлена на запись (см. createSteps/advanceQueue) — активна
|
// Очередь уже проставлена на запись (см. createSteps/advanceQueue) — активна
|
||||||
// ровно одна строка со статусом «На согласовании» на фазу, искать «первую по
|
// ровно одна строка со статусом «На согласовании» на фазу, искать «первую по
|
||||||
// порядку» не нужно.
|
// порядку» не нужно.
|
||||||
public async getPendingRowForCurrentUser(requestId: number): Promise<IApprovalRow | undefined> {
|
//
|
||||||
|
// Если пользователь сам не назначен на активную строку, но именно он когда-то
|
||||||
|
// делегировал её текущему держателю (delegatedById) — строка тоже считается
|
||||||
|
// «его», но с isDelegator=true: DecisionPanel в этом случае даёт только
|
||||||
|
// переназначить (не решать за делегата), см. её использование там.
|
||||||
|
public async getPendingRowForCurrentUser(requestId: number): Promise<IPendingRow | undefined> {
|
||||||
const rows = await this.approval.getForRequest(requestId);
|
const rows = await this.approval.getForRequest(requestId);
|
||||||
const userId = await this.approval.getCurrentUserId();
|
const userId = await this.approval.getCurrentUserId();
|
||||||
// Активные строки текущего шага: согласующий («На согласовании») или, когда
|
// Активные строки текущего шага: согласующий («На согласовании») или, когда
|
||||||
@@ -45,11 +57,17 @@ export class ApproverService {
|
|||||||
const active = rows.filter(
|
const active = rows.filter(
|
||||||
(r) => r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution
|
(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 — между
|
// Делегирование: согласующий добавляет делеганта ПЕРЕД собой (Order — между
|
||||||
// предыдущей строкой и своей). Делегат согласует раньше и сам не делегирует.
|
// предыдущей строкой и своей). Делегат согласует раньше и сам не делегирует
|
||||||
|
// (allowReassign всегда false у новой строки) — зато делегировавший (текущий
|
||||||
|
// пользователь) сохраняет право переназначить эту строку ещё раз, если делегат
|
||||||
|
// тоже окажется недоступен (см. getPendingRowForCurrentUser/isDelegator).
|
||||||
public async delegate(
|
public async delegate(
|
||||||
requestId: number,
|
requestId: number,
|
||||||
currentRowId: number,
|
currentRowId: number,
|
||||||
@@ -62,6 +80,7 @@ export class ApproverService {
|
|||||||
const prevOrder = before.length > 0 ? before[before.length - 1].order : current.order - 1;
|
const prevOrder = before.length > 0 ? before[before.length - 1].order : current.order - 1;
|
||||||
const newOrder = (prevOrder + current.order) / 2;
|
const newOrder = (prevOrder + current.order) / 2;
|
||||||
const position = await this.approval.getJobTitle(user.loginName);
|
const position = await this.approval.getJobTitle(user.loginName);
|
||||||
|
const delegatedById = await this.approval.getCurrentUserId();
|
||||||
// Делегат наследует фазу той строки, вместо которой согласует (согласование/исполнение).
|
// Делегат наследует фазу той строки, вместо которой согласует (согласование/исполнение).
|
||||||
// Обе строки — делегат и прежний согласующий — уходят в «Ждёт очереди»,
|
// Обе строки — делегат и прежний согласующий — уходят в «Ждёт очереди»,
|
||||||
// activateNextQueued затем активирует делегата (у него меньший order).
|
// activateNextQueued затем активирует делегата (у него меньший order).
|
||||||
@@ -70,7 +89,8 @@ export class ApproverService {
|
|||||||
{ email: user.email, position, allowReassign: false },
|
{ email: user.email, position, allowReassign: false },
|
||||||
newOrder,
|
newOrder,
|
||||||
REQUEST_STATUS.queued,
|
REQUEST_STATUS.queued,
|
||||||
current.phase
|
current.phase,
|
||||||
|
delegatedById
|
||||||
);
|
);
|
||||||
await this.approval.setStatus(current.id, REQUEST_STATUS.queued);
|
await this.approval.setStatus(current.id, REQUEST_STATUS.queued);
|
||||||
await this.approval.activateNextQueued(requestId);
|
await this.approval.activateNextQueued(requestId);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export { getRequestTypes, resolveRoute } from './approvalRoute.service';
|
|||||||
export { ApprovalListService } from './approvalList.service';
|
export { ApprovalListService } from './approvalList.service';
|
||||||
export type { IApproverToWrite } from './approvalList.service';
|
export type { IApproverToWrite } from './approvalList.service';
|
||||||
export { ApproverService } from './approverDecision.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 { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service';
|
||||||
export { notifyActiveStep } from './notify.service';
|
export { notifyActiveStep } from './notify.service';
|
||||||
export type { INotifyTarget } from './notify.service';
|
export type { INotifyTarget } from './notify.service';
|
||||||
|
|||||||
@@ -6,4 +6,8 @@ export interface IApprovalRow {
|
|||||||
order: number; // колонка Order (очередь согласования)
|
order: number; // колонка Order (очередь согласования)
|
||||||
allowReassign: boolean; // AllowReassignToOtherApprovers
|
allowReassign: boolean; // AllowReassignToOtherApprovers
|
||||||
phase: string; // Phase: «Согласование» | «Исполнение» (см. APPROVAL_PHASE)
|
phase: string; // Phase: «Согласование» | «Исполнение» (см. APPROVAL_PHASE)
|
||||||
|
/** Id пользователя сайта, который делегировал ЭТУ строку (DelegatedBy). 0 — строка не делегирована
|
||||||
|
* (пришла из маршрута напрямую). Позволяет делегировавшему переназначить шаг ещё раз,
|
||||||
|
* если делегат недоступен (например, в отпуске) — см. ApproverService.getPendingRowForCurrentUser. */
|
||||||
|
delegatedById: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,13 @@
|
|||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Блок переназначения (делегирования) — отделён от кнопок решения тонкой линией.
|
||||||
|
.delegate {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid $gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
.state {
|
.state {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import * as React from 'react';
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import styles from './DecisionPanel.module.scss';
|
import styles from './DecisionPanel.module.scss';
|
||||||
import { Button, Alert, FormField, TextArea, Icon } from '../../ui';
|
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 { APPROVAL_PHASE } from '../models/statuses';
|
||||||
|
import { ISiteUser } from '../models/ISiteUser';
|
||||||
|
import { UserPicker } from './UserPicker';
|
||||||
|
|
||||||
interface IDecisionPanelProps {
|
interface IDecisionPanelProps {
|
||||||
service: ApproverService;
|
service: ApproverService;
|
||||||
@@ -14,22 +16,31 @@ interface IDecisionPanelProps {
|
|||||||
/** Выполнить ПЕРЕД применением решения (напр. сохранить поля, заполненные
|
/** Выполнить ПЕРЕД применением решения (напр. сохранить поля, заполненные
|
||||||
* согласующим). Если бросит — решение не применяется, показывается ошибка. */
|
* согласующим). Если бросит — решение не применяется, показывается ошибка. */
|
||||||
onBeforeDecide?: () => Promise<void>;
|
onBeforeDecide?: () => Promise<void>;
|
||||||
|
/** Поиск пользователей — нужен для переназначения (делегирования). Без него
|
||||||
|
* кнопка «Переназначить» не показывается. */
|
||||||
|
search?: (query: string) => Promise<ISiteUser[]>;
|
||||||
|
/** Вызывается после успешного переназначения (обновить экран/матрицу). */
|
||||||
|
onDelegated?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Панель решения согласующего: проверяет, его ли сейчас очередь, и если да —
|
// Панель решения текущего шага: согласующий (Согласовать/Вернуть/Отклонить) или
|
||||||
// показывает комментарий и кнопки Согласовать / Вернуть / Отклонить.
|
// исполнитель («Выполнено»). Если у шага разрешено переназначение (allowReassign)
|
||||||
|
// — можно передать шаг другому сотруднику (делегат встаёт перед текущим).
|
||||||
export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
|
export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
|
||||||
service,
|
service,
|
||||||
requestId,
|
requestId,
|
||||||
onDecided,
|
onDecided,
|
||||||
onBeforeDecide,
|
onBeforeDecide,
|
||||||
|
search,
|
||||||
|
onDelegated,
|
||||||
}) => {
|
}) => {
|
||||||
const [checking, setChecking] = useState<boolean>(true);
|
const [checking, setChecking] = useState<boolean>(true);
|
||||||
const [rowId, setRowId] = useState<number | undefined>(undefined);
|
const [pendingRow, setPendingRow] = useState<IPendingRow | undefined>(undefined);
|
||||||
const [phase, setPhase] = useState<string>(APPROVAL_PHASE.approval);
|
|
||||||
const [comment, setComment] = useState<string>('');
|
const [comment, setComment] = useState<string>('');
|
||||||
const [submitting, setSubmitting] = useState<boolean>(false);
|
const [submitting, setSubmitting] = useState<boolean>(false);
|
||||||
const [resultStatus, setResultStatus] = useState<string | undefined>(undefined);
|
const [resultStatus, setResultStatus] = useState<string | undefined>(undefined);
|
||||||
|
const [delegated, setDelegated] = useState<boolean>(false);
|
||||||
|
const [showDelegate, setShowDelegate] = useState<boolean>(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -39,16 +50,13 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
|
|||||||
.getPendingRowForCurrentUser(requestId)
|
.getPendingRowForCurrentUser(requestId)
|
||||||
.then((row) => {
|
.then((row) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
setRowId(row?.id);
|
setPendingRow(row);
|
||||||
if (row) {
|
|
||||||
setPhase(row.phase);
|
|
||||||
}
|
|
||||||
setChecking(false);
|
setChecking(false);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
setError(`Не удалось проверить очередь согласования: ${(e as Error).message}`);
|
setError(`Не удалось проверить очередь: ${(e as Error).message}`);
|
||||||
setChecking(false);
|
setChecking(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -58,7 +66,7 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
|
|||||||
}, [requestId, service]);
|
}, [requestId, service]);
|
||||||
|
|
||||||
async function decide(decision: ApproverDecision): Promise<void> {
|
async function decide(decision: ApproverDecision): Promise<void> {
|
||||||
if (rowId === undefined) {
|
if (!pendingRow) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ((decision === 'return' || decision === 'reject') && comment.trim().length === 0) {
|
if ((decision === 'return' || decision === 'reject') && comment.trim().length === 0) {
|
||||||
@@ -68,11 +76,10 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
|
|||||||
setError(null);
|
setError(null);
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
// Сохраняем правки согласующего (если есть) ДО применения решения.
|
|
||||||
if (onBeforeDecide) {
|
if (onBeforeDecide) {
|
||||||
await 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);
|
setResultStatus(status);
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
if (onDecided) {
|
if (onDecided) {
|
||||||
@@ -84,14 +91,46 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDelegate(user: ISiteUser): Promise<void> {
|
||||||
|
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;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (delegated) {
|
||||||
|
return (
|
||||||
|
<div className={styles.panel}>
|
||||||
|
<Alert variant="ok">Шаг переназначен — заявка передана выбранному сотруднику.</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (resultStatus) {
|
if (resultStatus) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.panel}>
|
<div className={styles.panel}>
|
||||||
@@ -103,9 +142,42 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Фаза исполнения: одно действие «Выполнено» (без обязательного комментария),
|
// Блок переназначения — общий для согласующего и исполнителя (когда разрешено).
|
||||||
// строка помечается как «Согласовано» → computeRequestStatus переведёт заявку в
|
const delegateBlock = canDelegate ? (
|
||||||
// «Выполнение»/«Закрыта».
|
<div className={styles.delegate}>
|
||||||
|
{showDelegate ? (
|
||||||
|
<FormField label="Кому переназначить">
|
||||||
|
<UserPicker
|
||||||
|
search={search}
|
||||||
|
onPick={handleDelegate}
|
||||||
|
placeholder="Начните вводить ФИО сотрудника…"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
) : (
|
||||||
|
<Button variant="secondary" disabled={submitting} onClick={() => setShowDelegate(true)}>
|
||||||
|
<Icon name="people" size="sm" />
|
||||||
|
Переназначить
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
// Строка сейчас у делегата, а не у самого пользователя — решать за делегата
|
||||||
|
// нельзя, только переназначить (если он, например, недоступен — в отпуске).
|
||||||
|
if (isDelegator) {
|
||||||
|
return (
|
||||||
|
<div className={styles.panel}>
|
||||||
|
<div className={styles.title}>{isExecution ? 'Исполнение' : 'Ваше решение'}</div>
|
||||||
|
<Alert variant="warn">
|
||||||
|
Этот шаг сейчас у сотрудника, которому вы его переназначили. Если он недоступен —
|
||||||
|
переназначьте шаг ещё раз.
|
||||||
|
</Alert>
|
||||||
|
{error && <Alert variant="err">{error}</Alert>}
|
||||||
|
{delegateBlock}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isExecution) {
|
if (isExecution) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.panel}>
|
<div className={styles.panel}>
|
||||||
@@ -125,6 +197,7 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
|
|||||||
Выполнено
|
Выполнено
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{delegateBlock}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -155,6 +228,7 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
|
|||||||
Отклонить
|
Отклонить
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{delegateBlock}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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_CLIENT_ID = '9232d9e2-9807-4753-808f-07250e5474b0';
|
||||||
export const APPROVAL_API_BASE_URL = 'https://api.tnsu.kz/approval-route';
|
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: поток оповещения согласующего/исполнителя.
|
* Power Automate: поток оповещения согласующего/исполнителя.
|
||||||
* Триггер «When an HTTP request is received» защищён Entra («любой пользователь
|
* Триггер «When an HTTP request is received» защищён Entra («любой пользователь
|
||||||
|
|||||||
@@ -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<IDepartment[]> {
|
||||||
|
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<IDepartment[]> | undefined;
|
||||||
|
export function getDepartments(context: WebPartContext): Promise<IDepartment[]> {
|
||||||
|
if (!cache) {
|
||||||
|
cache = fetchDepartments(context).catch((e) => {
|
||||||
|
cache = undefined;
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// GET /Dictionary/Departments (api.tnsu.kz) → IDepartment[]
|
||||||
|
export interface IDepartment {
|
||||||
|
Id: string;
|
||||||
|
Name: string;
|
||||||
|
CompanyId: string;
|
||||||
|
Company: string;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<IDepartmentPickerProps> = ({
|
||||||
|
context,
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
readOnly
|
||||||
|
}) => {
|
||||||
|
const contextReadOnly = useFormReadOnly();
|
||||||
|
const effectiveReadOnly = readOnly ?? contextReadOnly;
|
||||||
|
|
||||||
|
const [departments, setDepartments] = React.useState<IDepartment[]>([]);
|
||||||
|
const [open, setOpen] = React.useState<boolean>(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 (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
className={styles.input}
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
readOnly={effectiveReadOnly}
|
||||||
|
onChange={(e) => onChange(e.target.value, '')}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onBlur={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
{!effectiveReadOnly && open && suggestions.length > 0 && (
|
||||||
|
<div className={styles.list}>
|
||||||
|
{suggestions.map((d) => (
|
||||||
|
<button
|
||||||
|
key={d.Id}
|
||||||
|
type="button"
|
||||||
|
className={styles.item}
|
||||||
|
// preventDefault на mousedown — иначе input теряет фокус (onBlur) раньше onClick, и список закрывается до выбора.
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => handlePick(d)}
|
||||||
|
>
|
||||||
|
{d.Name}
|
||||||
|
<span className={styles.company}>{d.Company}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import styles from './RequestNumber.module.scss';
|
import styles from './RequestNumber.module.scss';
|
||||||
|
|
||||||
/** Номер заявки (Id элемента списка) — плашка в правом углу шапки формы. */
|
/** Уникальный код заявки ({requestTypeId}-{YY}{MM}-{itemId}) — плашка в правом углу шапки формы. */
|
||||||
export const RequestNumber: React.FC<{ id: number }> = ({ id }) => (
|
export const RequestNumber: React.FC<{ code: string }> = ({ code }) => (
|
||||||
<span className={styles.badge}>№{id}</span>
|
<span className={styles.badge}>№{code}</span>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user