Merge remote-tracking branch 'origin/main' into left-panel
This commit is contained in:
@@ -125,6 +125,20 @@ $sp-7: 28px; $sp-8: 32px;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Вид поля «только чтение» (режим согласующего / отправленная заявка):
|
||||||
|
// приглушённый фон, серый текст, без золотого фокуса — визуально «неактивно».
|
||||||
|
// $sel — псевдокласс состояния поля (:read-only для input/textarea, :disabled для select).
|
||||||
|
@mixin readonly-field($sel) {
|
||||||
|
&#{$sel},
|
||||||
|
&#{$sel}:focus {
|
||||||
|
background: $gray-50;
|
||||||
|
color: $gray-500;
|
||||||
|
border-color: $gray-200;
|
||||||
|
box-shadow: none;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@mixin hairline {
|
@mixin hairline {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: $grad-hairline;
|
background: $grad-hairline;
|
||||||
|
|||||||
@@ -1,12 +1,25 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useState, useMemo, useCallback } from 'react';
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
import styles from './RecruitingHireForm.module.scss';
|
import styles from './RecruitingHireForm.module.scss';
|
||||||
import { Button, Alert, FormCard, FormActions, FileDropzone, CollapseSection, FormField, Icon } from '../../shared/ui';
|
import {
|
||||||
|
Alert,
|
||||||
|
FormCard,
|
||||||
|
FormModeProvider,
|
||||||
|
FileDropzone,
|
||||||
|
CollapseSection,
|
||||||
|
FormField,
|
||||||
|
Icon,
|
||||||
|
RequestNumber,
|
||||||
|
SubmitActions,
|
||||||
|
useRequestSubmit
|
||||||
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
getPlannedMatrix,
|
getPlannedMatrix,
|
||||||
REQUEST_STATUS,
|
REQUEST_STATUS,
|
||||||
|
isEditableStatus,
|
||||||
IApproverToWrite
|
IApproverToWrite
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { uploadAttachments } from '../../shared/attachments/attachments.service';
|
import { uploadAttachments } from '../../shared/attachments/attachments.service';
|
||||||
@@ -18,21 +31,16 @@ import { CandidateSection } from './sections/CandidateSection';
|
|||||||
import { PpeSection } from './sections/PpeSection';
|
import { PpeSection } from './sections/PpeSection';
|
||||||
import { ConditionsSection } from './sections/ConditionsSection';
|
import { ConditionsSection } from './sections/ConditionsSection';
|
||||||
|
|
||||||
type ActionStatus = 'idle' | 'saving' | 'success' | 'error';
|
|
||||||
type ActionKind = 'draft' | 'submit';
|
|
||||||
|
|
||||||
const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved }) => {
|
const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved }) => {
|
||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [actionStatus, setActionStatus] = useState<ActionStatus>('idle');
|
|
||||||
const [actionKind, setActionKind] = useState<ActionKind | undefined>(undefined);
|
|
||||||
const [status, setStatus] = useState<string | undefined>(undefined);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
const [requestId, setRequestId] = useState<number | undefined>(undefined);
|
||||||
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
|
const submit = useRequestSubmit();
|
||||||
|
|
||||||
const set: SetField = (key, value) => {
|
const readOnly = !isEditableStatus(requestStatus);
|
||||||
setData((prev) => ({ ...prev, [key]: value }));
|
|
||||||
setActionStatus('idle');
|
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||||
};
|
|
||||||
|
|
||||||
// Маршрут согласования (планируемый) резолвится по email инициатора — текущего
|
// Маршрут согласования (планируемый) резолвится по email инициатора — текущего
|
||||||
// пользователя (кандидата ещё нет в системе).
|
// пользователя (кандидата ещё нет в системе).
|
||||||
@@ -42,7 +50,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
() => getPlannedMatrix(context, HIRE_REQUEST_TYPE_ID, userEmail, approval, 'Approval'),
|
() => getPlannedMatrix(context, HIRE_REQUEST_TYPE_ID, userEmail, approval, 'Approval'),
|
||||||
[context, userEmail, approval]
|
[context, userEmail, approval]
|
||||||
);
|
);
|
||||||
const matrix = useMatrixEditor(loadMatrix, userEmail, true, true, (login) =>
|
const matrix = useMatrixEditor(loadMatrix, userEmail, !readOnly, true, (login) =>
|
||||||
approval.getJobTitle(login)
|
approval.getJobTitle(login)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -55,41 +63,36 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
approval.getJobTitle(login)
|
approval.getJobTitle(login)
|
||||||
);
|
);
|
||||||
|
|
||||||
const saving = actionStatus === 'saving';
|
|
||||||
const isValid = data.CandidateName.trim().length > 0 && data.Position.trim().length > 0;
|
const isValid = data.CandidateName.trim().length > 0 && data.Position.trim().length > 0;
|
||||||
|
const validate = (): string | undefined =>
|
||||||
|
isValid ? undefined : 'Заполните обязательные поля: Ф.И.О. кандидата, Должность.';
|
||||||
|
|
||||||
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
|
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
|
||||||
rows.map((r) => ({ email: r.email, position: r.position, allowReassign: r.allowReassign }));
|
rows.map((r) => ({ email: r.email, position: r.position, allowReassign: r.allowReassign }));
|
||||||
|
|
||||||
async function run(kind: ActionKind, requestStatus: string): Promise<void> {
|
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
|
||||||
if (!isValid) {
|
const id = await saveHireRequest(
|
||||||
setError('Заполните обязательные поля: Ф.И.О. кандидата, Должность.');
|
requestId,
|
||||||
return;
|
data,
|
||||||
}
|
toApprovers(matrix.rows),
|
||||||
setError(null);
|
toApprovers(executors.rows),
|
||||||
setActionKind(kind);
|
status
|
||||||
setActionStatus('saving');
|
);
|
||||||
try {
|
setRequestId(id);
|
||||||
const id = await saveHireRequest(
|
setRequestStatus(status);
|
||||||
data,
|
if (files.length > 0) {
|
||||||
toApprovers(matrix.rows),
|
|
||||||
toApprovers(executors.rows),
|
|
||||||
requestStatus
|
|
||||||
);
|
|
||||||
await uploadAttachments(DATA_LIST_TITLE, id, files); // файлы — после создания элемента
|
await uploadAttachments(DATA_LIST_TITLE, id, files); // файлы — после создания элемента
|
||||||
setStatus(requestStatus);
|
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
|
||||||
setActionStatus('success');
|
|
||||||
if (onSaved) {
|
|
||||||
onSaved();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setActionStatus('error');
|
|
||||||
setError(`Ошибка сохранения: ${(e as Error).message}`);
|
|
||||||
}
|
}
|
||||||
}
|
if (onSaved) {
|
||||||
|
onSaved();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveDraft = (): Promise<void> => run('draft', REQUEST_STATUS.draft);
|
const handleSaveDraft = (): Promise<void> =>
|
||||||
const handleSubmit = (): Promise<void> => run('submit', REQUEST_STATUS.onApproval);
|
submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
||||||
|
const handleSubmit = (): Promise<void> =>
|
||||||
|
submit.run('submit', save(REQUEST_STATUS.onApproval), validate);
|
||||||
|
|
||||||
const sectionProps = { data, set, context };
|
const sectionProps = { data, set, context };
|
||||||
|
|
||||||
@@ -100,32 +103,39 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
<span>К списку заявок</span>
|
<span>К списку заявок</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<FormCard title={requestType.title} subtitle="Категория: Отдел рекрутинга">
|
<FormCard
|
||||||
{status && (
|
title={requestType.title}
|
||||||
<div className={styles.statusRow}>
|
subtitle="Категория: Отдел рекрутинга"
|
||||||
<span className={styles.statusPill}>{status}</span>
|
headerRight={
|
||||||
</div>
|
requestId || requestStatus ? (
|
||||||
)}
|
<>
|
||||||
|
{requestStatus && <StatusBadge status={requestStatus} />}
|
||||||
{actionStatus === 'success' && (
|
{requestId && <RequestNumber id={requestId} />}
|
||||||
|
</>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{submit.savedKind && !submit.error && (
|
||||||
<Alert variant="ok">
|
<Alert variant="ok">
|
||||||
{actionKind === 'draft'
|
{submit.savedKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'}
|
||||||
? 'Черновик сохранён.'
|
|
||||||
: 'Заявка отправлена на согласование.'}
|
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{error && <Alert variant="err">{error}</Alert>}
|
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
||||||
|
|
||||||
<ApplicationSection {...sectionProps} />
|
<FormModeProvider readOnly={readOnly}>
|
||||||
<CandidateSection {...sectionProps} />
|
<ApplicationSection {...sectionProps} />
|
||||||
<PpeSection {...sectionProps} />
|
<CandidateSection {...sectionProps} />
|
||||||
<ConditionsSection {...sectionProps} />
|
<PpeSection {...sectionProps} />
|
||||||
|
<ConditionsSection {...sectionProps} />
|
||||||
|
</FormModeProvider>
|
||||||
|
|
||||||
<CollapseSection title="Вложения" icon="upload">
|
{!readOnly && (
|
||||||
<FormField label="Файлы" full>
|
<CollapseSection title="Вложения" icon="upload">
|
||||||
<FileDropzone files={files} onChange={setFiles} />
|
<FormField label="Файлы" full>
|
||||||
</FormField>
|
<FileDropzone files={files} onChange={setFiles} />
|
||||||
</CollapseSection>
|
</FormField>
|
||||||
|
</CollapseSection>
|
||||||
|
)}
|
||||||
|
|
||||||
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
||||||
|
|
||||||
@@ -139,19 +149,14 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FormActions>
|
<SubmitActions
|
||||||
<Button variant="secondary" onClick={onCancel} disabled={saving}>
|
submit={submit}
|
||||||
Отмена
|
isValid={isValid}
|
||||||
</Button>
|
readOnly={readOnly}
|
||||||
<Button variant="secondary" onClick={handleSaveDraft} disabled={!isValid || saving}>
|
onCancel={onCancel}
|
||||||
<Icon name="document" size="sm" />
|
onSaveDraft={handleSaveDraft}
|
||||||
Сохранить черновик
|
onSubmit={handleSubmit}
|
||||||
</Button>
|
/>
|
||||||
<Button variant="accent" onClick={handleSubmit} disabled={!isValid || saving}>
|
|
||||||
<Icon name="send" size="sm" />
|
|
||||||
{saving && actionKind === 'submit' ? 'Отправка…' : 'Отправить на согласование'}
|
|
||||||
</Button>
|
|
||||||
</FormActions>
|
|
||||||
</FormCard>
|
</FormCard>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useState, useEffect, useMemo } from 'react';
|
|||||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import styles from './RecruitingHireView.module.scss';
|
import styles from './RecruitingHireView.module.scss';
|
||||||
import { Alert, FormCard, FormModeProvider, CollapseSection, FormField, AttachmentList } from '../../shared/ui';
|
import { Alert, FormCard, FormModeProvider, CollapseSection, FormField, AttachmentList } from '../../shared/ui';
|
||||||
import { ApproverService, DecisionPanel } from '../../shared/approval';
|
import { ApproverService, DecisionPanel, StatusBadge } from '../../shared/approval';
|
||||||
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
||||||
import { IFormData, EMPTY_DATA, SetField } from './model';
|
import { IFormData, EMPTY_DATA, SetField } from './model';
|
||||||
import { loadHireRequest } from './load';
|
import { loadHireRequest } from './load';
|
||||||
@@ -26,6 +26,7 @@ const NOOP_SET: SetField = () => undefined;
|
|||||||
// через <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
|
// через <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
|
||||||
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 [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);
|
||||||
@@ -42,7 +43,8 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
Promise.all([loadHireRequest(itemId), getAttachments(DATA_LIST_TITLE, itemId)])
|
Promise.all([loadHireRequest(itemId), getAttachments(DATA_LIST_TITLE, itemId)])
|
||||||
.then(([loaded, files]) => {
|
.then(([loaded, files]) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
setData(loaded);
|
setData(loaded.data);
|
||||||
|
setStatus(loaded.status);
|
||||||
setAttachments(files);
|
setAttachments(files);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -74,7 +76,11 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<FormCard title="Заявка на прием" subtitle={`№${itemId} · режим согласования`}>
|
<FormCard
|
||||||
|
title="Заявка на прием"
|
||||||
|
subtitle={`№${itemId} · режим согласования`}
|
||||||
|
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||||
|
>
|
||||||
<FormModeProvider readOnly>
|
<FormModeProvider readOnly>
|
||||||
<ApplicationSection {...sectionProps} />
|
<ApplicationSection {...sectionProps} />
|
||||||
<CandidateSection {...sectionProps} />
|
<CandidateSection {...sectionProps} />
|
||||||
|
|||||||
@@ -10,13 +10,18 @@ const SCALAR_FIELDS: (keyof IFormData)[] = [
|
|||||||
'SalaryAfterProbation', 'WorkSchedule', 'EmploymentForm'
|
'SalaryAfterProbation', 'WorkSchedule', 'EmploymentForm'
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Прочитать заявку «на прием» по id и разложить в модель формы (для режима чтения). */
|
export interface ILoadedRequest {
|
||||||
export async function loadHireRequest(itemId: number): Promise<IFormData> {
|
data: IFormData;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Прочитать заявку «на прием» по id: модель формы + статус (для режима чтения). */
|
||||||
|
export async function loadHireRequest(itemId: number): Promise<ILoadedRequest> {
|
||||||
const sp = getSP();
|
const sp = getSP();
|
||||||
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)()) as Record<string, unknown>;
|
.select(...SCALAR_FIELDS, 'Status')()) 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>;
|
||||||
@@ -32,5 +37,5 @@ export async function loadHireRequest(itemId: number): Promise<IFormData> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return { data, status: (item.Status as string) || '' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,11 +21,10 @@ function buildTitle(data: IFormData): string {
|
|||||||
return ['Прием', data.CandidateName, data.Position].filter(Boolean).join(' — ');
|
return ['Прием', data.CandidateName, data.Position].filter(Boolean).join(' — ');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Записать контент заявки со статусом. Возвращает Id созданного элемента. */
|
/** Поля элемента списка данных из модели формы (текст/выбор/даты/числа). */
|
||||||
async function writeContent(data: IFormData, status: string): Promise<number> {
|
function buildItem(data: IFormData, status: string): Record<string, unknown> {
|
||||||
const item: Record<string, unknown> = { Title: buildTitle(data), Status: status };
|
const item: Record<string, unknown> = { Title: buildTitle(data), Status: status };
|
||||||
|
|
||||||
// Текст/выбор/даты/числа.
|
|
||||||
(Object.keys(data) as (keyof IFormData)[]).forEach((key) => {
|
(Object.keys(data) as (keyof IFormData)[]).forEach((key) => {
|
||||||
const value = data[key];
|
const value = data[key];
|
||||||
if (NUMBER_FIELDS.indexOf(key) !== -1) {
|
if (NUMBER_FIELDS.indexOf(key) !== -1) {
|
||||||
@@ -35,23 +34,35 @@ async function writeContent(data: IFormData, status: string): Promise<number> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const added = await getSP().web.lists.getByTitle(DATA_LIST_TITLE).items.add(item);
|
return item;
|
||||||
return (added as { Id: number }).Id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сохранить заявку «на прием»: контент + маршрут (согласующие + исполнители) со статусом.
|
* Сохранить заявку «на прием»: контент + маршрут (согласующие + исполнители) со статусом.
|
||||||
|
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||||
* status = «Черновик» (сохранение) или «На согласовании» (отправка).
|
* status = «Черновик» (сохранение) или «На согласовании» (отправка).
|
||||||
* executors может быть пустым — тогда после согласования заявка станет «Согласовано».
|
* executors может быть пустым — тогда после согласования заявка станет «Согласовано».
|
||||||
* Возвращает Id заявки.
|
* Возвращает Id заявки.
|
||||||
*/
|
*/
|
||||||
export async function saveHireRequest(
|
export async function saveHireRequest(
|
||||||
|
existingId: number | undefined,
|
||||||
data: IFormData,
|
data: IFormData,
|
||||||
approvers: IApproverToWrite[],
|
approvers: IApproverToWrite[],
|
||||||
executors: IApproverToWrite[],
|
executors: IApproverToWrite[],
|
||||||
status: string
|
status: string
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const id = await writeContent(data, status);
|
const list = getSP().web.lists.getByTitle(DATA_LIST_TITLE);
|
||||||
|
const item = buildItem(data, status);
|
||||||
|
|
||||||
|
let id: number;
|
||||||
|
if (existingId) {
|
||||||
|
await list.items.getById(existingId).update(item);
|
||||||
|
id = existingId;
|
||||||
|
} else {
|
||||||
|
const added = await list.items.add(item);
|
||||||
|
id = (added as { Id: number }).Id;
|
||||||
|
}
|
||||||
|
|
||||||
await writeRoute(hireApprovalService(), id, approvers, executors, status);
|
await writeRoute(hireApprovalService(), id, approvers, executors, status);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,25 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||||
import styles from './WorkplaceSetupForm.module.scss';
|
import styles from './WorkplaceSetupForm.module.scss';
|
||||||
import { Button, Alert, FormCard, FormActions, FileDropzone, CollapseSection, FormField, Icon } from '../../shared/ui';
|
import {
|
||||||
|
Alert,
|
||||||
|
FormCard,
|
||||||
|
FormModeProvider,
|
||||||
|
FileDropzone,
|
||||||
|
CollapseSection,
|
||||||
|
FormField,
|
||||||
|
Icon,
|
||||||
|
RequestNumber,
|
||||||
|
SubmitActions,
|
||||||
|
useRequestSubmit
|
||||||
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
getPlannedMatrix,
|
getPlannedMatrix,
|
||||||
REQUEST_STATUS,
|
REQUEST_STATUS,
|
||||||
|
isEditableStatus,
|
||||||
IApproverToWrite
|
IApproverToWrite
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { uploadAttachments } from '../../shared/attachments/attachments.service';
|
import { uploadAttachments } from '../../shared/attachments/attachments.service';
|
||||||
@@ -22,20 +35,18 @@ import { loadPrefillFromHire } from './prefill';
|
|||||||
import { EmployeeSection } from './sections/EmployeeSection';
|
import { EmployeeSection } from './sections/EmployeeSection';
|
||||||
import { EquipmentSection } from './sections/EquipmentSection';
|
import { EquipmentSection } from './sections/EquipmentSection';
|
||||||
|
|
||||||
type ActionStatus = 'idle' | 'saving' | 'success' | 'error';
|
|
||||||
type ActionKind = 'draft' | 'submit';
|
|
||||||
|
|
||||||
const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved, sourceId }) => {
|
const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved, sourceId }) => {
|
||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [actionStatus, setActionStatus] = useState<ActionStatus>('idle');
|
|
||||||
const [actionKind, setActionKind] = useState<ActionKind | undefined>(undefined);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
// Id и статус текущей заявки: заполняются после первого сохранения.
|
||||||
|
const [requestId, setRequestId] = useState<number | undefined>(undefined);
|
||||||
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
|
const submit = useRequestSubmit();
|
||||||
|
|
||||||
const set: SetField = (key, value) => {
|
// Редактировать можно новую/черновик/возвращённую/отклонённую; иначе только чтение.
|
||||||
setData((prev) => ({ ...prev, [key]: value }));
|
const readOnly = !isEditableStatus(requestStatus);
|
||||||
setActionStatus('idle');
|
|
||||||
};
|
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||||
|
|
||||||
// Предзаполнение из «Заявки на прием», если форма открыта «на основе» (rpsource).
|
// Предзаполнение из «Заявки на прием», если форма открыта «на основе» (rpsource).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -51,7 +62,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
setError(`Не удалось предзаполнить из заявки на прием №${sourceId}: ${(e as Error).message}`);
|
submit.setError(`Не удалось предзаполнить из заявки на прием №${sourceId}: ${(e as Error).message}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
@@ -65,7 +76,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
() => getPlannedMatrix(context, WORKPLACE_REQUEST_TYPE_ID, userEmail, approval, 'Approval'),
|
() => getPlannedMatrix(context, WORKPLACE_REQUEST_TYPE_ID, userEmail, approval, 'Approval'),
|
||||||
[context, userEmail, approval]
|
[context, userEmail, approval]
|
||||||
);
|
);
|
||||||
const matrix = useMatrixEditor(loadMatrix, userEmail, true, true, (login) => approval.getJobTitle(login));
|
const matrix = useMatrixEditor(loadMatrix, userEmail, !readOnly, true, (login) => approval.getJobTitle(login));
|
||||||
|
|
||||||
// Фаза исполнения: исполнители из маршрута (status='Execution'), только для чтения.
|
// Фаза исполнения: исполнители из маршрута (status='Execution'), только для чтения.
|
||||||
const loadExecutors = useCallback(
|
const loadExecutors = useCallback(
|
||||||
@@ -76,41 +87,37 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
approval.getJobTitle(login)
|
approval.getJobTitle(login)
|
||||||
);
|
);
|
||||||
|
|
||||||
const saving = actionStatus === 'saving';
|
|
||||||
const isValid = data.FullName.trim().length > 0 && data.Position.trim().length > 0;
|
const isValid = data.FullName.trim().length > 0 && data.Position.trim().length > 0;
|
||||||
|
const validate = (): string | undefined =>
|
||||||
|
isValid ? undefined : 'Заполните обязательные поля: Ф.И.О., Должность.';
|
||||||
|
|
||||||
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
|
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
|
||||||
rows.map((r) => ({ email: r.email, position: r.position, allowReassign: r.allowReassign }));
|
rows.map((r) => ({ email: r.email, position: r.position, allowReassign: r.allowReassign }));
|
||||||
|
|
||||||
async function run(kind: ActionKind, requestStatus: string): Promise<void> {
|
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
|
||||||
if (!isValid) {
|
const id = await saveWorkplaceRequest(
|
||||||
setError('Заполните обязательные поля: Ф.И.О., Должность.');
|
requestId,
|
||||||
return;
|
data,
|
||||||
}
|
toApprovers(matrix.rows),
|
||||||
setError(null);
|
toApprovers(executors.rows),
|
||||||
setActionKind(kind);
|
status,
|
||||||
setActionStatus('saving');
|
sourceId
|
||||||
try {
|
);
|
||||||
const id = await saveWorkplaceRequest(
|
setRequestId(id);
|
||||||
data,
|
setRequestStatus(status);
|
||||||
toApprovers(matrix.rows),
|
if (files.length > 0) {
|
||||||
toApprovers(executors.rows),
|
|
||||||
requestStatus,
|
|
||||||
sourceId
|
|
||||||
);
|
|
||||||
await uploadAttachments(DATA_LIST_TITLE, id, files);
|
await uploadAttachments(DATA_LIST_TITLE, id, files);
|
||||||
setActionStatus('success');
|
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
|
||||||
if (onSaved) {
|
|
||||||
onSaved();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setActionStatus('error');
|
|
||||||
setError(`Ошибка сохранения: ${(e as Error).message}`);
|
|
||||||
}
|
}
|
||||||
}
|
if (onSaved) {
|
||||||
|
onSaved();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveDraft = (): Promise<void> => run('draft', REQUEST_STATUS.draft);
|
const handleSaveDraft = (): Promise<void> =>
|
||||||
const handleSubmit = (): Promise<void> => run('submit', REQUEST_STATUS.onApproval);
|
submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
||||||
|
const handleSubmit = (): Promise<void> =>
|
||||||
|
submit.run('submit', save(REQUEST_STATUS.onApproval), validate);
|
||||||
|
|
||||||
const sectionProps = { data, set, context };
|
const sectionProps = { data, set, context };
|
||||||
|
|
||||||
@@ -121,22 +128,37 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
<span>К списку заявок</span>
|
<span>К списку заявок</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<FormCard title={requestType.title} subtitle="Категория: IT">
|
<FormCard
|
||||||
{actionStatus === 'success' && (
|
title={requestType.title}
|
||||||
|
subtitle="Категория: IT"
|
||||||
|
headerRight={
|
||||||
|
requestId || requestStatus ? (
|
||||||
|
<>
|
||||||
|
{requestStatus && <StatusBadge status={requestStatus} />}
|
||||||
|
{requestId && <RequestNumber id={requestId} />}
|
||||||
|
</>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{submit.savedKind && !submit.error && (
|
||||||
<Alert variant="ok">
|
<Alert variant="ok">
|
||||||
{actionKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'}
|
{submit.savedKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{error && <Alert variant="err">{error}</Alert>}
|
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
||||||
|
|
||||||
<EmployeeSection {...sectionProps} />
|
<FormModeProvider readOnly={readOnly}>
|
||||||
<EquipmentSection {...sectionProps} />
|
<EmployeeSection {...sectionProps} />
|
||||||
|
<EquipmentSection {...sectionProps} />
|
||||||
|
</FormModeProvider>
|
||||||
|
|
||||||
<CollapseSection title="Вложения" icon="upload">
|
{!readOnly && (
|
||||||
<FormField label="Файлы" full>
|
<CollapseSection title="Вложения" icon="upload">
|
||||||
<FileDropzone files={files} onChange={setFiles} />
|
<FormField label="Файлы" full>
|
||||||
</FormField>
|
<FileDropzone files={files} onChange={setFiles} />
|
||||||
</CollapseSection>
|
</FormField>
|
||||||
|
</CollapseSection>
|
||||||
|
)}
|
||||||
|
|
||||||
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
||||||
|
|
||||||
@@ -150,19 +172,14 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FormActions>
|
<SubmitActions
|
||||||
<Button variant="secondary" onClick={onCancel} disabled={saving}>
|
submit={submit}
|
||||||
Отмена
|
isValid={isValid}
|
||||||
</Button>
|
readOnly={readOnly}
|
||||||
<Button variant="secondary" onClick={handleSaveDraft} disabled={!isValid || saving}>
|
onCancel={onCancel}
|
||||||
<Icon name="document" size="sm" />
|
onSaveDraft={handleSaveDraft}
|
||||||
Сохранить черновик
|
onSubmit={handleSubmit}
|
||||||
</Button>
|
/>
|
||||||
<Button variant="accent" onClick={handleSubmit} disabled={!isValid || saving}>
|
|
||||||
<Icon name="send" size="sm" />
|
|
||||||
{saving && actionKind === 'submit' ? 'Отправка…' : 'Отправить на согласование'}
|
|
||||||
</Button>
|
|
||||||
</FormActions>
|
|
||||||
</FormCard>
|
</FormCard>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useState, useEffect, useMemo } from 'react';
|
|||||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import styles from './WorkplaceSetupView.module.scss';
|
import styles from './WorkplaceSetupView.module.scss';
|
||||||
import { Alert, FormCard, FormModeProvider, CollapseSection, FormField, AttachmentList } from '../../shared/ui';
|
import { Alert, FormCard, FormModeProvider, CollapseSection, FormField, AttachmentList } from '../../shared/ui';
|
||||||
import { ApproverService, DecisionPanel } from '../../shared/approval';
|
import { ApproverService, DecisionPanel, StatusBadge } from '../../shared/approval';
|
||||||
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
||||||
import { IFormData, EMPTY_DATA, SetField } from './model';
|
import { IFormData, EMPTY_DATA, SetField } from './model';
|
||||||
import { loadWorkplaceRequest } from './load';
|
import { loadWorkplaceRequest } from './load';
|
||||||
@@ -21,6 +21,7 @@ const NOOP_SET: SetField = () => undefined;
|
|||||||
// Заявка «орг. рабочего места» только для просмотра (режим согласующего).
|
// Заявка «орг. рабочего места» только для просмотра (режим согласующего).
|
||||||
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 [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);
|
||||||
@@ -36,7 +37,8 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
Promise.all([loadWorkplaceRequest(itemId), getAttachments(DATA_LIST_TITLE, itemId)])
|
Promise.all([loadWorkplaceRequest(itemId), getAttachments(DATA_LIST_TITLE, itemId)])
|
||||||
.then(([loaded, files]) => {
|
.then(([loaded, files]) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
setData(loaded);
|
setData(loaded.data);
|
||||||
|
setStatus(loaded.status);
|
||||||
setAttachments(files);
|
setAttachments(files);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -68,7 +70,11 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<FormCard title="Организация рабочего места" subtitle={`№${itemId} · режим согласования`}>
|
<FormCard
|
||||||
|
title="Организация рабочего места"
|
||||||
|
subtitle={`№${itemId} · режим согласования`}
|
||||||
|
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||||
|
>
|
||||||
<FormModeProvider readOnly>
|
<FormModeProvider readOnly>
|
||||||
<EmployeeSection {...sectionProps} />
|
<EmployeeSection {...sectionProps} />
|
||||||
<EquipmentSection {...sectionProps} />
|
<EquipmentSection {...sectionProps} />
|
||||||
|
|||||||
@@ -8,12 +8,17 @@ const SCALAR_FIELDS: (keyof IFormData)[] = [
|
|||||||
'Software', 'AccessRights'
|
'Software', 'AccessRights'
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Прочитать заявку по id и разложить в модель формы (для режима чтения). */
|
export interface ILoadedRequest {
|
||||||
export async function loadWorkplaceRequest(itemId: number): Promise<IFormData> {
|
data: IFormData;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Прочитать заявку по id: модель формы + статус (для режима чтения). */
|
||||||
|
export async function loadWorkplaceRequest(itemId: number): Promise<ILoadedRequest> {
|
||||||
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')()) as Record<string, unknown>;
|
.select(...SCALAR_FIELDS, 'NeedsEquipment', 'Status')()) 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>;
|
||||||
@@ -31,5 +36,5 @@ export async function loadWorkplaceRequest(itemId: number): Promise<IFormData> {
|
|||||||
|
|
||||||
data.NeedsEquipment = item.NeedsEquipment === true;
|
data.NeedsEquipment = item.NeedsEquipment === true;
|
||||||
|
|
||||||
return data;
|
return { data, status: (item.Status as string) || '' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { IFormData, EMPTY_DATA } from './model';
|
|||||||
* Копируются согласованные поля; остальное остаётся пустым для ручного заполнения.
|
* Копируются согласованные поля; остальное остаётся пустым для ручного заполнения.
|
||||||
*/
|
*/
|
||||||
export async function loadPrefillFromHire(hireId: number): Promise<IFormData> {
|
export async function loadPrefillFromHire(hireId: number): Promise<IFormData> {
|
||||||
const hire = await loadHireRequest(hireId);
|
const { data: hire } = await loadHireRequest(hireId);
|
||||||
return {
|
return {
|
||||||
...EMPTY_DATA,
|
...EMPTY_DATA,
|
||||||
FullName: hire.CandidateName,
|
FullName: hire.CandidateName,
|
||||||
|
|||||||
@@ -17,11 +17,8 @@ function buildTitle(data: IFormData): string {
|
|||||||
return ['Раб. место', data.FullName, data.Position].filter(Boolean).join(' — ');
|
return ['Раб. место', data.FullName, data.Position].filter(Boolean).join(' — ');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Поля элемента списка данных из модели формы. */
|
||||||
* Записать контент заявки. sourceHireId — id исходной «Заявки на прием»
|
function buildItem(data: IFormData, status: string, sourceHireId?: number): Record<string, unknown> {
|
||||||
* (записывается в lookup-колонку SourceHire для прослеживаемости). Возвращает Id.
|
|
||||||
*/
|
|
||||||
async function writeContent(data: IFormData, status: string, sourceHireId?: number): Promise<number> {
|
|
||||||
const item: Record<string, unknown> = { Title: buildTitle(data), Status: status };
|
const item: Record<string, unknown> = { Title: buildTitle(data), Status: status };
|
||||||
|
|
||||||
(Object.keys(data) as (keyof IFormData)[]).forEach((key) => {
|
(Object.keys(data) as (keyof IFormData)[]).forEach((key) => {
|
||||||
@@ -37,23 +34,35 @@ async function writeContent(data: IFormData, status: string, sourceHireId?: numb
|
|||||||
item.SourceHireId = sourceHireId; // lookup → «Прием на работу — данные»
|
item.SourceHireId = sourceHireId; // lookup → «Прием на работу — данные»
|
||||||
}
|
}
|
||||||
|
|
||||||
const added = await getSP().web.lists.getByTitle(DATA_LIST_TITLE).items.add(item);
|
return item;
|
||||||
return (added as { Id: number }).Id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сохранить заявку «орг. рабочего места»: контент + маршрут (согласующие + исполнители).
|
* Сохранить заявку «орг. рабочего места»: контент + маршрут (согласующие + исполнители).
|
||||||
|
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||||
* Исполнители пишутся сразу — фаза исполнения стартует автоматически после согласования.
|
* Исполнители пишутся сразу — фаза исполнения стартует автоматически после согласования.
|
||||||
* sourceHireId — если заявка создана на основе «прием».
|
* sourceHireId — если заявка создана на основе «прием». Возвращает Id заявки.
|
||||||
*/
|
*/
|
||||||
export async function saveWorkplaceRequest(
|
export async function saveWorkplaceRequest(
|
||||||
|
existingId: number | undefined,
|
||||||
data: IFormData,
|
data: IFormData,
|
||||||
approvers: IApproverToWrite[],
|
approvers: IApproverToWrite[],
|
||||||
executors: IApproverToWrite[],
|
executors: IApproverToWrite[],
|
||||||
status: string,
|
status: string,
|
||||||
sourceHireId?: number
|
sourceHireId?: number
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const id = await writeContent(data, status, sourceHireId);
|
const list = getSP().web.lists.getByTitle(DATA_LIST_TITLE);
|
||||||
|
const item = buildItem(data, status, sourceHireId);
|
||||||
|
|
||||||
|
let id: number;
|
||||||
|
if (existingId) {
|
||||||
|
await list.items.getById(existingId).update(item);
|
||||||
|
id = existingId;
|
||||||
|
} else {
|
||||||
|
const added = await list.items.add(item);
|
||||||
|
id = (added as { Id: number }).Id;
|
||||||
|
}
|
||||||
|
|
||||||
await writeRoute(workplaceApprovalService(), id, approvers, executors, status);
|
await writeRoute(workplaceApprovalService(), id, approvers, executors, status);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export {
|
|||||||
REQUEST_STATUS_CHOICES,
|
REQUEST_STATUS_CHOICES,
|
||||||
APPROVAL_PHASE,
|
APPROVAL_PHASE,
|
||||||
APPROVAL_PHASE_CHOICES,
|
APPROVAL_PHASE_CHOICES,
|
||||||
|
EDITABLE_STATUSES,
|
||||||
|
isEditableStatus,
|
||||||
} from './models/statuses';
|
} from './models/statuses';
|
||||||
export type { ISiteUser } from './models/ISiteUser';
|
export type { ISiteUser } from './models/ISiteUser';
|
||||||
export type { IMatrixRow } from './models/IMatrixRow';
|
export type { IMatrixRow } from './models/IMatrixRow';
|
||||||
@@ -26,3 +28,4 @@ export type { IUseMatrixEditor } from './useMatrixEditor';
|
|||||||
export { ApprovalMatrix } from './ui/ApprovalMatrix';
|
export { ApprovalMatrix } from './ui/ApprovalMatrix';
|
||||||
export { UserPicker } from './ui/UserPicker';
|
export { UserPicker } from './ui/UserPicker';
|
||||||
export { DecisionPanel } from './ui/DecisionPanel';
|
export { DecisionPanel } from './ui/DecisionPanel';
|
||||||
|
export { StatusBadge } from './ui/StatusBadge';
|
||||||
|
|||||||
@@ -13,6 +13,20 @@ export const REQUEST_STATUS = {
|
|||||||
closed: 'Закрыта',
|
closed: 'Закрыта',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
// Статусы, при которых заявку ещё можно править (форма редактируема).
|
||||||
|
// Всё остальное (На согласовании / Согласовано / Выполнение / Закрыта) — только чтение.
|
||||||
|
// «Отклонено» здесь по решению 2026-08-04: отклонённую можно доработать и переотправить.
|
||||||
|
export const EDITABLE_STATUSES: string[] = [
|
||||||
|
REQUEST_STATUS.draft,
|
||||||
|
REQUEST_STATUS.returned,
|
||||||
|
REQUEST_STATUS.rejected,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Новая (без статуса) или заявка в редактируемом статусе → форму можно менять.
|
||||||
|
export function isEditableStatus(status: string | undefined): boolean {
|
||||||
|
return !status || EDITABLE_STATUSES.indexOf(status) !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
// Полный набор значений для Choice-колонки при провижининге.
|
// Полный набор значений для Choice-колонки при провижининге.
|
||||||
export const REQUEST_STATUS_CHOICES: string[] = [
|
export const REQUEST_STATUS_CHOICES: string[] = [
|
||||||
REQUEST_STATUS.draft,
|
REQUEST_STATUS.draft,
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
@import '../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draft {
|
||||||
|
background: $gray-100;
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onApproval {
|
||||||
|
background: $blue-50;
|
||||||
|
color: $blue-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approved {
|
||||||
|
background: $green-50;
|
||||||
|
color: $green-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rejected {
|
||||||
|
background: $red-50;
|
||||||
|
color: $red-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.returned {
|
||||||
|
background: $gold-50;
|
||||||
|
color: $amber-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution {
|
||||||
|
background: $navy-50;
|
||||||
|
color: $navy-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closed {
|
||||||
|
background: $gray-100;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './StatusBadge.module.scss';
|
||||||
|
import { REQUEST_STATUS } from '../models/statuses';
|
||||||
|
|
||||||
|
// Цвет бейджа по статусу заявки. Неизвестный статус → нейтральный (как черновик).
|
||||||
|
const VARIANT: Record<string, string> = {
|
||||||
|
[REQUEST_STATUS.draft]: styles.draft,
|
||||||
|
[REQUEST_STATUS.onApproval]: styles.onApproval,
|
||||||
|
[REQUEST_STATUS.approved]: styles.approved,
|
||||||
|
[REQUEST_STATUS.rejected]: styles.rejected,
|
||||||
|
[REQUEST_STATUS.returned]: styles.returned,
|
||||||
|
[REQUEST_STATUS.execution]: styles.execution,
|
||||||
|
[REQUEST_STATUS.closed]: styles.closed,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Цветная плашка статуса заявки (шапка формы / просмотра). */
|
||||||
|
export const StatusBadge: React.FC<{ status: string }> = ({ status }) => (
|
||||||
|
<span className={`${styles.badge} ${VARIANT[status] || styles.draft}`}>{status}</span>
|
||||||
|
);
|
||||||
@@ -8,6 +8,13 @@
|
|||||||
padding: 32px;
|
padding: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-family: $font-serif;
|
font-family: $font-serif;
|
||||||
font-size: $fs-form-title;
|
font-size: $fs-form-title;
|
||||||
@@ -15,6 +22,13 @@
|
|||||||
color: $navy-900;
|
color: $navy-900;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.headerRight {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: $gray-400;
|
color: $gray-400;
|
||||||
|
|||||||
@@ -4,15 +4,22 @@ import styles from './FormCard.module.scss';
|
|||||||
interface IFormCardProps {
|
interface IFormCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
|
/** Контент в правом верхнем углу шапки (например, номер заявки). */
|
||||||
|
headerRight?: React.ReactNode;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Белая карточка формы: заголовок (Lora) + золотой hairline + содержимое. */
|
/** Белая карточка формы: заголовок (Lora) + золотой hairline + содержимое. */
|
||||||
export const FormCard: React.FC<IFormCardProps> = ({ title, subtitle, children }) => {
|
export const FormCard: React.FC<IFormCardProps> = ({ title, subtitle, headerRight, children }) => {
|
||||||
return (
|
return (
|
||||||
<div className={styles.card}>
|
<div className={styles.card}>
|
||||||
<div className={styles.title}>{title}</div>
|
<div className={styles.header}>
|
||||||
{subtitle && <div className={styles.subtitle}>{subtitle}</div>}
|
<div>
|
||||||
|
<div className={styles.title}>{title}</div>
|
||||||
|
{subtitle && <div className={styles.subtitle}>{subtitle}</div>}
|
||||||
|
</div>
|
||||||
|
{headerRight && <div className={styles.headerRight}>{headerRight}</div>}
|
||||||
|
</div>
|
||||||
<hr className={styles.hairline} />
|
<hr className={styles.hairline} />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
@import '../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $navy-50;
|
||||||
|
color: $navy-800;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .3px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './RequestNumber.module.scss';
|
||||||
|
|
||||||
|
/** Номер заявки (Id элемента списка) — плашка в правом углу шапки формы. */
|
||||||
|
export const RequestNumber: React.FC<{ id: number }> = ({ id }) => (
|
||||||
|
<span className={styles.badge}>№{id}</span>
|
||||||
|
);
|
||||||
@@ -16,4 +16,5 @@
|
|||||||
transition: .18s;
|
transition: .18s;
|
||||||
|
|
||||||
@include input-focus-gold;
|
@include input-focus-gold;
|
||||||
|
@include readonly-field(':disabled');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Button } from '../Button/Button';
|
||||||
|
import { Icon } from '../Icon/Icon';
|
||||||
|
import { FormActions } from '../FormActions/FormActions';
|
||||||
|
import { IRequestSubmit } from '../useRequestSubmit';
|
||||||
|
|
||||||
|
export interface ISubmitActionsProps {
|
||||||
|
submit: IRequestSubmit;
|
||||||
|
isValid: boolean;
|
||||||
|
/** Заявка в нередактируемом статусе (На согласовании/…): сохранять нельзя. */
|
||||||
|
readOnly: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSaveDraft: () => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
/** Подпись основной кнопки. По умолчанию — «Отправить на согласование». */
|
||||||
|
submitLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Единый ряд кнопок форм подачи: Отмена / Сохранить черновик / Отправить.
|
||||||
|
// Логика доступности — здесь, в одном месте, чтобы все формы вели себя одинаково:
|
||||||
|
// • сохранять/отправлять нельзя, пока идёт сохранение или заявка уже read-only;
|
||||||
|
// • «Отмена» доступна всегда (возврат к списку), кроме момента сохранения.
|
||||||
|
export const SubmitActions: React.FC<ISubmitActionsProps> = ({
|
||||||
|
submit,
|
||||||
|
isValid,
|
||||||
|
readOnly,
|
||||||
|
onCancel,
|
||||||
|
onSaveDraft,
|
||||||
|
onSubmit,
|
||||||
|
submitLabel = 'Отправить на согласование',
|
||||||
|
}) => {
|
||||||
|
const { saving, kind } = submit;
|
||||||
|
const locked = saving || readOnly;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormActions>
|
||||||
|
<Button variant="secondary" onClick={onCancel} disabled={saving}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={onSaveDraft} disabled={!isValid || locked}>
|
||||||
|
<Icon name="document" size="sm" />
|
||||||
|
Сохранить черновик
|
||||||
|
</Button>
|
||||||
|
<Button variant="accent" onClick={onSubmit} disabled={!isValid || locked}>
|
||||||
|
<Icon name="send" size="sm" />
|
||||||
|
{saving && kind === 'submit' ? 'Отправка…' : submitLabel}
|
||||||
|
</Button>
|
||||||
|
</FormActions>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
transition: .18s;
|
transition: .18s;
|
||||||
|
|
||||||
@include input-focus-gold;
|
@include input-focus-gold;
|
||||||
|
@include readonly-field(':read-only');
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: $gray-400;
|
color: $gray-400;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
transition: .18s;
|
transition: .18s;
|
||||||
|
|
||||||
@include input-focus-gold;
|
@include input-focus-gold;
|
||||||
|
@include readonly-field(':read-only');
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: $gray-400;
|
color: $gray-400;
|
||||||
|
|||||||
@@ -49,6 +49,16 @@
|
|||||||
box-shadow: 0 0 0 3px rgba(212, 160, 30, .25);
|
box-shadow: 0 0 0 3px rgba(212, 160, 30, .25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Только чтение: приглушаем переключатель и подпись.
|
||||||
|
.input:disabled + .track {
|
||||||
|
opacity: .5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:disabled ~ .text {
|
||||||
|
color: $gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: $gray-700;
|
color: $gray-700;
|
||||||
|
|||||||
@@ -19,3 +19,7 @@ export { PeopleField } from './PeopleField/PeopleField';
|
|||||||
export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
|
export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
|
||||||
export { FormModeProvider, useFormReadOnly } from './FormMode';
|
export { FormModeProvider, useFormReadOnly } from './FormMode';
|
||||||
export { Icon } from './Icon/Icon';
|
export { Icon } from './Icon/Icon';
|
||||||
|
export { SubmitActions } from './SubmitActions/SubmitActions';
|
||||||
|
export { RequestNumber } from './RequestNumber/RequestNumber';
|
||||||
|
export { useRequestSubmit } from './useRequestSubmit';
|
||||||
|
export type { IRequestSubmit, SubmitKind } from './useRequestSubmit';
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
// Общая машина сохранения заявки для ВСЕХ форм портала.
|
||||||
|
// Отвечает ТОЛЬКО за транзиентное состояние операции (идёт сохранение / ошибка /
|
||||||
|
// что сохранили). Блокировка формы (readOnly) считается из СТАТУСА заявки на
|
||||||
|
// стороне формы через isEditableStatus — не здесь: так одинаково блокируются
|
||||||
|
// и «после отправки», и «открыли уже отправленную».
|
||||||
|
export type SubmitKind = 'draft' | 'submit';
|
||||||
|
|
||||||
|
export interface IRequestSubmit {
|
||||||
|
saving: boolean; // идёт сохранение
|
||||||
|
kind: SubmitKind | undefined; // текущее/последнее действие (для подписи кнопки)
|
||||||
|
savedKind: SubmitKind | undefined; // последнее УСПЕШНОЕ сохранение (для плашки)
|
||||||
|
error: string | undefined;
|
||||||
|
/**
|
||||||
|
* Запустить сохранение. validate (опц.) возвращает текст ошибки или undefined.
|
||||||
|
* Повторный вызов во время сохранения игнорируется (защита от дабл-клика).
|
||||||
|
*/
|
||||||
|
run: (
|
||||||
|
kind: SubmitKind,
|
||||||
|
save: () => Promise<void>,
|
||||||
|
validate?: () => string | undefined
|
||||||
|
) => Promise<void>;
|
||||||
|
setError: (message: string | undefined) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRequestSubmit(): IRequestSubmit {
|
||||||
|
const [saving, setSaving] = useState<boolean>(false);
|
||||||
|
const [kind, setKind] = useState<SubmitKind | undefined>(undefined);
|
||||||
|
const [savedKind, setSavedKind] = useState<SubmitKind | undefined>(undefined);
|
||||||
|
const [error, setError] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const run = async (
|
||||||
|
k: SubmitKind,
|
||||||
|
save: () => Promise<void>,
|
||||||
|
validate?: () => string | undefined
|
||||||
|
): Promise<void> => {
|
||||||
|
if (saving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setKind(k);
|
||||||
|
const validationError = validate ? validate() : undefined;
|
||||||
|
if (validationError) {
|
||||||
|
setError(validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(undefined);
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await save();
|
||||||
|
setSavedKind(k);
|
||||||
|
} catch (e) {
|
||||||
|
setError(`Ошибка сохранения: ${(e as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { saving, kind, savedKind, error, run, setError };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user